refactor(agents): migrate .agent/ planning tree to .agents/

Standardize on the .agents/ directory (shared with project skills):
phases/, user_stories/, reports/, screenshots/, validate.sh, and
phase-sessions/ + pipeline.log all move to .agents/ (git mv preserves
history; runtime artifacts move alongside).

Updates every reference in AGENTS.md, README.md, .gitignore, app
docstrings, and test story headers. Historical KB content in data/
and the runtime pipeline.log transcript are left untouched.
This commit is contained in:
2026-09-05 10:57:07 -04:00
parent 766702c750
commit dbf2af26c6
1118 changed files with 664 additions and 664 deletions
@@ -0,0 +1,27 @@
# Task 01 — The share token + public read API + page route
**Phase:** `51_share_chat` · **Source:** `TODO.md:6` — "Need a way to share a chat with a link so others can see it anonymously."
**Story:** n/a (TODO-derived)
## Objective
A saved chat can be shared (a token), unshared (revoked), and read publicly by token; the `/shared/<token>` URL serves the shared page (a real route ahead of the static mount) with the cache-busting contract.
## Work
1. `alembic/versions/0009_saved_chat_share_token.py` — `revision = "0009"`, `down_revision = "0008"` (verify with `uv run alembic heads`): `op.add_column("saved_chats", sa.Column("share_token", postgresql.UUID(as_uuid=True), nullable=True))` + `op.create_index("ix_saved_chats_share_token", "saved_chats", ["share_token"], unique=True)` (a unique index on a nullable column — Postgres treats NULLs as distinct, the `git_sources.path` house precedent, phase 38); the down reverses both. `app/models.py` — the `SavedChat.share_token: Mapped[uuid.UUID | None]` column (nullable unique, `index=True`… expressed as `unique=True, nullable=True` on the `mapped_column` to match the migration) + the docstring line (the phase-38 `path` column's comment style). Apply: `uv run alembic upgrade head`.
2. `app/schemas.py` — `SharedChatOut` — `title: str`, `messages: list[ChatMessage]` (the **public** read shape: no id, no timestamps, no token — a shared chat is a content snapshot, not a handle).
3. `app/api/chats.py`:
- `POST "/{chat_id}/share"` (admin router) → `{"chat_id": …, "share_url": "/shared/<token>"}` — 200, **idempotent**: an existing token is returned unchanged; a new token is `uuid.uuid4()`, persisted, and `updated_at` is **not** bumped (sharing is not a content edit — write the token with a Core `session.execute(update(SavedChat).where(...).values(share_token=…))`, which skips the ORM `onupdate`; pin this in the tests); 404 on unknown chat.
- `POST "/{chat_id}/unshare"` (admin router) → `{"chat_id": …, "shared": false}` — the token set NULL (the same Core-update pattern), idempotent (an unshared chat unshares cleanly); 404 on unknown.
- `GET "/shared/{token}"` (public — **no** admin dependency; put it on a second module-level `public_router = APIRouter(tags=["chats"])` in the same file, registered in `main.py` with `prefix="/api"`, so the JSON endpoint is `GET /api/shared/<token>`) → `SharedChatOut`; 404 `{"detail": "unknown or revoked share link"}` for a wrong or revoked token (one message — no enumeration between the two cases).
4. **The page route** — `app/main.py`: `GET /shared/{token}` (a small router in `app/api/chats.py` or an inline route, registered **without** a prefix and **before** the static mount — the API-routes-first convention; `/shared/<uuid>` is not a static file, so without this route the mount would 404 it) → `FileResponse(static_dir / "shared.html")` (the page lands in task 03 — guard the missing file with an explicit check returning the same 404 JSON as the API, so a stale deploy never 500s).
5. `app/core/caching.py` — extend the middleware's known-page dispatch: a path starting with `"/shared/"` gets the same treatment as `HTML_PAGES` (no-cache + `?v=` asset-rewrite on the `text/html` body — the `FileResponse` body is drained by the existing `_read_body` path). Comment it (phase 51: the dynamic share page). `tests/unit/test_caching.py` — pins: a `/shared/<uuid>` path is treated as a known HTML page (no-cache + rewrite), an `/api/shared/<uuid>` path passes through untouched, and an unknown path is untouched.
6. `tests/integration/test_chats_api.py` — extend the house file: share (200 + `share_url` matching `/^\/shared\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/`; a second call returns the **same** token), unshare (token NULL; the public read 404s; unshare on an unshared chat → 200 idempotent), the public read (a fresh anonymous client: 200 with title + messages round-tripping and **no** `id`/`created_at`/`updated_at`/`share_token` keys in the body; a wrong token 404s with the "unknown or revoked" detail; a revoked token 404s with the same detail), the `updated_at`-unchanged pin for share/unshare, and 404s for share/unshare on unknown ids.
7. `tests/integration/test_migration_0009.py` (new) — the house pattern: upgrade/downgrade round-trip; the unique index exists; the NULLs-distinct behavior (two rows may both carry NULL; two identical non-NULL tokens are rejected).
## Testing & Quality
- Integration: as above; full suite green.
- Coverage: **>90%** on `app/`.
## Completion Criteria
- [ ] `POST /api/chats/<id>/share` is idempotent and leaves `updated_at` alone; `unshare` revokes; `GET /api/shared/<token>` is public + 404-safe; `GET /shared/<token>` serves the page route (404-JSON guard when the file is missing).
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.