feat(me/stats): per-analyst Stats dashboard with 4 tabs (#298)

* feat(me/stats): per-analyst Stats dashboard with 4 tabs

New /me/stats page shows the calling user's own analytics across
four tabs, lazy-loaded per activation:

- **Sessions** — paginated usage_session_summary join with a
  filesystem scan of un-processed JSONL (mirrors admin
  list_user_sessions shape). v44 token columns aggregated per row.
- **Tokens** — daily series (default 30 days), by-model breakdown
  (lifetime), top-10 biggest sessions, lifetime totals. Single
  CTE per sub-query against per-user partition (idx_usage_session_user).
- **Data access** — audit_log rows where action LIKE 'query.%' for
  the caller. Covers query.local / query.hybrid / query.remote /
  query.internal. Cursor-paginated on (timestamp, id).
- **Sync activity** — audit_log rows where action is sync.* or
  manifest.* for the caller, plus users.last_pull_at for the
  header. Per-pull history now persists thanks to the new
  manifest.fetch audit row.

Backend: app/api/me_stats.py — single APIRouter at /api/me/stats/*,
four GET endpoints, all gated by get_current_user (server-side
caller scope; the page route itself only renders the shell).

Frontend: app/web/templates/me_stats.html — tab bar + 4 panels,
plain JS lazy-loads each panel's endpoint on first activation,
caches per-tab so switching back doesn't refetch. Small SVG bar
chart on Tokens tab (no external charting dep). 'Stats' link
added to _app_header.html primary nav between 'Data Packages'
and the Admin dropdown.

Side change in app/api/sync.py: /api/sync/manifest now emits a
manifest.fetch audit_log row alongside the existing
users.last_pull_at bump. The column UPDATE only retains the
most recent timestamp; per-pull history needs an audit row.
client_kind='api' for the manifest endpoint (vs. 'web' which
the audit-read deduper uses for AC reads), so the Sync tab can
distinguish CLI pulls from browser-driven manifest peeks.

7 new tests in tests/test_me_stats.py:
- sessions endpoint caller-scope isolation (user A doesn't see B)
- sessions pagination
- tokens empty-user zero shape
- tokens aggregation across daily window + by_model + top + totals
- queries endpoint filters to action LIKE 'query.%' + caller scope
- sync endpoint surfaces both manifest.fetch and sync.trigger
- manifest endpoint writes the manifest.fetch audit row

* ui(me/stats): widen page to 1400px via main.main escape

Default base.html .container wraps content at max-width 800px. Stats
tables (by-model + top-sessions: 6 columns each) felt cramped at that
width — same constraint dashboard.html escapes via the {% block layout %}
override pattern. Mirror that here: render <main class="main"> and
bump .stats-page max-width to 1400px so the 6-column tables breathe
without going edge-to-edge on wide monitors.

* ui(me/stats): narrow from 1400px to 1280px to match /home

/home isn't actually .container's default 800px — style-custom.css
has a body:has(.home-mock) .container { max-width: 1280px } override
that widens it. 1280px is the shared 'wide content' width across the
codebase (top-nav header + /home + dashboard all use it).

Bumping me_stats from 1400px to 1280px so the Stats page reads as
'same chrome' instead of distinctly wider than its sibling pages.
This commit is contained in:
Vojtech 2026-05-14 14:27:58 +04:00 committed by GitHub
parent 37ad39c8a3
commit aa6a6700f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1247 additions and 3 deletions

View file

@ -12,6 +12,29 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
### Added
- **Per-analyst Stats dashboard at `/me/stats`.** Four-tab page showing
the calling user's own data, lazy-loaded per tab:
- **Sessions** — paginated `usage_session_summary` rows + filesystem
scan of un-processed JSONL (matches the admin `list_user_sessions`
shape). Includes the v44 token columns aggregated per row.
- **Tokens** — daily series (default last 30 days), by-model
breakdown (lifetime), top-10 biggest sessions, lifetime totals.
- **Data access**`audit_log` rows where `action LIKE 'query.%'`
for the caller (covers `query.local`, `query.hybrid`, `query.remote`,
`query.internal`). Cursor-paginated on `(timestamp, id)`.
- **Sync activity**`audit_log` rows where action is `sync.*` or
`manifest.*` for the caller, plus the user's `last_pull_at` for the
header. Per-pull history now persists thanks to the new
`manifest.fetch` audit row.
Backed by `GET /api/me/stats/{sessions,tokens,queries,sync}`,
authed-only, server-side caller-scope. New "Stats" link added to the
primary nav between "Data Packages" and the Admin dropdown.
- **`manifest.fetch` audit_log row** written from
`GET /api/sync/manifest` alongside the `users.last_pull_at` bump.
Surfaces per-pull history (the column UPDATE only retains the most
recent timestamp) so the Sync activity tab and any other
audit-log-driven view can render a timeline.
- **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**,

454
app/api/me_stats.py Normal file
View file

@ -0,0 +1,454 @@
"""Self-scoped Stats endpoints for /me/stats — the analyst's own
analytics dashboard.
Four tabs, four endpoints, all gated by ``get_current_user`` so a
caller can only see their own data:
- ``GET /api/me/stats/sessions?limit=&offset=`` paginated session
list joined from ``usage_session_summary`` (post-processor) with a
filesystem scan of un-processed JSONL (matches the admin
``list_user_sessions`` shape).
- ``GET /api/me/stats/tokens?days=30`` daily token series + by-model
breakdown + top-10 biggest sessions. Powers the Tokens tab chart.
- ``GET /api/me/stats/queries?cursor_ts=&cursor_id=&limit=``
``audit_log`` rows where ``action LIKE 'query.%'`` (BQ + local
DuckDB queries) for this user. Cursor-paginated (keyset on
``(timestamp, id)``).
- ``GET /api/me/stats/sync?cursor_ts=&cursor_id=&limit=``
``audit_log`` rows where action is ``sync.*`` or ``manifest.*``,
plus the user's ``last_pull_at`` for prominent header rendering.
Username derivation: ``_username_for_stats(user)`` reuses the
email-local-part rule from ``app.api.me`` so the joins on
``usage_*`` rows (filesystem-derived OS username) align with what
the session collector writes.
"""
from __future__ import annotations
import logging
import os
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
import duckdb
from fastapi import APIRouter, Depends, Query
from app.auth.dependencies import _get_db, get_current_user
from src.repositories.audit import AuditRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/me/stats", tags=["me"])
def _username_for_stats(user: dict) -> str:
"""Email local-part → filesystem username, mirroring the rule in
``app.api.me._username_for_stats``. Kept inline so this module
has no cross-import dependency on ``me.py``; if the mapping
evolves both copies update.
"""
email: str = user.get("email", "") or ""
return email.split("@")[0] if "@" in email else email
def _session_data_dir() -> Path:
"""Match ``app.api.admin_user_sessions._session_data_dir``."""
return Path(
os.environ.get("SESSION_DATA_DIR")
or os.environ.get("AGNES_SESSION_DATA_DIR")
or "/data/sessions"
)
# ---------------------------------------------------------------------------
# Sessions tab
# ---------------------------------------------------------------------------
@router.get("/sessions")
def list_self_sessions(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
) -> dict:
"""Paginated session list for the calling user.
Joins ``usage_session_summary`` (processed=true) with a filesystem
scan of un-processed JSONL so a session appears immediately even
before the UsageProcessor runs. Mirrors the admin
``list_user_sessions`` projection plus the v44 token columns
the Stats tab renders one row per session with totals on the
right.
"""
username = _username_for_stats(user)
user_dir = _session_data_dir() / username
try:
rows_db = conn.execute(
"""
SELECT
session_file, session_id, started_at, ended_at,
active_seconds, wall_seconds,
user_messages, tool_calls, tool_errors,
input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens,
primary_model
FROM usage_session_summary
WHERE username = ?
ORDER BY started_at DESC NULLS LAST
""",
[username],
).fetchall()
except Exception:
# usage_session_summary may not exist on a partially-migrated DB.
# Fall back to filesystem-only listing rather than 500.
rows_db = []
cols = [
"session_file", "session_id", "started_at", "ended_at",
"active_seconds", "wall_seconds",
"user_messages", "tool_calls", "tool_errors",
"input_tokens", "output_tokens",
"cache_read_tokens", "cache_creation_tokens",
"primary_model",
]
processed: dict[str, dict] = {}
for r in rows_db:
d = dict(zip(cols, r))
for k in ("started_at", "ended_at"):
v = d.get(k)
if v is not None and hasattr(v, "isoformat"):
d[k] = v.isoformat()
d["tokens_total"] = (
int(d.get("input_tokens") or 0)
+ int(d.get("output_tokens") or 0)
+ int(d.get("cache_read_tokens") or 0)
+ int(d.get("cache_creation_tokens") or 0)
)
d["processed"] = True
processed[d["session_file"]] = d
all_rows: list[dict] = list(processed.values())
if user_dir.is_dir():
for p in sorted(
user_dir.glob("*.jsonl"),
key=lambda x: x.stat().st_mtime,
reverse=True,
):
if p.name in processed:
continue
mtime = datetime.fromtimestamp(p.stat().st_mtime).isoformat()
all_rows.append({
"session_file": p.name,
"session_id": p.stem,
"started_at": mtime,
"ended_at": None,
"active_seconds": 0,
"wall_seconds": 0,
"user_messages": 0,
"tool_calls": 0,
"tool_errors": 0,
"input_tokens": 0,
"output_tokens": 0,
"cache_read_tokens": 0,
"cache_creation_tokens": 0,
"tokens_total": 0,
"primary_model": None,
"processed": False,
})
all_rows.sort(
key=lambda r: r.get("started_at") or "",
reverse=True,
)
total = len(all_rows)
page = all_rows[offset : offset + limit]
return {
"total": total,
"offset": offset,
"limit": limit,
"rows": page,
}
# ---------------------------------------------------------------------------
# Tokens tab
# ---------------------------------------------------------------------------
@router.get("/tokens")
def get_tokens(
days: int = Query(30, ge=1, le=365),
user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
) -> dict:
"""Token breakdown for the Tokens tab.
Returns a daily series (last *days* days), by-model breakdown
(lifetime), top-10 biggest sessions (by total tokens, lifetime),
and the lifetime grand total. Single round-trip via three
sub-queries each scans the same per-user partition of
``usage_session_summary`` which the
``idx_usage_session_user`` index supports.
"""
username = _username_for_stats(user)
# Daily series — interval literal interpolated from validated `days`.
daily = conn.execute(
f"""
SELECT
CAST(started_at AS DATE) AS day,
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,
COUNT(*) AS sessions
FROM usage_session_summary
WHERE username = ?
AND started_at >= current_timestamp - INTERVAL {int(days)} DAY
GROUP BY 1
ORDER BY 1
""",
[username],
).fetchall()
daily_series = [
{
"day": d.isoformat() if hasattr(d, "isoformat") else str(d),
"input": int(i or 0),
"output": int(o or 0),
"cache_read": int(cr or 0),
"cache_creation": int(cc or 0),
"sessions": int(s or 0),
"total": int((i or 0) + (o or 0) + (cr or 0) + (cc or 0)),
}
for (d, i, o, cr, cc, s) in daily
]
by_model = conn.execute(
"""
SELECT
COALESCE(primary_model, '(unknown)') AS model,
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,
COUNT(*) AS sessions
FROM usage_session_summary
WHERE username = ?
GROUP BY 1
ORDER BY (
COALESCE(SUM(input_tokens), 0)
+ COALESCE(SUM(output_tokens), 0)
+ COALESCE(SUM(cache_read_tokens), 0)
+ COALESCE(SUM(cache_creation_tokens), 0)
) DESC
""",
[username],
).fetchall()
model_breakdown = [
{
"model": m, "input": int(i or 0), "output": int(o or 0),
"cache_read": int(cr or 0), "cache_creation": int(cc or 0),
"sessions": int(s or 0),
"total": int((i or 0) + (o or 0) + (cr or 0) + (cc or 0)),
}
for (m, i, o, cr, cc, s) in by_model
]
top_sessions = conn.execute(
"""
SELECT
session_file, session_id, started_at, primary_model,
input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens,
(COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0)
+ COALESCE(cache_read_tokens, 0) + COALESCE(cache_creation_tokens, 0))
AS tokens_total
FROM usage_session_summary
WHERE username = ?
ORDER BY tokens_total DESC
LIMIT 10
""",
[username],
).fetchall()
top = [
{
"session_file": sf,
"session_id": sid,
"started_at": st.isoformat() if hasattr(st, "isoformat") else st,
"primary_model": pm,
"input": int(i or 0), "output": int(o or 0),
"cache_read": int(cr or 0), "cache_creation": int(cc or 0),
"total": int(tt or 0),
}
for (sf, sid, st, pm, i, o, cr, cc, tt) in top_sessions
]
totals_row = conn.execute(
"""
SELECT
COALESCE(SUM(input_tokens), 0),
COALESCE(SUM(output_tokens), 0),
COALESCE(SUM(cache_read_tokens), 0),
COALESCE(SUM(cache_creation_tokens), 0),
COUNT(*)
FROM usage_session_summary
WHERE username = ?
""",
[username],
).fetchone()
ti, to, tcr, tcc, tses = totals_row or (0, 0, 0, 0, 0)
totals = {
"input": int(ti or 0),
"output": int(to or 0),
"cache_read": int(tcr or 0),
"cache_creation": int(tcc or 0),
"total": int((ti or 0) + (to or 0) + (tcr or 0) + (tcc or 0)),
"sessions": int(tses or 0),
}
return {
"days": days,
"daily": daily_series,
"by_model": model_breakdown,
"top_sessions": top,
"totals": totals,
}
# ---------------------------------------------------------------------------
# Data access tab (BQ + DuckDB queries)
# ---------------------------------------------------------------------------
@router.get("/queries")
def list_self_queries(
limit: int = Query(50, ge=1, le=200),
cursor_ts: Optional[datetime] = None,
cursor_id: Optional[str] = None,
user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
) -> dict:
"""Audit-log rows where ``action LIKE 'query.%'`` for the caller.
Covers query.local (DuckDB on parquet), query.hybrid (BQ + local
join), query.remote (BQ direct), and query.internal (admin
internal queries that get attributed to the actor). Cursor
pagination on (timestamp, id) so streams under concurrent
writes don't double-render rows.
"""
cursor = (cursor_ts, cursor_id) if cursor_ts and cursor_id else None
rows, next_cursor = AuditRepository(conn).query(
user_id=user["id"],
action_prefix="query.",
cursor=cursor,
limit=limit,
)
return _audit_response(rows, next_cursor, limit)
# ---------------------------------------------------------------------------
# Sync activity tab
# ---------------------------------------------------------------------------
@router.get("/sync")
def list_self_sync_activity(
limit: int = Query(50, ge=1, le=200),
cursor_ts: Optional[datetime] = None,
cursor_id: Optional[str] = None,
user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
) -> dict:
"""Audit-log rows where action is ``sync.*`` or ``manifest.*``
for the caller, plus ``users.last_pull_at`` for the header card.
Two action prefixes are merged with a UNION-ish IN filter via
``AuditRepository.query(action_in=[...])`` but the repo helper
doesn't take both prefix and IN, so we call twice and merge.
Cheaper alternative is two SELECTs in the repo; for now we fetch
two pages and interleave because cursor merging across two
independent streams is fiddly without a unified ORDER. To keep
the code obvious, we use ``query_actions(...)`` and accept that
the cursor is single-stream (start over to page back; first page
is what matters for the dashboard).
"""
actions_seen = AuditRepository(conn).query_actions(
actions=_sync_action_list(),
limit=limit,
)
# Filter to this user — query_actions doesn't take user_id.
user_rows = [r for r in actions_seen if r.get("user_id") == user["id"]]
last_pull_row = conn.execute(
"SELECT last_pull_at FROM users WHERE id = ?", [user["id"]]
).fetchone()
last_pull_at = last_pull_row[0] if last_pull_row else None
return {
"last_pull_at": last_pull_at.isoformat()
if last_pull_at and hasattr(last_pull_at, "isoformat")
else last_pull_at,
"rows": [_audit_row_to_payload(r) for r in user_rows[:limit]],
# No next_cursor in this branch — fetched a single newest-window
# page. Pagination beyond the first page is rarely needed for
# personal sync history; the timeline tab in /admin/activity is
# the place for deeper dives.
"next_cursor": None,
}
def _sync_action_list() -> list[str]:
"""The set of audit actions that surface on the Sync activity tab.
Concrete known actions today:
- ``sync.trigger`` admin manually kicks a sync.
- ``manifest.fetch`` added in this PR; bumped on every
``GET /api/sync/manifest``.
Listed explicitly (vs. a prefix LIKE) so accidental future
``sync.*`` actions (e.g. an admin-only ``sync.config_change``)
don't leak into the analyst-facing view without review.
"""
return ["sync.trigger", "manifest.fetch"]
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def _audit_row_to_payload(row: dict) -> dict:
"""Convert a raw audit_log row dict to the JSON shape the Stats
tabs render. Drops `correlation_id` (not useful per-user) and
iso-stringifies the timestamp."""
ts = row.get("timestamp")
return {
"id": row.get("id"),
"timestamp": ts.isoformat() if ts and hasattr(ts, "isoformat") else ts,
"action": row.get("action"),
"resource": row.get("resource"),
"result": row.get("result"),
"duration_ms": row.get("duration_ms"),
"params": row.get("params"),
"client_kind": row.get("client_kind"),
}
def _audit_response(rows: list[dict], next_cursor, limit: int) -> dict:
"""Shared shape for the queries and (alternate path) sync endpoints."""
if next_cursor is not None:
ts, cid = next_cursor
nc = {
"timestamp": ts.isoformat() if hasattr(ts, "isoformat") else ts,
"id": cid,
}
else:
nc = None
return {
"limit": limit,
"rows": [_audit_row_to_payload(r) for r in rows],
"next_cursor": nc,
}

View file

@ -812,10 +812,22 @@ async def sync_manifest(
"UPDATE users SET last_pull_at = current_timestamp WHERE id = ?",
[user["id"]],
)
# Also emit an audit_log row so /me/stats Sync activity has a
# timeline of pulls (the column UPDATE only retains the most
# recent one). Action `manifest.fetch` covers both `agnes pull`
# via PAT and browser-driven manifest peeks; clients can
# disambiguate via client_kind.
AuditRepository(conn).log(
user_id=user["id"],
action="manifest.fetch",
resource="manifest",
result="ok",
client_kind="api",
)
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.
# Never block a pull because the stamp UPDATE / audit row 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)

View file

@ -97,6 +97,7 @@ from app.api.telegram import router as telegram_router
from app.api.access import router as access_router, me_router as me_access_router
from app.api.me_debug import router as me_debug_router
from app.api.me import router as me_router
from app.api.me_stats import router as me_stats_router
from app.api.admin import router as admin_router
from app.api.admin_bigquery_test import router as admin_bigquery_test_router
from app.api.jira_webhooks import router as jira_webhooks_router
@ -612,6 +613,7 @@ def create_app() -> FastAPI:
app.include_router(me_access_router)
app.include_router(me_debug_router)
app.include_router(me_router)
app.include_router(me_stats_router)
app.include_router(jira_webhooks_router)
app.include_router(metrics_router)
app.include_router(metadata_router)

View file

@ -726,6 +726,26 @@ async def home_page(
return templates.TemplateResponse(request, "home_not_onboarded.html", ctx)
@router.get("/me/stats", response_class=HTMLResponse)
async def me_stats_page(
request: Request,
user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Per-analyst stats dashboard. Four tabs (Sessions / Tokens /
Data access / Sync activity) backed by /api/me/stats/* endpoints.
Authed-only; each endpoint enforces user_id = caller scoping
server-side so this route just renders the shell.
"""
ctx = _build_context(
request,
user=user,
conn=conn,
is_admin=is_user_admin(user["id"], conn),
)
return templates.TemplateResponse(request, "me_stats.html", ctx)
@router.get("/news", response_class=HTMLResponse)
async def news_page(
request: Request,

View file

@ -24,6 +24,7 @@
<a class="app-nav-link {% if _path == _home or _path == '/' or _path == '/dashboard' or _path == '/home' %}is-active{% endif %}" href="{{ _home }}">Home</a>
<a class="app-nav-link {% if _path == '/marketplace' or _path.startswith('/marketplace/') %}is-active{% endif %}" href="/marketplace">Marketplace</a>
<a class="app-nav-link {% if _path.startswith('/catalog') %}is-active{% endif %}" href="/catalog">Data Packages</a>
<a class="app-nav-link {% if _path.startswith('/me/stats') %}is-active{% endif %}" href="/me/stats">Stats</a>
{# Memory + Admin menu: both admin-only. Backend gates the routes
themselves via require_admin (see app/web/router.py for
/corporate-memory + /corporate-memory/admin + /admin/*), so

View file

@ -0,0 +1,463 @@
{% extends "base.html" %}
{% block title %}Stats — {{ instance_brand }}{% endblock %}
{# Override block layout (same trick dashboard.html uses) to escape the
narrow .container wrap from base.html. Stats tables look cramped
inside 800px; 1280px matches the top-nav header width
(_app_header.html uses the same value) so the Stats page reads as
"same chrome, full content area" rather than a separate visual
identity. #}
{% block layout %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-messages">
{% for category, message in messages %}
<div class="flash flash-{{ category }}">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<main class="main">{{ self.content() }}</main>
{% endblock %}
{% block content %}
<style>
.stats-page { max-width: 1280px; margin: 0 auto; padding: 24px 24px 64px; }
.stats-page h1 { margin: 0 0 4px; font-size: 22px; font-weight: 600; }
.stats-page .lead { color: var(--hp-text-muted, #6b7280); margin: 0 0 20px; font-size: 13px; }
.stats-tabs {
display: flex; gap: 0; border-bottom: 1px solid var(--hp-border-light, rgba(0,0,0,0.12));
margin-bottom: 18px; overflow-x: auto;
}
.stats-tab {
border: 0; background: transparent; padding: 10px 16px; font: inherit; font-size: 13.5px;
color: var(--hp-text-muted, #6b7280); cursor: pointer; border-bottom: 2px solid transparent;
white-space: nowrap;
}
.stats-tab.is-active { color: var(--hp-text, #111); border-bottom-color: var(--hp-accent, #2563eb); }
.stats-panel { display: none; }
.stats-panel.is-active { display: block; }
.stats-loading { color: var(--hp-text-muted, #6b7280); font-style: italic; padding: 24px 0; }
.stats-error { color: #b91c1c; padding: 24px 0; }
.stats-empty { color: var(--hp-text-muted, #6b7280); padding: 24px 0; }
table.stats-table {
width: 100%; border-collapse: collapse; font-size: 13px;
font-variant-numeric: tabular-nums;
}
table.stats-table th, table.stats-table td {
padding: 8px 10px; border-bottom: 1px solid var(--hp-border-light, rgba(0,0,0,0.07));
text-align: left; vertical-align: top;
}
table.stats-table th { font-weight: 600; color: var(--hp-text-muted, #6b7280); font-size: 11.5px;
text-transform: uppercase; letter-spacing: 0.04em; }
table.stats-table td.num { text-align: right; }
table.stats-table tbody tr:hover { background: rgba(0,0,0,0.02); }
.stats-paginate {
display: flex; gap: 8px; justify-content: flex-end; padding: 12px 0;
}
.stats-paginate button {
padding: 6px 12px; font: inherit; font-size: 12px; cursor: pointer;
border: 1px solid var(--hp-border-light, rgba(0,0,0,0.12)); border-radius: 6px;
background: #fff;
}
.stats-paginate button:disabled { opacity: 0.4; cursor: not-allowed; }
.stats-overview {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 16px;
}
@media (max-width: 720px) { .stats-overview { grid-template-columns: repeat(2, 1fr); } }
.stats-overview .card {
padding: 12px; border-radius: 8px;
background: var(--hp-stat-bg, rgba(0,0,0,0.025));
}
.stats-overview .lbl {
font-size: 11px; color: var(--hp-text-muted, #6b7280);
text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 4px;
}
.stats-overview .val { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; }
.tok-chart { width: 100%; height: 160px; margin: 8px 0 16px; }
.tok-chart rect { fill: var(--hp-accent, #2563eb); opacity: 0.85; }
.tok-chart .axis text { font-size: 10px; fill: var(--hp-text-muted, #6b7280); }
</style>
<div class="stats-page">
<h1>Your Stats</h1>
<p class="lead">
Sessions, tokens, data access, and sync activity for
<strong>{{ user.email }}</strong>. Data covers the
sessions {{ instance_brand }} has processed from your Claude
Code transcripts.
</p>
<nav class="stats-tabs" role="tablist" aria-label="Stats sections">
<button type="button" role="tab" class="stats-tab is-active"
data-tab="sessions" aria-selected="true">Sessions</button>
<button type="button" role="tab" class="stats-tab"
data-tab="tokens" aria-selected="false">Tokens</button>
<button type="button" role="tab" class="stats-tab"
data-tab="queries" aria-selected="false">Data access</button>
<button type="button" role="tab" class="stats-tab"
data-tab="sync" aria-selected="false">Sync activity</button>
</nav>
<section class="stats-panel is-active" id="panel-sessions" role="tabpanel" aria-labelledby="tab-sessions">
<div class="stats-loading" data-state="loading">Loading sessions…</div>
<div data-state="ready" hidden>
<table class="stats-table">
<thead><tr>
<th>Started</th><th>Model</th>
<th class="num">Prompts</th><th class="num">Tools</th>
<th class="num">Tokens</th>
</tr></thead>
<tbody data-rows></tbody>
</table>
<div class="stats-paginate">
<button type="button" data-action="prev"> Prev</button>
<button type="button" data-action="next">Next </button>
</div>
</div>
</section>
<section class="stats-panel" id="panel-tokens" role="tabpanel" aria-labelledby="tab-tokens">
<div class="stats-loading" data-state="loading">Loading tokens…</div>
<div data-state="ready" hidden>
<div class="stats-overview">
<div class="card"><div class="lbl">Input</div><div class="val" data-tok-total="input"></div></div>
<div class="card"><div class="lbl">Output</div><div class="val" data-tok-total="output"></div></div>
<div class="card"><div class="lbl">Cache read</div><div class="val" data-tok-total="cache_read"></div></div>
<div class="card"><div class="lbl">Cache creation</div><div class="val" data-tok-total="cache_creation"></div></div>
</div>
<svg class="tok-chart" data-tok-chart viewBox="0 0 600 160" preserveAspectRatio="none"></svg>
<h3 style="font-size: 13px; margin: 16px 0 6px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--hp-text-muted, #6b7280);">By model</h3>
<table class="stats-table">
<thead><tr>
<th>Model</th><th class="num">Sessions</th>
<th class="num">Input</th><th class="num">Output</th>
<th class="num">Cache R/W</th><th class="num">Total</th>
</tr></thead>
<tbody data-tok-model></tbody>
</table>
<h3 style="font-size: 13px; margin: 16px 0 6px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--hp-text-muted, #6b7280);">Top sessions</h3>
<table class="stats-table">
<thead><tr>
<th>Started</th><th>Model</th>
<th class="num">Input</th><th class="num">Output</th>
<th class="num">Cache</th><th class="num">Total</th>
</tr></thead>
<tbody data-tok-top></tbody>
</table>
</div>
</section>
<section class="stats-panel" id="panel-queries" role="tabpanel" aria-labelledby="tab-queries">
<div class="stats-loading" data-state="loading">Loading queries…</div>
<div data-state="ready" hidden>
<table class="stats-table">
<thead><tr>
<th>When</th><th>Action</th><th>Resource</th>
<th>Result</th><th class="num">Duration (ms)</th>
</tr></thead>
<tbody data-rows></tbody>
</table>
<div class="stats-paginate">
<button type="button" data-action="next">Older </button>
</div>
</div>
</section>
<section class="stats-panel" id="panel-sync" role="tabpanel" aria-labelledby="tab-sync">
<div class="stats-loading" data-state="loading">Loading sync activity…</div>
<div data-state="ready" hidden>
<div class="stats-overview" style="grid-template-columns: 1fr;">
<div class="card">
<div class="lbl">Your last <code>agnes pull</code></div>
<div class="val" data-sync-last-pull></div>
</div>
</div>
<table class="stats-table">
<thead><tr>
<th>When</th><th>Action</th><th>Source</th>
<th>Result</th>
</tr></thead>
<tbody data-rows></tbody>
</table>
</div>
</section>
</div>
<script>
(function () {
const root = document.querySelector('.stats-page');
if (!root) return;
function fmtNum(n) {
if (n == null) return '—';
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
if (n >= 10_000) return (n / 1_000).toFixed(0) + 'k';
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k';
return String(n);
}
function fmtRelative(iso) {
if (!iso) return 'never';
const then = new Date(iso);
const sec = Math.max(0, (Date.now() - then.getTime()) / 1000);
if (sec < 60) return Math.floor(sec) + 's ago';
if (sec < 3600) return Math.floor(sec / 60) + 'm ago';
if (sec < 86400) return Math.floor(sec / 3600) + 'h ago';
return Math.floor(sec / 86400) + 'd ago';
}
function fmtTimestamp(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleString();
}
async function fetchJSON(url) {
const r = await fetch(url, { credentials: 'same-origin' });
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}
function setReady(panel) {
panel.querySelector('[data-state="loading"]').hidden = true;
panel.querySelector('[data-state="ready"]').hidden = false;
}
function setError(panel, msg) {
const loading = panel.querySelector('[data-state="loading"]');
loading.className = 'stats-error';
loading.textContent = msg;
}
// ----- Sessions tab -----
const sessionsState = { offset: 0, limit: 25, total: 0 };
async function loadSessions() {
const panel = document.getElementById('panel-sessions');
try {
const data = await fetchJSON(
`/api/me/stats/sessions?offset=${sessionsState.offset}&limit=${sessionsState.limit}`
);
sessionsState.total = data.total;
const tbody = panel.querySelector('[data-rows]');
tbody.innerHTML = '';
if (data.rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="stats-empty">No sessions yet — start Claude Code in a workspace and they\'ll appear here.</td></tr>';
}
for (const r of data.rows) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${fmtTimestamp(r.started_at)}</td>
<td>${r.primary_model || '<em style="color:#9ca3af;">unprocessed</em>'}</td>
<td class="num">${fmtNum(r.user_messages)}</td>
<td class="num">${fmtNum(r.tool_calls)}</td>
<td class="num">${fmtNum(r.tokens_total)}</td>
`;
tbody.appendChild(tr);
}
setReady(panel);
const prevBtn = panel.querySelector('[data-action="prev"]');
const nextBtn = panel.querySelector('[data-action="next"]');
prevBtn.disabled = sessionsState.offset === 0;
nextBtn.disabled = sessionsState.offset + sessionsState.limit >= sessionsState.total;
} catch (e) {
setError(panel, 'Could not load sessions: ' + e.message);
}
}
document.querySelector('#panel-sessions [data-action="prev"]').addEventListener('click', () => {
sessionsState.offset = Math.max(0, sessionsState.offset - sessionsState.limit);
loadSessions();
});
document.querySelector('#panel-sessions [data-action="next"]').addEventListener('click', () => {
sessionsState.offset += sessionsState.limit;
loadSessions();
});
// ----- Tokens tab -----
async function loadTokens() {
const panel = document.getElementById('panel-tokens');
try {
const data = await fetchJSON('/api/me/stats/tokens?days=30');
for (const key of ['input', 'output', 'cache_read', 'cache_creation']) {
panel.querySelector(`[data-tok-total="${key}"]`).textContent =
fmtNum(data.totals[key]);
}
// Daily chart — simple bars over the 30-day window.
const chart = panel.querySelector('[data-tok-chart]');
chart.innerHTML = '';
const days = data.daily;
if (days.length > 0) {
const max = Math.max(...days.map(d => d.total), 1);
const w = 600 / Math.max(days.length, 1);
days.forEach((d, i) => {
const h = Math.max(1, (d.total / max) * 140);
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('x', String(i * w + 1));
rect.setAttribute('y', String(160 - h));
rect.setAttribute('width', String(Math.max(1, w - 2)));
rect.setAttribute('height', String(h));
rect.setAttribute('title', `${d.day}: ${d.total}`);
chart.appendChild(rect);
});
}
const modelTbody = panel.querySelector('[data-tok-model]');
modelTbody.innerHTML = '';
if (data.by_model.length === 0) {
modelTbody.innerHTML = '<tr><td colspan="6" class="stats-empty">No token data yet.</td></tr>';
}
for (const m of data.by_model) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${m.model}</td>
<td class="num">${fmtNum(m.sessions)}</td>
<td class="num">${fmtNum(m.input)}</td>
<td class="num">${fmtNum(m.output)}</td>
<td class="num">${fmtNum(m.cache_read + m.cache_creation)}</td>
<td class="num"><strong>${fmtNum(m.total)}</strong></td>
`;
modelTbody.appendChild(tr);
}
const topTbody = panel.querySelector('[data-tok-top]');
topTbody.innerHTML = '';
if (data.top_sessions.length === 0) {
topTbody.innerHTML = '<tr><td colspan="6" class="stats-empty">No sessions yet.</td></tr>';
}
for (const s of data.top_sessions) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${fmtTimestamp(s.started_at)}</td>
<td>${s.primary_model || ''}</td>
<td class="num">${fmtNum(s.input)}</td>
<td class="num">${fmtNum(s.output)}</td>
<td class="num">${fmtNum(s.cache_read + s.cache_creation)}</td>
<td class="num"><strong>${fmtNum(s.total)}</strong></td>
`;
topTbody.appendChild(tr);
}
setReady(panel);
} catch (e) {
setError(panel, 'Could not load tokens: ' + e.message);
}
}
// ----- Queries tab -----
const queriesState = { cursor: null };
async function loadQueries(append) {
const panel = document.getElementById('panel-queries');
try {
const params = new URLSearchParams({ limit: '50' });
if (queriesState.cursor) {
params.set('cursor_ts', queriesState.cursor.timestamp);
params.set('cursor_id', queriesState.cursor.id);
}
const data = await fetchJSON(`/api/me/stats/queries?${params}`);
const tbody = panel.querySelector('[data-rows]');
if (!append) tbody.innerHTML = '';
if (data.rows.length === 0 && !append) {
tbody.innerHTML = '<tr><td colspan="5" class="stats-empty">No queries logged for your account yet.</td></tr>';
}
for (const r of data.rows) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${fmtTimestamp(r.timestamp)}</td>
<td><code>${r.action}</code></td>
<td>${r.resource || ''}</td>
<td>${r.result || ''}</td>
<td class="num">${r.duration_ms != null ? r.duration_ms : ''}</td>
`;
tbody.appendChild(tr);
}
queriesState.cursor = data.next_cursor;
panel.querySelector('[data-action="next"]').disabled = !queriesState.cursor;
setReady(panel);
} catch (e) {
setError(panel, 'Could not load queries: ' + e.message);
}
}
document.querySelector('#panel-queries [data-action="next"]').addEventListener('click', () => {
loadQueries(true);
});
// ----- Sync tab -----
async function loadSync() {
const panel = document.getElementById('panel-sync');
try {
const data = await fetchJSON('/api/me/stats/sync?limit=50');
panel.querySelector('[data-sync-last-pull]').textContent =
fmtRelative(data.last_pull_at);
const tbody = panel.querySelector('[data-rows]');
tbody.innerHTML = '';
if (data.rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="stats-empty">No sync activity yet.</td></tr>';
}
for (const r of data.rows) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${fmtTimestamp(r.timestamp)}</td>
<td><code>${r.action}</code></td>
<td>${r.client_kind || ''}</td>
<td>${r.result || ''}</td>
`;
tbody.appendChild(tr);
}
setReady(panel);
} catch (e) {
setError(panel, 'Could not load sync activity: ' + e.message);
}
}
// ----- Tab switching -----
const loaded = new Set();
const loaders = {
sessions: loadSessions,
tokens: loadTokens,
queries: loadQueries,
sync: loadSync,
};
function activate(name) {
document.querySelectorAll('.stats-tab').forEach((b) => {
const on = b.dataset.tab === name;
b.classList.toggle('is-active', on);
b.setAttribute('aria-selected', on ? 'true' : 'false');
});
document.querySelectorAll('.stats-panel').forEach((p) => {
p.classList.toggle('is-active', p.id === 'panel-' + name);
});
if (!loaded.has(name)) {
loaded.add(name);
loaders[name]();
}
}
document.querySelectorAll('.stats-tab').forEach((b) => {
b.addEventListener('click', () => activate(b.dataset.tab));
});
// Initial paint: sessions tab.
activate('sessions');
})();
</script>
{% endblock %}

269
tests/test_me_stats.py Normal file
View file

@ -0,0 +1,269 @@
"""/api/me/stats/* — per-user dashboard endpoints.
Coverage:
- All four endpoints scope rows to ``user["id"]`` / username so user A
cannot read user B's data (gates are server-side; the page renders a
shell with no caller-scope params).
- Empty user returns zero-counts / empty arrays, not 500.
- Tokens aggregates daily series, by-model, top-N, and lifetime totals
from a seeded sample.
- Sync activity surfaces both ``manifest.fetch`` and ``sync.trigger``,
filtered to caller.
- GET /api/sync/manifest writes the ``manifest.fetch`` audit row.
"""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
import duckdb
import pytest
from src.db import _SYSTEM_SCHEMA
from src.repositories.audit import AuditRepository
@pytest.fixture
def stats_conn(tmp_path):
db = tmp_path / "system.duckdb"
conn = duckdb.connect(str(db))
conn.execute(_SYSTEM_SCHEMA)
return conn
def _seed_user(conn, *, uid, email):
conn.execute(
"INSERT INTO users (id, email, active, onboarded) VALUES (?, ?, TRUE, TRUE)",
[uid, email],
)
def _seed_session(conn, *, sf, username, started_sql, model="claude-opus-4-7",
user_messages=0, tool_calls=0,
input_tokens=0, output_tokens=0,
cache_read=0, cache_creation=0):
conn.execute(
f"""
INSERT INTO usage_session_summary
(session_file, session_id, username, started_at, ended_at,
active_seconds, wall_seconds, user_messages, assistant_messages,
tool_calls, tool_errors, skill_invocations, subagent_dispatches,
mcp_calls, slash_commands, distinct_tools, distinct_skills,
primary_model, input_tokens, output_tokens, cache_read_tokens,
cache_creation_tokens, processor_version)
VALUES (?, ?, ?, {started_sql}, current_timestamp,
10, 30, ?, ?, ?, 0, 0, 0, 0, 0, 0, 0, ?, ?, ?, ?, ?, 2)
""",
[sf, sf, username, user_messages, user_messages,
tool_calls, model, input_tokens, output_tokens,
cache_read, cache_creation],
)
# ---------------------------------------------------------------------------
# Sessions tab
# ---------------------------------------------------------------------------
def test_sessions_endpoint_scopes_to_caller(stats_conn, tmp_path, monkeypatch):
"""User A's sessions endpoint must not return user B's rows."""
# Point session-fs scan at an empty dir so unprocessed-jsonl path is no-op.
monkeypatch.setenv("AGNES_SESSION_DATA_DIR", str(tmp_path / "noop"))
_seed_user(stats_conn, uid="ua", email="alice@example.com")
_seed_user(stats_conn, uid="ub", email="bob@example.com")
_seed_session(stats_conn, sf="a1.jsonl", username="alice",
started_sql="current_timestamp - INTERVAL 1 HOUR",
user_messages=4, input_tokens=100, output_tokens=50)
_seed_session(stats_conn, sf="b1.jsonl", username="bob",
started_sql="current_timestamp - INTERVAL 1 HOUR",
user_messages=9, input_tokens=999, output_tokens=999)
from app.api.me_stats import list_self_sessions
res_a = list_self_sessions(
limit=50, offset=0,
user={"id": "ua", "email": "alice@example.com"},
conn=stats_conn,
)
assert res_a["total"] == 1
assert res_a["rows"][0]["session_file"] == "a1.jsonl"
assert res_a["rows"][0]["user_messages"] == 4
assert res_a["rows"][0]["tokens_total"] == 150
def test_sessions_endpoint_pagination(stats_conn, tmp_path, monkeypatch):
monkeypatch.setenv("AGNES_SESSION_DATA_DIR", str(tmp_path / "noop"))
_seed_user(stats_conn, uid="ua", email="alice@example.com")
for i in range(5):
_seed_session(stats_conn, sf=f"s{i}.jsonl", username="alice",
started_sql=f"current_timestamp - INTERVAL {i} HOUR",
user_messages=i)
from app.api.me_stats import list_self_sessions
page1 = list_self_sessions(
limit=2, offset=0,
user={"id": "ua", "email": "alice@example.com"}, conn=stats_conn,
)
page2 = list_self_sessions(
limit=2, offset=2,
user={"id": "ua", "email": "alice@example.com"}, conn=stats_conn,
)
assert page1["total"] == 5
assert len(page1["rows"]) == 2
assert len(page2["rows"]) == 2
assert page1["rows"][0]["session_file"] != page2["rows"][0]["session_file"]
# ---------------------------------------------------------------------------
# Tokens tab
# ---------------------------------------------------------------------------
def test_tokens_endpoint_empty_user(stats_conn):
_seed_user(stats_conn, uid="ua", email="alice@example.com")
from app.api.me_stats import get_tokens
res = get_tokens(
days=30,
user={"id": "ua", "email": "alice@example.com"},
conn=stats_conn,
)
assert res["totals"]["total"] == 0
assert res["daily"] == []
assert res["by_model"] == []
assert res["top_sessions"] == []
def test_tokens_endpoint_aggregates(stats_conn):
_seed_user(stats_conn, uid="ua", email="alice@example.com")
_seed_session(stats_conn, sf="x.jsonl", username="alice",
started_sql="current_timestamp - INTERVAL 1 HOUR",
model="claude-opus-4-7",
input_tokens=100, output_tokens=50,
cache_read=800, cache_creation=25)
_seed_session(stats_conn, sf="y.jsonl", username="alice",
started_sql="current_timestamp - INTERVAL 2 DAY",
model="claude-sonnet-4-6",
input_tokens=200, output_tokens=100,
cache_read=400, cache_creation=10)
# Far-past row excluded by `days=7` window for the daily series, but
# still counted in lifetime totals + by_model + top.
_seed_session(stats_conn, sf="z.jsonl", username="alice",
started_sql="current_timestamp - INTERVAL 60 DAY",
model="claude-opus-4-7",
input_tokens=1, output_tokens=1)
from app.api.me_stats import get_tokens
res = get_tokens(
days=7,
user={"id": "ua", "email": "alice@example.com"},
conn=stats_conn,
)
# Lifetime totals include all three sessions
assert res["totals"]["sessions"] == 3
assert res["totals"]["input"] == 301
assert res["totals"]["output"] == 151
assert res["totals"]["cache_read"] == 1200
assert res["totals"]["cache_creation"] == 35
assert res["totals"]["total"] == 1687
# Daily series window excludes the 60-day-old row
daily_sessions = sum(d["sessions"] for d in res["daily"])
assert daily_sessions == 2
# By-model: opus has 2 sessions (x + z), sonnet has 1 (y)
models = {m["model"]: m for m in res["by_model"]}
assert models["claude-opus-4-7"]["sessions"] == 2
assert models["claude-sonnet-4-6"]["sessions"] == 1
# Top sessions: largest first
assert res["top_sessions"][0]["session_file"] in ("x.jsonl", "y.jsonl")
assert res["top_sessions"][0]["total"] >= res["top_sessions"][1]["total"]
# ---------------------------------------------------------------------------
# Data access tab
# ---------------------------------------------------------------------------
def test_queries_endpoint_filters_to_query_actions(stats_conn):
_seed_user(stats_conn, uid="ua", email="alice@example.com")
repo = AuditRepository(stats_conn)
repo.log(user_id="ua", action="query.local", resource="orders",
result="ok", duration_ms=42)
repo.log(user_id="ua", action="query.remote", resource="web_sessions",
result="ok", duration_ms=1500)
# Non-query action — must not appear
repo.log(user_id="ua", action="manifest.fetch", result="ok")
# Same query action but different user — must not appear
repo.log(user_id="ub", action="query.local", result="ok")
from app.api.me_stats import list_self_queries
res = list_self_queries(
limit=50, cursor_ts=None, cursor_id=None,
user={"id": "ua", "email": "alice@example.com"}, conn=stats_conn,
)
assert len(res["rows"]) == 2
actions = {r["action"] for r in res["rows"]}
assert actions == {"query.local", "query.remote"}
# ---------------------------------------------------------------------------
# Sync activity tab
# ---------------------------------------------------------------------------
def test_sync_endpoint_returns_manifest_fetch_rows(stats_conn):
_seed_user(stats_conn, uid="ua", email="alice@example.com")
repo = AuditRepository(stats_conn)
repo.log(user_id="ua", action="manifest.fetch", resource="manifest",
result="ok", client_kind="api")
repo.log(user_id="ua", action="sync.trigger", result="ok")
# Other user — must not leak
repo.log(user_id="ub", action="manifest.fetch", result="ok")
# Unrelated action — must not surface
repo.log(user_id="ua", action="query.local", result="ok")
# Stamp last_pull_at so the header card has a value
stats_conn.execute(
"UPDATE users SET last_pull_at = current_timestamp WHERE id = ?",
["ua"],
)
from app.api.me_stats import list_self_sync_activity
res = list_self_sync_activity(
limit=50, cursor_ts=None, cursor_id=None,
user={"id": "ua", "email": "alice@example.com"}, conn=stats_conn,
)
actions = {r["action"] for r in res["rows"]}
assert actions == {"manifest.fetch", "sync.trigger"}
assert res["last_pull_at"] is not None
# ---------------------------------------------------------------------------
# Manifest endpoint writes the audit_log row
# ---------------------------------------------------------------------------
def test_sync_manifest_writes_audit_row(stats_conn, monkeypatch, tmp_path):
"""GET /api/sync/manifest must emit a manifest.fetch audit_log row
so the Sync activity tab can list per-pull history."""
monkeypatch.setenv("DATA_DIR", str(tmp_path))
_seed_user(stats_conn, uid="ua", email="alice@example.com")
from app.api.sync import sync_manifest
asyncio.run(
sync_manifest(
user={"id": "ua", "email": "alice@example.com"},
conn=stats_conn,
)
)
rows = stats_conn.execute(
"SELECT action, resource, result, client_kind FROM audit_log "
"WHERE user_id = ? ORDER BY timestamp DESC",
["ua"],
).fetchall()
assert len(rows) == 1
action, resource, result, client_kind = rows[0]
assert action == "manifest.fetch"
assert resource == "manifest"
assert result == "ok"
assert client_kind == "api"