From 37ad39c8a31d7d0b26c9d632c1357345bb423678 Mon Sep 17 00:00:00 2001
From: Vojtech <119944107+cvrysanek@users.noreply.github.com>
Date: Thu, 14 May 2026 13:28:47 +0400
Subject: [PATCH] feat(home): status frame on /home (operator-gated,
onboarded-only) (#297)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(home): status frame on /home — last sync, sessions, prompts, tokens, projects
Adds the homepage status frame: a 5-card row above the install-hero /
offboard-strip on /home showing the calling user's Last sync (their
last `agnes pull`), Sessions, Prompts, Tokens used, and Projects worked
on, with a 24h/7d pill toggle.
Backed by `GET /api/me/home-stats?window=` (one DuckDB CTE joining
`users` + `usage_session_summary` + `usage_events`) and SSR'd from the
same `compute_home_stats` helper on initial paint so there's no
spinner. The window toggle is the only JS-driven path.
Side surfaces:
- `GET /api/sync/manifest` now stamps `users.last_pull_at` so
`agnes pull` (and the Claude Code SessionStart hook that wraps it)
imprints the analyst's last sync time for the new card.
- `usage_session_summary` gains four BIGINT token counters
(input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens)
summed from JSONL `message.usage.*` per assistant turn.
- `USAGE_PROCESSOR_VERSION` bumps 1 → 2 so the session-pipeline
reprocess loop invalidates stale summaries and backfills tokens
on the next tick.
Schema migration v43 → v44 is idempotent ALTERs (last_pull_at +
4 token columns) — fresh installs receive them from `_SYSTEM_SCHEMA`,
upgrade path runs `_v43_to_v44`. Defaults (NULL / 0) backfill
existing rows cleanly.
9 new tests in tests/test_home_stats.py cover the migration,
endpoint shapes (24h/7d/unknown/empty/missing-user), and the
manifest-side last_pull_at bump.
* docs(CHANGELOG): homepage status frame entries under [Unreleased]
The post-rebase release-cut now belongs to whichever PR lands next
after main rolled to 0.54.9. This PR logs its bullets under
[Unreleased] (Added: homepage status frame, per-user pull tracking,
token counters; Changed: schema v43 → v44 migration) so they ride
out with the next release-cut.
* fix(tests): bump test_schema_v42_migration asserts to v44
CI failed because tests/test_schema_v42_migration.py hardcoded
`assert SCHEMA_VERSION == 43` and `assert v == 43` after init.
v44 (homepage stats frame backing columns) was introduced in the
preceding feat commit; this aligns the existing v42-era migration
tests with the new schema version.
* feat(home): gate status frame on operator flag + user.onboarded
Two gates on the homepage status frame:
1. **Operator master switch** — `get_home_status_frame_visibility()` in
app/instance_config.py mirrors the existing `get_home_automode_visibility()`
shape: env var `AGNES_HOME_SHOW_STATUS_FRAME` > yaml
`instance.home.show_status_frame` > default `True`. Cautious-rollout
instances can disable the frame without forking; the yaml example
documents both knobs.
2. **Onboarded gate** — the template only renders the frame when the
caller's `users.onboarded` is true. First-day users see a clean
install-hero before all-zero stat cards; the frame appears
automatically on the next render after `agnes init` POSTs
`/api/me/onboarded`.
Router skips the `compute_home_stats` DB read entirely when either
gate is closed; `home_stats` arrives at the template as None in that
branch and the `{% if %}` shortcuts the include.
Why both gates: PostHog feature flags evaluated and rejected — this
codebase uses PostHog for analytics capture only, not feature gating;
adding a per-user feature_enabled() call on the /home critical path
would couple the homepage render to a remote eval and still require
an admin master switch. The onboarded gate is a UX coherence rule
layered on top of the operator switch, not an A/B test signal.
3 new tests in test_home_stats.py cover the env-var resolution
(falsey values + default-true). The yaml example gets a `home:`
block documenting both `show_automode` (pre-existing flag, was
undocumented in the example) and `show_status_frame`.
---
CHANGELOG.md | 34 +++
app/api/me.py | 131 +++++++++
app/api/sync.py | 20 +-
app/instance_config.py | 26 ++
app/web/router.py | 16 ++
app/web/templates/_home_stats.html | 205 ++++++++++++++
app/web/templates/home_not_onboarded.html | 11 +
config/instance.yaml.example | 7 +
services/session_processors/usage_lib.py | 31 ++-
src/db.py | 56 +++-
src/repositories/usage.py | 9 +-
tests/test_db_schema_version.py | 13 +-
tests/test_home_stats.py | 319 ++++++++++++++++++++++
tests/test_schema_v42_migration.py | 13 +-
14 files changed, 877 insertions(+), 14 deletions(-)
create mode 100644 app/web/templates/_home_stats.html
create mode 100644 tests/test_home_stats.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f00d80..c067806 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,7 +10,41 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
## [Unreleased]
+### Added
+
+- **Homepage status frame.** The `/home` page now opens with a 5-card
+ status row above the install-hero / offboard-strip: **Last sync**
+ (your last `agnes pull`), **Sessions**, **Prompts**, **Tokens used**,
+ **Projects worked on**. A pill toggle switches the window between
+ 24h (default) and 7d. Backed by `GET /api/me/home-stats?window=` which
+ joins `users`, `usage_session_summary`, and `usage_events` in a
+ single DuckDB round-trip; the initial paint is SSR'd from the same
+ helper (`app.api.me.compute_home_stats`) so there's no spinner.
+ Visibility is gated on (a) the operator flag
+ `instance.home.show_status_frame` (yaml) /
+ `AGNES_HOME_SHOW_STATUS_FRAME` (env), default `true`, AND (b) the
+ caller being `onboarded`. Cautious-rollout instances can hide the
+ frame entirely; on every install, first-day users still see a clean
+ install-hero before zero-value stats show up.
+- **Per-user pull tracking.** `GET /api/sync/manifest` now stamps
+ `users.last_pull_at` as a side effect. `agnes pull` (and the
+ Claude Code `SessionStart` hook that wraps it) imprints the
+ analyst's "last sync" timestamp for the new homepage card.
+- **Token counters on `usage_session_summary`.** Four new BIGINT
+ columns (`input_tokens`, `output_tokens`, `cache_read_tokens`,
+ `cache_creation_tokens`) summed from JSONL `message.usage.*` per
+ assistant turn. `USAGE_PROCESSOR_VERSION` bumps 1 → 2, which the
+ session-pipeline reprocess loop uses to invalidate stale summaries
+ and backfill tokens on the next tick.
+
### Changed
+
+- Schema migration **v43 → v44** (`_v43_to_v44`): idempotent `ALTER
+ TABLE … ADD COLUMN IF NOT EXISTS` for `users.last_pull_at` plus the
+ four token columns above. Fresh installs receive them inline from
+ `_SYSTEM_SCHEMA`; upgrade path runs the function. All new columns
+ default to NULL / 0 so existing rows backfill cleanly without a
+ separate migration step.
- **Marketplace cover photos served with aggressive browser caching.**
`/api/marketplace/curated/.../asset/...`, `/api/marketplace/curated/.../mirrored/...`,
and `/api/store/entities/{id}/photo` now respond with
diff --git a/app/api/me.py b/app/api/me.py
index 913890f..7f5c41d 100644
--- a/app/api/me.py
+++ b/app/api/me.py
@@ -57,3 +57,134 @@ async def post_onboarded(
result="ok",
)
return {"status": "ok", "onboarded": target}
+
+
+# ---------------------------------------------------------------------------
+# GET /api/me/home-stats — backing data for the /home status frame
+# ---------------------------------------------------------------------------
+
+
+_WINDOW_INTERVALS = {
+ "24h": "INTERVAL 24 HOUR",
+ "7d": "INTERVAL 7 DAY",
+}
+
+
+def _username_for_stats(user: dict) -> str:
+ """Map a users row to the filesystem username used by the session
+ collector and stored in ``usage_session_summary.username``.
+
+ Mirrors ``app.api.admin_user_sessions._username_from_user``: the
+ session collector writes JSONL under the OS username of the agent
+ process which, for current deployments, equals the email local-part.
+ Kept inline here so this endpoint has no cross-module dependency on
+ an admin-only helper; if the mapping evolves both copies must update.
+ """
+ email: str = user.get("email", "") or ""
+ return email.split("@")[0] if "@" in email else email
+
+
+def compute_home_stats(
+ conn: duckdb.DuckDBPyConnection, user: dict, window: str = "24h"
+) -> dict:
+ """Pure helper that returns the home-stats payload for the given user.
+
+ Shared by the HTTP endpoint and the /home Jinja handler (server-side
+ initial render). Unknown windows clamp to ``24h`` so callers never
+ need to pre-validate. Returns a dict with ISO-stringified
+ ``last_pull_at`` (or None) so the same shape works for both JSON
+ serialization and Jinja rendering.
+ """
+ interval = _WINDOW_INTERVALS.get(window)
+ if interval is None:
+ window = "24h"
+ interval = _WINDOW_INTERVALS["24h"]
+
+ username = _username_for_stats(user)
+
+ # f-string interpolates only the validated interval literal above;
+ # all user-controlled input flows through bound parameters.
+ sql = f"""
+ WITH win AS (
+ SELECT current_timestamp - {interval} AS since
+ ),
+ sess AS (
+ SELECT
+ COUNT(*) AS sessions,
+ COALESCE(SUM(user_messages), 0) AS prompts,
+ COALESCE(SUM(input_tokens), 0) AS input_tokens,
+ COALESCE(SUM(output_tokens), 0) AS output_tokens,
+ COALESCE(SUM(cache_read_tokens), 0) AS cache_read,
+ COALESCE(SUM(cache_creation_tokens), 0) AS cache_creation
+ FROM usage_session_summary, win
+ WHERE username = ? AND started_at >= win.since
+ ),
+ proj AS (
+ SELECT COUNT(DISTINCT cwd) AS projects
+ FROM usage_events, win
+ WHERE username = ?
+ AND cwd IS NOT NULL
+ AND occurred_at >= win.since
+ ),
+ u AS (
+ SELECT last_pull_at FROM users WHERE id = ?
+ )
+ SELECT
+ u.last_pull_at,
+ sess.sessions, sess.prompts,
+ sess.input_tokens, sess.output_tokens,
+ sess.cache_read, sess.cache_creation,
+ proj.projects
+ FROM u, sess, proj
+ """
+ row = conn.execute(sql, [username, username, user["id"]]).fetchone()
+
+ if row is None:
+ return {
+ "window": window,
+ "last_pull_at": None,
+ "sessions": 0,
+ "prompts": 0,
+ "tokens": {
+ "input": 0, "output": 0,
+ "cache_read": 0, "cache_creation": 0,
+ "total": 0,
+ },
+ "projects": 0,
+ }
+
+ (last_pull_at, sessions, prompts,
+ input_t, output_t, cache_read, cache_creation, projects) = row
+ return {
+ "window": window,
+ "last_pull_at": last_pull_at.isoformat() if last_pull_at else None,
+ "sessions": int(sessions or 0),
+ "prompts": int(prompts or 0),
+ "tokens": {
+ "input": int(input_t or 0),
+ "output": int(output_t or 0),
+ "cache_read": int(cache_read or 0),
+ "cache_creation": int(cache_creation or 0),
+ "total": int((input_t or 0) + (output_t or 0)
+ + (cache_read or 0) + (cache_creation or 0)),
+ },
+ "projects": int(projects or 0),
+ }
+
+
+@router.get("/home-stats")
+async def get_home_stats(
+ window: str = "24h",
+ user: dict = Depends(get_current_user),
+ conn: duckdb.DuckDBPyConnection = Depends(_get_db),
+):
+ """Return the five counters rendered in the /home status frame for
+ the calling user, over a 24-hour or 7-day window.
+
+ Single round-trip: one DuckDB query joins ``users``,
+ ``usage_session_summary``, and ``usage_events`` so the homepage
+ renders without N+1. Missing rows (new user, no telemetry yet)
+ surface as zeros / null rather than 404 — the frame still renders
+ cleanly for first-day analysts.
+ """
+ return compute_home_stats(conn, user, window)
diff --git a/app/api/sync.py b/app/api/sync.py
index cb9c486..1b1cf94 100644
--- a/app/api/sync.py
+++ b/app/api/sync.py
@@ -798,7 +798,25 @@ async def sync_manifest(
user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
- """Return hash-based manifest of all synced data, filtered per user."""
+ """Return hash-based manifest of all synced data, filtered per user.
+
+ Side-effect: stamps ``users.last_pull_at`` so the /home status frame
+ can show when the analyst last pulled. This GET is the canonical
+ "I am about to sync" signal — agnes pull hits it first, then
+ downloads parquets whose hash changed. UI bumps (manifest browsed in
+ a browser session) also count; cheap and accurate enough for a
+ homepage card.
+ """
+ try:
+ conn.execute(
+ "UPDATE users SET last_pull_at = current_timestamp WHERE id = ?",
+ [user["id"]],
+ )
+ except Exception:
+ # Never block a pull because the stamp UPDATE hit a transient
+ # issue (locked WAL, partial migration window). The manifest
+ # itself is the load-bearing payload.
+ pass
return _build_manifest_for_user(conn, user)
diff --git a/app/instance_config.py b/app/instance_config.py
index 207523e..14d27da 100644
--- a/app/instance_config.py
+++ b/app/instance_config.py
@@ -250,6 +250,32 @@ def get_home_automode_visibility() -> bool:
return str(raw).strip().lower() not in ("0", "false", "no", "off", "")
+def get_home_status_frame_visibility() -> bool:
+ """Whether /home renders the homepage status frame (Last sync,
+ Sessions, Prompts, Tokens, Projects).
+
+ The template ALSO gates rendering on ``users.onboarded`` so a
+ fresh user sees a clean install-hero before the all-zero stat
+ cards. This helper is the operator-level master switch; the
+ onboarding gate is a UX coherence rule layered on top.
+
+ Cautious-rollout instances that would rather not expose token
+ counters to analysts yet can disable with
+ ``AGNES_HOME_SHOW_STATUS_FRAME=0`` (or
+ ``instance.home.show_status_frame: false`` in YAML).
+
+ Resolution: env var > ``instance.home.show_status_frame`` YAML > True.
+ Shape mirrors :func:`get_home_automode_visibility` so Terraform
+ overrides land the same way.
+ """
+ raw = os.environ.get("AGNES_HOME_SHOW_STATUS_FRAME")
+ if raw is None:
+ raw = get_value("instance", "home", "show_status_frame", default=True)
+ if isinstance(raw, bool):
+ return raw
+ return str(raw).strip().lower() not in ("0", "false", "no", "off", "")
+
+
def get_instance_name() -> str:
return get_value("instance", "name", default="AI Data Analyst")
diff --git a/app/web/router.py b/app/web/router.py
index b31a11d..fd089f2 100644
--- a/app/web/router.py
+++ b/app/web/router.py
@@ -693,6 +693,20 @@ async def home_page(
news = NewsTemplateRepository(conn).get_current_published()
news_intro = news["intro"] if (news and news.get("intro")) else ""
+ # Homepage status frame (Last sync, Sessions, Prompts, Tokens, Projects).
+ # Gated on (a) operator flag instance.home.show_status_frame /
+ # AGNES_HOME_SHOW_STATUS_FRAME (default on), AND (b) the user being
+ # onboarded — first-day users see a clean install-hero before zero-value
+ # stat cards. When either gate is closed we skip the DB read entirely.
+ from app.api.me import compute_home_stats
+ from app.instance_config import get_home_status_frame_visibility
+ status_frame_enabled = get_home_status_frame_visibility()
+ home_stats = (
+ compute_home_stats(conn, user, "24h")
+ if (status_frame_enabled and onboarded)
+ else None
+ )
+
# Single template renders both states. The post-onboarding view keeps
# the install-steps + connector prompts + auto-mode card visible —
# they stay relevant for adding a second machine, a missing connector,
@@ -706,6 +720,8 @@ async def home_page(
onboarded=onboarded,
is_admin=is_user_admin(user["id"], conn),
news_intro=news_intro,
+ home_stats=home_stats,
+ status_frame_enabled=status_frame_enabled,
)
return templates.TemplateResponse(request, "home_not_onboarded.html", ctx)
diff --git a/app/web/templates/_home_stats.html b/app/web/templates/_home_stats.html
new file mode 100644
index 0000000..c42a962
--- /dev/null
+++ b/app/web/templates/_home_stats.html
@@ -0,0 +1,205 @@
+{# Homepage status frame — five counters with a 24h/7d window toggle.
+
+ Initial paint is server-rendered from `home_stats` in ctx (no
+ spinner, no FOUC). The toggle JS calls GET /api/me/home-stats?window=
+ and rewrites the card values in place. #}
+
+
+
+
+
+
Status
+
+
+
+
+
+
+
+
+
Last sync
+
+ {% if home_stats.last_pull_at %}—{% else %}never{% endif %}
+