Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28015ed1a2 | |||
| 376a6daddc | |||
| fb9b4fa422 | |||
| daebb54f47 | |||
| a598221812 | |||
| bada72f87e | |||
| 7d8371dea1 | |||
| 3a51425ec7 | |||
| 7c6c906db2 |
+164
@@ -23,6 +23,170 @@ skip versions are the composition of each intervening adjacent
|
||||
release's steps in order — no A-to-B path is pre-computed beyond
|
||||
that.
|
||||
|
||||
## 0.24.0 — 2026-05-28
|
||||
|
||||
**Minor — one new secret required before this version serves tag
|
||||
suggestions; no schema migration; no behavior change for deployments
|
||||
that do not bind the key.** Roadmap item #27: as a contributor fills in
|
||||
the propose-RFC form, the backend asks Claude Haiku to recommend tags
|
||||
drawn from the collection's existing tag set, surfaced as clickable
|
||||
suggestion chips. Shipped from driver session 0025.0. This is the §9.1
|
||||
"Slice 2" AI-suggested chips that the propose modal has carried a
|
||||
deferred placeholder for since Slice 1.
|
||||
|
||||
- **`POST /api/rfcs/suggest-tags`** (contributor-gated, per-user
|
||||
rate-limited). Takes the partial draft (`title`, `pitch`,
|
||||
`use_case`) and returns `{ "suggestions": [{ "tag", "confidence" },
|
||||
…] }`. The model is constrained to the corpus's existing tags — v1
|
||||
tags are free-form chip input, so "the taxonomy" is the de-facto set
|
||||
of distinct tags the existing RFCs carry. The model MUST NOT invent
|
||||
tags; taxonomy extension is a deliberate out-of-scope follow-up.
|
||||
- **Always Claude Haiku, for cost.** Tag suggestion uses Haiku
|
||||
regardless of the `ENABLED_MODELS` chat-picker universe, via the new
|
||||
`providers.construct_haiku()` factory. There is no RFC slug at propose
|
||||
time, so the §6.7 per-RFC funder credential path does not apply — the
|
||||
surface runs on the operator's own `ANTHROPIC_API_KEY`.
|
||||
- **Degrades to silence, never error.** No key bound, an empty corpus,
|
||||
an empty draft, a rate-limited caller, or an unparseable model reply
|
||||
all yield an empty list; the propose modal hides its suggestion row,
|
||||
and the rest of the app is unaffected. So a deployment that does not
|
||||
set the key sees no change at all.
|
||||
- **Privacy disclosure.** The modal carries an inline note that the
|
||||
draft text is sent to Anthropic to generate the suggestions. This is
|
||||
required for honesty (the text leaves the deployment before the RFC
|
||||
is submitted); cookie-consent does not gate it because the user has
|
||||
actively typed into a draft surface. The exact wording wants a
|
||||
counsel pass before OHM's deploy, per the #22 drafting discipline.
|
||||
- **Frontend.** `ProposeModal` debounce-posts the draft (700 ms, with a
|
||||
stale-response guard); suggestion chips are clickable and add to the
|
||||
tag list (nothing auto-applies). `api.suggestTags()` is forgiving —
|
||||
any non-OK response resolves to `[]`.
|
||||
|
||||
Tests: 11 new (`test_tag_suggest_vertical.py`) — contributor-gating,
|
||||
universe-constrained filtering, no-key / empty-corpus / rate-limit
|
||||
paths, plus units for the universe gather, the tolerant reply parser,
|
||||
the max cap, the empty-draft short-circuit, and provider-failure
|
||||
fallback. Full suite 351 green.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. You **MUST** bind `ANTHROPIC_API_KEY` for the suggestion surface to
|
||||
work. On OHM this is an operator gesture — the key never touches the
|
||||
conversation. Set it from your own terminal via stdin (so the bytes
|
||||
never land in shell history):
|
||||
|
||||
```bash
|
||||
printf '%s' "$(pbpaste)" | .venv/bin/ohm-rfc-app-flotilla \
|
||||
secret set ohm-rfc-app ANTHROPIC_API_KEY
|
||||
```
|
||||
|
||||
(The §18 chat stack already reads this same key, so a deployment
|
||||
that has chat configured **MAY** already have it bound — confirm with
|
||||
`flotilla secret list ohm-rfc-app`.) If the key is absent the app
|
||||
still boots and serves normally; tag suggestions are simply
|
||||
unavailable until it is set.
|
||||
2. You **MAY** tune the per-user rate limit via `TAG_SUGGEST_RATE_MAX`
|
||||
(default 30) and `TAG_SUGGEST_RATE_WINDOW_SECONDS` (default 60). The
|
||||
defaults apply when unset.
|
||||
3. You **SHOULD** have counsel review the modal's disclosure wording
|
||||
before exposing the surface to users, per the #22 drafting
|
||||
discipline — the copy is honest as written, but the legal review is
|
||||
the right call for any "your text is sent to a third party" notice.
|
||||
|
||||
## 0.23.0 — 2026-05-28
|
||||
|
||||
Roadmap item #29: signing in lands the user on their most recently
|
||||
viewed app state instead of the empty-state home view. Shipped from
|
||||
driver session 0022.0. Per-user, server-side, on by default; first-ever
|
||||
sign-ins still land on home.
|
||||
|
||||
1. **Server-side last-state.** A new `user_session_state` table holds one
|
||||
row per user: `last_route TEXT`, `last_route_state TEXT` (light view
|
||||
state encoded as JSON — SQLite has no native JSONB — never draft-buffer
|
||||
contents), `resume_enabled INTEGER NOT NULL DEFAULT 1`, and
|
||||
`last_updated_at`. Per-user (not per-device), matching the
|
||||
"go to where I was last" intent.
|
||||
2. **Route-change capture.** A new frontend hook (`frontend/src/lib/
|
||||
useLastState.js`) debounce-posts the current route + light state to
|
||||
`PUT /api/me/last-state` for authenticated users only; anonymous
|
||||
sessions are a no-op. The handler upserts the user's row and no-ops
|
||||
when `resume_enabled` is 0.
|
||||
3. **Sign-in redirect.** The stored `last_route` (+ decoded state +
|
||||
`resume_enabled`) is folded onto the existing `/api/auth/me` payload
|
||||
(no new GET endpoint), so the frontend reads it on boot. On a hard
|
||||
sign-in landing (app booted on `/`), the app redirects to the stored
|
||||
route. The redirect is gated on the #21-Part-C Amplitude `identify`
|
||||
having fired (`identifyReady`), preserving identify-then-track
|
||||
ordering — the first event on the resumed route carries the user_id.
|
||||
4. **Edge cases.** Stale routes (an RFC since withdrawn or now
|
||||
unreadable) are a graceful no-op — existing routing falls through to
|
||||
the catalog/empty-state. Opt-out ships at the column level
|
||||
(`resume_enabled`); the profile-settings toggle UI is a follow-up.
|
||||
Stored state is route + light view state only, never draft contents
|
||||
(documented in `SPEC.md` §6.8).
|
||||
|
||||
Migration `022_user_session_state.sql` creates the table. No new
|
||||
environment variables, overlay keys, or secrets.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. Deployments **MUST** apply database migrations; `022_user_session_state.sql`
|
||||
runs automatically on next boot (the migration runner globs
|
||||
`backend/migrations/*.sql` and applies any not yet recorded in
|
||||
`schema_migrations`). The migration is additive — a new table — and
|
||||
requires no data backfill.
|
||||
2. No new environment variables, overlay keys, or secrets. No operator
|
||||
action beyond the standard `flotilla deploy ohm-rfc-app`.
|
||||
|
||||
## 0.22.0 — 2026-05-28
|
||||
|
||||
Roadmap item #26: an optional **"What will you be using this for?"**
|
||||
field on both propose surfaces. Shipped from driver session 0022.0.
|
||||
Additive and backward-compatible — no behavior changes for anyone who
|
||||
leaves the field blank.
|
||||
|
||||
1. **Propose-RFC modal.** Below the required "Why is this RFC needed?"
|
||||
field (`pitch`) sits a new optional **"What will you be using this
|
||||
RFC for?"** textarea. "Needed" is the abstract justification; "using
|
||||
it for" is the concrete ground-truth use case — captured without
|
||||
being forced.
|
||||
2. **Propose-PR modal.** Below the required "Why is this change needed?"
|
||||
field (`description`) sits a new optional **"What will you be using
|
||||
this change for?"** textarea.
|
||||
3. **Display.** The RFC view (`RFCView` / `ProposalView`) and PR review
|
||||
view (`PRView`) render the captured use case alongside the existing
|
||||
"why" text, with a muted "left blank" treatment when absent.
|
||||
4. **Persistence (deployment note).** In this deployment the framework's
|
||||
`rfcs` / PR surfaces are the Gitea-backed cache tables `cached_rfcs`
|
||||
/ `cached_prs`, rebuilt by the reconciler. Because the propose write
|
||||
path is endpoint → Gitea → reconcile (and the reconciler doesn't
|
||||
carry the new field), the durable home is a canonical, reconcile-proof
|
||||
side table `proposed_use_cases` (keyed by `(scope, pr_number)`) that
|
||||
the propose/open endpoints write directly and the view endpoints read
|
||||
back. The literal nullable `proposed_use_case` columns named by the
|
||||
roadmap are also added to `cached_rfcs` / `cached_prs` for parity and
|
||||
any future reconciler that learns to carry the field. NULL/blank use
|
||||
cases simply never write a side-table row — absence is "left blank".
|
||||
|
||||
Migration `021_proposed_use_case.sql` adds the two cache columns
|
||||
(`ALTER TABLE … ADD COLUMN proposed_use_case TEXT`), the
|
||||
`proposed_use_cases` canonical table, and its lookup indexes. The
|
||||
reconciler's upsert (`ON CONFLICT DO UPDATE`) sets only known columns,
|
||||
so the added cache columns survive reconciles. Backend validators accept
|
||||
the field as NULL/omitted (no required-validation) with an 8000-char cap
|
||||
matching the existing PR `description` bound; blank/whitespace is treated
|
||||
as absent.
|
||||
|
||||
Upgrade steps:
|
||||
|
||||
1. Deployments **MUST** apply database migrations; `021_proposed_use_case.sql`
|
||||
runs automatically on next boot (the migration runner globs
|
||||
`backend/migrations/*.sql` and applies any not yet recorded in
|
||||
`schema_migrations`). The migration is additive — nullable cache
|
||||
columns plus a new table — and requires no data backfill.
|
||||
2. No new environment variables, overlay keys, or secrets. No operator
|
||||
action beyond the standard `flotilla deploy ohm-rfc-app`.
|
||||
|
||||
## 0.21.0 — 2026-05-28
|
||||
|
||||
UX-polish wave. Roadmap items #31 (comprehensive UX polish — foundation
|
||||
|
||||
@@ -714,6 +714,47 @@ The lighter half ships the structural shape — frontmatter, consent,
|
||||
resolution, revocation. The heavier half ships the runtime
|
||||
hardening.
|
||||
|
||||
### 6.8 Sign-in state resume (roadmap item #29, v0.23.0)
|
||||
|
||||
Signing in lands the user back on their most recently-viewed app
|
||||
state instead of always on the empty-state home view. The model is
|
||||
**per-user**, not per-device — the safe default the roadmap calls
|
||||
for: a sign-in on any device resumes the most-recently-recorded
|
||||
route. A per-device split and a profile-settings toggle UI are
|
||||
follow-ups; v0.23.0 ships the column-level opt-out flag
|
||||
(`resume_enabled`, default on) and the default-on behavior.
|
||||
|
||||
**Storage.** A single row per user in `user_session_state`
|
||||
(migration 022): `user_id` (PK, FK → `users.id`, cascade-delete),
|
||||
`last_route` (TEXT, the frontend pathname), `last_route_state`
|
||||
(TEXT, JSON-encoded — SQLite has no native JSONB, so JSON-as-TEXT
|
||||
matches how the app stores every other JSON blob), `resume_enabled`
|
||||
(INTEGER, default 1), and `last_updated_at`.
|
||||
|
||||
**Wiring.** A debounced (~1s) frontend route-change hook posts the
|
||||
current route to `PUT /api/me/last-state` for authenticated users
|
||||
(anonymous: no-op). The stored `last_route` is folded onto the
|
||||
existing `/api/auth/me` payload (no extra round-trip); on sign-in,
|
||||
after the §21-Part-C Amplitude `identify` fires, the frontend
|
||||
`navigate()`s to it. The identify-then-redirect ordering is
|
||||
preserved: the redirect is gated on identify having fired.
|
||||
|
||||
**Stale state.** If the stored route is an RFC since withdrawn or
|
||||
one the user lost rights to read, the redirect is a graceful no-op:
|
||||
`navigate(last_route)` lands on whatever that route renders today,
|
||||
and the existing routing already falls through to the
|
||||
catalog/empty-state for a missing/unreadable RFC. No special-casing
|
||||
on the server.
|
||||
|
||||
**Privacy (binding).** The stored state is **route + light view
|
||||
state ONLY** — scroll anchors, open-tab selection, filter chips, and
|
||||
the like. It **MUST NOT** carry draft-buffer contents, PR/comment
|
||||
draft text, or any user-typed content. The frontend never sends such
|
||||
content; the `last_route_state` column comment in migration 022 and
|
||||
this paragraph are the contract. Resume state is purposely cheap to
|
||||
discard: a deliberate "clear" (or `resume_enabled = 0`) drops the
|
||||
user back to today's empty-state behavior.
|
||||
|
||||
---
|
||||
|
||||
## 7. The left pane
|
||||
|
||||
@@ -38,10 +38,22 @@ GITEA_WEBHOOK_SECRET=change-me-to-a-shared-secret
|
||||
# Comma-separated list of provider keys to enable. Per the §19.2
|
||||
# per-RFC-model topic, this is app-wide until that topic lands.
|
||||
ENABLED_MODELS=claude
|
||||
# ANTHROPIC_API_KEY also powers the §9.1 propose-RFC tag suggestions
|
||||
# (roadmap #27) — that surface always uses Claude Haiku for cost,
|
||||
# independent of ENABLED_MODELS. With no key set, tag suggestions are
|
||||
# simply unavailable (the modal hides the row); the rest of the app is
|
||||
# unaffected.
|
||||
ANTHROPIC_API_KEY=
|
||||
GOOGLE_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# --- Tag suggestions (§9.1 / roadmap #27) ---
|
||||
# Per-user rate limit on the suggest-tags endpoint (cost backstop; the
|
||||
# modal debounces and the endpoint is contributor-gated). Optional —
|
||||
# these defaults apply when unset.
|
||||
TAG_SUGGEST_RATE_MAX=30
|
||||
TAG_SUGGEST_RATE_WINDOW_SECONDS=60
|
||||
|
||||
# --- Email (§15.4) ---
|
||||
# Leave SMTP_HOST unset to use the stdout fallback — the integration
|
||||
# tests rely on it, and a dev environment without a real SMTP provider
|
||||
|
||||
+175
-1
@@ -39,6 +39,7 @@ from . import (
|
||||
notify,
|
||||
philosophy,
|
||||
providers as providers_mod,
|
||||
tag_suggest,
|
||||
)
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
@@ -51,6 +52,22 @@ class ProposeBody(BaseModel):
|
||||
slug: str = Field(min_length=1, max_length=80)
|
||||
pitch: str = Field(min_length=1)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
# Roadmap #26: optional "What will you be using this RFC for?" — the
|
||||
# concrete ground-truth use case, distinct from the `pitch`'s abstract
|
||||
# "why is this needed." Optional (NULL/omitted accepted), no minimum,
|
||||
# generous cap matching the pitch's free-text body bound.
|
||||
proposed_use_case: str | None = Field(default=None, max_length=8000)
|
||||
|
||||
|
||||
class SuggestTagsBody(BaseModel):
|
||||
# Roadmap #27: the partial propose-RFC draft, sent as the user types
|
||||
# (debounced on the frontend). All fields optional — suggestions
|
||||
# refine as the draft fills in. `pitch` is the "why is this needed"
|
||||
# rationale; `use_case` is the #26 optional ground-truth field.
|
||||
# Bounds mirror the propose body's free-text caps.
|
||||
title: str = Field(default="", max_length=200)
|
||||
pitch: str = Field(default="", max_length=8000)
|
||||
use_case: str = Field(default="", max_length=8000)
|
||||
|
||||
|
||||
class DeclineBody(BaseModel):
|
||||
@@ -62,6 +79,18 @@ class FunderCredentialBody(BaseModel):
|
||||
api_key: str = Field(min_length=1, max_length=2048)
|
||||
|
||||
|
||||
class LastStateBody(BaseModel):
|
||||
# v0.23.0 / roadmap item #29: server-side sign-in state resume.
|
||||
# `route` is a frontend pathname the user was last on (bounded so a
|
||||
# hostile client can't stuff arbitrary blobs through). `state` is an
|
||||
# optional bag of *light* view state (scroll anchors, open tab,
|
||||
# filter chips). PRIVACY: it MUST NOT carry draft-buffer contents —
|
||||
# the frontend only ever sends ephemeral view state, and the column
|
||||
# comment in migration 022 + SPEC §6.2 are the binding contract.
|
||||
route: str = Field(min_length=1, max_length=2048)
|
||||
state: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class BetaRequestBody(BaseModel):
|
||||
# v0.8.0 — captured on the first OTC sign-in. All three fields are
|
||||
# required so the admin queue has a coherent triage shape.
|
||||
@@ -349,6 +378,28 @@ def make_router(
|
||||
)
|
||||
has_passcode = bool(row and row["passcode_hash"])
|
||||
passcode_set_at = row["passcode_set_at"] if (row and has_passcode) else None
|
||||
# v0.23.0 / item #29: fold the sign-in-resume state onto the
|
||||
# same round-trip the frontend already makes on boot. When
|
||||
# resume is disabled (resume_enabled = 0) we hand back a null
|
||||
# route so the client never redirects; the stored row stays put
|
||||
# so re-enabling later resumes the last-known route.
|
||||
state_row = db.conn().execute(
|
||||
"SELECT last_route, last_route_state, resume_enabled "
|
||||
"FROM user_session_state WHERE user_id = ?",
|
||||
(user.user_id,),
|
||||
).fetchone()
|
||||
resume_enabled = bool(state_row["resume_enabled"]) if state_row else True
|
||||
last_route = (
|
||||
state_row["last_route"]
|
||||
if (state_row and resume_enabled)
|
||||
else None
|
||||
)
|
||||
last_route_state = None
|
||||
if state_row and resume_enabled and state_row["last_route_state"]:
|
||||
try:
|
||||
last_route_state = json.loads(state_row["last_route_state"])
|
||||
except (ValueError, TypeError):
|
||||
last_route_state = None
|
||||
return {
|
||||
"authenticated": True,
|
||||
"user": {
|
||||
@@ -365,6 +416,10 @@ def make_router(
|
||||
"needs_profile": needs_profile,
|
||||
"has_passcode": has_passcode,
|
||||
"passcode_set_at": passcode_set_at,
|
||||
# v0.23.0 / item #29 — sign-in state resume.
|
||||
"resume_enabled": resume_enabled,
|
||||
"last_route": last_route,
|
||||
"last_route_state": last_route_state,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -434,6 +489,52 @@ def make_router(
|
||||
notify.fan_out_new_beta_request(requester_user_id=user.user_id)
|
||||
return {"ok": True}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# v0.23.0 (§6.2, roadmap item #29): server-side sign-in state
|
||||
# resume. The frontend debounce-posts the user's current route +
|
||||
# a small bag of light view state here on every route change; the
|
||||
# next sign-in reads `last_route` off `/api/auth/me` and redirects.
|
||||
#
|
||||
# Per-user (NOT per-device) — one row per user, keyed on user_id.
|
||||
# `resume_enabled` is the opt-out flag (default on); when it's 0
|
||||
# this endpoint no-ops so a user who turned resume off doesn't keep
|
||||
# silently rewriting their stored route. PRIVACY: the body carries
|
||||
# route + light state ONLY, never draft-buffer contents (migration
|
||||
# 022 column comment + SPEC §6.2 are the binding contract).
|
||||
#
|
||||
# `require_user` (not `require_contributor`) — a pending/granted
|
||||
# distinction is irrelevant for "remember where I was", and a
|
||||
# pending user navigating read-only surfaces should still resume.
|
||||
# Anonymous callers get the 401 `require_user` raises.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.put("/api/me/last-state")
|
||||
async def put_last_state(body: LastStateBody, request: Request) -> dict[str, Any]:
|
||||
user = auth.require_user(request)
|
||||
# Respect the opt-out: if a row already exists with resume
|
||||
# disabled, leave it untouched and report the no-op. A first-
|
||||
# ever POST (no row yet) defaults to enabled and stores.
|
||||
existing = db.conn().execute(
|
||||
"SELECT resume_enabled FROM user_session_state WHERE user_id = ?",
|
||||
(user.user_id,),
|
||||
).fetchone()
|
||||
if existing is not None and not existing["resume_enabled"]:
|
||||
return {"ok": True, "stored": False}
|
||||
state_json = json.dumps(body.state) if body.state is not None else None
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO user_session_state
|
||||
(user_id, last_route, last_route_state, last_updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
last_route = excluded.last_route,
|
||||
last_route_state = excluded.last_route_state,
|
||||
last_updated_at = excluded.last_updated_at
|
||||
""",
|
||||
(user.user_id, body.route, state_json),
|
||||
)
|
||||
return {"ok": True, "stored": True}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# v0.11.0: trust device for 30 days (§6.2, roadmap item #9).
|
||||
#
|
||||
@@ -557,12 +658,36 @@ def make_router(
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not found")
|
||||
return _serialize_rfc(row)
|
||||
payload = _serialize_rfc(row)
|
||||
# Roadmap #26: surface the optional propose-time use case on the
|
||||
# RFC view. The idea PR closes on merge, but the canonical row in
|
||||
# `proposed_use_cases` persists; look it up by slug (the latest
|
||||
# 'rfc'-scope row for this slug). NULL == "left blank".
|
||||
uc = db.conn().execute(
|
||||
"""
|
||||
SELECT use_case FROM proposed_use_cases
|
||||
WHERE scope = 'rfc' AND rfc_slug = ?
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
payload["proposed_use_case"] = uc["use_case"] if uc else None
|
||||
return payload
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §7.3 / §9.3: pending ideas
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def _proposal_use_case(pr_number: int) -> str | None:
|
||||
"""Roadmap #26: read the optional use case for an idea PR from the
|
||||
canonical side table. Returns None when none was supplied (the
|
||||
"left blank" sentinel the frontend renders tastefully)."""
|
||||
row = db.conn().execute(
|
||||
"SELECT use_case FROM proposed_use_cases WHERE scope = 'rfc' AND pr_number = ?",
|
||||
(pr_number,),
|
||||
).fetchone()
|
||||
return row["use_case"] if row else None
|
||||
|
||||
@router.get("/api/proposals")
|
||||
async def list_proposals() -> dict[str, Any]:
|
||||
rows = db.conn().execute(
|
||||
@@ -582,6 +707,7 @@ def make_router(
|
||||
"description": r["description"],
|
||||
"opened_by": r["opened_by"],
|
||||
"opened_at": r["opened_at"],
|
||||
"proposed_use_case": _proposal_use_case(r["pr_number"]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -630,6 +756,7 @@ def make_router(
|
||||
"opened_at": row["opened_at"],
|
||||
"entry": entry_payload,
|
||||
"affordances": affordances,
|
||||
"proposed_use_case": _proposal_use_case(pr_number),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
@@ -706,8 +833,55 @@ def make_router(
|
||||
# cache write is idempotent.)
|
||||
await cache.refresh_meta_pulls(config, gitea)
|
||||
|
||||
# Roadmap #26: persist the optional use case to the canonical,
|
||||
# reconcile-proof side table keyed by the idea PR number. NULL/
|
||||
# blank simply writes no row (absence == "left blank"). Done after
|
||||
# the refresh so the cache row exists; the mirror onto cached_prs
|
||||
# keeps the cache column in parity for any read that uses it.
|
||||
use_case = (payload.proposed_use_case or "").strip()
|
||||
if use_case:
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
|
||||
VALUES ('rfc', ?, ?, ?)
|
||||
ON CONFLICT(scope, pr_number) DO UPDATE SET use_case = excluded.use_case
|
||||
""",
|
||||
(slug, pr["number"], use_case),
|
||||
)
|
||||
db.conn().execute(
|
||||
"UPDATE cached_prs SET proposed_use_case = ? WHERE pr_kind = 'idea' AND pr_number = ?",
|
||||
(use_case, pr["number"]),
|
||||
)
|
||||
|
||||
return {"pr_number": pr["number"], "slug": slug}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §9.1 Slice 2 (roadmap #27): Claude Haiku tag suggestions as the
|
||||
# propose-RFC fields fill in. The modal debounce-posts the partial
|
||||
# draft; we constrain Haiku to the corpus's existing tag set and
|
||||
# return a short ranked list of clickable chips. Gated to
|
||||
# contributors (same gate as propose) so the cost surface is bounded
|
||||
# to people who can actually file an RFC; rate-limited per user as a
|
||||
# backstop. Degrades to an empty list (no error) when no Anthropic
|
||||
# key is bound, the corpus has no tags yet, or the draft is empty —
|
||||
# so the modal simply shows nothing extra.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/suggest-tags")
|
||||
async def suggest_rfc_tags(payload: SuggestTagsBody, request: Request) -> dict[str, Any]:
|
||||
user = auth.require_contributor(request)
|
||||
if not tag_suggest.rate_limit_ok(user.user_id):
|
||||
raise HTTPException(429, "Too many tag-suggestion requests; please slow down.")
|
||||
provider = tag_suggest.haiku_provider(config)
|
||||
if provider is None:
|
||||
return {"suggestions": []}
|
||||
universe = tag_suggest.gather_tag_universe()
|
||||
draft = tag_suggest.Draft(
|
||||
title=payload.title, pitch=payload.pitch, use_case=payload.use_case
|
||||
)
|
||||
suggestions = tag_suggest.suggest(provider, draft, universe)
|
||||
return {"suggestions": suggestions}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §9.3: merge / decline / withdraw an idea PR
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@@ -42,6 +42,11 @@ RFC_FILE_PATH = "RFC.md"
|
||||
class OpenPRBody(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=240)
|
||||
description: str = Field(max_length=8000)
|
||||
# Roadmap #26: optional "What will you be using this change for?" —
|
||||
# the concrete ground-truth use case sibling to the required
|
||||
# "why is this change needed" (the `description`). Optional, generous
|
||||
# cap matching the description bound.
|
||||
proposed_use_case: str | None = Field(default=None, max_length=8000)
|
||||
|
||||
|
||||
class PRDescriptionBody(BaseModel):
|
||||
@@ -173,6 +178,26 @@ def make_router(
|
||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||
|
||||
await _refresh_after_pr_write(rfc)
|
||||
|
||||
# Roadmap #26: persist the optional use case to the canonical,
|
||||
# reconcile-proof side table keyed by the PR number. Blank/omitted
|
||||
# writes no row (absence == "left blank"). The mirror onto
|
||||
# cached_prs keeps the cache column in parity.
|
||||
use_case = (body.proposed_use_case or "").strip()
|
||||
if use_case:
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
|
||||
VALUES ('pr', ?, ?, ?)
|
||||
ON CONFLICT(scope, pr_number) DO UPDATE SET use_case = excluded.use_case
|
||||
""",
|
||||
(slug, pr["number"], use_case),
|
||||
)
|
||||
db.conn().execute(
|
||||
"UPDATE cached_prs SET proposed_use_case = ? WHERE rfc_slug = ? AND pr_number = ?",
|
||||
(use_case, slug, pr["number"]),
|
||||
)
|
||||
|
||||
return {"pr_number": pr["number"], "slug": slug, "branch": branch}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
@@ -300,6 +325,7 @@ def make_router(
|
||||
"pr_number": pr_number,
|
||||
"title": pr_row["title"],
|
||||
"description": pr_row["description"],
|
||||
"proposed_use_case": _pr_use_case(pr_number),
|
||||
"state": pr_row["state"],
|
||||
"opened_by": pr_row["opened_by"],
|
||||
"opened_at": pr_row["opened_at"],
|
||||
@@ -762,6 +788,17 @@ def _can_edit_pr_text(rfc, pr_row, viewer) -> bool:
|
||||
return _can_withdraw(rfc, pr_row, viewer)
|
||||
|
||||
|
||||
def _pr_use_case(pr_number: int) -> str | None:
|
||||
"""Roadmap #26: the optional propose-PR use case from the canonical
|
||||
side table, or None when the change was opened without one ("left
|
||||
blank")."""
|
||||
row = db.conn().execute(
|
||||
"SELECT use_case FROM proposed_use_cases WHERE scope = 'pr' AND pr_number = ?",
|
||||
(pr_number,),
|
||||
).fetchone()
|
||||
return row["use_case"] if row else None
|
||||
|
||||
|
||||
def _pr_capabilities(rfc, pr_row, viewer) -> dict:
|
||||
return {
|
||||
"can_merge": _can_merge(rfc, viewer) and pr_row["state"] == "open",
|
||||
|
||||
@@ -184,6 +184,21 @@ def load_providers(env: dict) -> dict[str, BaseProvider]:
|
||||
return providers
|
||||
|
||||
|
||||
def construct_haiku(api_key: str) -> AnthropicProvider:
|
||||
"""A dedicated Claude Haiku provider, independent of the
|
||||
`ENABLED_MODELS` chat-picker universe.
|
||||
|
||||
The §9.1 tag-suggestion surface (roadmap #27) always wants the
|
||||
cheap + fast model regardless of which models the operator surfaces
|
||||
in the §8.12 picker, so it constructs Haiku directly from the
|
||||
operator's Anthropic key rather than going through `load_providers`.
|
||||
The model id is sourced from the same `_CLAUDE_VARIANTS` table the
|
||||
picker uses, so a model-string bump lands in one place.
|
||||
"""
|
||||
model, name = _CLAUDE_VARIANTS["claude-haiku"]
|
||||
return AnthropicProvider(api_key=api_key, model=model, display_name=name)
|
||||
|
||||
|
||||
def load_from_config(config) -> dict[str, BaseProvider]:
|
||||
"""Convenience adapter so callers can pass our Config dataclass directly."""
|
||||
env = {
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Roadmap #27 — Claude Haiku tag suggestions for the propose-RFC modal.
|
||||
|
||||
A cheap, fast assist: given the partial RFC draft a user is typing, ask
|
||||
Claude Haiku to recommend tags drawn ONLY from the tags already in use
|
||||
across the corpus. v1 has no curated tag list — tags are free-form chip
|
||||
input (§9.1) — so "the taxonomy" is the de-facto set of distinct tags
|
||||
the existing RFCs already carry. The model is constrained to that set
|
||||
and MUST NOT invent new tags; taxonomy extension (letting the model
|
||||
propose genuinely new tags) is a deferred follow-up per the roadmap.
|
||||
|
||||
Why Haiku specifically: cost. Picking a few reasonable tags from a known
|
||||
set is well within Haiku's range, and the modal fires this repeatedly as
|
||||
the user types, so the per-call price has to stay small.
|
||||
|
||||
The whole surface degrades to silence rather than error: no Anthropic
|
||||
key, no provider, an empty corpus, a rate-limited caller, an empty
|
||||
draft, or an unparseable model reply all yield an empty suggestion list.
|
||||
The propose modal hides its suggestion row on an empty list, so the
|
||||
fallback is simply the existing free-form chip input with nothing extra
|
||||
shown.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import db
|
||||
from .providers import BaseProvider, construct_haiku
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Bound the universe handed to the model so a large corpus can't blow up
|
||||
# the prompt size (and the cost). The most-common tags matter most.
|
||||
_UNIVERSE_CAP = 200
|
||||
|
||||
# How many suggestions we ever return to the modal.
|
||||
DEFAULT_MAX_SUGGESTIONS = 6
|
||||
|
||||
|
||||
@dataclass
|
||||
class Draft:
|
||||
"""The partial propose-RFC draft. `pitch` is the "why is this needed"
|
||||
rationale; `use_case` is the #26 optional ground-truth field."""
|
||||
|
||||
title: str = ""
|
||||
pitch: str = ""
|
||||
use_case: str = ""
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
return not (self.title.strip() or self.pitch.strip() or self.use_case.strip())
|
||||
|
||||
|
||||
def haiku_provider(config) -> BaseProvider | None:
|
||||
"""Construct a dedicated Claude Haiku provider from the operator's
|
||||
Anthropic key, or return None when no key is configured.
|
||||
|
||||
None means "suggestions unavailable" — the caller returns an empty
|
||||
list and the modal shows nothing. This is the seam tests monkeypatch
|
||||
to inject a fake provider without a real key. There is no RFC slug at
|
||||
propose time, so the §6.7 per-RFC funder path deliberately does not
|
||||
apply: tag suggestion always runs on the operator's own key.
|
||||
"""
|
||||
key = getattr(config, "anthropic_api_key", "") or ""
|
||||
if not key:
|
||||
return None
|
||||
return construct_haiku(key)
|
||||
|
||||
|
||||
def gather_tag_universe(cap: int = _UNIVERSE_CAP) -> list[str]:
|
||||
"""Every distinct tag in use across the cached corpus, most-common
|
||||
first (ties broken alphabetically for determinism), capped.
|
||||
|
||||
This is the universe the model is constrained to. An empty corpus
|
||||
yields an empty list, which short-circuits suggestion to silence.
|
||||
"""
|
||||
rows = db.conn().execute("SELECT tags_json FROM cached_rfcs").fetchall()
|
||||
counts: dict[str, int] = {}
|
||||
for r in rows:
|
||||
try:
|
||||
tags = json.loads(r["tags_json"] or "[]")
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if not isinstance(tags, list):
|
||||
continue
|
||||
for t in tags:
|
||||
if not isinstance(t, str):
|
||||
continue
|
||||
tag = t.strip()
|
||||
if tag:
|
||||
counts[tag] = counts.get(tag, 0) + 1
|
||||
ranked = sorted(counts, key=lambda t: (-counts[t], t.lower()))
|
||||
return ranked[:cap]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rate limiting — in-process, per-user sliding window.
|
||||
#
|
||||
# Cost control, not security: the `require_contributor` gate already
|
||||
# bounds callers to authenticated beta users, and the modal debounces.
|
||||
# This is the backstop against a stuck/abusive client hammering the
|
||||
# endpoint. In-memory is sufficient (single-process uvicorn on the VM)
|
||||
# and resets on restart, which is fine for a cost guard.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CALLS: dict[int, list[float]] = {}
|
||||
|
||||
|
||||
def _rate_config() -> tuple[int, float]:
|
||||
try:
|
||||
max_calls = int(os.environ.get("TAG_SUGGEST_RATE_MAX", "30"))
|
||||
except ValueError:
|
||||
max_calls = 30
|
||||
try:
|
||||
window = float(os.environ.get("TAG_SUGGEST_RATE_WINDOW_SECONDS", "60"))
|
||||
except ValueError:
|
||||
window = 60.0
|
||||
return max_calls, window
|
||||
|
||||
|
||||
def rate_limit_ok(user_id: int, *, now: float | None = None) -> bool:
|
||||
"""True if this call is within the per-user window; records the call.
|
||||
`now` is injectable for tests (defaults to a monotonic clock)."""
|
||||
max_calls, window = _rate_config()
|
||||
t = time.monotonic() if now is None else now
|
||||
calls = _CALLS.setdefault(user_id, [])
|
||||
cutoff = t - window
|
||||
calls[:] = [c for c in calls if c > cutoff]
|
||||
if len(calls) >= max_calls:
|
||||
return False
|
||||
calls.append(t)
|
||||
return True
|
||||
|
||||
|
||||
def reset_rate_limits() -> None:
|
||||
"""Test seam — clear the in-process window state."""
|
||||
_CALLS.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt + parse.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SYSTEM = (
|
||||
"You suggest topic tags for a draft RFC — a structured proposal "
|
||||
"document in a collection. You are given the draft's title, its "
|
||||
"rationale, an optional use case, and the exact set of tags already "
|
||||
"in use across the collection. Choose the tags from that set that "
|
||||
"best fit the draft.\n"
|
||||
"Rules:\n"
|
||||
"- Choose ONLY from the provided tag set. Never invent a tag.\n"
|
||||
"- Order best-fit first. Omit weak fits rather than padding the list.\n"
|
||||
"- Return at most {max} tags.\n"
|
||||
"Respond with ONLY a JSON array, no prose, of the form:\n"
|
||||
'[{{"tag": "<exact tag from the set>", "confidence": <number between 0 and 1>}}]\n'
|
||||
"If no tag in the set fits the draft, return []."
|
||||
)
|
||||
|
||||
|
||||
def _build_messages(draft: Draft, universe: list[str], max_suggestions: int):
|
||||
system = _SYSTEM.format(max=max_suggestions)
|
||||
parts: list[str] = []
|
||||
if draft.title.strip():
|
||||
parts.append(f"Title: {draft.title.strip()[:300]}")
|
||||
if draft.pitch.strip():
|
||||
parts.append(f"Why this RFC is needed:\n{draft.pitch.strip()[:4000]}")
|
||||
if draft.use_case.strip():
|
||||
parts.append(f"What it will be used for:\n{draft.use_case.strip()[:4000]}")
|
||||
parts.append(
|
||||
"Tags already in use (choose only from these):\n" + ", ".join(universe)
|
||||
)
|
||||
return system, [{"role": "user", "content": "\n\n".join(parts)}]
|
||||
|
||||
|
||||
def _extract_json_array(text: str):
|
||||
start = text.find("[")
|
||||
end = text.rfind("]")
|
||||
if start == -1 or end == -1 or end < start:
|
||||
return None
|
||||
try:
|
||||
return json.loads(text[start : end + 1])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_reply(text: str, universe: list[str], max_suggestions: int) -> list[dict]:
|
||||
"""Parse the model reply into a clean, universe-constrained list.
|
||||
|
||||
Tolerant of the model returning bare strings or objects, extra prose
|
||||
around the JSON, unknown/invented tags (dropped), duplicate tags
|
||||
(deduped), and missing/garbage confidences (defaulted + clamped).
|
||||
"""
|
||||
data = _extract_json_array(text or "")
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
# Map back to the canonical spelling in the universe, case-insensitively,
|
||||
# so a model that lowercases a tag still resolves to the real one.
|
||||
canonical = {t.lower(): t for t in universe}
|
||||
out: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
raw = item.get("tag")
|
||||
conf = item.get("confidence")
|
||||
elif isinstance(item, str):
|
||||
raw, conf = item, None
|
||||
else:
|
||||
continue
|
||||
if not isinstance(raw, str):
|
||||
continue
|
||||
tag = canonical.get(raw.strip().lower())
|
||||
if tag is None or tag in seen:
|
||||
continue
|
||||
try:
|
||||
c = float(conf) if conf is not None else 0.5
|
||||
except (ValueError, TypeError):
|
||||
c = 0.5
|
||||
c = max(0.0, min(1.0, c))
|
||||
out.append({"tag": tag, "confidence": round(c, 3)})
|
||||
seen.add(tag)
|
||||
if len(out) >= max_suggestions:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def suggest(
|
||||
provider: BaseProvider,
|
||||
draft: Draft,
|
||||
universe: list[str],
|
||||
max_suggestions: int = DEFAULT_MAX_SUGGESTIONS,
|
||||
) -> list[dict]:
|
||||
"""Orchestrate one suggestion call. Returns [] for an empty draft or
|
||||
empty universe (no model call), and for any provider/parse failure."""
|
||||
if draft.is_empty() or not universe:
|
||||
return []
|
||||
system, history = _build_messages(draft, universe, max_suggestions)
|
||||
try:
|
||||
text = provider.send(system, history)
|
||||
except Exception as exc: # provider/network failure → silent empty
|
||||
log.warning("tag-suggest provider failed: %s", exc)
|
||||
return []
|
||||
return parse_reply(text, universe, max_suggestions)
|
||||
@@ -0,0 +1,47 @@
|
||||
-- Roadmap #26 (rfc-app v0.22.0): the optional "What will you be using
|
||||
-- this for?" capture on the two propose surfaces.
|
||||
--
|
||||
-- The roadmap's framing names "the rfcs table" and "the PR-metadata
|
||||
-- table" for a `proposed_use_case TEXT NULL` column. In this deployment
|
||||
-- those two surfaces are the cache tables `cached_rfcs` and `cached_prs`
|
||||
-- (002_cache.sql). We add the nullable column to each, matching the
|
||||
-- existing naming convention (no NOT NULL, no default — NULL is the
|
||||
-- "left blank" sentinel the view surfaces render tastefully).
|
||||
--
|
||||
-- BUT: those tables are *cache*, rebuilt from Gitea by the §4.1
|
||||
-- reconciler (cache.py). The reconciler's INSERT...ON CONFLICT DO UPDATE
|
||||
-- sets only the columns it knows about, so an unlisted column is
|
||||
-- preserved on the update path — yet a propose/open never *writes* the
|
||||
-- column through the cache (the write path is endpoint -> Gitea ->
|
||||
-- reconcile, and the reconciler does not carry this field). So the cache
|
||||
-- column alone would always read NULL.
|
||||
--
|
||||
-- The durable home is therefore a dedicated app-truth table the propose
|
||||
-- /open endpoints write directly (keyed by the PR number, which is the
|
||||
-- stable identity for both idea PRs and rfc_branch PRs) and the view
|
||||
-- endpoints read back. This is not cache — it is canonical and survives
|
||||
-- any reconcile. The cache columns are added too for parity with the
|
||||
-- roadmap's literal shape and for any future reconciler that learns to
|
||||
-- carry the field, but the side table is the source of truth read at
|
||||
-- view time.
|
||||
|
||||
ALTER TABLE cached_rfcs ADD COLUMN proposed_use_case TEXT;
|
||||
ALTER TABLE cached_prs ADD COLUMN proposed_use_case TEXT;
|
||||
|
||||
-- Canonical, reconcile-proof store. One row per propose/open that
|
||||
-- supplied a use case. `scope` distinguishes the propose-RFC surface
|
||||
-- ('rfc') from the propose-PR-against-an-RFC surface ('pr'); `pr_number`
|
||||
-- is the join key the endpoints already have in hand. NULL/omitted use
|
||||
-- cases simply never write a row here, so absence == "left blank".
|
||||
CREATE TABLE proposed_use_cases (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('rfc', 'pr')),
|
||||
rfc_slug TEXT NOT NULL,
|
||||
pr_number INTEGER NOT NULL,
|
||||
use_case TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (scope, pr_number)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_proposed_use_cases_lookup ON proposed_use_cases (scope, pr_number);
|
||||
CREATE INDEX idx_proposed_use_cases_slug ON proposed_use_cases (scope, rfc_slug);
|
||||
@@ -0,0 +1,54 @@
|
||||
-- v0.23.0 / roadmap item #29: server-side sign-in state resume.
|
||||
--
|
||||
-- Track each authenticated user's last-viewed route + a small bag of
|
||||
-- "light" component state so that the *next* sign-in can land the user
|
||||
-- back where they left off, rather than always dropping them on the
|
||||
-- empty-state home view.
|
||||
--
|
||||
-- Storage shape (one row per user — per-user, NOT per-device, per the
|
||||
-- #29 "safe default"):
|
||||
--
|
||||
-- * `user_id` — PRIMARY KEY and FK into users(id) with cascade on
|
||||
-- delete. INTEGER to match users.id (INTEGER PRIMARY KEY
|
||||
-- AUTOINCREMENT). A deleted user automatically loses their stored
|
||||
-- resume state. One row per user means a later sign-in on any
|
||||
-- device resumes the most-recently-recorded route — the per-user
|
||||
-- model the roadmap asks for.
|
||||
--
|
||||
-- * `last_route` — the frontend pathname the user was last on
|
||||
-- (e.g. "/rfc/open-human-model"). TEXT, nullable until the first
|
||||
-- route-change POST lands. NEVER contains draft-buffer contents —
|
||||
-- it is a route only. See SPEC §6.2 "Sign-in state resume
|
||||
-- (privacy)".
|
||||
--
|
||||
-- * `last_route_state` — a JSON-encoded bag of *light* component
|
||||
-- state (scroll anchors, open-tab selection, filter chips, etc.).
|
||||
-- SQLite has no native JSONB; we store JSON as TEXT exactly as the
|
||||
-- rest of the app stores its JSON blobs (json.dumps / json.loads,
|
||||
-- cf. permission_events.details, actions.details). Nullable.
|
||||
-- PRIVACY INVARIANT: this column MUST NOT carry draft-buffer text,
|
||||
-- PR bodies, comment drafts, or any user-typed content — only
|
||||
-- ephemeral view state safe to replay. The PUT handler is the
|
||||
-- enforcement point; the column comment is the contract.
|
||||
--
|
||||
-- * `resume_enabled` — the per-user opt-out flag. 1 (default) means
|
||||
-- "resume me where I left off"; 0 means "always land on home". The
|
||||
-- PUT handler no-ops the upsert when this is 0, and the read path
|
||||
-- refuses to hand back a stored route when this is 0. A
|
||||
-- profile-settings toggle UI to flip this is a follow-up (the
|
||||
-- column + default-on behavior ship now); see CHANGELOG v0.23.0.
|
||||
--
|
||||
-- * `last_updated_at` — TEXT timestamp, app convention
|
||||
-- `datetime('now')`, matching device_trust.last_seen_at /
|
||||
-- users.last_seen_at. Refreshed on every successful upsert.
|
||||
--
|
||||
-- No new env vars. The debounce interval for the frontend route-change
|
||||
-- POST is a frontend constant (~1s), not a server knob.
|
||||
|
||||
CREATE TABLE user_session_state (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
last_route TEXT,
|
||||
last_route_state TEXT, -- JSON-encoded light state, nullable
|
||||
resume_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
@@ -0,0 +1,161 @@
|
||||
"""End-to-end vertical for roadmap #26 (rfc-app v0.22.0): the optional
|
||||
"What will you be using this for?" capture on the two propose surfaces.
|
||||
|
||||
Reuses the FakeGitea + session helpers from test_propose_vertical.py and
|
||||
the active-RFC seed from test_rfc_view_vertical.py. Proves:
|
||||
|
||||
(a) propose-RFC persists and returns `proposed_use_case` when supplied,
|
||||
and the value survives onto the merged super-draft's RFC view;
|
||||
(b) propose-RFC accepts a NULL / omitted use case ("left blank");
|
||||
(c) propose-PR persists and returns `proposed_use_case` when supplied,
|
||||
and accepts a NULL / omitted one.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
from test_pr_flow_vertical import _cut_branch_and_accept_change
|
||||
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# propose-RFC
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_propose_rfc_persists_and_returns_use_case(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
|
||||
|
||||
r = client.post("/api/rfcs/propose", json={
|
||||
"title": "Open Human Model",
|
||||
"slug": "open-human-model",
|
||||
"pitch": "A shared definition of what we mean by *human*.",
|
||||
"tags": ["identity"],
|
||||
"proposed_use_case": "Wiring OHM into the OpenXML consent surface.",
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
pr_number = r.json()["pr_number"]
|
||||
|
||||
# The pending-idea list carries the use case.
|
||||
items = client.get("/api/proposals").json()["items"]
|
||||
assert items[0]["proposed_use_case"] == "Wiring OHM into the OpenXML consent surface."
|
||||
|
||||
# The pending-idea detail view carries it too.
|
||||
proposal = client.get(f"/api/proposals/{pr_number}").json()
|
||||
assert proposal["proposed_use_case"] == "Wiring OHM into the OpenXML consent surface."
|
||||
|
||||
# Merge as owner; the use case survives onto the RFC view (looked
|
||||
# up by slug from the canonical side table, since the idea PR
|
||||
# closes on merge).
|
||||
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test")
|
||||
r = client.post(f"/api/proposals/{pr_number}/merge")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
view = client.get("/api/rfcs/open-human-model").json()
|
||||
assert view["proposed_use_case"] == "Wiring OHM into the OpenXML consent surface."
|
||||
|
||||
|
||||
def test_propose_rfc_use_case_optional(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=3, login="carol", role="contributor")
|
||||
sign_in_as(client, user_id=3, gitea_login="carol", display_name="Carol", role="contributor")
|
||||
|
||||
# Omitted entirely.
|
||||
r = client.post("/api/rfcs/propose", json={
|
||||
"title": "No Use Case", "slug": "no-use-case", "pitch": "p", "tags": [],
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
pr_a = r.json()["pr_number"]
|
||||
|
||||
# Explicit null.
|
||||
r = client.post("/api/rfcs/propose", json={
|
||||
"title": "Null Use Case", "slug": "null-use-case", "pitch": "p",
|
||||
"tags": [], "proposed_use_case": None,
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
pr_b = r.json()["pr_number"]
|
||||
|
||||
# Blank/whitespace — treated as "left blank", no row written.
|
||||
r = client.post("/api/rfcs/propose", json={
|
||||
"title": "Blank Use Case", "slug": "blank-use-case", "pitch": "p",
|
||||
"tags": [], "proposed_use_case": " ",
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
pr_c = r.json()["pr_number"]
|
||||
|
||||
for pr in (pr_a, pr_b, pr_c):
|
||||
assert client.get(f"/api/proposals/{pr}").json()["proposed_use_case"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# propose-PR (against an active RFC)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_propose_pr_persists_and_returns_use_case(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
branch, _ = _cut_branch_and_accept_change(
|
||||
client, fake, slug="ohm",
|
||||
original="Open Human Model is a framework for representing humans.",
|
||||
proposed="Open Human Model is a framework for representing humans across systems.",
|
||||
)
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/open-pr",
|
||||
json={
|
||||
"title": "Tighten the opening",
|
||||
"description": "Scope to systems.",
|
||||
"proposed_use_case": "Building a cross-system consent registry.",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
pr_number = r.json()["pr_number"]
|
||||
|
||||
pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
|
||||
assert pr["proposed_use_case"] == "Building a cross-system consent registry."
|
||||
|
||||
|
||||
def test_propose_pr_use_case_optional(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
branch, _ = _cut_branch_and_accept_change(
|
||||
client, fake, slug="ohm",
|
||||
original="It defines consent, trait, and agency in compatible terms.",
|
||||
proposed="It defines consent, trait, harm, and agency in compatible terms.",
|
||||
)
|
||||
# No proposed_use_case key at all.
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/open-pr",
|
||||
json={"title": "Add harm", "description": "Name harm explicitly."},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
pr_number = r.json()["pr_number"]
|
||||
|
||||
pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
|
||||
assert pr["proposed_use_case"] is None
|
||||
@@ -0,0 +1,140 @@
|
||||
"""End-to-end integration tests for the v0.23.0 sign-in state-resume
|
||||
vertical (§6.2, roadmap item #29).
|
||||
|
||||
New behavior: each authenticated user's last-viewed route + a small bag
|
||||
of light view state is tracked server-side, so the next sign-in can land
|
||||
them back where they left off rather than on the empty-state home view.
|
||||
|
||||
The tests below prove:
|
||||
|
||||
* `PUT /api/me/last-state` requires auth — an anonymous client gets
|
||||
401, and nothing is stored.
|
||||
* An authenticated PUT upserts the route, and the stored route is
|
||||
read back for that user off `GET /api/auth/me` (`last_route`).
|
||||
* A second PUT overwrites (upsert, one row per user) — the latest
|
||||
route wins.
|
||||
* `last_route_state` round-trips as decoded JSON on `/api/auth/me`.
|
||||
* Per-user isolation: user A's stored route is not visible to user B.
|
||||
* `resume_enabled = 0` disables resume: the PUT no-ops (does not
|
||||
rewrite the stored route) and `/api/auth/me` hands back a null
|
||||
`last_route` even though a stored row exists.
|
||||
|
||||
The fakes from `test_propose_vertical` give us a working app harness +
|
||||
the `sign_in_as` / `provision_user_row` seams.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
def test_put_last_state_requires_auth(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
# Anonymous — no session cookie set.
|
||||
r = client.put("/api/me/last-state", json={"route": "/rfc/open-human-model"})
|
||||
assert r.status_code == 401
|
||||
# Nothing landed in the table.
|
||||
row = db.conn().execute("SELECT COUNT(*) AS n FROM user_session_state").fetchone()
|
||||
assert row["n"] == 0
|
||||
|
||||
|
||||
def test_put_last_state_upserts_and_reads_back(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
|
||||
|
||||
# First POST stores a route + light state.
|
||||
r = client.put(
|
||||
"/api/me/last-state",
|
||||
json={"route": "/rfc/open-human-model", "state": {"tab": "discussion", "scroll": 420}},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["stored"] is True
|
||||
|
||||
# /api/auth/me hands the route + decoded state back.
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["authenticated"] is True
|
||||
assert me["user"]["resume_enabled"] is True
|
||||
assert me["user"]["last_route"] == "/rfc/open-human-model"
|
||||
assert me["user"]["last_route_state"] == {"tab": "discussion", "scroll": 420}
|
||||
|
||||
# A later POST overwrites — one row per user, latest wins.
|
||||
r = client.put("/api/me/last-state", json={"route": "/proposals/7"})
|
||||
assert r.status_code == 200
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["user"]["last_route"] == "/proposals/7"
|
||||
# state was omitted on the second POST → cleared to null.
|
||||
assert me["user"]["last_route_state"] is None
|
||||
|
||||
|
||||
def test_last_state_is_per_user(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
provision_user_row(user_id=3, login="bob", role="contributor")
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
|
||||
client.put("/api/me/last-state", json={"route": "/rfc/alice-route"})
|
||||
|
||||
# Switch to bob — he has no stored route yet.
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor", email="bob@test")
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["user"]["last_route"] is None
|
||||
|
||||
client.put("/api/me/last-state", json={"route": "/rfc/bob-route"})
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["user"]["last_route"] == "/rfc/bob-route"
|
||||
|
||||
# Back to alice — her route is untouched by bob's write.
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["user"]["last_route"] == "/rfc/alice-route"
|
||||
|
||||
|
||||
def test_resume_disabled_no_ops_put_and_hides_route(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test")
|
||||
|
||||
# Seed a stored row, then flip resume_enabled off directly (the
|
||||
# profile-settings toggle UI to do this from the client is a
|
||||
# follow-up; the column + behavior ship now).
|
||||
client.put("/api/me/last-state", json={"route": "/rfc/before-disable"})
|
||||
db.conn().execute(
|
||||
"UPDATE user_session_state SET resume_enabled = 0 WHERE user_id = ?",
|
||||
(2,),
|
||||
)
|
||||
|
||||
# /api/auth/me reports resume off and hands back a null route
|
||||
# even though a stored row exists.
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["user"]["resume_enabled"] is False
|
||||
assert me["user"]["last_route"] is None
|
||||
|
||||
# A PUT while disabled no-ops: stored=False and the stored route
|
||||
# is NOT rewritten.
|
||||
r = client.put("/api/me/last-state", json={"route": "/rfc/after-disable"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["stored"] is False
|
||||
row = db.conn().execute(
|
||||
"SELECT last_route FROM user_session_state WHERE user_id = ?", (2,)
|
||||
).fetchone()
|
||||
assert row["last_route"] == "/rfc/before-disable"
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Vertical + unit coverage for roadmap #27 (rfc-app v0.24.0): Claude
|
||||
Haiku tag suggestions on the propose-RFC modal.
|
||||
|
||||
Reuses the FakeGitea + session helpers from test_propose_vertical.py.
|
||||
The Anthropic call is never made for real — tests monkeypatch the
|
||||
`tag_suggest.haiku_provider` seam with a stub provider whose `send`
|
||||
returns canned text, so the HTTP contract is exercised without a key.
|
||||
|
||||
Proves:
|
||||
(a) the endpoint is contributor-gated (anon → 401);
|
||||
(b) a contributor gets suggestions, filtered to the corpus tag
|
||||
universe, with invented tags dropped;
|
||||
(c) no Anthropic key bound → empty list, not an error;
|
||||
(d) an empty corpus → empty list (model is never even called);
|
||||
(e) the per-user rate limit surfaces as a 429;
|
||||
plus unit coverage of the universe gather, the reply parser's tolerance,
|
||||
and the suggest() orchestration short-circuits.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubProvider:
|
||||
"""A BaseProvider stand-in whose send() returns a fixed string (or
|
||||
raises, to exercise the graceful-failure path)."""
|
||||
|
||||
def __init__(self, reply: str = "[]", *, raises: bool = False):
|
||||
self.reply = reply
|
||||
self.raises = raises
|
||||
self.calls: list[tuple[str, list]] = []
|
||||
|
||||
def send(self, system, history):
|
||||
self.calls.append((system, history))
|
||||
if self.raises:
|
||||
raise RuntimeError("boom")
|
||||
return self.reply
|
||||
|
||||
|
||||
def _seed_tags(slug: str, title: str, tags: list[str], state: str = "active") -> None:
|
||||
from app import db
|
||||
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO cached_rfcs (slug, title, state, tags_json) VALUES (?, ?, ?, ?)",
|
||||
(slug, title, state, json.dumps(tags)),
|
||||
)
|
||||
|
||||
|
||||
def _use_provider(monkeypatch, provider) -> None:
|
||||
monkeypatch.setattr("app.tag_suggest.haiku_provider", lambda config: provider)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_rate_limits():
|
||||
from app import tag_suggest
|
||||
|
||||
tag_suggest.reset_rate_limits()
|
||||
yield
|
||||
tag_suggest.reset_rate_limits()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoint (vertical)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_anonymous_cannot_suggest(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.post("/api/rfcs/suggest-tags", json={"title": "X"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_contributor_gets_filtered_suggestions(app_with_fake_gitea, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
_seed_tags("ohm", "OHM", ["identity", "schema", "consent"])
|
||||
_seed_tags("other", "Other", ["identity", "governance"])
|
||||
|
||||
# Model returns two real tags (one lowercased to test canonical
|
||||
# mapping is exact-set anyway), plus one invented tag that MUST
|
||||
# be dropped.
|
||||
reply = json.dumps([
|
||||
{"tag": "identity", "confidence": 0.9},
|
||||
{"tag": "consent", "confidence": 0.7},
|
||||
{"tag": "totally-invented", "confidence": 0.99},
|
||||
])
|
||||
stub = StubProvider(reply=reply)
|
||||
_use_provider(monkeypatch, stub)
|
||||
|
||||
r = client.post("/api/rfcs/suggest-tags", json={
|
||||
"title": "Consent and identity",
|
||||
"pitch": "We need a shared definition of consent tied to identity.",
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
tags = [s["tag"] for s in r.json()["suggestions"]]
|
||||
assert tags == ["identity", "consent"]
|
||||
# the model was actually invoked
|
||||
assert len(stub.calls) == 1
|
||||
# the universe (deduped distinct tags) was handed to the model
|
||||
user_msg = stub.calls[0][1][0]["content"]
|
||||
assert "identity" in user_msg and "governance" in user_msg
|
||||
|
||||
|
||||
def test_no_api_key_returns_empty(app_with_fake_gitea, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
_seed_tags("ohm", "OHM", ["identity"])
|
||||
|
||||
# No key bound (test env has no ANTHROPIC_API_KEY) → provider None.
|
||||
# (Explicitly assert the seam returns None given the test config.)
|
||||
from app import tag_suggest
|
||||
assert tag_suggest.haiku_provider(app.state.config) is None
|
||||
|
||||
r = client.post("/api/rfcs/suggest-tags", json={"title": "X", "pitch": "y"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["suggestions"] == []
|
||||
|
||||
|
||||
def test_empty_corpus_returns_empty_without_calling_model(app_with_fake_gitea, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
stub = StubProvider(reply=json.dumps([{"tag": "x", "confidence": 1}]))
|
||||
_use_provider(monkeypatch, stub)
|
||||
|
||||
# No cached_rfcs rows → empty universe → suggest() short-circuits.
|
||||
r = client.post("/api/rfcs/suggest-tags", json={"title": "X", "pitch": "y"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["suggestions"] == []
|
||||
assert stub.calls == [] # model never invoked on an empty universe
|
||||
|
||||
|
||||
def test_rate_limit_surfaces_429(app_with_fake_gitea, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
monkeypatch.setenv("TAG_SUGGEST_RATE_MAX", "2")
|
||||
monkeypatch.setenv("TAG_SUGGEST_RATE_WINDOW_SECONDS", "60")
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
_seed_tags("ohm", "OHM", ["identity"])
|
||||
_use_provider(monkeypatch, StubProvider(reply="[]"))
|
||||
|
||||
body = {"title": "X", "pitch": "y"}
|
||||
assert client.post("/api/rfcs/suggest-tags", json=body).status_code == 200
|
||||
assert client.post("/api/rfcs/suggest-tags", json=body).status_code == 200
|
||||
# Third call inside the window trips the limit.
|
||||
assert client.post("/api/rfcs/suggest-tags", json=body).status_code == 429
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Units
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_gather_tag_universe_dedupes_and_ranks(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import tag_suggest
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
# The db is initialized in the app's lifespan; enter the client
|
||||
# context so cached_rfcs exists before we seed it directly.
|
||||
with TestClient(app):
|
||||
_seed_tags("a", "A", ["identity", "schema"])
|
||||
_seed_tags("b", "B", ["identity", " schema ", "consent", ""]) # whitespace + empty
|
||||
_seed_tags("c", "C", ["identity"])
|
||||
|
||||
universe = tag_suggest.gather_tag_universe()
|
||||
# identity (3) > schema (2) > consent (1); whitespace trimmed/merged,
|
||||
# empties dropped.
|
||||
assert universe == ["identity", "schema", "consent"]
|
||||
|
||||
|
||||
def test_parse_reply_tolerates_junk():
|
||||
from app import tag_suggest
|
||||
|
||||
universe = ["identity", "schema", "consent"]
|
||||
|
||||
# Prose around the JSON, an invented tag, a bare string, a dup, a
|
||||
# missing confidence, and a garbage confidence.
|
||||
text = (
|
||||
"Sure! Here are the tags:\n"
|
||||
'[{"tag": "identity", "confidence": 0.9}, '
|
||||
'{"tag": "invented", "confidence": 1}, '
|
||||
'"schema", '
|
||||
'{"tag": "identity", "confidence": 0.5}, '
|
||||
'{"tag": "consent"}, '
|
||||
'{"tag": "consent", "confidence": "high"}]\n'
|
||||
"Hope that helps!"
|
||||
)
|
||||
out = tag_suggest.parse_reply(text, universe, max_suggestions=6)
|
||||
tags = [s["tag"] for s in out]
|
||||
assert tags == ["identity", "schema", "consent"] # invented dropped, deduped
|
||||
by_tag = {s["tag"]: s["confidence"] for s in out}
|
||||
assert by_tag["identity"] == 0.9
|
||||
assert by_tag["schema"] == 0.5 # bare string defaults to 0.5
|
||||
assert by_tag["consent"] == 0.5 # missing/garbage confidence → 0.5
|
||||
|
||||
|
||||
def test_parse_reply_empty_on_unparseable():
|
||||
from app import tag_suggest
|
||||
|
||||
assert tag_suggest.parse_reply("no json here", ["a"], 6) == []
|
||||
assert tag_suggest.parse_reply("", ["a"], 6) == []
|
||||
assert tag_suggest.parse_reply("[]", ["a"], 6) == []
|
||||
|
||||
|
||||
def test_parse_reply_respects_max():
|
||||
from app import tag_suggest
|
||||
|
||||
universe = ["a", "b", "c", "d", "e"]
|
||||
text = json.dumps([{"tag": t, "confidence": 0.5} for t in universe])
|
||||
out = tag_suggest.parse_reply(text, universe, max_suggestions=3)
|
||||
assert [s["tag"] for s in out] == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_suggest_short_circuits_empty_draft():
|
||||
from app import tag_suggest
|
||||
|
||||
stub = StubProvider(reply=json.dumps([{"tag": "a", "confidence": 1}]))
|
||||
draft = tag_suggest.Draft(title=" ", pitch="", use_case="")
|
||||
assert tag_suggest.suggest(stub, draft, ["a"]) == []
|
||||
assert stub.calls == [] # never called for an empty draft
|
||||
|
||||
|
||||
def test_suggest_returns_empty_on_provider_failure():
|
||||
from app import tag_suggest
|
||||
|
||||
stub = StubProvider(raises=True)
|
||||
draft = tag_suggest.Draft(title="Real title", pitch="a reason")
|
||||
assert tag_suggest.suggest(stub, draft, ["identity"]) == []
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.21.0",
|
||||
"version": "0.24.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { Routes, Route, Link, Navigate, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { getMe, subscribeToNotifications } from './api'
|
||||
import { anonymize, EVENTS, identify, track } from './lib/analytics'
|
||||
import { useLastState } from './lib/useLastState'
|
||||
import Catalog from './components/Catalog.jsx'
|
||||
import Inbox from './components/Inbox.jsx'
|
||||
import RFCView from './components/RFCView.jsx'
|
||||
@@ -42,6 +43,12 @@ export default function App() {
|
||||
// "Privacy & cookies" tab dispatches a `rfc-app:cookie-consent-reopen`
|
||||
// event that bumps this.
|
||||
const [consentReopenTick, setConsentReopenTick] = useState(0)
|
||||
// v0.23.0 / item #29 — flips true once the #21-Part-C identify effect
|
||||
// has fired (or once we've confirmed there's no authenticated user to
|
||||
// identify). useLastState gates its resume redirect on this so the
|
||||
// redirect always happens AFTER identify, preserving identify-then-
|
||||
// track ordering.
|
||||
const [identifyReady, setIdentifyReady] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
// v0.15.0 — Page Viewed event taxonomy. We fire on every
|
||||
@@ -86,6 +93,9 @@ export default function App() {
|
||||
if (viewer?.first_sign_in_at) props.first_sign_in_at = ['__setOnce__', viewer.first_sign_in_at]
|
||||
if (viewer?.created_at) props.account_created_at = ['__setOnce__', viewer.created_at]
|
||||
identify({ user_id: String(uid), properties: props })
|
||||
// v0.23.0 / item #29 — identify has now fired for this sign-in;
|
||||
// release useLastState's resume redirect (it waits on this).
|
||||
setIdentifyReady(true)
|
||||
} else if (uid == null && lastUserIdRef.current != null) {
|
||||
// Sign-out edge — App-level reset is handled separately by the
|
||||
// sign-out gesture that fires User Signed Out. Clear our local
|
||||
@@ -94,6 +104,14 @@ export default function App() {
|
||||
}
|
||||
}, [me?.authenticated, me?.user?.id, me?.user?.role, me?.user?.permission_state, me?.user?.passcode_set, me?.user?.device_trusted])
|
||||
|
||||
// v0.23.0 / item #29 — once `me` has resolved, if there's no
|
||||
// authenticated user there is nothing to identify, so release the
|
||||
// resume gate immediately (anonymous boots have no resume to do, but
|
||||
// the hook still needs the gate resolved to be a clean no-op).
|
||||
useEffect(() => {
|
||||
if (me != null && !me.authenticated) setIdentifyReady(true)
|
||||
}, [me])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setConsentReopenTick(t => t + 1)
|
||||
window.addEventListener('rfc-app:cookie-consent-reopen', handler)
|
||||
@@ -107,6 +125,18 @@ export default function App() {
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
// v0.23.0 / item #29 — server-side sign-in state resume. The hook
|
||||
// debounce-posts the current route for authenticated users and, once
|
||||
// identify has fired, redirects a fresh sign-in (which hard-lands on
|
||||
// "/") to the user's stored last route. Anonymous users: no-op.
|
||||
useLastState({
|
||||
authenticated: !!me?.authenticated,
|
||||
pathname: location.pathname,
|
||||
identifyReady,
|
||||
lastRoute: me?.authenticated ? me.user?.last_route : null,
|
||||
navigate,
|
||||
})
|
||||
|
||||
// §15.3 — subscribe to the live SSE stream for authenticated viewers
|
||||
// so the badge counter and the toast surface stay in lockstep with
|
||||
// the inbox. Tabs that miss an event because they were closed pick
|
||||
|
||||
+56
-4
@@ -25,6 +25,25 @@ export async function getMe() {
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── v0.23.0: sign-in state resume (§6.2, roadmap item #29) ───────────────
|
||||
//
|
||||
// The route-change hook (useLastState) debounce-posts the user's current
|
||||
// route + a small bag of *light* view state here for authenticated users.
|
||||
// The next sign-in reads `last_route` off `/api/auth/me` and redirects.
|
||||
// Privacy: `state` carries ephemeral view state ONLY — never draft-buffer
|
||||
// contents (see SPEC §6.2). Best-effort: callers ignore failures (an
|
||||
// offline/401 POST must never disrupt navigation).
|
||||
export async function putLastState(route, state) {
|
||||
const body = { route }
|
||||
if (state != null) body.state = state
|
||||
const res = await fetch('/api/me/last-state', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── v0.7.0: email + one-time-code sign-in (§6.2) ─────────────────────────
|
||||
//
|
||||
// The legacy /auth/login → /auth/callback OAuth flow remains during the
|
||||
@@ -166,15 +185,47 @@ export async function getProposal(prNumber) {
|
||||
return jsonOrThrow(await fetch(`/api/proposals/${prNumber}`))
|
||||
}
|
||||
|
||||
export async function proposeRFC({ title, slug, pitch, tags }) {
|
||||
export async function proposeRFC({ title, slug, pitch, tags, proposedUseCase }) {
|
||||
const res = await fetch('/api/rfcs/propose', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, slug, pitch, tags: tags || [] }),
|
||||
// #26: proposed_use_case is optional; send null when blank so the
|
||||
// backend treats it as "left blank".
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
slug,
|
||||
pitch,
|
||||
tags: tags || [],
|
||||
proposed_use_case: proposedUseCase || null,
|
||||
}),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Roadmap #27: Claude Haiku tag suggestions for the propose-RFC modal.
|
||||
// Returns a (possibly empty) array of { tag, confidence }. Deliberately
|
||||
// forgiving — any non-OK response (rate limit, transient error, no key
|
||||
// configured server-side) resolves to [] so the modal just shows nothing
|
||||
// rather than surfacing an error for what is a best-effort assist.
|
||||
export async function suggestTags({ title, pitch, useCase }) {
|
||||
try {
|
||||
const res = await fetch('/api/rfcs/suggest-tags', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: title || '',
|
||||
pitch: pitch || '',
|
||||
use_case: useCase || '',
|
||||
}),
|
||||
})
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return Array.isArray(data.suggestions) ? data.suggestions : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function mergeProposal(prNumber) {
|
||||
const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' })
|
||||
return jsonOrThrow(res)
|
||||
@@ -492,13 +543,14 @@ export async function draftPRText(slug, branch) {
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function openPR(slug, branch, { title, description }) {
|
||||
export async function openPR(slug, branch, { title, description, proposedUseCase }) {
|
||||
const res = await fetch(
|
||||
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/open-pr`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, description }),
|
||||
// #26: proposed_use_case is optional; null when blank.
|
||||
body: JSON.stringify({ title, description, proposed_use_case: proposedUseCase || null }),
|
||||
},
|
||||
)
|
||||
return jsonOrThrow(res)
|
||||
|
||||
@@ -15,6 +15,8 @@ import { EVENTS, track } from '../lib/analytics'
|
||||
export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpened }) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
// #26: optional ground-truth use case for this change.
|
||||
const [useCase, setUseCase] = useState('')
|
||||
const [drafting, setDrafting] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [confirmed, setConfirmed] = useState(!branchIsPrivate)
|
||||
@@ -39,7 +41,11 @@ export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpen
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { pr_number } = await openPR(slug, branch, { title: title.trim(), description: description.trim() })
|
||||
const { pr_number } = await openPR(slug, branch, {
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
proposedUseCase: useCase.trim() || null,
|
||||
})
|
||||
// v0.15.0 — analytics: fire on §10.2 PR-open success. slug
|
||||
// and pr_number are the join keys; title/description stay out.
|
||||
track(EVENTS.PR_OPENED, { rfc_slug: slug, pr_number })
|
||||
@@ -104,6 +110,20 @@ export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpen
|
||||
what was argued, what shifted, what the arbiters are asked
|
||||
to consider.
|
||||
</p>
|
||||
<label className="modal-label">What will you be using this change for? (optional)</label>
|
||||
<textarea
|
||||
className="modal-textarea"
|
||||
value={useCase}
|
||||
onChange={e => setUseCase(e.target.value)}
|
||||
placeholder="The concrete thing this change unlocks for you. Optional."
|
||||
disabled={drafting || submitting}
|
||||
rows={3}
|
||||
maxLength={8000}
|
||||
/>
|
||||
<p className="field-help">
|
||||
#26: the concrete ground-truth use case — distinct from "why
|
||||
it's needed" above. Leave blank if you'd rather not say.
|
||||
</p>
|
||||
{error && <p className="field-error">{error}</p>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
|
||||
@@ -220,6 +220,17 @@ export default function PRView({ viewer }) {
|
||||
{pr.description && (
|
||||
<p className="pr-description">{pr.description}</p>
|
||||
)}
|
||||
{/* #26: the optional ground-truth use case for this change,
|
||||
captured when the PR was opened. Muted "left blank"
|
||||
treatment when none was supplied. */}
|
||||
<div className="pr-use-case" style={{ margin: '6px 0', fontSize: 13 }}>
|
||||
<span style={{ fontWeight: 700, color: '#888', textTransform: 'uppercase', letterSpacing: '0.05em', fontSize: 11 }}>
|
||||
Intended use case:
|
||||
</span>{' '}
|
||||
{pr.proposed_use_case
|
||||
? <span style={{ whiteSpace: 'pre-wrap' }}>{pr.proposed_use_case}</span>
|
||||
: <span style={{ color: '#999', fontStyle: 'italic' }}>left blank</span>}
|
||||
</div>
|
||||
{pr.capabilities?.can_edit_text && (
|
||||
<button className="btn-link" onClick={startHeaderEdit}>Edit title & description</button>
|
||||
)}
|
||||
|
||||
@@ -163,6 +163,14 @@ export default function ProposalView({ viewer, onChange }) {
|
||||
className="entry-body"
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(data.entry?.body || '') }}
|
||||
/>
|
||||
|
||||
{/* #26: the optional ground-truth use case the proposer supplied. */}
|
||||
<h3 style={{ fontSize: 13, fontWeight: 700, color: '#888', textTransform: 'uppercase', letterSpacing: '0.05em', marginTop: 24 }}>
|
||||
Intended use case
|
||||
</h3>
|
||||
{data.proposed_use_case
|
||||
? <div className="entry-body" dangerouslySetInnerHTML={{ __html: marked.parse(data.proposed_use_case) }} />
|
||||
: <p style={{ color: '#999', fontStyle: 'italic' }}>Left blank by the proposer.</p>}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,17 +2,24 @@
|
||||
//
|
||||
// Title (required) and pitch (required textarea), with a slug field
|
||||
// that auto-fills from the title via the same deterministic kebab-case
|
||||
// the backend uses. Tags are chip-input (free-form for slice 1; the
|
||||
// AI-suggested chips of §9.1 are deferred to Slice 2 when the AI surface
|
||||
// is wired up).
|
||||
// the backend uses. Tags are chip-input (free-form), with the §9.1
|
||||
// Slice 2 AI-suggested chips (roadmap #27) wired in: as the draft fills
|
||||
// in, the backend asks Claude Haiku for tags drawn from the corpus's
|
||||
// existing tag set, surfaced as clickable suggestion chips. The assist
|
||||
// is best-effort — it stays silent when unavailable.
|
||||
//
|
||||
// The submit button drives the §17 POST /api/rfcs/propose endpoint;
|
||||
// success navigates the proposer to the pending-idea view per §9.3.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { proposeRFC } from '../api'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { proposeRFC, suggestTags } from '../api'
|
||||
import { EVENTS, track } from '../lib/analytics'
|
||||
|
||||
// How long the draft must sit unchanged before we ask for suggestions —
|
||||
// long enough to fire on typing pauses / field-blur, not on every
|
||||
// keystroke (the backend is also per-user rate-limited as a backstop).
|
||||
const SUGGEST_DEBOUNCE_MS = 700
|
||||
|
||||
function slugify(title) {
|
||||
return title
|
||||
.toLowerCase()
|
||||
@@ -26,21 +33,56 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
|
||||
const [slug, setSlug] = useState('')
|
||||
const [slugEdited, setSlugEdited] = useState(false)
|
||||
const [pitch, setPitch] = useState('')
|
||||
// #26: optional ground-truth use case, sibling to the required pitch.
|
||||
const [useCase, setUseCase] = useState('')
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
const [tags, setTags] = useState([])
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
// #27: Claude Haiku tag suggestions. `suggestions` is the latest
|
||||
// ranked list from the backend ({ tag, confidence }); we render the
|
||||
// subset not already chosen. `suggestedOnce` gates the disclosure +
|
||||
// row so they only appear after the assist has actually run.
|
||||
const [suggestions, setSuggestions] = useState([])
|
||||
const [suggestedOnce, setSuggestedOnce] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!slugEdited) setSlug(slugify(title))
|
||||
}, [title, slugEdited])
|
||||
|
||||
// #27: debounced tag-suggestion fetch. Fires after the draft sits
|
||||
// unchanged for SUGGEST_DEBOUNCE_MS, only once there's something to go
|
||||
// on (a title). A stale-response guard keeps an earlier in-flight
|
||||
// request from clobbering a newer one.
|
||||
const suggestSeq = useRef(0)
|
||||
useEffect(() => {
|
||||
if (!title.trim()) {
|
||||
setSuggestions([])
|
||||
return
|
||||
}
|
||||
const handle = setTimeout(async () => {
|
||||
const seq = ++suggestSeq.current
|
||||
const result = await suggestTags({ title, pitch, useCase })
|
||||
if (seq !== suggestSeq.current) return // a newer request superseded us
|
||||
setSuggestions(Array.isArray(result) ? result : [])
|
||||
if (result && result.length) setSuggestedOnce(true)
|
||||
}, SUGGEST_DEBOUNCE_MS)
|
||||
return () => clearTimeout(handle)
|
||||
}, [title, pitch, useCase])
|
||||
|
||||
// Suggestions the user hasn't already added.
|
||||
const freshSuggestions = suggestions.filter(s => !tags.includes(s.tag))
|
||||
|
||||
function addTag() {
|
||||
const t = tagInput.trim()
|
||||
if (t && !tags.includes(t)) setTags([...tags, t])
|
||||
setTagInput('')
|
||||
}
|
||||
|
||||
function addSuggested(tag) {
|
||||
if (!tags.includes(tag)) setTags([...tags, tag])
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (!title.trim() || !slug || !pitch.trim()) return
|
||||
@@ -52,6 +94,7 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
|
||||
slug,
|
||||
pitch: pitch.trim(),
|
||||
tags,
|
||||
proposedUseCase: useCase.trim() || null,
|
||||
})
|
||||
// v0.15.0 — analytics: fire on the §9.1 propose-RFC submit.
|
||||
// Slug is a stable, low-cardinality identifier (kebab-case
|
||||
@@ -105,6 +148,19 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
|
||||
required
|
||||
/>
|
||||
|
||||
<label htmlFor="propose-use-case">What will you be using this RFC for? (optional)</label>
|
||||
<textarea
|
||||
id="propose-use-case"
|
||||
value={useCase}
|
||||
onChange={e => setUseCase(e.target.value)}
|
||||
placeholder="The concrete thing you intend to build or do with this RFC. Optional, but it helps ground the work."
|
||||
rows={3}
|
||||
/>
|
||||
<p className="field-help">
|
||||
The concrete ground-truth use case — distinct from "why it's
|
||||
needed" above. Leave blank if you'd rather not say.
|
||||
</p>
|
||||
|
||||
<label htmlFor="propose-tag">Tags (optional)</label>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 4 }}>
|
||||
<input
|
||||
@@ -136,6 +192,45 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* #27: Claude Haiku suggested tags. Clickable chips that add
|
||||
to the tag list; nothing auto-applies. The disclosure is
|
||||
required — the draft text is sent to Anthropic to generate
|
||||
these — so it renders whenever the suggestion row does. */}
|
||||
{(freshSuggestions.length > 0 || (suggestedOnce && suggestions.length > 0)) && (
|
||||
<div style={{ marginTop: 4, marginBottom: 14 }}>
|
||||
{freshSuggestions.length > 0 && (
|
||||
<>
|
||||
<p className="field-help" style={{ marginTop: 0, marginBottom: 4 }}>
|
||||
Suggested tags — click to add:
|
||||
</p>
|
||||
<div>
|
||||
{freshSuggestions.map(s => (
|
||||
<button
|
||||
key={s.tag}
|
||||
type="button"
|
||||
className="entry-tag"
|
||||
onClick={() => addSuggested(s.tag)}
|
||||
title={`Add "${s.tag}"`}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
marginRight: 4,
|
||||
marginBottom: 4,
|
||||
border: '1px dashed var(--color-border, #ccc)',
|
||||
background: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>+ {s.tag}</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p className="field-help" style={{ marginTop: 4, marginBottom: 0, fontStyle: 'italic' }}>
|
||||
Suggestions are generated by Claude (Anthropic). The text you've
|
||||
entered above is sent to Anthropic to produce them.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewer && (
|
||||
<p className="field-help" style={{ marginTop: 14, marginBottom: 0 }}>
|
||||
Owner: <strong>{viewer.display_name || viewer.gitea_login}</strong> — you'll be the first owner of this super-draft. Additional owners can claim later (§13.1).
|
||||
|
||||
@@ -669,6 +669,19 @@ export default function RFCView({ viewer }) {
|
||||
: 'main is read-only — PRs are the only path to change it. Open a branch to propose edits.'}
|
||||
</div>
|
||||
)}
|
||||
{/* #26: the optional ground-truth use case captured at propose
|
||||
time. Shown on the canonical (main) view; muted "left blank"
|
||||
treatment when the proposer didn't supply one. */}
|
||||
{branchParam === 'main' && (
|
||||
<div className="rfc-use-case" style={{ margin: '8px 0 16px', padding: '10px 14px', borderLeft: '3px solid #e0e0e0', background: '#fafafa' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#888', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>
|
||||
Intended use case
|
||||
</div>
|
||||
{entry.proposed_use_case
|
||||
? <div style={{ whiteSpace: 'pre-wrap' }}>{entry.proposed_use_case}</div>
|
||||
: <span style={{ color: '#999', fontStyle: 'italic' }}>Left blank by the proposer.</span>}
|
||||
</div>
|
||||
)}
|
||||
{inDiscuss && branchParam !== 'main' && (
|
||||
<div className="discuss-mode-banner">
|
||||
Discuss mode on <strong>{branchParam}</strong> — chat freely;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// useLastState — v0.23.0 / roadmap item #29: server-side sign-in state
|
||||
// resume. Two responsibilities, kept in one small hook so App.jsx's
|
||||
// surface stays minimal:
|
||||
//
|
||||
// 1. Debounce-post the authenticated user's current route to
|
||||
// `PUT /api/me/last-state` on every route change (~1s debounce so
|
||||
// a fast click-through doesn't spray the endpoint). Anonymous
|
||||
// users: no-op. Best-effort: a failed POST never disrupts nav.
|
||||
//
|
||||
// 2. On the first authenticated load, read the stored `last_route`
|
||||
// (handed back on `/api/auth/me`) and `navigate()` to it ONCE.
|
||||
// Stale routes (a withdrawn RFC, a slug the user lost rights to)
|
||||
// are not special-cased here: navigating lands on whatever that
|
||||
// route renders today, and the existing routing already falls
|
||||
// through to the catalog/empty-state for a missing RFC. Keeping
|
||||
// this dumb is deliberate (see SPEC §6.2 + the #29 scope note).
|
||||
//
|
||||
// Ordering with #21 Part C (Amplitude identify): App.jsx fires
|
||||
// `identify` in its own effect when `me.user.id` first appears. This
|
||||
// hook's resume redirect is gated on `identifyReady` — App.jsx flips it
|
||||
// true only after the identify effect has run — so the redirect always
|
||||
// happens AFTER identify, preserving the identify-then-track ordering
|
||||
// the roadmap calls out.
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { putLastState } from '../api'
|
||||
|
||||
// ~1s debounce on the route-change POST. A frontend constant, not a
|
||||
// server knob — the backend takes whatever lands.
|
||||
const DEBOUNCE_MS = 1000
|
||||
|
||||
// Routes we never want to resume *to* — auth/landing surfaces that
|
||||
// would be nonsensical or hostile to drop a returning user onto. We
|
||||
// still record them (cheap, and the user may legitimately be sitting on
|
||||
// /docs), but the resume redirect skips them and falls through to the
|
||||
// default landing. Anything not listed resumes normally.
|
||||
const NON_RESUMABLE_PREFIXES = ['/login', '/welcome', '/invites/', '/invitations/']
|
||||
|
||||
function isResumable(route) {
|
||||
if (!route || typeof route !== 'string') return false
|
||||
if (route === '/') return false // "/" is already the default landing
|
||||
return !NON_RESUMABLE_PREFIXES.some(p => route.startsWith(p))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {boolean} opts.authenticated whether a viewer is signed in
|
||||
* @param {string} opts.pathname current location.pathname
|
||||
* @param {boolean} opts.identifyReady App flips true after identify fires
|
||||
* @param {string|null} opts.lastRoute stored route from /api/auth/me
|
||||
* @param {function} opts.navigate react-router navigate()
|
||||
*/
|
||||
export function useLastState({ authenticated, pathname, identifyReady, lastRoute, navigate }) {
|
||||
// ── 1. Debounced route-change POST ────────────────────────────────
|
||||
const timerRef = useRef(null)
|
||||
useEffect(() => {
|
||||
if (!authenticated) return undefined
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => {
|
||||
// Best-effort — swallow failures so an offline/401 POST never
|
||||
// surfaces as a navigation error.
|
||||
putLastState(pathname).catch(() => {})
|
||||
}, DEBOUNCE_MS)
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [authenticated, pathname])
|
||||
|
||||
// ── 2. One-time resume redirect ───────────────────────────────────
|
||||
// Fires once, after identify is ready, when there's a resumable
|
||||
// stored route AND the user is currently sitting on the default
|
||||
// landing ("/"). We only redirect from "/" so we never yank a user
|
||||
// who deep-linked somewhere specific (or refreshed mid-RFC) back to
|
||||
// their last route.
|
||||
const resumedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (resumedRef.current) return
|
||||
if (!authenticated || !identifyReady) return
|
||||
// Only resume when the app booted on the default landing — a hard
|
||||
// sign-in nav lands on "/", which is exactly the case we want.
|
||||
if (pathname !== '/') {
|
||||
resumedRef.current = true // user deep-linked; don't resume later either
|
||||
return
|
||||
}
|
||||
if (isResumable(lastRoute)) {
|
||||
resumedRef.current = true
|
||||
navigate(lastRoute, { replace: true })
|
||||
} else {
|
||||
resumedRef.current = true // nothing to resume to; keep default landing
|
||||
}
|
||||
}, [authenticated, identifyReady, lastRoute, pathname, navigate])
|
||||
}
|
||||
Reference in New Issue
Block a user