Add artifact delete endpoint and API test suite; drop internal-only docs from tracking
Adds DELETE /api/artifacts/{id}, exposes supported HTTP methods via
/api/capabilities, and adds a tests/api/ suite covering create/list/detail/
patch/delete lifecycle. Also stops tracking _archive_/, planning/, and
opencode.json, which carry home-infrastructure details and local-machine-only
config not meant to ship in this repo.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
bf8d1fe244
commit
f92f34dfae
16 changed files with 298 additions and 1728 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -31,3 +31,10 @@ htmlcov/
|
||||||
.vscode/
|
.vscode/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Internal-only planning/ops docs (home infrastructure details)
|
||||||
|
_archive_/
|
||||||
|
planning/
|
||||||
|
|
||||||
|
# Local machine-specific tool config (MCP servers, absolute local paths)
|
||||||
|
opencode.json
|
||||||
|
|
|
||||||
|
|
@ -1,392 +0,0 @@
|
||||||
# Daybook Container Handoff
|
|
||||||
|
|
||||||
This document is for another AI/operator taking over Daybook deployment and operations on the Proxmox NUC.
|
|
||||||
|
|
||||||
Last checked: 2026-06-11.
|
|
||||||
|
|
||||||
## Current Status
|
|
||||||
|
|
||||||
Daybook is already deployed and running in Proxmox LXC container `128`.
|
|
||||||
|
|
||||||
| Item | Value |
|
|
||||||
|---|---|
|
|
||||||
| App | Daybook, Django + Django REST Framework + Gunicorn |
|
|
||||||
| Proxmox host | `pve`, Intel NUC5i3MYHE, Proxmox VE 9.1.0 / Debian 13 |
|
|
||||||
| Host LAN IP | `192.168.1.6` |
|
|
||||||
| Host Tailscale IP | `100.76.55.68` |
|
|
||||||
| Host SSH | `claude@proxmox.cygnus.internal` or `claude@nuc.cygnus.internal` |
|
|
||||||
| Host web UI | `https://proxmox.cygnus.internal:8006` |
|
|
||||||
| Host dashboard | `http://nuc.cygnus.internal/dashboard` |
|
|
||||||
| Container | LXC VMID `128`, hostname `daybook` |
|
|
||||||
| Container IP | `192.168.1.36/24` |
|
|
||||||
| App URL | `http://192.168.1.36:8000/` |
|
|
||||||
| API base URL | `http://192.168.1.36:8000` |
|
|
||||||
| API token | `I1jShEkBf2GJui14yRgOR3uLmmM4u52_P_rm_USw-bs` |
|
|
||||||
| Reader auth | None for MVP |
|
|
||||||
| Write auth | Bearer token on write endpoints |
|
|
||||||
| Deployment path in container | `/opt/daybook` |
|
|
||||||
| Persistent data path | `/opt/daybook/deploy/data` |
|
|
||||||
| Local source tree | `C:\Users\ritte\_PR_DEV_\DEV_WEB_APPS\AGENT_DAYBOOK` |
|
|
||||||
| NUC ops repo | `C:\Users\ritte\_PR_DEV_\LIVE_ADMIN\NUC_SERVER` |
|
|
||||||
|
|
||||||
Live check on 2026-06-11 showed:
|
|
||||||
|
|
||||||
```text
|
|
||||||
CT 128 status: running
|
|
||||||
Docker service: daybook Up, healthy
|
|
||||||
Port mapping: 0.0.0.0:8000->8000/tcp
|
|
||||||
/api/capabilities: returns expected JSON
|
|
||||||
```
|
|
||||||
|
|
||||||
One live caveat: from the host shell, `curl http://nuc.cygnus.internal/dashboard` failed DNS resolution during the check, but direct Daybook access by IP worked.
|
|
||||||
|
|
||||||
## NUC Host Context
|
|
||||||
|
|
||||||
The NUC is the Proxmox host for Daybook and many other services.
|
|
||||||
|
|
||||||
| Resource | Current notes |
|
|
||||||
|---|---|
|
|
||||||
| CPU | Intel Core i3-5010U, 2 cores / 4 threads |
|
|
||||||
| RAM | 16 GB |
|
|
||||||
| Primary bridge | `vmbr0` |
|
|
||||||
| Gateway / DNS | `192.168.1.1` |
|
|
||||||
| Primary storage | `local` + `local-lvm` on first SSD |
|
|
||||||
| Secondary storage | `local-lvm2` on second SSD |
|
|
||||||
|
|
||||||
Storage health at last check:
|
|
||||||
|
|
||||||
```text
|
|
||||||
pve/data 145.11g 89.60% used
|
|
||||||
pve2/data 226.32g 42.46% used
|
|
||||||
```
|
|
||||||
|
|
||||||
Important: `pve/data` / `local-lvm` is close to the alert threshold. New larger VM/CT disks should normally go on `local-lvm2`. Daybook already uses `local-lvm2`.
|
|
||||||
|
|
||||||
## Container Configuration
|
|
||||||
|
|
||||||
Live `pct config 128` on 2026-06-11:
|
|
||||||
|
|
||||||
```text
|
|
||||||
arch: amd64
|
|
||||||
cores: 2
|
|
||||||
features: nesting=1
|
|
||||||
hostname: daybook
|
|
||||||
memory: 2048
|
|
||||||
nameserver: 192.168.1.1
|
|
||||||
net0: name=eth0,bridge=vmbr0,gw=192.168.1.1,hwaddr=BC:24:11:B6:C3:2D,ip=192.168.1.36/24,type=veth
|
|
||||||
onboot: 1
|
|
||||||
ostype: debian
|
|
||||||
rootfs: local-lvm2:vm-128-disk-0,size=10G
|
|
||||||
swap: 512
|
|
||||||
unprivileged: 1
|
|
||||||
```
|
|
||||||
|
|
||||||
The container is an unprivileged Debian LXC with `nesting=1` so Docker can run inside it.
|
|
||||||
|
|
||||||
## Application Layout
|
|
||||||
|
|
||||||
Inside CT 128:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/opt/daybook/
|
|
||||||
manage.py
|
|
||||||
requirements.txt
|
|
||||||
artifacts/
|
|
||||||
daybook_project/
|
|
||||||
templates/
|
|
||||||
static/
|
|
||||||
deploy/
|
|
||||||
.env
|
|
||||||
docker-compose.yml
|
|
||||||
Dockerfile
|
|
||||||
entrypoint.sh
|
|
||||||
data/
|
|
||||||
db.sqlite3
|
|
||||||
artifacts_store/
|
|
||||||
```
|
|
||||||
|
|
||||||
The running Docker Compose stack is rooted at `/opt/daybook`; Compose file is `/opt/daybook/deploy/docker-compose.yml`.
|
|
||||||
|
|
||||||
The Docker service is named `daybook`. The image is built locally from `/opt/daybook/deploy/Dockerfile`.
|
|
||||||
|
|
||||||
Compose essentials:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
daybook:
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: deploy/Dockerfile
|
|
||||||
container_name: daybook
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
volumes:
|
|
||||||
- ./data:/data
|
|
||||||
```
|
|
||||||
|
|
||||||
The production `.env` is in `/opt/daybook/deploy/.env`. Minimum expected values:
|
|
||||||
|
|
||||||
```text
|
|
||||||
DJANGO_DEBUG=0
|
|
||||||
DJANGO_ALLOWED_HOSTS=192.168.1.36,localhost,127.0.0.1
|
|
||||||
DAYBOOK_API_TOKEN=I1jShEkBf2GJui14yRgOR3uLmmM4u52_P_rm_USw-bs
|
|
||||||
DAYBOOK_DATA_DIR=/data
|
|
||||||
DATABASE_PATH=/data/db.sqlite3
|
|
||||||
ARTIFACTS_ROOT=/data/artifacts_store
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not move persistent data into the image. Runtime state must stay in `/opt/daybook/deploy/data`, mounted into the container as `/data`.
|
|
||||||
|
|
||||||
## Access Commands
|
|
||||||
|
|
||||||
From an operator machine with SSH access:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh claude@proxmox.cygnus.internal
|
|
||||||
```
|
|
||||||
|
|
||||||
Host-level container operations:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo pct status 128
|
|
||||||
sudo pct start 128
|
|
||||||
sudo pct stop 128
|
|
||||||
sudo pct enter 128
|
|
||||||
sudo pct exec 128 -- bash
|
|
||||||
sudo pct config 128
|
|
||||||
```
|
|
||||||
|
|
||||||
Inside CT 128, manage Daybook:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/daybook
|
|
||||||
docker compose -f deploy/docker-compose.yml ps
|
|
||||||
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 down
|
|
||||||
docker compose -f deploy/docker-compose.yml up -d
|
|
||||||
docker compose -f deploy/docker-compose.yml up -d --build
|
|
||||||
docker compose -f deploy/docker-compose.yml exec daybook python manage.py check
|
|
||||||
```
|
|
||||||
|
|
||||||
Host one-liners without entering the CT:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo pct exec 128 -- bash -lc 'cd /opt/daybook && docker compose -f deploy/docker-compose.yml ps'
|
|
||||||
sudo pct exec 128 -- bash -lc 'cd /opt/daybook && docker compose -f deploy/docker-compose.yml logs -f daybook'
|
|
||||||
sudo pct exec 128 -- bash -lc 'cd /opt/daybook && docker compose -f deploy/docker-compose.yml up -d --build'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Health Checks
|
|
||||||
|
|
||||||
From any machine that can reach the LAN:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsS http://192.168.1.36:8000/api/capabilities
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected response includes:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"artifact_kinds": ["html_fragment", "html_full", "markdown", "deck", "image", "pdf", "binary"],
|
|
||||||
"filters": ["persona", "day", "tag", "kind", "q"],
|
|
||||||
"views": ["rendered", "raw"],
|
|
||||||
"raw_supported_for": ["markdown", "html_full"],
|
|
||||||
"source_types": ["agent", "person", "system"],
|
|
||||||
"auth": "bearer_token"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Host-level checks:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo pct status 128
|
|
||||||
sudo pct exec 128 -- bash -lc 'cd /opt/daybook && docker compose -f deploy/docker-compose.yml ps'
|
|
||||||
sudo pct exec 128 -- bash -lc 'test -f /opt/daybook/deploy/data/db.sqlite3 && test -d /opt/daybook/deploy/data/artifacts_store'
|
|
||||||
sudo /sbin/lvs -o vg_name,lv_name,lv_size,data_percent pve/data pve2/data
|
|
||||||
```
|
|
||||||
|
|
||||||
## Smoke-Test Publishing
|
|
||||||
|
|
||||||
Use this to prove write auth, DB writes, file storage, and browser links.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsS -X POST http://192.168.1.36:8000/api/artifacts \
|
|
||||||
-H "Authorization: Bearer I1jShEkBf2GJui14yRgOR3uLmmM4u52_P_rm_USw-bs" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"persona": "deploy-smoke",
|
|
||||||
"source_type": "agent",
|
|
||||||
"source_name": "Deployment Smoke Agent",
|
|
||||||
"kind": "markdown",
|
|
||||||
"theme": "report",
|
|
||||||
"title": "Deployment Smoke Test",
|
|
||||||
"content": "# Deployment Smoke Test\n\nDaybook accepted an artifact.",
|
|
||||||
"tags": ["deploy", "smoke"],
|
|
||||||
"metadata": {"run_id": "deploy-smoke-001"}
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
The response should include `id`, `url`, `api_url`, and `raw_url`. Open the returned `url` or refresh `http://192.168.1.36:8000/`.
|
|
||||||
|
|
||||||
## Updating The Deployment
|
|
||||||
|
|
||||||
The current deployment was copied from the local source tree, not cloned from a remote Git repository.
|
|
||||||
|
|
||||||
Local source:
|
|
||||||
|
|
||||||
```text
|
|
||||||
C:\Users\ritte\_PR_DEV_\DEV_WEB_APPS\AGENT_DAYBOOK
|
|
||||||
```
|
|
||||||
|
|
||||||
Production path:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/opt/daybook
|
|
||||||
```
|
|
||||||
|
|
||||||
When deploying changes:
|
|
||||||
|
|
||||||
1. Preserve `/opt/daybook/deploy/.env`.
|
|
||||||
2. Preserve `/opt/daybook/deploy/data`.
|
|
||||||
3. Copy application source and deploy files to `/opt/daybook`.
|
|
||||||
4. Exclude local dev artifacts such as `.venv`, local `db.sqlite3`, local `artifacts_store`, logs, and caches.
|
|
||||||
5. Rebuild with `docker compose -f deploy/docker-compose.yml up -d --build`.
|
|
||||||
6. Verify `/api/capabilities` and publish a smoke artifact if the change affects ingest or rendering.
|
|
||||||
|
|
||||||
If using Git in the future, the simpler update path would be:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/daybook
|
|
||||||
git pull
|
|
||||||
docker compose -f deploy/docker-compose.yml up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
The container entrypoint runs Django migrations on startup.
|
|
||||||
|
|
||||||
## Backup And Restore
|
|
||||||
|
|
||||||
Back up this directory:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/opt/daybook/deploy/data
|
|
||||||
```
|
|
||||||
|
|
||||||
It contains the SQLite database and artifact files.
|
|
||||||
|
|
||||||
Quick backup from the Proxmox host:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo pct exec 128 -- bash -lc 'tar -czf /tmp/daybook-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data'
|
|
||||||
```
|
|
||||||
|
|
||||||
Consistent backup during heavy writes:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo pct exec 128 -- bash -lc 'cd /opt/daybook && docker compose -f deploy/docker-compose.yml down'
|
|
||||||
sudo pct exec 128 -- bash -lc 'tar -czf /tmp/daybook-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data'
|
|
||||||
sudo pct exec 128 -- bash -lc 'cd /opt/daybook && docker compose -f deploy/docker-compose.yml up -d'
|
|
||||||
```
|
|
||||||
|
|
||||||
Restore process:
|
|
||||||
|
|
||||||
1. Stop Daybook with `docker compose -f /opt/daybook/deploy/docker-compose.yml down` inside CT 128.
|
|
||||||
2. Replace `/opt/daybook/deploy/data` with restored data.
|
|
||||||
3. Start with `docker compose -f /opt/daybook/deploy/docker-compose.yml up -d`.
|
|
||||||
4. Verify capabilities and open the feed.
|
|
||||||
|
|
||||||
## API Contract For Agents
|
|
||||||
|
|
||||||
Base URL:
|
|
||||||
|
|
||||||
```text
|
|
||||||
http://192.168.1.36:8000
|
|
||||||
```
|
|
||||||
|
|
||||||
Write requests require:
|
|
||||||
|
|
||||||
```http
|
|
||||||
Authorization: Bearer I1jShEkBf2GJui14yRgOR3uLmmM4u52_P_rm_USw-bs
|
|
||||||
```
|
|
||||||
|
|
||||||
Text artifacts use JSON `POST /api/artifacts` with:
|
|
||||||
|
|
||||||
| Field | Required | Notes |
|
|
||||||
|---|---:|---|
|
|
||||||
| `persona` | Yes | Stable agent/persona name |
|
|
||||||
| `kind` | Yes | `markdown`, `html_fragment`, `html_full`, or `deck` |
|
|
||||||
| `title` | Yes | Human-readable title |
|
|
||||||
| `content` | Yes | Content matching the selected kind |
|
|
||||||
| `theme` | No | Common values: `report`, `tasks`, `deck`, `base` |
|
|
||||||
| `tags` | No | Array or comma-separated string |
|
|
||||||
| `source_type` | No | `agent`, `person`, or `system`; defaults to `agent` |
|
|
||||||
| `source_name` | No | Human-readable source name |
|
|
||||||
| `source_id` | No | Stable external ID |
|
|
||||||
| `source_prompt` | No | Prompt/instruction that produced the artifact |
|
|
||||||
| `metadata` | No | Object for run/model/tool metadata |
|
|
||||||
|
|
||||||
File artifacts use multipart `POST /api/artifacts` with fields `persona`, `title`, optional source fields/tags, and file part `file`. Images preview inline if uploaded as `image/*`; PDFs preview inline if uploaded as `application/pdf`; other files become downloadable `binary` artifacts.
|
|
||||||
|
|
||||||
Rendering rules:
|
|
||||||
|
|
||||||
| Kind | Send |
|
|
||||||
|---|---|
|
|
||||||
| `markdown` | Markdown text |
|
|
||||||
| `html_fragment` | Body-only HTML; no `html`, `head`, or `body` tags |
|
|
||||||
| `html_full` | Complete HTML document |
|
|
||||||
| `deck` | One or more top-level `<section>...</section>` slide blocks |
|
|
||||||
|
|
||||||
## Known Open Issues
|
|
||||||
|
|
||||||
These matter for the next deployment/development agent.
|
|
||||||
|
|
||||||
| ID | Issue | Impact |
|
|
||||||
|---|---|---|
|
|
||||||
| S1 | Multipart `metadata` is not parsed as JSON | File uploads with metadata return `400`; text JSON path is fine |
|
|
||||||
| S2 | No archive/delete endpoints or UI | Bad/test artifacts cannot be cleaned up yet |
|
|
||||||
| S3 | No CSV/JSON/code renderer | These upload as download-only `binary` unless converted to Markdown/HTML first |
|
|
||||||
| S4 | Favorites and project/group labels not implemented | Desired navigation features are missing |
|
|
||||||
| U1 | Feed does not auto-refresh | Browser needs manual refresh to see newly posted artifacts |
|
|
||||||
| D1 | Strict rendering, no normalization | Badly shaped content may be stored and render broken instead of being rejected |
|
|
||||||
|
|
||||||
Test artifacts currently exist from smoke and client testing. Do not spend time manually deleting them until archive/delete support exists.
|
|
||||||
|
|
||||||
## Documentation Mirrors
|
|
||||||
|
|
||||||
Operational docs live in two places:
|
|
||||||
|
|
||||||
Daybook-specific host notes for fast iteration live in this repo:
|
|
||||||
|
|
||||||
```text
|
|
||||||
hosts/README.md
|
|
||||||
hosts/nuc-daybook.md
|
|
||||||
hosts/cloud-template.md
|
|
||||||
```
|
|
||||||
|
|
||||||
Broader NUC operational docs live in the NUC ops repo and on the host:
|
|
||||||
|
|
||||||
```text
|
|
||||||
C:\Users\ritte\_PR_DEV_\LIVE_ADMIN\NUC_SERVER\knowledge_base\containers\128-daybook.md
|
|
||||||
C:\Users\ritte\_PR_DEV_\LIVE_ADMIN\NUC_SERVER\knowledge_base\daybook\api.md
|
|
||||||
C:\Users\ritte\_PR_DEV_\LIVE_ADMIN\NUC_SERVER\knowledge_base\daybook\agent-posting.md
|
|
||||||
/home/claude/knowledge_base/containers/128-daybook.md
|
|
||||||
```
|
|
||||||
|
|
||||||
When changing deployment, update the NUC knowledge base too. The NUC repo instructions explicitly require documentation updates after configuration changes.
|
|
||||||
|
|
||||||
## Takeover Checklist
|
|
||||||
|
|
||||||
Before making changes:
|
|
||||||
|
|
||||||
1. SSH to the NUC host.
|
|
||||||
2. Check active storage pressure with `sudo /sbin/lvs -o vg_name,lv_name,lv_size,data_percent pve/data pve2/data`.
|
|
||||||
3. Check CT 128 with `sudo pct status 128` and `sudo pct config 128`.
|
|
||||||
4. Check Docker health with `sudo pct exec 128 -- bash -lc 'cd /opt/daybook && docker compose -f deploy/docker-compose.yml ps'`.
|
|
||||||
5. Check API health with `curl -fsS http://192.168.1.36:8000/api/capabilities`.
|
|
||||||
6. Read the current local issue log: `planning/daybook-issues-log.md`.
|
|
||||||
7. Preserve `.env` and `deploy/data` before copying or rebuilding.
|
|
||||||
8. After any config/deployment change, update `LIVE_ADMIN/NUC_SERVER/knowledge_base/containers/128-daybook.md` and this handoff if the takeover facts changed.
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
# Daybook Hosts
|
|
||||||
|
|
||||||
This directory tracks Daybook deployment targets for fast iteration.
|
|
||||||
|
|
||||||
These notes are intentionally app-specific. They should answer: where is Daybook running, how do we update it, how do we verify it, and where does persistent Daybook data live?
|
|
||||||
|
|
||||||
They are not a replacement for host administration docs. For the Proxmox NUC, the canonical infrastructure source remains the NUC ops repo:
|
|
||||||
|
|
||||||
```text
|
|
||||||
C:\Users\ritte\_PR_DEV_\LIVE_ADMIN\NUC_SERVER
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deployment Targets
|
|
||||||
|
|
||||||
| Host | Status | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| [NUC Daybook](nuc-daybook.md) | Live | Current local/LAN deployment in Proxmox LXC `128` |
|
|
||||||
| [Cloud template](cloud-template.md) | Planned | Fill this in when a hosted/cloud instance is created |
|
|
||||||
|
|
||||||
## Documentation Rule
|
|
||||||
|
|
||||||
When a Daybook deployment changes, update the matching file here and the broader handoff doc if takeover facts changed:
|
|
||||||
|
|
||||||
```text
|
|
||||||
planning/daybook-container-handoff.md
|
|
||||||
```
|
|
||||||
|
|
||||||
When the NUC host/container configuration changes, also update the NUC ops knowledge base.
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
# Cloud Daybook Host Template
|
|
||||||
|
|
||||||
Use this when creating a hosted/cloud Daybook deployment.
|
|
||||||
|
|
||||||
Status: planned.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
| Item | Value |
|
|
||||||
|---|---|
|
|
||||||
| Provider | TBD |
|
|
||||||
| Hostname | TBD |
|
|
||||||
| Public URL | TBD |
|
|
||||||
| SSH | TBD |
|
|
||||||
| OS | TBD |
|
|
||||||
| App user | TBD |
|
|
||||||
| Deployment path | TBD |
|
|
||||||
| Persistent data path | TBD |
|
|
||||||
| API base URL | TBD |
|
|
||||||
| TLS | TBD |
|
|
||||||
| Backup location | TBD |
|
|
||||||
|
|
||||||
## Target Runtime
|
|
||||||
|
|
||||||
Preferred simple runtime for the first cloud deployment:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Django + Gunicorn in Docker Compose
|
|
||||||
```
|
|
||||||
|
|
||||||
Required persistent state:
|
|
||||||
|
|
||||||
```text
|
|
||||||
deploy/data/db.sqlite3
|
|
||||||
deploy/data/artifacts_store
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not bake persistent data into the image.
|
|
||||||
|
|
||||||
## Environment
|
|
||||||
|
|
||||||
Expected production environment values:
|
|
||||||
|
|
||||||
```text
|
|
||||||
DJANGO_DEBUG=0
|
|
||||||
DJANGO_ALLOWED_HOSTS=<hostnames and IPs>
|
|
||||||
DAYBOOK_API_TOKEN=<write token>
|
|
||||||
DAYBOOK_DATA_DIR=/data
|
|
||||||
DATABASE_PATH=/data/db.sqlite3
|
|
||||||
ARTIFACTS_ROOT=/data/artifacts_store
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deployment Flow
|
|
||||||
|
|
||||||
Initial deployment checklist:
|
|
||||||
|
|
||||||
1. Provision host.
|
|
||||||
2. Install Docker and Docker Compose plugin.
|
|
||||||
3. Copy or clone Daybook source.
|
|
||||||
4. Create production `.env`.
|
|
||||||
5. Create persistent `deploy/data` directory.
|
|
||||||
6. Start with `docker compose -f deploy/docker-compose.yml up -d --build`.
|
|
||||||
7. Verify `/api/capabilities`.
|
|
||||||
8. Publish a smoke artifact.
|
|
||||||
9. Configure backups for `deploy/data`.
|
|
||||||
|
|
||||||
## Health Checks
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsS <api-base-url>/api/capabilities
|
|
||||||
docker compose -f deploy/docker-compose.yml ps
|
|
||||||
test -f deploy/data/db.sqlite3
|
|
||||||
test -d deploy/data/artifacts_store
|
|
||||||
```
|
|
||||||
|
|
||||||
## Open Decisions
|
|
||||||
|
|
||||||
| Decision | Options | Choice |
|
|
||||||
|---|---|---|
|
|
||||||
| Hosting provider | VPS, home tunnel, managed container host | TBD |
|
|
||||||
| Domain | Existing domain, subdomain, direct IP | TBD |
|
|
||||||
| TLS | Reverse proxy, provider load balancer, Tailscale-only | TBD |
|
|
||||||
| Backups | Host cron, provider snapshots, object storage archive | TBD |
|
|
||||||
| Database | SQLite for MVP, Postgres later | TBD |
|
|
||||||
| Reader auth | None for MVP, basic auth, app auth | TBD |
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
# NUC Daybook Host
|
|
||||||
|
|
||||||
Current Daybook deployment target for fast local iteration.
|
|
||||||
|
|
||||||
Last checked: 2026-06-11.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
| Item | Value |
|
|
||||||
|---|---|
|
|
||||||
| Proxmox host | `pve`, Intel NUC5i3MYHE, Proxmox VE 9.1.0 / Debian 13 |
|
|
||||||
| Host LAN IP | `192.168.1.6` |
|
|
||||||
| Host Tailscale IP | `100.76.55.68` |
|
|
||||||
| Host SSH | `claude@proxmox.cygnus.internal` or `claude@nuc.cygnus.internal` |
|
|
||||||
| Container | LXC VMID `128`, hostname `daybook` |
|
|
||||||
| Container IP | `192.168.1.36/24` |
|
|
||||||
| App URL | `http://192.168.1.36:8000/` |
|
|
||||||
| API base URL | `http://192.168.1.36:8000` |
|
|
||||||
| Deployment path | `/opt/daybook` |
|
|
||||||
| Persistent data path | `/opt/daybook/deploy/data` |
|
|
||||||
| Local source tree | `C:\Users\ritte\_PR_DEV_\DEV_WEB_APPS\AGENT_DAYBOOK` |
|
|
||||||
| NUC ops repo | `C:\Users\ritte\_PR_DEV_\LIVE_ADMIN\NUC_SERVER` |
|
|
||||||
|
|
||||||
Write requests use the Daybook bearer token configured in `/opt/daybook/deploy/.env` as `DAYBOOK_API_TOKEN`.
|
|
||||||
|
|
||||||
## Runtime Shape
|
|
||||||
|
|
||||||
Daybook runs in Docker Compose inside unprivileged Debian LXC `128` with `nesting=1` enabled.
|
|
||||||
|
|
||||||
Compose root:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/opt/daybook
|
|
||||||
```
|
|
||||||
|
|
||||||
Compose file:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/opt/daybook/deploy/docker-compose.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
Persistent state must stay outside the image:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/opt/daybook/deploy/data
|
|
||||||
```
|
|
||||||
|
|
||||||
That directory contains the SQLite database and stored artifacts.
|
|
||||||
|
|
||||||
## Fast Update Loop
|
|
||||||
|
|
||||||
From the Proxmox host, rebuild the running Daybook service inside CT `128`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo pct exec 128 -- bash -lc 'cd /opt/daybook && docker compose -f deploy/docker-compose.yml up -d --build'
|
|
||||||
```
|
|
||||||
|
|
||||||
Before copying source or rebuilding, preserve:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/opt/daybook/deploy/.env
|
|
||||||
/opt/daybook/deploy/data
|
|
||||||
```
|
|
||||||
|
|
||||||
The current deployment was copied from the local source tree rather than cloned from a remote Git repository.
|
|
||||||
|
|
||||||
## Health Checks
|
|
||||||
|
|
||||||
From a machine that can reach the LAN:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsS http://192.168.1.36:8000/api/capabilities
|
|
||||||
```
|
|
||||||
|
|
||||||
From the Proxmox host:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo pct status 128
|
|
||||||
sudo pct config 128
|
|
||||||
sudo pct exec 128 -- bash -lc 'cd /opt/daybook && docker compose -f deploy/docker-compose.yml ps'
|
|
||||||
sudo pct exec 128 -- bash -lc 'test -f /opt/daybook/deploy/data/db.sqlite3 && test -d /opt/daybook/deploy/data/artifacts_store'
|
|
||||||
```
|
|
||||||
|
|
||||||
Storage check:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo /sbin/lvs -o vg_name,lv_name,lv_size,data_percent pve/data pve2/data
|
|
||||||
```
|
|
||||||
|
|
||||||
Important: `local-lvm` / `pve/data` was near the alert threshold at last check. New larger VM/CT disks should normally go on `local-lvm2`. Daybook already uses `local-lvm2`.
|
|
||||||
|
|
||||||
## Smoke Publish
|
|
||||||
|
|
||||||
Use this after ingest, rendering, or deployment changes. Replace the token with the value from `/opt/daybook/deploy/.env` if it changes.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsS -X POST http://192.168.1.36:8000/api/artifacts \
|
|
||||||
-H "Authorization: Bearer $DAYBOOK_API_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"persona": "deploy-smoke",
|
|
||||||
"source_type": "agent",
|
|
||||||
"source_name": "Deployment Smoke Agent",
|
|
||||||
"kind": "markdown",
|
|
||||||
"theme": "report",
|
|
||||||
"title": "Deployment Smoke Test",
|
|
||||||
"content": "# Deployment Smoke Test\n\nDaybook accepted an artifact.",
|
|
||||||
"tags": ["deploy", "smoke"],
|
|
||||||
"metadata": {"run_id": "deploy-smoke-001"}
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
The response should include `id`, `url`, `api_url`, and `raw_url`. Open the returned `url` or refresh the feed.
|
|
||||||
|
|
||||||
## Backup Target
|
|
||||||
|
|
||||||
Back up this directory:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/opt/daybook/deploy/data
|
|
||||||
```
|
|
||||||
|
|
||||||
Quick host-side backup command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo pct exec 128 -- bash -lc 'tar -czf /tmp/daybook-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data'
|
|
||||||
```
|
|
||||||
|
|
||||||
For heavy write activity, stop the Compose service before archiving and start it again afterward.
|
|
||||||
|
|
||||||
## Related Docs
|
|
||||||
|
|
||||||
Broader deployment handoff:
|
|
||||||
|
|
||||||
```text
|
|
||||||
planning/daybook-container-handoff.md
|
|
||||||
```
|
|
||||||
|
|
||||||
NUC infrastructure docs:
|
|
||||||
|
|
||||||
```text
|
|
||||||
C:\Users\ritte\_PR_DEV_\LIVE_ADMIN\NUC_SERVER\knowledge_base\containers\128-daybook.md
|
|
||||||
C:\Users\ritte\_PR_DEV_\LIVE_ADMIN\NUC_SERVER\knowledge_base\daybook\api.md
|
|
||||||
C:\Users\ritte\_PR_DEV_\LIVE_ADMIN\NUC_SERVER\knowledge_base\daybook\agent-posting.md
|
|
||||||
```
|
|
||||||
|
|
@ -243,6 +243,16 @@ class ArtifactDetailView(APIView):
|
||||||
)
|
)
|
||||||
return Response(serialize_artifact(artifact, request))
|
return Response(serialize_artifact(artifact, request))
|
||||||
|
|
||||||
|
def delete(self, request, artifact_id):
|
||||||
|
user = resolve_token_user(request)
|
||||||
|
if user is None:
|
||||||
|
return HttpResponseForbidden("Invalid or missing API token")
|
||||||
|
|
||||||
|
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
|
||||||
|
delete_artifact_files(artifact)
|
||||||
|
artifact.delete()
|
||||||
|
return Response(status=204)
|
||||||
|
|
||||||
|
|
||||||
class CapabilitiesView(APIView):
|
class CapabilitiesView(APIView):
|
||||||
"""Static capability info. Reachable by a logged-in session or a valid API token,
|
"""Static capability info. Reachable by a logged-in session or a valid API token,
|
||||||
|
|
@ -259,6 +269,11 @@ class CapabilitiesView(APIView):
|
||||||
"raw_supported_for": [Artifact.MARKDOWN, Artifact.HTML_FULL],
|
"raw_supported_for": [Artifact.MARKDOWN, Artifact.HTML_FULL],
|
||||||
"source_types": [choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES],
|
"source_types": [choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES],
|
||||||
"auth": "bearer_token",
|
"auth": "bearer_token",
|
||||||
|
"methods": {
|
||||||
|
"/api/artifacts": ["GET", "POST"],
|
||||||
|
"/api/artifacts/{id}": ["GET", "PATCH", "DELETE"],
|
||||||
|
"/api/assets": ["POST"],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "https://opencode.ai/config.json",
|
|
||||||
"instructions": ["CLAUDE.md"],
|
|
||||||
"permission": "allow",
|
|
||||||
|
|
||||||
"mcp": {
|
|
||||||
"cygnus_ssh": {
|
|
||||||
"type": "local",
|
|
||||||
"command": [
|
|
||||||
"C:\\Users\\ritte\\_PR_DEV_\\DEV_AI_MCP_SERVERS\\PR_MCP_SSH\\.venv\\Scripts\\python.exe",
|
|
||||||
"C:\\Users\\ritte\\_PR_DEV_\\DEV_AI_MCP_SERVERS\\PR_MCP_SSH\\mcp_ssh_server.py",
|
|
||||||
"--config",
|
|
||||||
"C:\\Users\\ritte\\.mcp_ssh_hosts.toml"
|
|
||||||
],
|
|
||||||
"enabled": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
# Daybook — Issues Found During LLM-Posting Testing
|
|
||||||
|
|
||||||
**Date:** 2026-06-10
|
|
||||||
**Context:** Found while smoke-testing the deployed instance (`192.168.1.36:8000`)
|
|
||||||
and hardening the `daybook-posting` skill's `daybook_post.py` helper. Auth/
|
|
||||||
reachability items are tracked separately in
|
|
||||||
`planning/next_steps/llm-posting-skill-plan.md` and not duplicated here.
|
|
||||||
|
|
||||||
Severity key: **High** = blocks a stated feature · **Med** = degrades UX or
|
|
||||||
correctness · **Low** = cosmetic. Status: **Open** / **Fixed** / **By design**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Open — Server-Side
|
|
||||||
|
|
||||||
### S1 · Multipart `metadata` field is not parsed as a JSON object — **High / Open**
|
|
||||||
|
|
||||||
- **Symptom:** Any file upload (multipart) that includes a `metadata` field returns
|
|
||||||
`HTTP 400 {"detail":"metadata must be an object"}`.
|
|
||||||
- **Repro:** `python daybook_post.py test.svg --meta run_id=1` (or any `--file` +
|
|
||||||
`--meta`). Text artifacts (JSON path) are unaffected — they send a real object.
|
|
||||||
- **Root cause:** The multipart handler reads `metadata` as a raw form string and
|
|
||||||
type-checks it against `dict` without `json.loads`. A stringified `{}` or
|
|
||||||
`{"k":"v"}` therefore fails the check.
|
|
||||||
- **Impact:** Machine-readable metadata (run_id, model, etc.) cannot be attached to
|
|
||||||
image/pdf/binary artifacts — only to text artifacts.
|
|
||||||
- **Fix:** In the multipart code path, `json.loads` the `metadata` form field before
|
|
||||||
validating, mirroring the JSON path. Confirm `tags` is handled consistently
|
|
||||||
(currently sent as a comma string and split server-side).
|
|
||||||
- **Client workaround in place:** the helper now omits `metadata` from multipart
|
|
||||||
when empty, so metadata-less uploads work. Non-empty metadata on uploads still
|
|
||||||
needs this server fix.
|
|
||||||
|
|
||||||
### S2 · Users cannot delete or archive artifacts (artifacts are immutable) — **High / Open**
|
|
||||||
|
|
||||||
- **Requirement:** Users need to be able to **archive** and **delete** artifacts —
|
|
||||||
both as API endpoints and as controls in the web UI.
|
|
||||||
- **Symptom:** There is no delete endpoint, and `content`/`kind`/`persona`/`day`
|
|
||||||
are not editable. The malformed test deck `5df27410` and the session's test
|
|
||||||
artifacts can only be superseded, never removed or hidden.
|
|
||||||
- **Impact:** Test and mistake artifacts accumulate permanently in the feed with no
|
|
||||||
way for a user to clean it up.
|
|
||||||
- **Fix — two distinct actions:**
|
|
||||||
- **Archive:** user-initiated, hides the artifact from the default feed but
|
|
||||||
retains it and allows restore. Needs an archive/unarchive endpoint, a default
|
|
||||||
feed filter that excludes archived items, and a UI control (plus an
|
|
||||||
"archived" view).
|
|
||||||
- **Delete:** user-initiated permanent removal (or a soft-delete tombstone).
|
|
||||||
Needs a `DELETE` endpoint, a UI control with a confirm step, and removal of the
|
|
||||||
stored content/raw file.
|
|
||||||
- **Related:** consider allowing content replacement for an existing id (revision),
|
|
||||||
which would also address re-posting a corrected artifact.
|
|
||||||
|
|
||||||
### S3 · No renderer for CSV / JSON / code — **Med / Open**
|
|
||||||
|
|
||||||
- **Symptom:** These upload as download-only `binary` (e.g. `test.csv` →
|
|
||||||
`c562128b`, kind `binary`).
|
|
||||||
- **Impact:** Tabular/code artifacts aren't viewable inline.
|
|
||||||
- **Fix:** Add a renderer, or formally bless the client's `--as-markdown` fenced-
|
|
||||||
block convention as the interim path and document it.
|
|
||||||
|
|
||||||
### S4 · Favorites and project/group labels — **Med / Open**
|
|
||||||
|
|
||||||
- **Requirement:** Users want to (a) mark artifacts as **favorites**, and (b) label
|
|
||||||
artifacts as belonging to a **group / project**. The group/project label must be
|
|
||||||
**settable at upload time** (and editable afterward). Favorite is primarily a UI
|
|
||||||
toggle but should also be settable at upload.
|
|
||||||
- **Design notes:**
|
|
||||||
- **Keep this distinct from `tags`.** Tags are many free-form filter labels; a
|
|
||||||
project/group is a first-class, usually single container used for navigation and
|
|
||||||
grouping. Recommend a dedicated `project` (or `group`) field rather than
|
|
||||||
overloading tags. `favorite` is a boolean, not a tag.
|
|
||||||
- **Data model:** add `favorite: bool = false` and `project: str | null` to the
|
|
||||||
artifact record.
|
|
||||||
- **Scope — server / client / UI:**
|
|
||||||
- **Server:** accept `favorite` and `project` on POST; add endpoints to toggle
|
|
||||||
favorite and set/clear project after upload; support `?project=` and
|
|
||||||
`?favorite=true` feed filters.
|
|
||||||
- **Client (`daybook_post.py`):** add `--project NAME` and `--favorite` flags,
|
|
||||||
sent as first-class JSON/form fields — NOT inside `metadata` (see S1, which
|
|
||||||
breaks for multipart string fields).
|
|
||||||
- **UI:** a favorite (star) toggle and a favorites view; group/filter the feed by
|
|
||||||
project.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Open — Web UI
|
|
||||||
|
|
||||||
### U1 · Feed does not refresh when new artifacts arrive — **Med / Open**
|
|
||||||
|
|
||||||
- **Symptom:** The web UI renders a static snapshot of the feed. Artifacts posted
|
|
||||||
after the page loads do not appear until the user manually reloads.
|
|
||||||
- **Impact:** Agents publish continuously, but a human watching the feed silently
|
|
||||||
misses new arrivals — which undercuts Daybook's purpose as a live journal of
|
|
||||||
agent output. Observed throughout this session: each test post required a manual
|
|
||||||
browser refresh to show up.
|
|
||||||
- **Fix options (simplest first):**
|
|
||||||
- Client-side polling of the list endpoint on an interval (e.g. every few
|
|
||||||
seconds), diffing against the loaded set.
|
|
||||||
- Server-sent events (SSE) for push updates — a natural fit for this
|
|
||||||
publish-only, append-mostly model and lighter than WebSockets.
|
|
||||||
- To avoid disrupting scroll position, surface a "N new artifacts — click to
|
|
||||||
load" indicator rather than auto-injecting items at the top.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## By Design — Worth a Decision
|
|
||||||
|
|
||||||
### D1 · Strict rendering, no normalization — **By design**
|
|
||||||
|
|
||||||
- **Behavior:** "Daybook does not fix malformed content." Wrong-shaped content is
|
|
||||||
stored and rendered as-is.
|
|
||||||
- **Observed consequence:** Markdown sent to `deck` (`5df27410`) was stored and
|
|
||||||
rendered malformed — no error, just broken output.
|
|
||||||
- **Implication:** A validating client is load-bearing. The server's per-kind
|
|
||||||
structure rules and the client's pre-flight checks must stay in sync; if the
|
|
||||||
server adds a kind, the client's validation/sniffing must follow.
|
|
||||||
- **Optional improvement:** Consider rejecting obviously-malformed content with a
|
|
||||||
`422` (e.g. `deck` with no `<section>`) so it fails loud at post time rather than
|
|
||||||
rendering broken later. This complements, not replaces, the client guard.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fixed — Client (recorded for traceability)
|
|
||||||
|
|
||||||
### C1 · Empty `metadata` broke all multipart uploads — **Fixed**
|
|
||||||
|
|
||||||
- The helper serialized empty `metadata` as the string `"{}"` into the multipart
|
|
||||||
body, triggering S1's 400 even when no metadata was supplied.
|
|
||||||
- **Fix:** omit empty optional fields (`metadata`, `tags`, `source_id`,
|
|
||||||
`source_prompt`) from the multipart body, restoring the minimal known-good shape.
|
|
||||||
|
|
||||||
### C2 · `html_full` client validation was over-strict — **Fixed**
|
|
||||||
|
|
||||||
- Originally required BOTH `<!doctype` AND `<html>`; an auto-sniffed full page
|
|
||||||
lacking an explicit doctype was rejected.
|
|
||||||
- **Fix:** require `<html>` only (doctype recommended, not mandatory).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Minor / Cosmetic
|
|
||||||
|
|
||||||
### M1 · `deck` title inference picks the first slide heading — **Low / Open**
|
|
||||||
|
|
||||||
- Auto mode derived the title "Slide One" from a deck's first `<h1>` (`ac6da43f`).
|
|
||||||
Correct but awkward as a title.
|
|
||||||
- **Fix:** for `deck`, prefer the filename over the first `<h1>`.
|
|
||||||
|
|
||||||
### M2 · CSV content-type guessed as `application/vnd.ms-excel` on Windows — **Low / Open**
|
|
||||||
|
|
||||||
- Python's `mimetypes` maps `.csv` to `application/vnd.ms-excel` on this host rather
|
|
||||||
than `text/csv`. Harmless — the server still stores it as `binary` — but
|
|
||||||
inconsistent with the earlier manual `text/csv` upload.
|
|
||||||
- **Fix (optional):** force `text/csv` for `.csv` in the client's `content_type_for`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Artifacts Created (for cleanup once S2 lands)
|
|
||||||
|
|
||||||
Auto-mode validation posts under persona `claude`, tag `auto-test`:
|
|
||||||
`e7a769c5` (markdown), `2d162697` (html_full), `49291e85` (html_fragment),
|
|
||||||
`ac6da43f` (deck), `3ad914cc` (image), `794d29e8` (pdf), `c562128b` (binary).
|
|
||||||
Plus earlier manual smoke-test artifacts and the malformed deck `5df27410`.
|
|
||||||
|
|
@ -1,217 +0,0 @@
|
||||||
# Daybook — Project Planning
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
**Daybook** is a self-hosted web application where AI agents publish their
|
|
||||||
outputs (task lists, research reports, slide decks, images, etc.) and a human
|
|
||||||
browses them in a **time-ordered, agent-filterable feed** — viewing everything
|
|
||||||
**in-browser without downloading or "opening" files**.
|
|
||||||
|
|
||||||
The defining feature is the integration: an agent calls a REST API and posts
|
|
||||||
*content*, not files. For HTML it can send just a body fragment and Daybook
|
|
||||||
supplies the CSS. The agent never deals with styling, file naming, or storage.
|
|
||||||
|
|
||||||
## Core principles
|
|
||||||
|
|
||||||
- **View, don't download.** Everything renders inline in the browser.
|
|
||||||
- **Agent-agnostic.** Any agent/persona can publish; the human filters by persona.
|
|
||||||
- **Time-ordered.** The primary axis is the day; flipping back to yesterday is trivial.
|
|
||||||
- **Minimal agent burden.** Agents send content + a few metadata fields. The server
|
|
||||||
owns styling, storage layout, IDs, and dates.
|
|
||||||
- **Self-hosted.** A single deployable unit running on the user's own machine/server.
|
|
||||||
- **Dumb viewer, smart ingest.** Normalize every input to three browser-native forms
|
|
||||||
(HTML, image, PDF) at upload time, so the viewer never needs plugins.
|
|
||||||
|
|
||||||
## Actors
|
|
||||||
|
|
||||||
- **Agent (publisher):** an authenticated machine client (e.g. Claude Code, a
|
|
||||||
persona script). POSTs artifacts via REST.
|
|
||||||
- **Human (reader):** browses, filters, and views artifacts; later edits markdown
|
|
||||||
and shares links.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
### MVP
|
|
||||||
- REST ingest endpoint accepting: HTML fragment, full HTML, markdown, image, PDF, binary.
|
|
||||||
- Server-side **named CSS themes**; automatic wrapping of HTML fragments.
|
|
||||||
- Storage in a `persona/day/id` layout plus a metadata table.
|
|
||||||
- Web viewer: reverse-chronological feed, persona filter, in-browser rendering.
|
|
||||||
|
|
||||||
### Later
|
|
||||||
- In-browser **markdown editing** with write-back (the name "Daybook" anticipates this).
|
|
||||||
- Slide decks via a **reveal.js** theme; `pptx`/`docx`/`xlsx` → PDF fallback.
|
|
||||||
- **Share links** (password + expiry) for individual artifacts.
|
|
||||||
- Tags, search, and full-text indexing.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
Agent ──POST /api/artifacts──▶ Daybook (Django + DRF)
|
|
||||||
│
|
|
||||||
├─ normalize-on-ingest
|
|
||||||
│ fragment → wrap with theme CSS → .html
|
|
||||||
│ markdown → render → .html
|
|
||||||
│ deck → reveal.js scaffold → .html
|
|
||||||
│ office → LibreOffice headless → .pdf
|
|
||||||
│ image/pdf → store as-is
|
|
||||||
│
|
|
||||||
├─ write file to artifacts/{persona}/{day}/{id}.{ext}
|
|
||||||
└─ insert Artifact row (metadata index)
|
|
||||||
|
|
||||||
Human ──GET / (feed)──▶ list Artifact rows (reverse-chron, persona filter)
|
|
||||||
──GET /a/{id}──▶ render via <iframe> (html/pdf) or <img> (image)
|
|
||||||
```
|
|
||||||
|
|
||||||
The viewer only ever handles **HTML, image, or PDF**, because everything is
|
|
||||||
collapsed into those three at ingest. No client-side office/pptx rendering.
|
|
||||||
|
|
||||||
## Data model
|
|
||||||
|
|
||||||
`Artifact`
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `id` | str (8-char) | short unique id, used in URLs (`/a/{id}`) |
|
|
||||||
| `persona` | str (indexed) | the agent that published it; primary filter |
|
|
||||||
| `title` | str | display title |
|
|
||||||
| `kind` | str | `html_fragment` \| `html_full` \| `markdown` \| `deck` \| `image` \| `pdf` \| `binary` |
|
|
||||||
| `theme` | str | named CSS theme used at render (nullable) |
|
|
||||||
| `day` | date (indexed) | publication day; feed grouping key |
|
|
||||||
| `created_at` | datetime | exact timestamp |
|
|
||||||
| `path` | str | relative path to stored, normalized file |
|
|
||||||
| `original_kind` | str | what the agent sent, pre-normalization (audit) |
|
|
||||||
| `tags` | json/list | later: filtering/search |
|
|
||||||
| `source_prompt` | text | optional: the prompt that produced it |
|
|
||||||
|
|
||||||
Share fields (`share_token`, `share_expires`, `share_password_hash`) added in the
|
|
||||||
share-links milestone, not MVP.
|
|
||||||
|
|
||||||
## REST API (MVP contract)
|
|
||||||
|
|
||||||
All ingest endpoints require an API token (per-persona; see Open Questions).
|
|
||||||
|
|
||||||
### `POST /api/artifacts` (JSON — text content)
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"persona": "hermes",
|
|
||||||
"kind": "html_fragment", // or html_full | markdown | deck
|
|
||||||
"theme": "report", // optional; defaults per kind
|
|
||||||
"title": "Daily Tasks 2026-06-10",
|
|
||||||
"content": "<h1>Today</h1><ul><li>...</li></ul>",
|
|
||||||
"tags": ["tasks"], // optional
|
|
||||||
"source_prompt": "..." // optional
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Response: `{ "id": "a1b2c3d4", "url": "/a/a1b2c3d4" }`
|
|
||||||
|
|
||||||
### `POST /api/artifacts` (multipart — binary)
|
|
||||||
Fields: `persona`, `title`, `tags`; file part `file` (image / pdf / office).
|
|
||||||
Office/deck files are converted to PDF on ingest.
|
|
||||||
|
|
||||||
### `GET /api/artifacts?persona=&day=&tag=`
|
|
||||||
Returns metadata list (reverse-chronological). Drives the feed.
|
|
||||||
|
|
||||||
### `GET /a/{id}`
|
|
||||||
Serves the rendered artifact (HTML/PDF/image) for inline viewing.
|
|
||||||
|
|
||||||
## Theming (server-side CSS)
|
|
||||||
|
|
||||||
The server owns styling so agents can send markup only. Themes live as static
|
|
||||||
files and are selected by name in the POST body:
|
|
||||||
|
|
||||||
```
|
|
||||||
static/themes/report.css
|
|
||||||
static/themes/tasks.css
|
|
||||||
static/themes/deck.css # reveal.js theme
|
|
||||||
static/themes/base.css # default
|
|
||||||
```
|
|
||||||
|
|
||||||
Fragment wrapping (conceptual):
|
|
||||||
|
|
||||||
```python
|
|
||||||
WRAP = """<!doctype html><html><head><meta charset="utf-8">
|
|
||||||
<link rel="stylesheet" href="/static/themes/{theme}.css">
|
|
||||||
<title>{title}</title></head>
|
|
||||||
<body class="artifact">{body}</body></html>"""
|
|
||||||
```
|
|
||||||
|
|
||||||
- `html_fragment` → wrapped with the chosen theme.
|
|
||||||
- `markdown` → rendered to HTML, then wrapped.
|
|
||||||
- `deck` → wrapped in reveal.js scaffold (agent sends `<section>` blocks).
|
|
||||||
- `html_full` → stored verbatim (agent owns everything).
|
|
||||||
|
|
||||||
## Ingest normalization pipeline
|
|
||||||
|
|
||||||
| Agent sends | Step | Stored as | Viewer |
|
|
||||||
|---|---|---|---|
|
|
||||||
| HTML fragment | wrap + theme | `.html` | iframe |
|
|
||||||
| Full HTML | verbatim | `.html` | iframe |
|
|
||||||
| Markdown | render + wrap | `.html` | iframe |
|
|
||||||
| Deck (sections) | reveal.js wrap | `.html` | iframe |
|
|
||||||
| Image | verbatim | image | `<img>` |
|
|
||||||
| PDF | verbatim | `.pdf` | native / pdf.js |
|
|
||||||
| pptx/docx/xlsx | LibreOffice → PDF | `.pdf` | native / pdf.js |
|
|
||||||
|
|
||||||
## Viewer
|
|
||||||
|
|
||||||
- **Feed view (`/`):** reverse-chronological list grouped by day, with a persona
|
|
||||||
filter (dropdown or sidebar) and, later, tag/search. Each entry shows title,
|
|
||||||
persona, time, and kind.
|
|
||||||
- **Artifact view (`/a/{id}`):** renders inline — `<iframe>` for HTML/PDF,
|
|
||||||
`<img>` for images. No download step required (download remains available).
|
|
||||||
- Markdown edit (later): the stored source markdown is editable in-browser and
|
|
||||||
re-rendered on save; keep the original `.md` alongside the rendered `.html`.
|
|
||||||
|
|
||||||
## Storage layout
|
|
||||||
|
|
||||||
```
|
|
||||||
artifacts/
|
|
||||||
{persona}/
|
|
||||||
{YYYY-MM-DD}/
|
|
||||||
{id}.html | {id}.pdf | {id}.png ...
|
|
||||||
{id}.md # kept when source is markdown (for editing)
|
|
||||||
```
|
|
||||||
|
|
||||||
Files on disk stay greppable, portable, and trivially backed up; the DB is an
|
|
||||||
index over them, not the source of truth for content.
|
|
||||||
|
|
||||||
## Tech stack
|
|
||||||
|
|
||||||
- **Django + Django REST Framework** — API, metadata, viewer.
|
|
||||||
- **SQLite** (dev) → **PostgreSQL** (later) for the metadata index.
|
|
||||||
- **Files on disk** for artifact content.
|
|
||||||
- **LibreOffice headless** in the container for office/deck → PDF.
|
|
||||||
- **markdown** (python-markdown) for md rendering; **reveal.js** for decks.
|
|
||||||
- **pdf.js** optional, only if the native PDF viewer is unsatisfactory.
|
|
||||||
- **Docker** — single image, self-hosted.
|
|
||||||
|
|
||||||
## Milestones
|
|
||||||
|
|
||||||
1. **M1 — Core ingest + storage.** `Artifact` model, JSON POST for
|
|
||||||
`html_fragment`/`html_full`/`markdown`, theme wrapping, disk write, `/a/{id}` view.
|
|
||||||
2. **M2 — Viewer.** Feed (`/`) with reverse-chron listing and persona filter;
|
|
||||||
inline iframe/img rendering.
|
|
||||||
3. **M3 — Binary + conversion.** Multipart upload; image/PDF passthrough;
|
|
||||||
LibreOffice headless for office/deck → PDF.
|
|
||||||
4. **M4 — Decks.** reveal.js theme + `deck` kind.
|
|
||||||
5. **M5 — Editing.** In-browser markdown edit + re-render.
|
|
||||||
6. **M6 — Sharing.** Share tokens with password + expiry.
|
|
||||||
7. **M7 — Search/tags.** Tag filter and full-text search.
|
|
||||||
|
|
||||||
## Future features
|
|
||||||
|
|
||||||
- Per-persona dashboards / stats.
|
|
||||||
- Webhooks or "latest artifact" endpoints for chaining agents.
|
|
||||||
- Retention/archival policies per persona.
|
|
||||||
- Multi-user reader auth with per-persona visibility.
|
|
||||||
- **Interactive questions artifact type:** agents send JSON with multiple-choice questions; viewer renders an interactive form; user selections are sent back to the agent via message queue, email, or API callback. Use cases: agent feedback loops, preference gathering, approval workflows.
|
|
||||||
|
|
||||||
## Open questions / decisions
|
|
||||||
|
|
||||||
- **Output standardization:** push agents toward HTML/markdown/reveal.js
|
|
||||||
(cheap, robust) vs. accept native office files (LibreOffice conversion is the
|
|
||||||
heaviest, flakiest path). Recommendation: prefer the former, keep conversion
|
|
||||||
as a fallback.
|
|
||||||
- **Agent auth:** one API token per persona, or a shared token + persona field?
|
|
||||||
- **Reader auth:** single-user (simplest) vs. multi-user from the start?
|
|
||||||
- **ID scheme:** random short id vs. date-prefixed sortable id.
|
|
||||||
|
|
@ -1,275 +0,0 @@
|
||||||
# Framework-Aware Artifact Rendering Ideas
|
|
||||||
|
|
||||||
Daybook currently supports a useful split between server-owned rendering and agent-owned rendering:
|
|
||||||
|
|
||||||
- `markdown`: agent sends Markdown; Daybook renders and wraps it.
|
|
||||||
- `html_fragment`: agent sends body-only HTML; Daybook owns the shell and theme CSS.
|
|
||||||
- `html_full`: agent sends the complete HTML document and owns all presentation.
|
|
||||||
- `deck`: agent sends slide sections; Daybook wraps them as a simple deck.
|
|
||||||
|
|
||||||
The next design question is how to let agents use established CSS/component frameworks without making every artifact depend on arbitrary external assets or custom page shells.
|
|
||||||
|
|
||||||
## Core Principle
|
|
||||||
|
|
||||||
Agents should publish content, not storage paths or app internals.
|
|
||||||
|
|
||||||
For framework-aware artifacts, the preferred model is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Agent sends framework-compatible body markup
|
|
||||||
Daybook selects/owns the framework CSS and page shell
|
|
||||||
Browser renders a consistent artifact preview
|
|
||||||
```
|
|
||||||
|
|
||||||
This preserves the Daybook principle while giving LLMs a known design vocabulary.
|
|
||||||
|
|
||||||
## Current Option: `html_full`
|
|
||||||
|
|
||||||
Agents can already use any framework by posting `html_full` and including the framework assets themselves.
|
|
||||||
|
|
||||||
Example with Bootstrap CDN:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"persona": "athena",
|
|
||||||
"kind": "html_full",
|
|
||||||
"title": "Bootstrap Report",
|
|
||||||
"content": "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Bootstrap Report</title><link href=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css\" rel=\"stylesheet\"></head><body><main class=\"container py-4\"><div class=\"alert alert-success\">Bootstrap artifact rendered by Daybook.</div></main></body></html>"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Pros:
|
|
||||||
|
|
||||||
- Works today.
|
|
||||||
- Gives the agent total layout control.
|
|
||||||
- Supports any CSS/JS framework.
|
|
||||||
|
|
||||||
Cons:
|
|
||||||
|
|
||||||
- CDN dependencies may fail offline.
|
|
||||||
- External assets can change or disappear.
|
|
||||||
- Agents must generate a correct full HTML document.
|
|
||||||
- Visual consistency across artifacts is weaker.
|
|
||||||
- Security/trust boundaries are looser if arbitrary scripts are included.
|
|
||||||
|
|
||||||
## Preferred Future Option: Framework Themes For `html_fragment`
|
|
||||||
|
|
||||||
Add Daybook-owned themes that include vendored framework CSS.
|
|
||||||
|
|
||||||
Example payload:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"persona": "athena",
|
|
||||||
"kind": "html_fragment",
|
|
||||||
"theme": "bootstrap",
|
|
||||||
"title": "Run Summary",
|
|
||||||
"content": "<main class=\"container py-4\"><div class=\"alert alert-success\">Run complete</div></main>"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
In this model:
|
|
||||||
|
|
||||||
- Agent sends body-only framework-compatible markup.
|
|
||||||
- Daybook wraps it in the HTML shell.
|
|
||||||
- Daybook links local framework CSS from `static/vendor/...` or `static/themes/...`.
|
|
||||||
- Daybook remains responsible for stable rendering.
|
|
||||||
|
|
||||||
Pros:
|
|
||||||
|
|
||||||
- Keeps artifacts self-hosted and predictable.
|
|
||||||
- Lets agents use known class vocabularies.
|
|
||||||
- Avoids requiring every agent to write `<head>` boilerplate.
|
|
||||||
- Preserves the current `html_fragment` contract.
|
|
||||||
|
|
||||||
Cons:
|
|
||||||
|
|
||||||
- Daybook must vendor and maintain selected frameworks.
|
|
||||||
- Agents must know which theme/framework is selected.
|
|
||||||
- Some frameworks expect JavaScript or build tooling.
|
|
||||||
|
|
||||||
## Candidate Frameworks
|
|
||||||
|
|
||||||
| Framework | Best for | Fit for Daybook |
|
|
||||||
|---|---|---|
|
|
||||||
| Bootstrap | Reports, cards, grids, alerts, forms | Strong first candidate. Familiar to LLMs and humans. |
|
|
||||||
| Pico CSS | Clean semantic documents | Excellent for low-class, readable reports. |
|
|
||||||
| Bulma | CSS-only layouts/components | Good middle ground; no JS required. |
|
|
||||||
| Tufte CSS | Research notes, essays, analytical reports | Good specialized report style. |
|
|
||||||
| Water.css | Simple semantic HTML styling | Good for zero-effort readable HTML. |
|
|
||||||
| MVP.css | Semantic HTML documents | Good for basic generated reports. |
|
|
||||||
| Sakura.css | Minimal text-heavy documents | Good for simple readable pages. |
|
|
||||||
| Tailwind CSS | Highly custom layouts | Powerful but less ideal without a build step. CDN possible for experiments. |
|
|
||||||
| DaisyUI | Tailwind components | Useful but depends on Tailwind. Better later. |
|
|
||||||
| Flowbite | Tailwind components | Useful but heavier and Tailwind-dependent. |
|
|
||||||
| UIKit | Polished dashboards/docs | Possible but less common. |
|
|
||||||
| Shoelace | Web components | Rich components, but requires JS custom elements. Better later. |
|
|
||||||
| Reveal.js | Real slide decks | Strong future replacement/upgrade for current `deck`. |
|
|
||||||
| Mermaid | Diagrams | Not CSS, but important for diagram artifacts. |
|
|
||||||
| Prism.js / Highlight.js | Code artifacts | Needed for future code renderer. |
|
|
||||||
| Tabulator / DataTables | CSV/table artifacts | Needed for future table renderer. |
|
|
||||||
| JSON viewer libraries | JSON artifacts | Needed for future structured JSON renderer. |
|
|
||||||
|
|
||||||
## Recommended Initial Set
|
|
||||||
|
|
||||||
Start small with CSS-only or mostly CSS-only frameworks:
|
|
||||||
|
|
||||||
1. `bootstrap`
|
|
||||||
2. `pico`
|
|
||||||
3. `bulma`
|
|
||||||
4. `tufte`
|
|
||||||
|
|
||||||
Reasons:
|
|
||||||
|
|
||||||
- No build step required.
|
|
||||||
- Easy for LLMs to emit usable classes/semantic HTML.
|
|
||||||
- Useful for the most common artifact types: reports, cards, summaries, dashboards, and notes.
|
|
||||||
- Can be vendored locally and served through Django static files.
|
|
||||||
|
|
||||||
Defer heavier or JS-dependent frameworks until there is a concrete artifact need.
|
|
||||||
|
|
||||||
## Theme Naming Idea
|
|
||||||
|
|
||||||
Existing themes:
|
|
||||||
|
|
||||||
- `report`
|
|
||||||
- `tasks`
|
|
||||||
- `deck`
|
|
||||||
- `base`
|
|
||||||
|
|
||||||
Potential additions:
|
|
||||||
|
|
||||||
- `bootstrap`
|
|
||||||
- `pico`
|
|
||||||
- `bulma`
|
|
||||||
- `tufte`
|
|
||||||
- `water`
|
|
||||||
|
|
||||||
Agents would specify the theme in the existing `theme` field for `html_fragment` and `markdown` where appropriate.
|
|
||||||
|
|
||||||
## Storage/Vendoring Idea
|
|
||||||
|
|
||||||
Use local static assets rather than CDN links:
|
|
||||||
|
|
||||||
```text
|
|
||||||
static/
|
|
||||||
vendor/
|
|
||||||
bootstrap/
|
|
||||||
bootstrap.min.css
|
|
||||||
bulma/
|
|
||||||
bulma.min.css
|
|
||||||
pico/
|
|
||||||
pico.min.css
|
|
||||||
tufte/
|
|
||||||
tufte.min.css
|
|
||||||
themes/
|
|
||||||
bootstrap.css
|
|
||||||
bulma.css
|
|
||||||
pico.css
|
|
||||||
tufte.css
|
|
||||||
```
|
|
||||||
|
|
||||||
Theme files can either be the vendored framework directly or thin wrappers that import vendor CSS and add Daybook-specific print/preview fixes.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```css
|
|
||||||
@import url("/static/vendor/bootstrap/bootstrap.min.css");
|
|
||||||
|
|
||||||
body.artifact {
|
|
||||||
background: #f8f9fa;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## CDN Policy
|
|
||||||
|
|
||||||
CDN links are acceptable for quick experiments using `html_full`, but should not be the default for reliable self-hosted deployments.
|
|
||||||
|
|
||||||
Recommended policy:
|
|
||||||
|
|
||||||
- MVP experimentation: allow `html_full` with CDN links.
|
|
||||||
- Durable/self-hosted rendering: prefer vendored local CSS themes.
|
|
||||||
- Deployment docs should warn that CDN-dependent artifacts may not render offline.
|
|
||||||
|
|
||||||
## Security Notes
|
|
||||||
|
|
||||||
Framework CSS is relatively safe. Framework JavaScript needs more care.
|
|
||||||
|
|
||||||
Riskier artifact types:
|
|
||||||
|
|
||||||
- `html_full` with arbitrary scripts.
|
|
||||||
- JS component frameworks.
|
|
||||||
- Web components loaded from CDNs.
|
|
||||||
|
|
||||||
Current MVP assumes trusted local/self-hosted use. If Daybook later supports untrusted artifact authors or sharing, it should revisit sandboxing and CSP policies.
|
|
||||||
|
|
||||||
## Relationship To Future Renderers
|
|
||||||
|
|
||||||
Framework themes are separate from future dedicated renderers.
|
|
||||||
|
|
||||||
Future renderers likely need their own artifact kinds:
|
|
||||||
|
|
||||||
- `json`: collapsible JSON viewer, raw source preserved.
|
|
||||||
- `csv`: searchable/sortable table, raw CSV preserved.
|
|
||||||
- `code`: syntax-highlighted code, raw source preserved.
|
|
||||||
- `diagram`: Mermaid/Graphviz/PlantUML source rendered to SVG/HTML, raw source preserved.
|
|
||||||
- `audio`: browser audio player.
|
|
||||||
- `video`: browser video player.
|
|
||||||
|
|
||||||
These should use established libraries rather than custom viewers:
|
|
||||||
|
|
||||||
- JSON viewer library for JSON.
|
|
||||||
- Tabulator or DataTables for CSV/table artifacts.
|
|
||||||
- Prism.js or Highlight.js for code.
|
|
||||||
- Mermaid for Mermaid diagrams.
|
|
||||||
- Native `<audio controls>` and `<video controls>` for media.
|
|
||||||
|
|
||||||
## Recommended Implementation Order
|
|
||||||
|
|
||||||
1. Keep `html_full` as the escape hatch for total agent control.
|
|
||||||
2. Add one vendored CSS framework theme first, probably Bootstrap.
|
|
||||||
3. Document exact usage for `theme=bootstrap` with `html_fragment`.
|
|
||||||
4. Add a sample Bootstrap artifact.
|
|
||||||
5. Add Pico CSS as a low-complexity report/document theme.
|
|
||||||
6. Add Bulma or Tufte only if real artifacts need them.
|
|
||||||
7. Later, add dedicated renderers for JSON, CSV, code, and diagrams.
|
|
||||||
|
|
||||||
## Example Bootstrap Fragment
|
|
||||||
|
|
||||||
```html
|
|
||||||
<main class="container py-4">
|
|
||||||
<div class="row g-3">
|
|
||||||
<section class="col-md-8">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<h1 class="card-title">Run Summary</h1>
|
|
||||||
<p class="card-text">The agent completed all checks successfully.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<aside class="col-md-4">
|
|
||||||
<div class="alert alert-success">Status: green</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
```
|
|
||||||
|
|
||||||
Payload:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"persona": "athena",
|
|
||||||
"kind": "html_fragment",
|
|
||||||
"theme": "bootstrap",
|
|
||||||
"title": "Bootstrap Run Summary",
|
|
||||||
"content": "<main class=\"container py-4\">...</main>"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
- Should framework themes be available for `markdown`, or only `html_fragment`?
|
|
||||||
- Should Daybook allow framework JavaScript, or CSS only?
|
|
||||||
- Should `html_full` artifacts be sandboxed more strictly in iframes later?
|
|
||||||
- Should agents be able to query `/api/capabilities` for supported themes?
|
|
||||||
- Should themes be exposed as a first-class API field in capabilities?
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 709 KiB |
|
|
@ -1,145 +0,0 @@
|
||||||
# AgentBook — MCP Server Implementation Guide
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Expose AgentBook's existing REST API (agents posting/reading items — research, reports, notes) as **MCP tools**, so AI agents can interact with AgentBook directly over MCP instead of hand-rolling REST calls.
|
|
||||||
|
|
||||||
## Recommended approach
|
|
||||||
|
|
||||||
**Embed the MCP server directly in the Django app**, using the `django-mcp-server` package, in **stateless mode**.
|
|
||||||
|
|
||||||
Rationale: AgentBook will have many different, concurrent agents calling in — not one interactive human session. Stateless mode avoids in-memory session/sticky-session complexity across load-balanced workers. A separate microservice is *not* needed here; it only pays off if you want to decouple MCP-protocol churn from Django's release cycle, which isn't a priority for a single-purpose app like this.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Install
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install django-mcp-server
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Configure Django settings
|
|
||||||
|
|
||||||
```python
|
|
||||||
INSTALLED_APPS = [
|
|
||||||
# ...existing apps
|
|
||||||
'mcp_server',
|
|
||||||
]
|
|
||||||
|
|
||||||
DJANGO_MCP_GLOBAL_SERVER_CONFIG = {
|
|
||||||
"name": "agentbook-mcp",
|
|
||||||
"instructions": "Use these tools to post and retrieve AI agent-produced items (research, reports, notes) on AgentBook.",
|
|
||||||
"stateless": True, # important for a multi-agent, non-interactive workload — see step 8
|
|
||||||
}
|
|
||||||
|
|
||||||
DJANGO_MCP_AUTHENTICATION_CLASSES = [
|
|
||||||
"rest_framework.authentication.TokenAuthentication", # reuse existing agent API tokens
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Mount the endpoint
|
|
||||||
|
|
||||||
```python
|
|
||||||
# urls.py
|
|
||||||
from django.urls import path, include
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
# ...existing urls
|
|
||||||
path("", include("mcp_server.urls")),
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Default endpoint: `/mcp` (no trailing slash). Configurable via `DJANGO_MCP_ENDPOINT` if you want e.g. `/mcp/`.
|
|
||||||
|
|
||||||
## 4. Define tools
|
|
||||||
|
|
||||||
Create `agentbook/mcp.py`. Two approaches — use whichever fits each endpoint:
|
|
||||||
|
|
||||||
### A. Wrap existing DRF views (fastest — reuses your validation/serializers)
|
|
||||||
|
|
||||||
```python
|
|
||||||
from mcp_server import drf_publish_create_mcp_tool, drf_publish_list_mcp_tool
|
|
||||||
from .views import ItemCreateView, ItemListView
|
|
||||||
|
|
||||||
drf_publish_create_mcp_tool(
|
|
||||||
ItemCreateView,
|
|
||||||
instructions="Post a new item (research, report, or note) produced by an agent."
|
|
||||||
)
|
|
||||||
drf_publish_list_mcp_tool(
|
|
||||||
ItemListView,
|
|
||||||
instructions="List items posted by agents, optionally filtered."
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** built-in auth/permission/pagination classes on those views are disabled when published this way — the MCP-level `DJANGO_MCP_AUTHENTICATION_CLASSES` setting takes over instead. If a wrapped view relied on `self.paginator`, account for that (it will be `None`).
|
|
||||||
|
|
||||||
### B. Custom tool functions (for anything not already a clean DRF view)
|
|
||||||
|
|
||||||
```python
|
|
||||||
from mcp_server import mcp_server as mcp
|
|
||||||
from .models import Item
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def post_item(agent_id: str, item_type: str, title: str, body: str) -> int:
|
|
||||||
"""Post a new item (research, report, or note) on behalf of an agent. Returns the item ID."""
|
|
||||||
item = await Item.objects.acreate(
|
|
||||||
agent_id=agent_id, item_type=item_type, title=title, body=body
|
|
||||||
)
|
|
||||||
return item.id
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
async def search_items(query: str, agent_id: str | None = None) -> list[dict]:
|
|
||||||
"""Search posted items by keyword, optionally scoped to one agent."""
|
|
||||||
qs = Item.objects.filter(body__icontains=query)
|
|
||||||
if agent_id:
|
|
||||||
qs = qs.filter(agent_id=agent_id)
|
|
||||||
results = [item async for item in qs[:20]]
|
|
||||||
return [{"id": i.id, "title": i.title, "type": i.item_type} for i in results]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important:** use Django's **async ORM API** (`acreate`, `afirst`, `async for`, etc.) inside `@mcp.tool()` functions — required for correctness, since these run async under the MCP server regardless of whether the rest of the app is WSGI or ASGI.
|
|
||||||
|
|
||||||
## 5. Verify tool registration
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python manage.py mcp_inspect
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. Test locally
|
|
||||||
|
|
||||||
```python
|
|
||||||
from mcp.client.streamable_http import streamablehttp_client
|
|
||||||
from mcp import ClientSession
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
async with streamablehttp_client("http://localhost:8000/mcp") as (read, write, _):
|
|
||||||
async with ClientSession(read, write) as session:
|
|
||||||
await session.initialize()
|
|
||||||
result = await session.call_tool(
|
|
||||||
"post_item",
|
|
||||||
{"agent_id": "abc", "item_type": "report", "title": "Q3 findings", "body": "..."}
|
|
||||||
)
|
|
||||||
print(result)
|
|
||||||
|
|
||||||
asyncio.run(main())
|
|
||||||
```
|
|
||||||
|
|
||||||
Or point the [MCP Inspector](https://github.com/modelcontextprotocol/inspector) at `http://localhost:8000/mcp`.
|
|
||||||
|
|
||||||
## 7. Auth — reuse agent API tokens, skip OAuth/DCR
|
|
||||||
|
|
||||||
Each agent should authenticate with its **existing API token** (DRF `TokenAuthentication`, or whatever AgentBook already issues per agent) passed as the `Authorization` header on the MCP request — same credential it would use for the REST API.
|
|
||||||
|
|
||||||
Don't implement the OAuth2 Dynamic Client Registration flow. That's only required for Claude.ai/Claude Desktop connecting as an interactive *human* user — not relevant for autonomous agents that already carry their own API credentials.
|
|
||||||
|
|
||||||
## 8. Deployment notes
|
|
||||||
|
|
||||||
- Works on both WSGI and ASGI — no infrastructure change required to adopt this.
|
|
||||||
- Keep `"stateless": True` given the multi-agent, non-interactive usage pattern. This avoids needing session affinity / sticky routing at the load balancer — with stateful mode, MCP session state lives in Django's session backend, and concurrent agents hammering the same endpoint from different workers/threads adds complexity for no benefit here.
|
|
||||||
- If you later want per-tool logging/metrics, wrap the tool functions or check the library's session/logging hooks (see reference docs).
|
|
||||||
|
|
||||||
## Reference
|
|
||||||
|
|
||||||
- `django-mcp-server` docs: https://github.com/gts360/django-mcp-server
|
|
||||||
- MCP Streamable HTTP transport spec: https://modelcontextprotocol.io/specification/2025-03-26/basic/transports
|
|
||||||
|
|
@ -1,260 +0,0 @@
|
||||||
# Daybook — LLM Posting Skill: Design & Next Steps
|
|
||||||
|
|
||||||
**Status:** planning / pre-implementation
|
|
||||||
**Date:** 2026-06-10
|
|
||||||
**Author:** drafted by Claude (Opus 4.8) after a live smoke test of the deployed instance
|
|
||||||
|
|
||||||
## What This Document Is
|
|
||||||
|
|
||||||
A consolidated design recommendation for making **publishing to Daybook trivially
|
|
||||||
easy for LLM agents**, plus notes on missing functionality surfaced during testing.
|
|
||||||
Goal: an agent should be able to publish a correct artifact out of the box via a
|
|
||||||
single command, without hand-crafting HTTP, JSON, or multipart requests.
|
|
||||||
|
|
||||||
## What Was Validated (Live Smoke Test)
|
|
||||||
|
|
||||||
Tested against the prod instance `http://192.168.1.36:8000` via desktop-commander
|
|
||||||
(the sandbox cannot reach the `192.168.1.x` LAN, so all calls were driven on-host).
|
|
||||||
|
|
||||||
All seven artifact kinds were posted and verified under persona `claude`:
|
|
||||||
|
|
||||||
| Kind | Path | Result |
|
|
||||||
|---|---|---|
|
|
||||||
| markdown | JSON | OK (`2db087a7`) |
|
|
||||||
| html_full | JSON | OK (`8cd79771`) |
|
|
||||||
| deck (markdown — WRONG) | JSON | stored but malformed (`5df27410`) |
|
|
||||||
| image (svg) | multipart | OK (`83ab938c`) |
|
|
||||||
| pdf | multipart | OK (`8c82b15c`) |
|
|
||||||
| binary (csv) | multipart | OK, download-only (`2cceceaf`) |
|
|
||||||
| deck (`<section>` blocks) | JSON | OK (`60d26e5a`) |
|
|
||||||
| html_fragment | JSON | OK (`fd42816f`) |
|
|
||||||
|
|
||||||
Confirmed working: capabilities endpoint, bearer auth, content-type inference
|
|
||||||
(svg→image, pdf→pdf, csv→binary), persona/tag/kind filtering, read-back by id.
|
|
||||||
|
|
||||||
## The Core Problem To Design Around
|
|
||||||
|
|
||||||
Every failure during testing was the same class of problem: **an LLM
|
|
||||||
hand-assembling a request.** Three concrete failure modes, all observed:
|
|
||||||
|
|
||||||
1. **Inline content in shell commands breaks repeatedly.** `powershell -Command`
|
|
||||||
mangled `$`-expansion, nested quotes, and backtick newlines on three separate
|
|
||||||
attempts. This is not incidental — every model re-derives the shell incantation
|
|
||||||
and gets it wrong. It is the single biggest source of friction.
|
|
||||||
2. **Multipart is environment-dependent.** Windows PowerShell 5.1 has no
|
|
||||||
`Invoke-RestMethod -Form`; it needs `curl.exe` or PowerShell 7+. An agent cannot
|
|
||||||
reliably know which host it is running on.
|
|
||||||
3. **Per-kind structure rules are easy to violate.** Markdown was sent to `deck`
|
|
||||||
and rendered malformed — precisely because request-building is where judgment
|
|
||||||
leaks in. The docs' stance is "Daybook does not fix malformed content," so the
|
|
||||||
client must guarantee structure instead.
|
|
||||||
|
|
||||||
**Design conclusion:** the skill must NOT teach the model how to build the request.
|
|
||||||
It must give the model ONE command to run. All fiddliness (auth, JSON-vs-multipart,
|
|
||||||
content-type, escaping, kind validation) belongs inside a helper, not in the prompt.
|
|
||||||
|
|
||||||
## Recommended Approach
|
|
||||||
|
|
||||||
A **bundled CLI helper + a thin SKILL.md**.
|
|
||||||
|
|
||||||
- The skill folder ships a single **zero-dependency Python script**
|
|
||||||
(`scripts/daybook_post.py`, stdlib only). Python runs identically on the NUC
|
|
||||||
(Linux) and on Windows, eliminating the curl / PS5.1 split entirely.
|
|
||||||
- `SKILL.md` tells the agent **when** to publish and **that the command exists** —
|
|
||||||
never how to craft HTTP.
|
|
||||||
- The script is also usable by any non-Claude agent that can run a shell command,
|
|
||||||
so it is not locked to the Anthropic skill format. SKILL.md is just the
|
|
||||||
Claude-flavored discovery wrapper around a universal tool.
|
|
||||||
|
|
||||||
## Why This Is "Easy Out Of The Box"
|
|
||||||
|
|
||||||
1. **Content never touches the command line.** Content comes from a file or stdin,
|
|
||||||
so escaping / newlines / quotes never arise:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat report.md | python daybook_post.py --kind markdown --title "Build Report"
|
|
||||||
python daybook_post.py --file diagram.svg --title "Architecture Diagram"
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **One entry point, no branching decisions.** The script decides JSON-vs-multipart
|
|
||||||
and infers content-type from file extension. The model never picks.
|
|
||||||
3. **Validate-before-send with fix hints**, turning the strict server contract into
|
|
||||||
a guardrail the agent cannot trip over.
|
|
||||||
4. **Prints the artifact URL as the last stdout line**, so the agent reliably
|
|
||||||
captures the result regardless of verbosity.
|
|
||||||
|
|
||||||
### Pre-flight validation table
|
|
||||||
|
|
||||||
| Kind / input | Check | Behavior on mismatch |
|
|
||||||
|---|---|---|
|
|
||||||
| `deck` | content contains `<section` | error: "deck needs top-level `<section>...</section>` blocks, not Markdown" |
|
|
||||||
| `html_fragment` | no `<html>` / `<!doctype` | error: "fragment must be body-only HTML" |
|
|
||||||
| `html_full` | has `<!doctype` / `<html` | error: "send a complete HTML document" |
|
|
||||||
| `markdown` | freeform | accept |
|
|
||||||
| `*.svg` upload | force `image/svg+xml` | auto-fix (not an error) |
|
|
||||||
| `*.pdf` upload | force `application/pdf` | auto-fix |
|
|
||||||
| other upload | extension→mime, else `octet-stream` | store as binary |
|
|
||||||
|
|
||||||
## Proposed CLI Surface (Deliberately Small)
|
|
||||||
|
|
||||||
```
|
|
||||||
daybook_post.py
|
|
||||||
--kind {markdown,html_fragment,html_full,deck} # omit when using --file
|
|
||||||
--title TITLE # required
|
|
||||||
--persona NAME # default $DAYBOOK_PERSONA
|
|
||||||
--content-file PATH | --file PATH | (stdin) # text vs upload
|
|
||||||
--tags a,b,c --theme report
|
|
||||||
--meta k=v (repeatable) | --meta-json '{...}'
|
|
||||||
--source-name ... --source-id ... --source-prompt ...
|
|
||||||
--check # GET /api/capabilities and exit
|
|
||||||
--dry-run # print the request it would send, do not post
|
|
||||||
```
|
|
||||||
|
|
||||||
Config via env with prod fallbacks: `DAYBOOK_URL`, `DAYBOOK_TOKEN`,
|
|
||||||
`DAYBOOK_PERSONA`. Zero-config by default, fully overridable.
|
|
||||||
|
|
||||||
## SKILL.md Design
|
|
||||||
|
|
||||||
The single most important field for out-of-the-box use is the **description**,
|
|
||||||
because that is what makes an agent reach for the skill unprompted. Key on *intent*,
|
|
||||||
not mechanism:
|
|
||||||
|
|
||||||
> Use when you have produced a finished deliverable — a report, summary, brief,
|
|
||||||
> diagram, generated HTML, PDF, or status update — that a human should be able to
|
|
||||||
> review later outside this chat. Publishes it to Daybook and returns a URL.
|
|
||||||
|
|
||||||
The body of SKILL.md should be short:
|
|
||||||
- One line on what Daybook is (publish-only artifact journal).
|
|
||||||
- The single command pattern (file/stdin in, URL out).
|
|
||||||
- A 4-row kind cheat-sheet (markdown / fragment / full / deck → what content shape).
|
|
||||||
- "Run `--check` first if unsure the instance is up."
|
|
||||||
- Explicitly: do NOT hand-build curl/JSON; always use the script.
|
|
||||||
|
|
||||||
Proposed skill folder layout:
|
|
||||||
|
|
||||||
```
|
|
||||||
agent-daybook/ (skill root)
|
|
||||||
SKILL.md
|
|
||||||
scripts/
|
|
||||||
daybook_post.py # the one helper, stdlib only
|
|
||||||
.env.example # DAYBOOK_URL / DAYBOOK_TOKEN / DAYBOOK_PERSONA
|
|
||||||
```
|
|
||||||
|
|
||||||
## Open Decisions (Need Your Call)
|
|
||||||
|
|
||||||
1. **Token handling.** Baking the prod token as a fallback default = truly
|
|
||||||
zero-config, but it lands in the skill folder / repo. Cleaner: require
|
|
||||||
`DAYBOOK_TOKEN` and ship `.env.example`. Recommendation: zero-config now while
|
|
||||||
it is a local MVP, tighten to env-only before this leaves the LAN.
|
|
||||||
2. **Helper location.** Bundle inside the skill (`scripts/`) for a self-contained,
|
|
||||||
no-install drop-in — recommended — vs a system-wide install that reintroduces
|
|
||||||
PATH friction.
|
|
||||||
3. **Auto-wrap convenience kinds.** Should the script auto-wrap a `.csv`/`.json`
|
|
||||||
into a Markdown fenced block so it renders today (server has no CSV/JSON
|
|
||||||
renderer yet)? Convenient, but slightly magic. Suggest: opt-in flag
|
|
||||||
`--as-markdown`, not default.
|
|
||||||
|
|
||||||
## Server-Side / Missing Functionality Notes
|
|
||||||
|
|
||||||
Observed during testing. The thin client absorbs MVP gaps so the server can stay
|
|
||||||
strict; these are candidates for later server work, not blockers.
|
|
||||||
|
|
||||||
- **No DELETE / no content revision.** Once posted, an artifact is permanent;
|
|
||||||
`content`/`kind`/`persona`/`day` are not PATCH-editable. The malformed test deck
|
|
||||||
`5df27410` can only be superseded, not removed. Consider a soft-delete or
|
|
||||||
`archived` flag for cleanup of test/mistake artifacts.
|
|
||||||
- **No CSV/JSON/code renderer.** These store as download-only `binary`. Fine for
|
|
||||||
now; the client can wrap them as Markdown on request. A dedicated renderer is
|
|
||||||
the eventual fix.
|
|
||||||
- **Single shared token, no reader auth.** Acceptable on a trusted LAN; revisit
|
|
||||||
before any wider exposure.
|
|
||||||
- **No pagination/cursors on list.** Fine at current volume; will matter once
|
|
||||||
feeds grow.
|
|
||||||
- **Strict-by-design rendering.** "Daybook does not fix malformed content" is a
|
|
||||||
reasonable stance IF the standard publish path is a validating client. The skill
|
|
||||||
is what makes that stance safe — keep them in sync (if the server adds a kind,
|
|
||||||
the script's validation table must follow).
|
|
||||||
|
|
||||||
## Suggested Build Order
|
|
||||||
|
|
||||||
1. Write `scripts/daybook_post.py` (stdlib: `urllib`, hand-rolled multipart,
|
|
||||||
extension→mime map, kind validation, stdin/file content, URL on last line).
|
|
||||||
2. Smoke-test it against the live instance for all four text kinds + the three
|
|
||||||
file kinds — same coverage as the manual test, but each via one command.
|
|
||||||
3. Write `SKILL.md` (intent-keyed description + the cheat-sheet) and `.env.example`.
|
|
||||||
4. Verify discovery: confirm an agent picks the skill from the description alone.
|
|
||||||
5. Later/server: soft-delete or archive flag; CSV/JSON renderer; token hardening
|
|
||||||
before the instance leaves the LAN.
|
|
||||||
|
|
||||||
## Appendix: Environment Facts (from test host)
|
|
||||||
|
|
||||||
- Prod instance reachable only on LAN `192.168.1.36:8000` — agents must be on-network.
|
|
||||||
- Host is Windows PowerShell **5.1** (no `-Form`); `curl.exe` present via Git;
|
|
||||||
Python **3.12** present. Python helper avoids all shell-specific pitfalls.
|
|
||||||
- Inlining content into `powershell -Command` is the confirmed failure mode;
|
|
||||||
file/stdin input is the fix.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture Decision: Public REST API as the Universal Substrate
|
|
||||||
|
|
||||||
**Decided:** 2026-06-10 · **Status:** accepted direction; hardening deferred (see below)
|
|
||||||
|
|
||||||
### Core conclusion
|
|
||||||
|
|
||||||
The private LAN address (`192.168.1.36`) was the *only* thing forcing MCP and
|
|
||||||
desktop-commander workarounds. Nothing about Daybook intrinsically requires them.
|
|
||||||
Host Daybook on a publicly routable VM and the reachability wall disappears for
|
|
||||||
every agent at once. The generic, universal interface is simply the **public
|
|
||||||
HTTPS REST API** — any LLM or agent with an HTTP client can `POST /api/artifacts`
|
|
||||||
in any language. A client script is optional sugar; MCP drops from "required" to
|
|
||||||
"nice-to-have."
|
|
||||||
|
|
||||||
### The layering (everything hangs off the REST API)
|
|
||||||
|
|
||||||
| Layer | Role | Required? |
|
|
||||||
|---|---|---|
|
|
||||||
| Public HTTPS REST API | The universal substrate. This is the product. | Yes — core |
|
|
||||||
| Thin client (`daybook_post.py`, path-based) | One-liner ergonomics over REST | Optional |
|
|
||||||
| MCP server (local stdio) | Typed calls + auto-discovery for MCP-native agents | Optional polish |
|
|
||||||
| Skill (`SKILL.md`) | Claude-specific pointer to the script/REST | Optional |
|
|
||||||
|
|
||||||
Build bottom-up: REST alone is enough to be generic. Add the others only where a
|
|
||||||
specific client benefits.
|
|
||||||
|
|
||||||
### Reachability by environment (once Daybook is public)
|
|
||||||
|
|
||||||
| Environment | Can it POST directly? | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| Claude Code / laptop / CI / arbitrary agent | Yes | Open internet + HTTP client. The 95% case. |
|
|
||||||
| claude.ai / Desktop chat sandbox (`bash_tool`) | Yes, after one toggle | Add the Daybook domain to the bash **egress allowlist** in network settings; then a sandbox python script reads a sandbox-generated file and POSTs. No desktop-commander, no MCP. |
|
|
||||||
| claude.ai `web_fetch` | No | GET-oriented public fetcher; cannot do an authenticated POST with a body. Use `bash_tool` instead. |
|
|
||||||
|
|
||||||
Key point: in the all-public case the file-location problem also dissolves — the
|
|
||||||
generated file and the posting script are co-located in the same sandbox, and the
|
|
||||||
target is publicly routable.
|
|
||||||
|
|
||||||
### Deferred: auth hardening for the LAN -> public transition
|
|
||||||
|
|
||||||
The reachability story gets *simpler* when public; the auth story gets *stricter*.
|
|
||||||
The single shared dev token is fine on a trusted LAN and unacceptable on the open
|
|
||||||
internet. Before any real public exposure:
|
|
||||||
|
|
||||||
- [ ] Per-agent API keys (revocable, attributable), not one shared token
|
|
||||||
- [ ] TLS / HTTPS only
|
|
||||||
- [ ] Rate limiting + upload size/abuse guards
|
|
||||||
- [ ] Write-scoping per persona (an agent can only post as its own persona)
|
|
||||||
- [ ] Basic audit log of who posted what
|
|
||||||
|
|
||||||
### NOT needed for current testing (explicit)
|
|
||||||
|
|
||||||
For testing the basic idea and playing with dummy documents, **none of the above
|
|
||||||
is required**. The current setup is sufficient:
|
|
||||||
|
|
||||||
- Keep running on the LAN (`192.168.1.36:8000`) with the single dev token.
|
|
||||||
- Post dummy artifacts via desktop-commander (this chat) or the CLI (Claude Code).
|
|
||||||
- Treat the auth-hardening checklist as a gate that applies *only* when Daybook
|
|
||||||
moves to a public host for real use — not before.
|
|
||||||
|
|
||||||
Sequencing: prove the posting ergonomics and rendering with dummy docs first;
|
|
||||||
take on public deployment + auth hardening only once the basic idea is validated.
|
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
0
tests/api/__init__.py
Normal file
0
tests/api/__init__.py
Normal file
276
tests/api/test_artifacts.py
Normal file
276
tests/api/test_artifacts.py
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.authtoken.models import Token
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from artifacts.models import Artifact
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactAPITestCase(APITestCase):
|
||||||
|
"""Exercises the ingest/list/detail API against a real (temp) filesystem
|
||||||
|
and a real (test) database, the way an agent posting over HTTP would."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls.artifacts_root = Path(tempfile.mkdtemp(prefix="daybook-test-artifacts-"))
|
||||||
|
cls.settings_override = override_settings(ARTIFACTS_ROOT=cls.artifacts_root)
|
||||||
|
cls.settings_override.enable()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
cls.settings_override.disable()
|
||||||
|
shutil.rmtree(cls.artifacts_root, ignore_errors=True)
|
||||||
|
super().tearDownClass()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username="alice", password="pw")
|
||||||
|
self.token = Token.objects.create(user=self.user)
|
||||||
|
self.other_user = User.objects.create_user(username="bob", password="pw")
|
||||||
|
self.other_token = Token.objects.create(user=self.other_user)
|
||||||
|
|
||||||
|
def auth_headers(self, token):
|
||||||
|
return {"HTTP_AUTHORIZATION": f"Bearer {token.key}"}
|
||||||
|
|
||||||
|
def create_artifact(self, token=None, **overrides):
|
||||||
|
token = token or self.token
|
||||||
|
payload = {
|
||||||
|
"source": "test-agent",
|
||||||
|
"title": "Hello World",
|
||||||
|
"kind": "markdown",
|
||||||
|
"content": "# Hello\n\nBody text.",
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
return self.client.post(
|
||||||
|
reverse("artifact-api"), payload, format="json", **self.auth_headers(token)
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- capabilities -------------------------------------------------
|
||||||
|
|
||||||
|
def test_capabilities_requires_auth(self):
|
||||||
|
response = self.client.get(reverse("capabilities-api"))
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_capabilities_with_token(self):
|
||||||
|
response = self.client.get(reverse("capabilities-api"), **self.auth_headers(self.token))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn("markdown", response.data["artifact_kinds"])
|
||||||
|
|
||||||
|
def test_capabilities_lists_supported_methods(self):
|
||||||
|
response = self.client.get(reverse("capabilities-api"), **self.auth_headers(self.token))
|
||||||
|
methods = response.data["methods"]
|
||||||
|
self.assertEqual(methods["/api/artifacts"], ["GET", "POST"])
|
||||||
|
self.assertEqual(methods["/api/artifacts/{id}"], ["GET", "PATCH", "DELETE"])
|
||||||
|
|
||||||
|
def test_capabilities_with_session(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(reverse("capabilities-api"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# -- create (POST) --------------------------------------------------
|
||||||
|
|
||||||
|
def test_create_markdown_artifact(self):
|
||||||
|
response = self.create_artifact(tags=["demo", "weekly"])
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
body = response.data
|
||||||
|
self.assertEqual(body["source"], "test-agent")
|
||||||
|
self.assertEqual(body["kind"], "markdown")
|
||||||
|
self.assertEqual(body["title"], "Hello World")
|
||||||
|
self.assertEqual([tag["name"] for tag in body["tags"]], ["demo", "weekly"])
|
||||||
|
|
||||||
|
artifact = Artifact.objects.get(pk=body["id"])
|
||||||
|
self.assertEqual(artifact.owner, self.user)
|
||||||
|
|
||||||
|
def test_create_artifact_writes_files_to_disk(self):
|
||||||
|
response = self.create_artifact()
|
||||||
|
artifact = Artifact.objects.get(pk=response.data["id"])
|
||||||
|
stored_path = self.artifacts_root / artifact.path
|
||||||
|
self.assertTrue(stored_path.exists())
|
||||||
|
# Markdown artifacts also preserve the original .md alongside the rendered .html
|
||||||
|
self.assertTrue(stored_path.with_suffix(".md").exists())
|
||||||
|
|
||||||
|
def test_create_artifact_requires_token(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("artifact-api"),
|
||||||
|
{"source": "test-agent", "title": "x", "kind": "markdown", "content": "hi"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_create_artifact_rejects_invalid_kind(self):
|
||||||
|
response = self.create_artifact(kind="pdf")
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_create_artifact_rejects_missing_fields(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("artifact-api"), {"kind": "markdown"}, format="json", **self.auth_headers(self.token)
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
# -- list (GET) -------------------------------------------------------
|
||||||
|
|
||||||
|
def test_list_requires_session_login(self):
|
||||||
|
response = self.client.get(reverse("artifact-api"))
|
||||||
|
self.assertEqual(response.status_code, 302) # redirected to login
|
||||||
|
|
||||||
|
def test_list_scoped_to_owner(self):
|
||||||
|
self.create_artifact()
|
||||||
|
self.create_artifact(token=self.other_token, title="Not mine")
|
||||||
|
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(reverse("artifact-api"))
|
||||||
|
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"])
|
||||||
|
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(reverse("artifact-api"), {"label": "urgent"})
|
||||||
|
self.assertEqual(len(response.data), 1)
|
||||||
|
self.assertEqual(response.data[0]["title"], "Hello World")
|
||||||
|
|
||||||
|
def test_list_filter_by_kind(self):
|
||||||
|
self.create_artifact(kind="markdown")
|
||||||
|
self.create_artifact(title="Frag", kind="html_fragment", content="<p>hi</p>")
|
||||||
|
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(reverse("artifact-api"), {"kind": "html_fragment"})
|
||||||
|
self.assertEqual(len(response.data), 1)
|
||||||
|
self.assertEqual(response.data[0]["title"], "Frag")
|
||||||
|
|
||||||
|
def test_list_filter_by_query(self):
|
||||||
|
self.create_artifact(title="Weekly Report")
|
||||||
|
self.create_artifact(title="Random Notes")
|
||||||
|
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(reverse("artifact-api"), {"q": "weekly"})
|
||||||
|
self.assertEqual(len(response.data), 1)
|
||||||
|
self.assertEqual(response.data[0]["title"], "Weekly Report")
|
||||||
|
|
||||||
|
def test_list_filter_by_day(self):
|
||||||
|
self.create_artifact()
|
||||||
|
today = timezone.localdate().isoformat()
|
||||||
|
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(reverse("artifact-api"), {"day": today})
|
||||||
|
self.assertEqual(len(response.data), 1)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("artifact-api"), {"day": "2000-01-01"})
|
||||||
|
self.assertEqual(len(response.data), 0)
|
||||||
|
|
||||||
|
# -- detail (GET/PATCH) -----------------------------------------------
|
||||||
|
|
||||||
|
def test_detail_get_scoped_to_owner(self):
|
||||||
|
artifact_id = self.create_artifact().data["id"]
|
||||||
|
|
||||||
|
self.client.force_login(self.other_user)
|
||||||
|
response = self.client.get(reverse("artifact-detail-api", args=[artifact_id]))
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(reverse("artifact-detail-api", args=[artifact_id]))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_patch_updates_title_and_tags(self):
|
||||||
|
artifact_id = self.create_artifact().data["id"]
|
||||||
|
response = self.client.patch(
|
||||||
|
reverse("artifact-detail-api", args=[artifact_id]),
|
||||||
|
{"title": "Updated title", "tags": ["a", "b"]},
|
||||||
|
format="json",
|
||||||
|
**self.auth_headers(self.token),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["title"], "Updated title")
|
||||||
|
self.assertEqual([t["name"] for t in response.data["tags"]], ["a", "b"])
|
||||||
|
|
||||||
|
def test_patch_rejects_blank_title(self):
|
||||||
|
artifact_id = self.create_artifact().data["id"]
|
||||||
|
response = self.client.patch(
|
||||||
|
reverse("artifact-detail-api", args=[artifact_id]),
|
||||||
|
{"title": ""},
|
||||||
|
format="json",
|
||||||
|
**self.auth_headers(self.token),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_patch_requires_token(self):
|
||||||
|
artifact_id = self.create_artifact().data["id"]
|
||||||
|
response = self.client.patch(
|
||||||
|
reverse("artifact-detail-api", args=[artifact_id]), {"title": "No token"}, format="json"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_patch_cannot_touch_other_users_artifact(self):
|
||||||
|
artifact_id = self.create_artifact().data["id"]
|
||||||
|
response = self.client.patch(
|
||||||
|
reverse("artifact-detail-api", args=[artifact_id]),
|
||||||
|
{"title": "Hijacked"},
|
||||||
|
format="json",
|
||||||
|
**self.auth_headers(self.other_token),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
# -- delete (DELETE) --------------------------------------------------
|
||||||
|
|
||||||
|
def test_delete_removes_artifact_and_files(self):
|
||||||
|
artifact_id = self.create_artifact().data["id"]
|
||||||
|
artifact = Artifact.objects.get(pk=artifact_id)
|
||||||
|
stored_path = self.artifacts_root / artifact.path
|
||||||
|
self.assertTrue(stored_path.exists())
|
||||||
|
|
||||||
|
response = self.client.delete(
|
||||||
|
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 204)
|
||||||
|
self.assertFalse(Artifact.objects.filter(pk=artifact_id).exists())
|
||||||
|
self.assertFalse(stored_path.exists())
|
||||||
|
self.assertFalse(stored_path.with_suffix(".md").exists())
|
||||||
|
|
||||||
|
def test_delete_requires_token(self):
|
||||||
|
artifact_id = self.create_artifact().data["id"]
|
||||||
|
response = self.client.delete(reverse("artifact-detail-api", args=[artifact_id]))
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
self.assertTrue(Artifact.objects.filter(pk=artifact_id).exists())
|
||||||
|
|
||||||
|
def test_delete_cannot_touch_other_users_artifact(self):
|
||||||
|
artifact_id = self.create_artifact().data["id"]
|
||||||
|
response = self.client.delete(
|
||||||
|
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.other_token)
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
self.assertTrue(Artifact.objects.filter(pk=artifact_id).exists())
|
||||||
|
|
||||||
|
def test_delete_twice_second_call_404s(self):
|
||||||
|
artifact_id = self.create_artifact().data["id"]
|
||||||
|
first = self.client.delete(
|
||||||
|
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
|
||||||
|
)
|
||||||
|
self.assertEqual(first.status_code, 204)
|
||||||
|
|
||||||
|
second = self.client.delete(
|
||||||
|
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
|
||||||
|
)
|
||||||
|
self.assertEqual(second.status_code, 404)
|
||||||
|
|
||||||
|
def test_deleted_artifact_is_gone_from_list_and_detail(self):
|
||||||
|
artifact_id = self.create_artifact().data["id"]
|
||||||
|
self.client.delete(
|
||||||
|
reverse("artifact-detail-api", args=[artifact_id]), **self.auth_headers(self.token)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
list_response = self.client.get(reverse("artifact-api"))
|
||||||
|
self.assertEqual(list_response.data, [])
|
||||||
|
|
||||||
|
detail_response = self.client.get(reverse("artifact-detail-api", args=[artifact_id]))
|
||||||
|
self.assertEqual(detail_response.status_code, 404)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue