diff --git a/api/vendor/agentfox-0.3.1-py3-none-any.whl b/api/vendor/agentfox-0.3.1-py3-none-any.whl index 10582b0a..db881488 100644 Binary files a/api/vendor/agentfox-0.3.1-py3-none-any.whl and b/api/vendor/agentfox-0.3.1-py3-none-any.whl differ diff --git a/dashboard/app/(product)/app/agents/[slug]/page.tsx b/dashboard/app/(product)/app/agents/[slug]/page.tsx index 1da020d3..c81830a5 100644 --- a/dashboard/app/(product)/app/agents/[slug]/page.tsx +++ b/dashboard/app/(product)/app/agents/[slug]/page.tsx @@ -29,6 +29,7 @@ import { AgentMap } from "@/components/product/AgentMap"; import { AccessEditor } from "@/components/product/agent/AccessEditor"; import { RANGE_DAYS, metricsQs, runsHref, verdictsFor, type Filters } from "@/lib/product/observe"; import { CATEGORIES, categoryLabel, rangeOf, ruleCategory, ruleTitle } from "@/lib/product/vocab"; +import { ensureRange } from "@/lib/product/range"; export const metadata: Metadata = appPageMetadata("Agent"); export const dynamic = "force-dynamic"; @@ -52,6 +53,7 @@ export default async function AgentDetail({ params, searchParams }: { params: Pr const sp = await searchParams; const requested = LEGACY[sp.tab || ""] || sp.tab; const tab = TABS.some((t) => t.key === requested) ? requested! : "overview"; + if (["overview", "rules", "activity"].includes(tab)) await ensureRange(`/app/agents/${encodeURIComponent(slug)}`, sp, slug); const f: Filters = { range: rangeOf(sp.range), agent: slug }; let posture: any; diff --git a/dashboard/app/(product)/app/agents/page.tsx b/dashboard/app/(product)/app/agents/page.tsx index b7a3564b..5f289490 100644 --- a/dashboard/app/(product)/app/agents/page.tsx +++ b/dashboard/app/(product)/app/agents/page.tsx @@ -2,6 +2,8 @@ import type { Metadata } from "next"; import Link from "next/link"; import { appPageMetadata } from "@/lib/site"; import { api, apiErrorProps, safeApi } from "@/lib/product/api"; +import { activeRange } from "@/lib/product/range"; +import { RANGE_WORDS } from "@/lib/product/vocab"; import { ApiDown } from "@/components/ui"; import { Card, Dot, Empty, Header, Pill, StackBar, Tabs, ago, href, num, pctOf } from "@/components/kit"; import { Modal } from "@/components/product/Modal"; @@ -24,8 +26,9 @@ export default async function Agents({ searchParams }: { searchParams: Promise ); } + const { range } = await activeRange(); const [usage, findings] = await Promise.all([ - safeApi("/api/metrics/breakdown?dim=agent&range=7d", { rows: [] }), + safeApi(`/api/metrics/breakdown?dim=agent&range=${range}`, { rows: [] }), safeApi("/api/findings?status=open", { findings: [] }), ]); @@ -88,7 +91,7 @@ export default async function Agents({ searchParams }: { searchParams: PromiseAgent Owner Risk - Last 7 days + Last {RANGE_WORDS[range]} Requests Stopped Issues diff --git a/dashboard/app/(product)/app/findings/page.tsx b/dashboard/app/(product)/app/findings/page.tsx index d260b5e3..7f8b6d92 100644 --- a/dashboard/app/(product)/app/findings/page.tsx +++ b/dashboard/app/(product)/app/findings/page.tsx @@ -42,7 +42,7 @@ export default async function Issues({ searchParams }: { searchParams: Promise("/api/agents", { agents: [] }), Promise.all(STATUSES.map((s) => safeApi(`/api/findings?status=${s.key}&limit=500${sp.agent ? `&agent=${sp.agent}` : ""}`, { findings: [] }))), ]); - const rows = [...(data.findings || [])].sort( + const rows = group(data.findings || []).sort( (a, b) => severityRank(a.severity) - severityRank(b.severity) || String(b.last_seen_at).localeCompare(String(a.last_seen_at)), ); const keep = { agent: sp.agent, severity: sp.severity }; @@ -55,7 +55,7 @@ export default async function Issues({ searchParams }: { searchParams: Promise ({ key: s.key, label: s.label, - count: counts[i].findings?.length, + count: group(counts[i].findings || []).length, href: href("/app/findings", { ...keep, status: s.key === "open" ? undefined : s.key }), }))} /> @@ -107,3 +107,24 @@ export default async function Issues({ searchParams }: { searchParams: Promise ); } + +/** + * One row per problem. Issues raised before findings carried a fingerprint (Sept + * 2026) were never merged, so the same problem can appear as many rows; they are + * counted together here, linking to the most recent. + */ +function group(findings: any[]): any[] { + const byKey = new Map(); + for (const f of findings) { + const key = [f.type, f.title, f.agent_slug || "", f.subject_type || "", f.subject_id || "", f.status].join("|"); + const seen = byKey.get(key); + const at = String(f.last_seen_at || f.created_at || ""); + if (!seen) { + byKey.set(key, { ...f, occurrences: f.occurrences || 1 }); + continue; + } + seen.occurrences += f.occurrences || 1; + if (at > String(seen.last_seen_at || seen.created_at || "")) Object.assign(seen, { id: f.id, last_seen_at: at }); + } + return Array.from(byKey.values()); +} diff --git a/dashboard/app/(product)/app/observe/page.tsx b/dashboard/app/(product)/app/observe/page.tsx index 5b3d8734..f1608cee 100644 --- a/dashboard/app/(product)/app/observe/page.tsx +++ b/dashboard/app/(product)/app/observe/page.tsx @@ -21,6 +21,7 @@ import { } from "@/components/kit"; import { FilterBar } from "@/components/kit/FilterBar"; import { filtersFrom, metricsQs, observeTabs, runsHref, type Filters } from "@/lib/product/observe"; +import { ensureRange } from "@/lib/product/range"; import { CATEGORIES, categoryLabel, detectorName, dimLabel, ruleCategory, ruleTitle } from "@/lib/product/vocab"; export const metadata: Metadata = appPageMetadata("Observe"); @@ -30,6 +31,7 @@ type SP = Record; export default async function Observe({ searchParams }: { searchParams: Promise }) { const sp = await searchParams; + await ensureRange("/app/observe", sp); const f = filtersFrom(sp); const tab = ["breaks", "security", "review", "cost"].includes(sp.tab || "") ? sp.tab! : "overview"; diff --git a/dashboard/app/(product)/app/page.tsx b/dashboard/app/(product)/app/page.tsx index 787cc3b4..008c9f37 100644 --- a/dashboard/app/(product)/app/page.tsx +++ b/dashboard/app/(product)/app/page.tsx @@ -2,11 +2,12 @@ import type { Metadata } from "next"; import Link from "next/link"; import { appPageMetadata } from "@/lib/site"; import { safeApi } from "@/lib/product/api"; -import { Card, Dot, Empty, Grid, Header, Kpi, Pill, SeverityPill, StackBar, TimeChart, ago, num, pctOf } from "@/components/kit"; +import { Card, Dot, Empty, Grid, Header, Kpi, Pill, SeverityPill, StackBar, TimeChart, ago, href, num, pctOf } from "@/components/kit"; import { Countdown } from "@/components/product/Countdown"; import { SetupProgress } from "@/components/product/home/SetupProgress"; import { runsHref } from "@/lib/product/observe"; -import { severityRank } from "@/lib/product/vocab"; +import { activeRange } from "@/lib/product/range"; +import { RANGE_WORDS, severityRank } from "@/lib/product/vocab"; // `appPageMetadata`, not a literal: it also clears the canonical URL, so this page // does not declare itself a duplicate of the marketing home page. @@ -21,11 +22,13 @@ type SP = { review_notice?: string; review_error?: string }; */ export default async function Home({ searchParams }: { searchParams: Promise }) { const sp = await searchParams; - const f = { range: "7d" as const }; + // The week, or the smallest window that holds the latest traffic in a quiet workspace. + const { range, last } = await activeRange(); + const f = { range }; const [onboarding, summary, byAgent, agents, approvals, findings, policies] = await Promise.all([ safeApi("/api/onboarding", null), - safeApi("/api/metrics/summary?range=7d", null), - safeApi("/api/metrics/breakdown?dim=agent&range=7d", { rows: [] }), + safeApi(`/api/metrics/summary?range=${range}`, null), + safeApi(`/api/metrics/breakdown?dim=agent&range=${range}`, { rows: [] }), safeApi("/api/agents", { agents: [] }), safeApi("/api/approvals?status=pending", { approvals: [] }), safeApi("/api/findings?status=open&limit=200", { findings: [] }), @@ -41,11 +44,11 @@ export default async function Home({ searchParams }: { searchParams: Promise .sort((a: any, c: any) => severityRank(a.severity) - severityRank(c.severity)) .slice(0, 4); const agentName: Record = Object.fromEntries((agents.agents || []).map((a: any) => [a.id, a.name || a.slug])); - const watching = (policies.policies || []).filter((x: any) => x.mode === "observe"); + const watching = (policies.policies || []).filter((x: any) => x.mode === "observe" && x.rules > 0); const usage: Record = Object.fromEntries((byAgent.rows || []).map((r: any) => [r.key, r])); const live = (agents.agents || []).filter((a: any) => a.status !== "draft"); const max = Math.max(1, ...Object.values(usage).map((r: any) => r.requests)); - const noTraffic = !t.requests && !p.requests; + const noTraffic = !last; return ( <> @@ -114,7 +117,7 @@ export default async function Home({ searchParams }: { searchParams: Promise ) : ( <> - x.allowed + x.masked + x.held + x.blocked)} /> + x.allowed + x.masked + x.held + x.blocked)} /> x.blocked)} /> x.held)} /> diff --git a/dashboard/app/(product)/app/policies/page.tsx b/dashboard/app/(product)/app/policies/page.tsx index 8b12e168..64330f59 100644 --- a/dashboard/app/(product)/app/policies/page.tsx +++ b/dashboard/app/(product)/app/policies/page.tsx @@ -27,6 +27,7 @@ import { WorkspaceCode } from "@/components/product/policies/WorkspaceCode"; import { LegacyJudgmentTab, LegacyPacksTab, LegacyTuningTab } from "@/components/product/policies/legacy"; import { metricsQs, runsHref } from "@/lib/product/observe"; import { loadRules, ruleMode, type RuleInfo } from "@/lib/product/rules"; +import { ensureRange } from "@/lib/product/range"; import { CATEGORIES, categoryLabel, rangeOf, ruleCategory, ruleTitle } from "@/lib/product/vocab"; export const metadata: Metadata = appPageMetadata("Policies", "What your agents may do, and how well each rule is working."); @@ -59,6 +60,7 @@ export default async function Policies({ searchParams }: { searchParams: Promise const legacy = LEGACY[sp.tab || ""]; const tab = legacy?.tab || (TABS.some((t) => t.key === sp.tab) ? sp.tab! : "rules"); const sec = legacy?.sec || sp.sec; + if (tab === "rules" || tab === "performance") await ensureRange("/app/policies", sp); return ( <> @@ -110,7 +112,8 @@ async function RulesTab({ sp }: { sp: SP }) { - {packs.map((p) => ( + {/* A pack with no rules (a framework profile with nothing in it yet) decides nothing. */} + {packs.filter((p) => p.rules.length > 0).map((p) => (
{p.name} diff --git a/dashboard/app/(product)/app/policies/rules/[ruleId]/page.tsx b/dashboard/app/(product)/app/policies/rules/[ruleId]/page.tsx index a7f6d605..65375549 100644 --- a/dashboard/app/(product)/app/policies/rules/[ruleId]/page.tsx +++ b/dashboard/app/(product)/app/policies/rules/[ruleId]/page.tsx @@ -9,6 +9,7 @@ import { RuleTests } from "@/components/product/policies/RuleTests"; import { RuleTuner } from "@/components/product/policies/RuleTuner"; import { RANGE_DAYS, metricsQs, runsHref } from "@/lib/product/observe"; import { loadRules, ruleMode } from "@/lib/product/rules"; +import { ensureRange } from "@/lib/product/range"; import { categoryLabel, rangeOf, ruleCategory, ruleTitle } from "@/lib/product/vocab"; export const metadata: Metadata = appPageMetadata("Rule"); @@ -29,6 +30,7 @@ export default async function RulePage({ params, searchParams }: { params: Promi const ruleId = decodeURIComponent(raw); const sp = await searchParams; const tab = TABS.some((t) => t.key === sp.tab) ? sp.tab! : "overview"; + if (tab === "overview" || tab === "examples") await ensureRange(`/app/policies/rules/${encodeURIComponent(ruleId)}`, sp); const range = rangeOf(sp.range); const custom = ruleId.startsWith("custom."); diff --git a/dashboard/app/(product)/app/traces/page.tsx b/dashboard/app/(product)/app/traces/page.tsx index 6afa9440..f1dd707e 100644 --- a/dashboard/app/(product)/app/traces/page.tsx +++ b/dashboard/app/(product)/app/traces/page.tsx @@ -7,6 +7,7 @@ import { Card, Header, Tabs, href, when } from "@/components/kit"; import { FilterBar } from "@/components/kit/FilterBar"; import { RunsTable } from "@/components/kit/RunsTable"; import { RANGE_DAYS, filtersFrom, keep, metricsQs, observeTabs, verdictsFor } from "@/lib/product/observe"; +import { ensureRange } from "@/lib/product/range"; import { ruleTitle } from "@/lib/product/vocab"; export const metadata: Metadata = appPageMetadata("Runs"); @@ -24,6 +25,7 @@ const OUTCOME_CHIPS = [ export default async function Runs({ searchParams }: { searchParams: Promise }) { const sp = await searchParams; + await ensureRange("/app/traces", sp); const f = filtersFrom(sp); const errorsOnly = sp.errors === "1" || sp.outcome === "errors"; diff --git a/dashboard/components/kit/FilterBar.tsx b/dashboard/components/kit/FilterBar.tsx index 64a95f2c..7623102e 100644 --- a/dashboard/components/kit/FilterBar.tsx +++ b/dashboard/components/kit/FilterBar.tsx @@ -44,7 +44,7 @@ export function FilterBar({ type="button" className={current === r.key ? "active" : ""} aria-pressed={current === r.key} - onClick={() => set("range", r.key === defaultRange ? "" : r.key)} + onClick={() => set("range", r.key)} > {r.label} diff --git a/dashboard/components/kit/RunsTable.tsx b/dashboard/components/kit/RunsTable.tsx index 445cd2d8..aafbf939 100644 --- a/dashboard/components/kit/RunsTable.tsx +++ b/dashboard/components/kit/RunsTable.tsx @@ -45,7 +45,13 @@ export function RunsTable({ runs, showAgent = true }: { runs: any[]; showAgent?: - + {t.checks === 0 ? ( + + Not checked + + ) : ( + + )} {t.would_verdict && outcomeOf(t.verdict) === "allowed" && ( {WOULD[t.would_verdict]} diff --git a/dashboard/lib/generated/reference/api.json b/dashboard/lib/generated/reference/api.json index 6c5388ee..589bdc71 100644 --- a/dashboard/lib/generated/reference/api.json +++ b/dashboard/lib/generated/reference/api.json @@ -1,6 +1,6 @@ { "generated_by": "scripts/gen/docs_reference.py", - "count": 239, + "count": 240, "routes": [ { "method": "GET", @@ -5956,6 +5956,41 @@ ], "body": null }, + { + "method": "GET", + "path": "/api/metrics/activity", + "section": "metrics", + "audience": "operator", + "summary": "Activity", + "description": "", + "params": [ + { + "name": "agent", + "in": "query", + "required": false, + "type": null + }, + { + "name": "environment", + "in": "query", + "required": false, + "type": null + }, + { + "name": "authorization", + "in": "header", + "required": false, + "type": null + }, + { + "name": "x-agentfox-user", + "in": "header", + "required": false, + "type": null + } + ], + "body": null + }, { "method": "GET", "path": "/api/metrics/breakdown", diff --git a/dashboard/lib/product/observe.ts b/dashboard/lib/product/observe.ts index 8d19b089..4d2d19d7 100644 --- a/dashboard/lib/product/observe.ts +++ b/dashboard/lib/product/observe.ts @@ -17,7 +17,9 @@ export function metricsQs(f: Filters, extra: Record = {}): strin /** Filters carried from one view to the next, so switching tabs keeps them. */ export function keep(f: Filters): Record { - return { range: f.range === "7d" ? undefined : f.range, agent: f.agent, env: f.env }; + // Always explicit: a view with no range picks one from the data (`lib/product/range.ts`), + // so dropping "7d" here would send a reader who chose a week back to the data's window. + return { range: f.range, agent: f.agent, env: f.env }; } export const OBSERVE_TABS = [ diff --git a/dashboard/lib/product/range.ts b/dashboard/lib/product/range.ts new file mode 100644 index 00000000..76898a3c --- /dev/null +++ b/dashboard/lib/product/range.ts @@ -0,0 +1,28 @@ +import { redirect } from "next/navigation"; +import { href } from "@/components/kit"; +import { safeApi } from "@/lib/product/api"; +import type { RangeKey } from "@/lib/product/vocab"; + +type SP = Record; + +/** + * The range a view opens on when the URL names none: the smallest window that holds + * the latest traffic (a week at least), so a quiet workspace opens on its data + * rather than on an empty week. Asked of the gateway (`/api/metrics/activity`). + */ +export async function activeRange(agent?: string): Promise<{ range: RangeKey; last: string | null }> { + const q = agent ? `?agent=${encodeURIComponent(agent)}` : ""; + const a = await safeApi(`/api/metrics/activity${q}`, { range: "7d", last_request_at: null }); + return { range: (a.range || "7d") as RangeKey, last: a.last_request_at }; +} + +/** + * For a page whose numbers depend on the range: with no range in the URL, send the + * reader to the URL with the range that has data, so the filter bar, the links to + * other tabs and everything on the page agree on one window. + */ +export async function ensureRange(path: string, sp: SP, agent?: string): Promise { + if (sp.range) return; + const { range } = await activeRange(agent ?? sp.agent); + if (range !== "7d") redirect(href(path, { ...sp, range })); +} diff --git a/dashboard/lib/product/vocab.ts b/dashboard/lib/product/vocab.ts index d22697d8..60f87521 100644 --- a/dashboard/lib/product/vocab.ts +++ b/dashboard/lib/product/vocab.ts @@ -93,6 +93,9 @@ export const RANGES = [ export type RangeKey = (typeof RANGES)[number]["key"]; +/** A range in words, for labels: "Requests · 30 days". */ +export const RANGE_WORDS: Record = { "24h": "24 hours", "7d": "7 days", "30d": "30 days", "90d": "90 days" }; + export function rangeOf(value?: string | null): RangeKey { return (RANGES.find((r) => r.key === value)?.key || "7d") as RangeKey; } diff --git a/demo/redteam-live-lang/vendor/agentfox-0.3.1-py3-none-any.whl b/demo/redteam-live-lang/vendor/agentfox-0.3.1-py3-none-any.whl index 10582b0a..db881488 100644 Binary files a/demo/redteam-live-lang/vendor/agentfox-0.3.1-py3-none-any.whl and b/demo/redteam-live-lang/vendor/agentfox-0.3.1-py3-none-any.whl differ diff --git a/docs/architecture/api-spec.md b/docs/architecture/api-spec.md index 0a07c7ec..4b10ce0b 100644 --- a/docs/architecture/api-spec.md +++ b/docs/architecture/api-spec.md @@ -104,7 +104,7 @@ from the code. Regenerate after changing any route: -239 operations, generated from the running app's OpenAPI document. Request and response schemas: `GET /openapi.json` or the interactive `/docs`. +240 operations, generated from the running app's OpenAPI document. Request and response schemas: `GET /openapi.json` or the interactive `/docs`. ### Inline enforcement (`/v1`) @@ -132,6 +132,7 @@ from the code. Regenerate after changing any route: | `GET` | `/api/memory` | List Entries | | `POST` | `/api/memory/{entry_id}/revoke` | Pull an entry immediately — the concrete fix for 'no way to find and | | `POST` | `/api/memory/{entry_id}/verify` | A human vouches for an entry — it stops decaying on the unverified TTL. | +| `GET` | `/api/metrics/activity` | Activity | | `GET` | `/api/metrics/breakdown` | Breakdown | | `GET` | `/api/metrics/errors` | Steps that failed — a tool that threw, a model call that errored — grouped. | | `GET` | `/api/metrics/rules` | How each rule behaved: fired, stopped something, or only watched. | diff --git a/src/agentfox/apps/gateway/routes/metrics.py b/src/agentfox/apps/gateway/routes/metrics.py index 75081375..1a2accff 100644 --- a/src/agentfox/apps/gateway/routes/metrics.py +++ b/src/agentfox/apps/gateway/routes/metrics.py @@ -11,6 +11,8 @@ * ``/breakdown`` — the same counts split by agent, environment, model, tool or surface * ``/rules`` — per rule: how often it fired, enforced vs only watching, trend * ``/errors`` — failed steps (tool and model errors), grouped +* ``/activity`` — when the last request was, and the smallest range that shows it, + so a view with no range chosen opens on data instead of an empty week Outcomes use the dashboard's four words, not the six verdicts: ``allowed``, ``masked`` (redact/mask/tokenize), ``held`` (escalate) and ``blocked``. @@ -23,7 +25,7 @@ from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.orm import Session from agentfox.apps.gateway.deps import current_user, db @@ -168,6 +170,34 @@ def _percentile(values: list[float], p: float) -> float: return round(values[k], 2) +@router.get("/activity") +def activity( + agent: str | None = None, + environment: str | None = None, + session: Session = Depends(db), + _user: User = Depends(current_user), +) -> dict[str, Any]: + q = select(func.max(Trace.started_at)) + if agent: + q = q.where(Trace.agent_slug == agent) + if environment: + q = q.where(Trace.environment == environment) + else: + q = q.where(Trace.environment != PLAYGROUND) + last = session.scalar(q) + if last is None: + return {"last_request_at": None, "range": "7d"} + age = dt.datetime.now(dt.UTC) - _aware(last) + # The smallest range from the default week up that contains it; past the + # longest, the longest. Never narrower than a week: recent traffic still + # reads better against its week than against one day. + fits = [k for k, (length, _step) in RANGES.items() if k != "24h" and age <= length] + return { + "last_request_at": _aware(last).isoformat(), + "range": fits[0] if fits else list(RANGES)[-1], + } + + @router.get("/summary") def summary( window: str = Query("7d", alias="range"), diff --git a/src/agentfox/platform/ledger/trace.py b/src/agentfox/platform/ledger/trace.py index 4fc46a64..ee5c01e4 100644 --- a/src/agentfox/platform/ledger/trace.py +++ b/src/agentfox/platform/ledger/trace.py @@ -371,6 +371,9 @@ def search_traces( fired: dict[str, list[str]] = {} tools: dict[str, list[str]] = {} surfaces: dict[str, list[str]] = {} + # How many checks ran. A run that reached us only as telemetry has none, and + # must not read as "allowed": nothing decided anything about it. + checks: dict[str, int] = {} # What a rule that is only watching would have done: the strongest effect among # fired rules in observe mode. A run that was allowed but would have been # blocked is the fact a team needs before switching a rule to enforce. @@ -380,6 +383,7 @@ def search_traces( for d in session.scalars( select(Decision).where(Decision.trace_id.in_({t.id for t in traces})) ): + checks[d.trace_id] = checks.get(d.trace_id, 0) + 1 for r in d.rules_fired_json or []: rid = r.get("rule_id") if isinstance(r, dict) else None if rid and rid not in fired.setdefault(d.trace_id, []): @@ -411,6 +415,7 @@ def search_traces( "tools": tools.get(t.id, []), "surfaces": surfaces.get(t.id, []), "would_verdict": would.get(t.id), + "checks": checks.get(t.id, 0), } for t in traces ] diff --git a/tests/apps/gateway/test_metrics.py b/tests/apps/gateway/test_metrics.py index 1df178ab..467aa06a 100644 --- a/tests/apps/gateway/test_metrics.py +++ b/tests/apps/gateway/test_metrics.py @@ -135,3 +135,31 @@ def test_playground_runs_are_excluded_unless_asked_for(client): "/api/traces?agent=metrics-bot&environment=playground", headers=ADMIN ).json()["traces"] assert len(played) == 1 + + +def test_activity_names_the_smallest_range_with_traffic(client): + from agentfox.core.db import session_scope + from agentfox.core.models import Trace + + old = dt.datetime.now(dt.UTC) - dt.timedelta(days=20) + with session_scope() as session: + session.add(Trace(agent_slug="old-bot", verdict="allow", started_at=old)) + session.add( + Trace(agent_slug="old-bot", verdict="allow", started_at=old, environment="playground") + ) + out = client.get("/api/metrics/activity?agent=old-bot", headers=ADMIN).json() + assert out["range"] == "30d" and out["last_request_at"].startswith(old.date().isoformat()) + assert client.get("/api/metrics/activity?agent=nobody", headers=ADMIN).json() == { + "last_request_at": None, + "range": "7d", + } + + +def test_a_run_with_no_checks_says_so(client): + from agentfox.core.db import session_scope + from agentfox.core.models import Trace + + with session_scope() as session: + session.add(Trace(agent_slug="otel-bot", verdict="allow")) + rows = client.get("/api/traces?agent=otel-bot", headers=ADMIN).json()["traces"] + assert rows and rows[0]["checks"] == 0