diff --git a/.claude/skills/aphoria-install/SKILL.md b/.claude/skills/aphoria-install/SKILL.md index b2d6df9..e09abb0 100644 --- a/.claude/skills/aphoria-install/SKILL.md +++ b/.claude/skills/aphoria-install/SKILL.md @@ -14,6 +14,16 @@ You are an expert at installing and running StemeDB and Aphoria. You guide users - User is troubleshooting installation or runtime issues - User needs to verify their installation is working +## Vision: Autonomous Learning + +Aphoria's goal is **autonomous pattern learning**: +- Scan projects → extract claims about what code does +- Compare against authorities → RFCs, OWASP, specs +- Learn from usage → patterns feed back to corpus +- Autonomous promotion → good patterns become authoritative automatically + +**Hooks are not optional.** They trigger the learning loop. Every commit = scan = observation = learning. + ## Principles ### 1. User Space First @@ -25,8 +35,8 @@ Aphoria standalone needs only Rust. StemeDB server is optional for solo develope ### 3. Fast Verification Every installation step has an immediate verification command. -### 4. Ephemeral by Default -Default scans are fast and ephemeral. Server/persistence is opt-in. +### 4. Hooks Are Mandatory +When setting up on a project, **always install pre-commit hooks**. Without hooks, no autonomous learning happens. Don't ask - just do it. ### 5. Progressive Disclosure Start with minimal setup, add complexity only when needed. @@ -215,19 +225,64 @@ curl http://localhost:18180/health # Expected: {"status":"ok"} ``` -### Phase 6 (Optional): Configure Hosted Mode +### Phase 6: Set Up Project (MANDATORY) + +When installing Aphoria on any project, **always do all three**: + +```bash +# 1. Create config +cat > aphoria.toml << 'EOF' +[project] +name = "my-project" +description = "Brief description" + +[scan] +domains = ["security", "cryptography"] + +[thresholds] +flag = 0.4 +block = 0.6 + +[learning] +persist = true +sync = true +EOF + +# 2. Install pre-commit hook (NOT OPTIONAL) +mkdir -p .git/hooks +cat > .git/hooks/pre-commit << 'EOF' +#!/bin/bash +aphoria scan --staged --exit-code +if [ $? -ne 0 ]; then + echo "Aphoria found conflicts." + exit 1 +fi +EOF +chmod +x .git/hooks/pre-commit + +# 3. Create documentation +cat > aphoria.md << 'EOF' +# Aphoria Integration + +This project uses Aphoria for autonomous security pattern learning. +Every commit triggers a scan. Observations feed back to the corpus. +See aphoria.toml for configuration. +EOF + +# 4. Run baseline +aphoria scan --persist --sync +``` + +**Do not skip the hook.** It's the trigger for autonomous learning. + +### Phase 7 (Optional): Configure Hosted Mode Connect Aphoria to StemeDB server: ```bash -# In project directory -cat > aphoria.toml << 'EOF' -[project] -name = "my-project" - +# Add to aphoria.toml [hosted] url = "http://localhost:18180" -EOF # Test connection aphoria scan --persist --sync diff --git a/.gitignore b/.gitignore index cb039ac..923f5ea 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,9 @@ Thumbs.db credentials.json service-account*.json +# Aphoria project data (contains keys) +.aphoria/ + # Python virtual environments .venv/ venv/ diff --git a/applications/aphoria/Cargo.toml b/applications/aphoria/Cargo.toml index 259f43f..637d641 100644 --- a/applications/aphoria/Cargo.toml +++ b/applications/aphoria/Cargo.toml @@ -61,7 +61,7 @@ dirs = "5.0" # Logging tracing = "0.1" -tracing-subscriber = "0.3" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } # rkyv for zero-copy (consistent with stemedb) rkyv = { version = "0.7", features = ["validation"] } diff --git a/applications/aphoria/src/cli/mod.rs b/applications/aphoria/src/cli/mod.rs index dc2e9c1..ecbaa95 100644 --- a/applications/aphoria/src/cli/mod.rs +++ b/applications/aphoria/src/cli/mod.rs @@ -36,6 +36,10 @@ pub struct Cli { #[arg(short, long, global = true)] pub config: Option, + /// Enable verbose logging (shows internal tracing output) + #[arg(short, long, global = true)] + pub verbose: bool, + #[command(subcommand)] pub command: Commands, } diff --git a/applications/aphoria/src/governance/audit.rs b/applications/aphoria/src/governance/audit.rs index 7b0bda5..9a7761d 100644 --- a/applications/aphoria/src/governance/audit.rs +++ b/applications/aphoria/src/governance/audit.rs @@ -159,6 +159,8 @@ impl AuditTrail { } /// Get all audit events. + /// + /// Resilient parser that skips malformed lines rather than failing. pub fn get_all_events(&self) -> Result, AphoriaError> { let path = self.events_path(); if !path.exists() { @@ -170,17 +172,53 @@ impl AuditTrail { let reader = BufReader::new(file); let mut events = Vec::new(); + let mut skipped = 0; + for line in reader.lines() { - let line = line - .map_err(|e| AphoriaError::Storage(format!("Failed to read audit line: {}", e)))?; + let line = match line { + Ok(l) => l, + Err(_) => { + skipped += 1; + continue; + } + }; if line.trim().is_empty() { continue; } - let event: AuditEvent = serde_json::from_str(&line) - .map_err(|e| AphoriaError::Storage(format!("Failed to parse event: {}", e)))?; - events.push(event); + // Try to parse each JSON object on the line (handles concatenated objects) + let mut remaining = line.trim(); + while !remaining.is_empty() { + // Find the end of the current JSON object + match serde_json::from_str::(remaining) { + Ok(event) => { + events.push(event); + // Consumed the whole line + break; + } + Err(_) => { + // Try to find where the first JSON object ends by looking for }{ + if let Some(split_pos) = remaining.find("}{") { + let first_obj = &remaining[..=split_pos]; + if let Ok(event) = serde_json::from_str::(first_obj) { + events.push(event); + } else { + skipped += 1; + } + remaining = &remaining[split_pos + 1..]; + } else { + // Can't parse, skip this line + skipped += 1; + break; + } + } + } + } + } + + if skipped > 0 { + tracing::warn!(skipped, "Skipped malformed audit entries"); } Ok(events) diff --git a/applications/aphoria/src/init.rs b/applications/aphoria/src/init.rs index 83327de..8c266f7 100644 --- a/applications/aphoria/src/init.rs +++ b/applications/aphoria/src/init.rs @@ -23,12 +23,12 @@ pub async fn show_status(config: &AphoriaConfig) -> Result } output.push_str("Aphoria status:\n"); - output.push_str(&format!(" Data directory: {}\\n", data_dir.display())); - output.push_str(&format!(" Project root: {}\\n", project_root.display())); + output.push_str(&format!(" Data directory: {}\n", data_dir.display())); + output.push_str(&format!(" Project root: {}\n", project_root.display())); if aphoria_dir.join("baseline").exists() { let baseline = std::fs::read_to_string(aphoria_dir.join("baseline"))?; - output.push_str(&format!(" Baseline: {}\\n", baseline.trim())); + output.push_str(&format!(" Baseline: {}\n", baseline.trim())); } else { output.push_str(" Baseline: none\n"); } diff --git a/applications/aphoria/src/main.rs b/applications/aphoria/src/main.rs index 65ec6bc..8212036 100644 --- a/applications/aphoria/src/main.rs +++ b/applications/aphoria/src/main.rs @@ -6,6 +6,7 @@ use std::process::ExitCode; use clap::Parser; +use tracing_subscriber::EnvFilter; use aphoria::AphoriaConfig; @@ -16,11 +17,16 @@ use cli::Cli; #[tokio::main] async fn main() -> ExitCode { - // Initialize tracing for internal logging - tracing_subscriber::fmt::init(); - let cli = Cli::parse(); + // Initialize tracing only if verbose or RUST_LOG is set + // Default: silent (clean CLI output) + if cli.verbose || std::env::var("RUST_LOG").is_ok() { + let filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("aphoria=info")); + tracing_subscriber::fmt().with_env_filter(filter).init(); + } + // Load configuration let config = match load_config(cli.config.as_deref()) { Ok(cfg) => cfg, diff --git a/applications/stemedb-dashboard/src/app/corpus/page.tsx b/applications/stemedb-dashboard/src/app/corpus/page.tsx new file mode 100644 index 0000000..9eecd54 --- /dev/null +++ b/applications/stemedb-dashboard/src/app/corpus/page.tsx @@ -0,0 +1,13 @@ +import { Header } from "@/components/layout/header"; +import { CorpusPanel } from "@/components/corpus"; + +export default function CorpusPage() { + return ( + <> +
+
+ +
+ + ); +} diff --git a/applications/stemedb-dashboard/src/app/scans/page.tsx b/applications/stemedb-dashboard/src/app/scans/page.tsx new file mode 100644 index 0000000..788159c --- /dev/null +++ b/applications/stemedb-dashboard/src/app/scans/page.tsx @@ -0,0 +1,13 @@ +import { Header } from "@/components/layout/header"; +import { ScansPanel } from "@/components/scans"; + +export default function ScansPage() { + return ( + <> +
+
+ +
+ + ); +} diff --git a/applications/stemedb-dashboard/src/components/corpus/constants.ts b/applications/stemedb-dashboard/src/components/corpus/constants.ts new file mode 100644 index 0000000..559b765 --- /dev/null +++ b/applications/stemedb-dashboard/src/components/corpus/constants.ts @@ -0,0 +1,20 @@ +// Corpus page constants + +export const CORPUS_FETCH_LIMIT = 100; +export const DEFAULT_MIN_PROJECTS = 1; + +// Re-export shared formatters for convenience +export { formatRelativeTime, formatUnixTimestamp } from "@/lib/format"; + +// Subject path parsing +export function extractDomain(subject: string): string { + // Extract domain from code://rust/*/tls → rust + const match = subject.match(/^code:\/\/([^/]+)/); + return match ? match[1] : "unknown"; +} + +export function extractConcept(subject: string): string { + // Extract last part of path: code://rust/*/tls/cert → cert + const parts = subject.split("/"); + return parts[parts.length - 1] || subject; +} diff --git a/applications/stemedb-dashboard/src/components/corpus/corpus-empty-state.tsx b/applications/stemedb-dashboard/src/components/corpus/corpus-empty-state.tsx new file mode 100644 index 0000000..b948354 --- /dev/null +++ b/applications/stemedb-dashboard/src/components/corpus/corpus-empty-state.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { Library, Search } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface CorpusEmptyStateProps { + hasFilter: boolean; + onClearFilter?: () => void; +} + +export function CorpusEmptyState({ hasFilter, onClearFilter }: CorpusEmptyStateProps) { + if (hasFilter) { + return ( +
+ +

+ No patterns match your filter +

+

+ Try adjusting the subject prefix or lowering the minimum projects threshold. +

+ {onClearFilter && ( + + )} +
+ ); + } + + return ( +
+ +

+ No community patterns yet +

+

+ When teams opt-in to community sharing, their anonymized scan patterns + will appear here. Run scans with community sharing enabled to contribute. +

+
+ ); +} diff --git a/applications/stemedb-dashboard/src/components/corpus/corpus-filters.tsx b/applications/stemedb-dashboard/src/components/corpus/corpus-filters.tsx new file mode 100644 index 0000000..cc22b25 --- /dev/null +++ b/applications/stemedb-dashboard/src/components/corpus/corpus-filters.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { X } from "lucide-react"; + +interface CorpusFiltersProps { + subjectPrefix: string; + minProjects: number; + onSubjectPrefixChange: (value: string) => void; + onMinProjectsChange: (value: number) => void; + totalCount: number; + filteredCount: number; +} + +export function CorpusFilters({ + subjectPrefix, + minProjects, + onSubjectPrefixChange, + onMinProjectsChange, + totalCount, + filteredCount, +}: CorpusFiltersProps) { + const hasActiveFilter = subjectPrefix !== "" || minProjects > 1; + + const handleClear = () => { + onSubjectPrefixChange(""); + onMinProjectsChange(1); + }; + + return ( +
+
+ onSubjectPrefixChange(e.target.value)} + className="max-w-md" + /> +
+ +
+ + onMinProjectsChange(Math.max(1, parseInt(e.target.value) || 1))} + className="w-20" + /> +
+ + {hasActiveFilter && ( + + )} + +
+ {filteredCount === totalCount + ? `${totalCount} patterns` + : `${filteredCount} of ${totalCount} patterns`} +
+
+ ); +} diff --git a/applications/stemedb-dashboard/src/components/corpus/corpus-list.tsx b/applications/stemedb-dashboard/src/components/corpus/corpus-list.tsx new file mode 100644 index 0000000..c902008 --- /dev/null +++ b/applications/stemedb-dashboard/src/components/corpus/corpus-list.tsx @@ -0,0 +1,21 @@ +"use client"; + +import type { PatternDto } from "@/lib/api"; +import { CorpusRow } from "./corpus-row"; + +interface CorpusListProps { + patterns: PatternDto[]; +} + +export function CorpusList({ patterns }: CorpusListProps) { + return ( +
+ {patterns.map((pattern) => ( + + ))} +
+ ); +} diff --git a/applications/stemedb-dashboard/src/components/corpus/corpus-loading-skeleton.tsx b/applications/stemedb-dashboard/src/components/corpus/corpus-loading-skeleton.tsx new file mode 100644 index 0000000..0d3aff9 --- /dev/null +++ b/applications/stemedb-dashboard/src/components/corpus/corpus-loading-skeleton.tsx @@ -0,0 +1,29 @@ +"use client"; + +export function CorpusLoadingSkeleton() { + return ( +
+ {/* Filter skeleton */} +
+
+
+
+ + {/* Pattern cards skeleton */} +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+
+ ); +} diff --git a/applications/stemedb-dashboard/src/components/corpus/corpus-panel.tsx b/applications/stemedb-dashboard/src/components/corpus/corpus-panel.tsx new file mode 100644 index 0000000..f0ef0dd --- /dev/null +++ b/applications/stemedb-dashboard/src/components/corpus/corpus-panel.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useState, useCallback, useEffect } from "react"; +import { + StemeDBClient, + type GetPatternsResponse, + ApiError, +} from "@/lib/api"; +import type { PanelState } from "@/lib/types"; +import { CORPUS_FETCH_LIMIT, DEFAULT_MIN_PROJECTS } from "./constants"; +import { ErrorState } from "@/components/shared/error-state"; +import { CorpusFilters } from "./corpus-filters"; +import { CorpusList } from "./corpus-list"; +import { CorpusLoadingSkeleton } from "./corpus-loading-skeleton"; +import { CorpusEmptyState } from "./corpus-empty-state"; + +export function CorpusPanel() { + const [state, setState] = useState>({ + status: "idle", + }); + const [subjectPrefix, setSubjectPrefix] = useState(""); + const [minProjects, setMinProjects] = useState(DEFAULT_MIN_PROJECTS); + + // Debounced filter values for API calls + const [debouncedPrefix, setDebouncedPrefix] = useState(""); + + // Debounce the subject prefix + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedPrefix(subjectPrefix); + }, 300); + return () => clearTimeout(timer); + }, [subjectPrefix]); + + const fetchData = useCallback(async () => { + setState({ status: "loading" }); + try { + const client = new StemeDBClient(); + const data = await client.getPatterns({ + subjectPrefix: debouncedPrefix || undefined, + minProjects, + limit: CORPUS_FETCH_LIMIT, + }); + setState({ status: "success", data }); + } catch (err) { + // 404 means no patterns - treat as empty success + if (err instanceof ApiError && err.status === 404) { + setState({ + status: "success", + data: { patterns: [], total_matching: 0 }, + }); + return; + } + const message = + err instanceof ApiError + ? err.userMessage + : err instanceof Error + ? err.message + : "Unknown error"; + setState({ status: "error", error: message }); + } + }, [debouncedPrefix, minProjects]); + + // Fetch on mount and when filters change + useEffect(() => { + fetchData(); + }, [fetchData]); + + // Patterns from successful state (filtering done server-side) + const patterns = state.status === "success" ? state.data.patterns : []; + + const handleClearFilter = useCallback(() => { + setSubjectPrefix(""); + setMinProjects(DEFAULT_MIN_PROJECTS); + }, []); + + const hasFilter = subjectPrefix !== "" || minProjects > DEFAULT_MIN_PROJECTS; + + return ( +
+ {/* Header */} +
+

+ Community Corpus +

+

+ Explore patterns discovered across projects using Aphoria. These anonymized + observations help establish community consensus on configurations and practices. +

+
+ + {/* Content */} +
+ {state.status === "idle" && } + {state.status === "loading" && } + + {state.status === "error" && ( + + )} + + {state.status === "success" && ( +
+ + + {patterns.length === 0 ? ( + + ) : ( + + )} +
+ )} +
+
+ ); +} diff --git a/applications/stemedb-dashboard/src/components/corpus/corpus-row.tsx b/applications/stemedb-dashboard/src/components/corpus/corpus-row.tsx new file mode 100644 index 0000000..1dbfef9 --- /dev/null +++ b/applications/stemedb-dashboard/src/components/corpus/corpus-row.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import type { PatternDto } from "@/lib/api"; +import { formatRelativeTime, extractDomain, extractConcept } from "./constants"; +import { Badge } from "@/components/ui/badge"; +import { Users, Clock, Eye } from "lucide-react"; + +interface CorpusRowProps { + pattern: PatternDto; + className?: string; +} + +export function CorpusRow({ pattern, className }: CorpusRowProps) { + const domain = extractDomain(pattern.subject); + const concept = extractConcept(pattern.subject); + + return ( +
+ {/* Header */} +
+
+
+ + {domain} + + + {pattern.subject} + +
+

+ {concept} + + {" "}.{pattern.predicate} + +

+
+
+ + {/* Value */} +
+ + {pattern.value} + +
+ + {/* Stats */} +
+
+ + {pattern.project_count} projects +
+
+ + {pattern.observation_count} observations +
+
+ + Last seen {formatRelativeTime(pattern.last_seen)} +
+
+
+ ); +} diff --git a/applications/stemedb-dashboard/src/components/corpus/index.ts b/applications/stemedb-dashboard/src/components/corpus/index.ts new file mode 100644 index 0000000..d4653ed --- /dev/null +++ b/applications/stemedb-dashboard/src/components/corpus/index.ts @@ -0,0 +1,7 @@ +export { CorpusPanel } from "./corpus-panel"; +export { CorpusFilters } from "./corpus-filters"; +export { CorpusList } from "./corpus-list"; +export { CorpusRow } from "./corpus-row"; +export { CorpusEmptyState } from "./corpus-empty-state"; +export { CorpusLoadingSkeleton } from "./corpus-loading-skeleton"; +export * from "./constants"; diff --git a/applications/stemedb-dashboard/src/components/layout/sidebar.tsx b/applications/stemedb-dashboard/src/components/layout/sidebar.tsx index c93ff1d..211a370 100644 --- a/applications/stemedb-dashboard/src/components/layout/sidebar.tsx +++ b/applications/stemedb-dashboard/src/components/layout/sidebar.tsx @@ -12,6 +12,8 @@ import { Menu, X, BookOpen, + Scan, + Library, } from "lucide-react"; import { useState } from "react"; import { cn } from "@/lib/utils"; @@ -23,6 +25,8 @@ const navigation = [ { name: "Quarantine", href: "/quarantine", icon: ShieldAlert }, { name: "Circuit Breakers", href: "/circuit", icon: Zap }, { name: "Audit Trail", href: "/audit", icon: FileText }, + { name: "Corpus", href: "/corpus", icon: Library }, + { name: "Scans", href: "/scans", icon: Scan }, ]; export function Sidebar() { diff --git a/applications/stemedb-dashboard/src/components/scans/constants.ts b/applications/stemedb-dashboard/src/components/scans/constants.ts new file mode 100644 index 0000000..d662f57 --- /dev/null +++ b/applications/stemedb-dashboard/src/components/scans/constants.ts @@ -0,0 +1,27 @@ +// Scans page constants + +export type VerdictType = "BLOCK" | "FLAG" | "PASS" | "ACK"; + +export const verdictLabels: Record = { + BLOCK: "Block", + FLAG: "Flag", + PASS: "Pass", + ACK: "Acknowledged", +}; + +export const verdictColors: Record = { + BLOCK: "bg-red-500/20 text-red-700 dark:text-red-300", + FLAG: "bg-amber-500/20 text-amber-700 dark:text-amber-300", + PASS: "bg-emerald-500/20 text-emerald-700 dark:text-emerald-300", + ACK: "bg-blue-500/20 text-blue-700 dark:text-blue-300", +}; + +export const verdictIcons: Record = { + BLOCK: "\u25CF", // ● + FLAG: "\u26A0", // ⚠ + PASS: "\u2713", // ✓ + ACK: "\u2714", // ✔ +}; + +// Re-export shared formatters for convenience +export { formatRelativeTime, formatUnixDateTime } from "@/lib/format"; diff --git a/applications/stemedb-dashboard/src/components/scans/finding-detail-sheet.tsx b/applications/stemedb-dashboard/src/components/scans/finding-detail-sheet.tsx new file mode 100644 index 0000000..fd4f8ae --- /dev/null +++ b/applications/stemedb-dashboard/src/components/scans/finding-detail-sheet.tsx @@ -0,0 +1,167 @@ +"use client"; + +import type { FindingDto } from "@/lib/api"; +import { VerdictBadge } from "./verdict-badge"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, +} from "@/components/ui/sheet"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { FileCode, AlertTriangle, BookOpen, Shield } from "lucide-react"; + +interface FindingDetailSheetProps { + finding: FindingDto | null; + isOpen: boolean; + onClose: () => void; +} + +export function FindingDetailSheet({ finding, isOpen, onClose }: FindingDetailSheetProps) { + if (!finding) return null; + + return ( + !open && onClose()}> + + +
+ +
+ + {finding.concept_path} + + + .{finding.predicate} + +
+ +
+ {/* Code Location */} +
+

+ + Source Location +

+
+
+ File: + {finding.file} +
+
+ Line: + {finding.line} +
+ +
+ Value in code: + + {finding.code_value} + +
+
+
+ + {/* Conflict Details */} + {finding.conflicts.length > 0 && ( +
+

+ + Conflicting Sources ({finding.conflicts.length}) +

+
+ {finding.conflicts.map((conflict, index) => ( +
+
+ + {conflict.source_class} + + {conflict.policy_source && ( + + via {conflict.policy_source.pack_name} v{conflict.policy_source.pack_version} + + )} +
+
+ Expected: + {conflict.value} +
+ {conflict.citation && ( +
+ + {conflict.citation} +
+ )} +
+ ))} +
+
+ )} + + {/* Acknowledgment */} + {finding.acknowledgment && ( +
+

+ + Acknowledged +

+
+
+ By: + {finding.acknowledgment.by} +
+
+ Reason: + {finding.acknowledgment.reason} +
+
+ {finding.acknowledgment.timestamp} +
+
+
+ )} + + {/* Debug Trace */} + {finding.trace && ( +
+

+ Resolution Trace +

+
+
+ Code claim: + {finding.trace.code_claim} +
+
+ Authority match: + {finding.trace.authority_match} +
+
+ Authority tier: + {finding.trace.authority_tier} +
+ +
+ Resolution: + {finding.trace.resolution} +
+
+
+ )} + + {/* Conflict Score */} +
+
+ Conflict Score + {(finding.conflict_score * 100).toFixed(1)}% +
+
+
+
+
+ ); +} diff --git a/applications/stemedb-dashboard/src/components/scans/finding-row.tsx b/applications/stemedb-dashboard/src/components/scans/finding-row.tsx new file mode 100644 index 0000000..cde9201 --- /dev/null +++ b/applications/stemedb-dashboard/src/components/scans/finding-row.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import type { FindingDto } from "@/lib/api"; +import { VerdictBadge } from "./verdict-badge"; +import { FileCode, ChevronRight } from "lucide-react"; + +interface FindingRowProps { + finding: FindingDto; + onClick?: () => void; + className?: string; +} + +export function FindingRow({ finding, onClick, className }: FindingRowProps) { + // Extract last part of concept path for display + const conceptParts = finding.concept_path.split("/"); + const conceptShort = conceptParts[conceptParts.length - 1] || finding.concept_path; + + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + onClick?.(); + } + }} + > + {/* Icon */} +
+ +
+ + {/* Content */} +
+
+ + {conceptShort} + .{finding.predicate} + + +
+
+ {finding.file}:{finding.line} + | + + {finding.code_value} + +
+
+ + {/* Arrow */} + +
+ ); +} diff --git a/applications/stemedb-dashboard/src/components/scans/index.ts b/applications/stemedb-dashboard/src/components/scans/index.ts new file mode 100644 index 0000000..7167c65 --- /dev/null +++ b/applications/stemedb-dashboard/src/components/scans/index.ts @@ -0,0 +1,11 @@ +export { ScansPanel } from "./scans-panel"; +export { ScanForm } from "./scan-form"; +export { ScansList } from "./scans-list"; +export { ScanRow } from "./scan-row"; +export { ScanDetail } from "./scan-detail"; +export { FindingRow } from "./finding-row"; +export { FindingDetailSheet } from "./finding-detail-sheet"; +export { VerdictBadge } from "./verdict-badge"; +export { ScansEmptyState } from "./scans-empty-state"; +export { ScansLoadingSkeleton } from "./scans-loading-skeleton"; +export * from "./constants"; diff --git a/applications/stemedb-dashboard/src/components/scans/scan-detail.tsx b/applications/stemedb-dashboard/src/components/scans/scan-detail.tsx new file mode 100644 index 0000000..9136b3b --- /dev/null +++ b/applications/stemedb-dashboard/src/components/scans/scan-detail.tsx @@ -0,0 +1,31 @@ +"use client"; + +import type { FindingDto } from "@/lib/api"; +import { FindingRow } from "./finding-row"; + +interface ScanDetailProps { + findings: FindingDto[]; + onFindingClick?: (finding: FindingDto) => void; +} + +export function ScanDetail({ findings, onFindingClick }: ScanDetailProps) { + if (findings.length === 0) { + return ( +
+ No findings in this scan. +
+ ); + } + + return ( +
+ {findings.map((finding, index) => ( + onFindingClick?.(finding)} + /> + ))} +
+ ); +} diff --git a/applications/stemedb-dashboard/src/components/scans/scan-form.tsx b/applications/stemedb-dashboard/src/components/scans/scan-form.tsx new file mode 100644 index 0000000..47de8e9 --- /dev/null +++ b/applications/stemedb-dashboard/src/components/scans/scan-form.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Loader2, Scan } from "lucide-react"; + +interface ScanFormProps { + onScan: (targetPath: string) => Promise; + isScanning: boolean; +} + +export function ScanForm({ onScan, isScanning }: ScanFormProps) { + const [targetPath, setTargetPath] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!targetPath.trim() || isScanning) return; + await onScan(targetPath.trim()); + }; + + return ( +
+
+ + setTargetPath(e.target.value)} + disabled={isScanning} + /> +
+ +
+ ); +} diff --git a/applications/stemedb-dashboard/src/components/scans/scan-row.tsx b/applications/stemedb-dashboard/src/components/scans/scan-row.tsx new file mode 100644 index 0000000..6af125a --- /dev/null +++ b/applications/stemedb-dashboard/src/components/scans/scan-row.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import type { ScanListItem, FindingDto } from "@/lib/api"; +import { formatRelativeTime } from "./constants"; +import { VerdictBadge } from "./verdict-badge"; +import { ScanDetail } from "./scan-detail"; +import { ChevronDown, ChevronRight, FolderCode, FileSearch } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; + +interface ScanRowProps { + scan: ScanListItem; + onFindingClick?: (finding: FindingDto) => void; + className?: string; +} + +export function ScanRow({ scan, onFindingClick, className }: ScanRowProps) { + const [expanded, setExpanded] = useState(false); + + const hasIssues = scan.summary.blocked > 0 || scan.summary.flagged > 0; + + return ( +
+ {/* Header */} +
setExpanded(!expanded)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + setExpanded(!expanded); + } + }} + > + {/* Expand icon */} +
+ {expanded ? ( + + ) : ( + + )} +
+ + {/* Project info */} +
+
+ + {scan.project} + + {formatRelativeTime(scan.timestamp)} + +
+
+ + + {scan.files_scanned} files + + {scan.claims_extracted} claims +
+
+ + {/* Summary badges */} +
+ {scan.summary.blocked > 0 && ( + + {scan.summary.blocked} blocked + + )} + {scan.summary.flagged > 0 && ( + + {scan.summary.flagged} flagged + + )} + {scan.summary.blocked === 0 && scan.summary.flagged === 0 && ( + + Clean + + )} +
+
+ + {/* Expanded detail */} + {expanded && ( +
+ +
+ )} +
+ ); +} diff --git a/applications/stemedb-dashboard/src/components/scans/scans-empty-state.tsx b/applications/stemedb-dashboard/src/components/scans/scans-empty-state.tsx new file mode 100644 index 0000000..aa9c0f4 --- /dev/null +++ b/applications/stemedb-dashboard/src/components/scans/scans-empty-state.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { Scan } from "lucide-react"; + +export function ScansEmptyState() { + return ( +
+ +

+ No scans yet +

+

+ Run your first scan by entering a project path above. Scans check your + code for conflicts against authoritative sources. +

+
+ ); +} diff --git a/applications/stemedb-dashboard/src/components/scans/scans-list.tsx b/applications/stemedb-dashboard/src/components/scans/scans-list.tsx new file mode 100644 index 0000000..5e1f027 --- /dev/null +++ b/applications/stemedb-dashboard/src/components/scans/scans-list.tsx @@ -0,0 +1,23 @@ +"use client"; + +import type { ScanListItem, FindingDto } from "@/lib/api"; +import { ScanRow } from "./scan-row"; + +interface ScansListProps { + scans: ScanListItem[]; + onFindingClick?: (finding: FindingDto) => void; +} + +export function ScansList({ scans, onFindingClick }: ScansListProps) { + return ( +
+ {scans.map((scan) => ( + + ))} +
+ ); +} diff --git a/applications/stemedb-dashboard/src/components/scans/scans-loading-skeleton.tsx b/applications/stemedb-dashboard/src/components/scans/scans-loading-skeleton.tsx new file mode 100644 index 0000000..79644d5 --- /dev/null +++ b/applications/stemedb-dashboard/src/components/scans/scans-loading-skeleton.tsx @@ -0,0 +1,35 @@ +"use client"; + +export function ScansLoadingSkeleton() { + return ( +
+ {/* Scan form skeleton */} +
+
+
+
+
+
+
+
+
+ + {/* Scan list skeleton */} +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
+
+ ); +} diff --git a/applications/stemedb-dashboard/src/components/scans/scans-panel.tsx b/applications/stemedb-dashboard/src/components/scans/scans-panel.tsx new file mode 100644 index 0000000..8c417cf --- /dev/null +++ b/applications/stemedb-dashboard/src/components/scans/scans-panel.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useState, useCallback, useEffect } from "react"; +import { + StemeDBClient, + type ListScansResponse, + type FindingDto, + ApiError, +} from "@/lib/api"; +import type { PanelState } from "@/lib/types"; +import { ErrorState } from "@/components/shared/error-state"; +import { ScanForm } from "./scan-form"; +import { ScansList } from "./scans-list"; +import { ScansLoadingSkeleton } from "./scans-loading-skeleton"; +import { ScansEmptyState } from "./scans-empty-state"; +import { FindingDetailSheet } from "./finding-detail-sheet"; + +export function ScansPanel() { + const [state, setState] = useState>({ + status: "idle", + }); + const [isScanning, setIsScanning] = useState(false); + const [scanError, setScanError] = useState(null); + const [selectedFinding, setSelectedFinding] = useState(null); + + const fetchData = useCallback(async () => { + setState({ status: "loading" }); + try { + const client = new StemeDBClient(); + const data = await client.listScans(); + setState({ status: "success", data }); + } catch (err) { + // 404 means no scans - treat as empty success + if (err instanceof ApiError && err.status === 404) { + setState({ + status: "success", + data: { scans: [] }, + }); + return; + } + const message = + err instanceof ApiError + ? err.userMessage + : err instanceof Error + ? err.message + : "Unknown error"; + setState({ status: "error", error: message }); + } + }, []); + + // Fetch on mount + useEffect(() => { + fetchData(); + }, [fetchData]); + + const handleScan = useCallback(async (targetPath: string) => { + setIsScanning(true); + setScanError(null); + try { + const client = new StemeDBClient(); + await client.runScan({ target_path: targetPath }); + // Refresh the list to show the new scan + await fetchData(); + } catch (err) { + const message = + err instanceof ApiError + ? err.userMessage + : err instanceof Error + ? err.message + : "Scan failed"; + setScanError(message); + } finally { + setIsScanning(false); + } + }, [fetchData]); + + const handleFindingClick = useCallback((finding: FindingDto) => { + setSelectedFinding(finding); + }, []); + + const handleCloseSheet = useCallback(() => { + setSelectedFinding(null); + }, []); + + return ( +
+ {/* Header */} +
+

+ Aphoria Scans +

+

+ Run scans to check your code for conflicts against authoritative sources. + View scan history and drill into findings for details. +

+
+ + {/* Scan Form */} +
+ + {scanError && ( +
+ {scanError} +
+ )} +
+ + {/* Content */} +
+

Recent Scans

+ + {state.status === "idle" && } + {state.status === "loading" && } + + {state.status === "error" && ( + + )} + + {state.status === "success" && ( + state.data.scans.length === 0 ? ( + + ) : ( + + ) + )} +
+ + {/* Finding Detail Sheet */} + +
+ ); +} diff --git a/applications/stemedb-dashboard/src/components/scans/verdict-badge.tsx b/applications/stemedb-dashboard/src/components/scans/verdict-badge.tsx new file mode 100644 index 0000000..05e5f03 --- /dev/null +++ b/applications/stemedb-dashboard/src/components/scans/verdict-badge.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { type VerdictType, verdictLabels, verdictColors, verdictIcons } from "./constants"; + +interface VerdictBadgeProps { + verdict: VerdictType; + className?: string; + size?: "sm" | "xs"; +} + +export function VerdictBadge({ verdict, className, size = "sm" }: VerdictBadgeProps) { + const label = verdictLabels[verdict]; + const color = verdictColors[verdict]; + const icon = verdictIcons[verdict]; + + const sizeClass = size === "xs" ? "text-[10px] px-1.5 py-0.5" : "text-xs px-2.5 py-0.5"; + + return ( + + {icon} + {label} + + ); +} diff --git a/applications/stemedb-dashboard/src/lib/api/client.ts b/applications/stemedb-dashboard/src/lib/api/client.ts index e5c3306..362f8c4 100644 --- a/applications/stemedb-dashboard/src/lib/api/client.ts +++ b/applications/stemedb-dashboard/src/lib/api/client.ts @@ -10,6 +10,10 @@ import { type SourceImpactResponse, type QuarantineSourceResponse, type RestoreSourceResponse, + type GetPatternsResponse, + type ScanRequest, + type ScanResponse, + type ListScansResponse, } from "./types"; export class StemeDBClient { @@ -162,6 +166,31 @@ export class StemeDBClient { getApiKey(): string | null { return this.apiKey; } + + // Aphoria methods + async getPatterns(params: { + subjectPrefix?: string; + minProjects?: number; + limit?: number; + } = {}): Promise { + const searchParams = new URLSearchParams(); + if (params.subjectPrefix) searchParams.set("subject_prefix", params.subjectPrefix); + if (params.minProjects !== undefined) searchParams.set("min_projects", String(params.minProjects)); + if (params.limit !== undefined) searchParams.set("limit", String(params.limit)); + const query = searchParams.toString(); + return this.fetch(`/v1/aphoria/patterns${query ? `?${query}` : ""}`); + } + + async runScan(request: ScanRequest): Promise { + return this.fetch("/v1/aphoria/scan", { + method: "POST", + body: JSON.stringify(request), + }); + } + + async listScans(): Promise { + return this.fetch("/v1/aphoria/scans"); + } } // Singleton client for server components diff --git a/applications/stemedb-dashboard/src/lib/api/types.ts b/applications/stemedb-dashboard/src/lib/api/types.ts index 1b18451..9eec26d 100644 --- a/applications/stemedb-dashboard/src/lib/api/types.ts +++ b/applications/stemedb-dashboard/src/lib/api/types.ts @@ -244,6 +244,103 @@ export interface RestoreSourceResponse { message: string; } +// ============================================================================ +// Aphoria DTOs +// ============================================================================ + +export interface PatternDto { + subject: string; + predicate: string; + value: string; + project_count: number; + observation_count: number; + first_seen: number; + last_seen: number; +} + +export interface GetPatternsResponse { + patterns: PatternDto[]; + total_matching: number; +} + +export interface FindingDto { + concept_path: string; + predicate: string; + code_value: string; + file: string; + line: number; + conflict_score: number; + verdict: "BLOCK" | "FLAG" | "PASS" | "ACK"; + conflicts: ConflictingSourceDto[]; + acknowledgment?: AcknowledgmentDto; + trace?: ConflictTraceDto; +} + +export interface ConflictingSourceDto { + path: string; + source_class: string; + value: string; + citation?: string; + policy_source?: PolicySourceDto; +} + +export interface PolicySourceDto { + pack_name: string; + pack_version: string; + issuer_hex: string; +} + +export interface AcknowledgmentDto { + timestamp: string; + by: string; + reason: string; +} + +export interface ConflictTraceDto { + code_claim: string; + authority_match: string; + authority_tier: string; + resolution: string; +} + +export interface ScanSummaryDto { + total: number; + blocked: number; + flagged: number; + passed: number; + acknowledged: number; +} + +export interface ScanResponse { + project: string; + scan_id: string; + files_scanned: number; + claims_extracted: number; + findings: FindingDto[]; + summary: ScanSummaryDto; +} + +export interface ScanRequest { + target_path: string; + format?: string; + fail_on_flag?: boolean; + debug?: boolean; +} + +export interface ScanListItem { + scan_id: string; + project: string; + files_scanned: number; + claims_extracted: number; + summary: ScanSummaryDto; + timestamp: number; + findings: FindingDto[]; +} + +export interface ListScansResponse { + scans: ScanListItem[]; +} + export class ApiError extends Error { public userMessage: string; diff --git a/applications/stemedb-dashboard/src/lib/format.ts b/applications/stemedb-dashboard/src/lib/format.ts index faf947c..ee43d87 100644 --- a/applications/stemedb-dashboard/src/lib/format.ts +++ b/applications/stemedb-dashboard/src/lib/format.ts @@ -2,6 +2,7 @@ /** * Format a timestamp as a relative time string (e.g., "2h ago", "5m ago") + * @param timestamp - Timestamp in milliseconds */ export function formatTimeAgo(timestamp: number): string { const now = Date.now(); @@ -16,6 +17,53 @@ export function formatTimeAgo(timestamp: number): string { return "just now"; } +/** + * Format a Unix timestamp (seconds) as a relative time string. + * Used for API responses that return Unix timestamps. + * @param timestamp - Unix timestamp in seconds + */ +export function formatRelativeTime(timestamp: number): string { + const now = Date.now() / 1000; + const diff = now - timestamp; + + if (diff < 60) return "just now"; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`; + return formatUnixTimestamp(timestamp); +} + +/** + * Format a Unix timestamp (seconds) as a date string. + * @param timestamp - Unix timestamp in seconds + * @param options - Intl.DateTimeFormatOptions for customization + */ +export function formatUnixTimestamp( + timestamp: number, + options?: Intl.DateTimeFormatOptions +): string { + const defaultOptions: Intl.DateTimeFormatOptions = { + month: "short", + day: "numeric", + year: "numeric", + }; + return new Date(timestamp * 1000).toLocaleDateString("en-US", options ?? defaultOptions); +} + +/** + * Format a Unix timestamp (seconds) as a date-time string. + * @param timestamp - Unix timestamp in seconds + */ +export function formatUnixDateTime(timestamp: number): string { + return new Date(timestamp * 1000).toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + /** * Format a timestamp as time (HH:MM) */ diff --git a/crates/stemedb-api/Cargo.toml b/crates/stemedb-api/Cargo.toml index a6d5425..60fc21b 100644 --- a/crates/stemedb-api/Cargo.toml +++ b/crates/stemedb-api/Cargo.toml @@ -8,7 +8,7 @@ description = "HTTP API for Episteme (StemeDB)" workspace = true [features] -default = [] +default = ["aphoria"] aphoria = ["dep:aphoria"] [dependencies] diff --git a/crates/stemedb-api/src/dto/aphoria/responses.rs b/crates/stemedb-api/src/dto/aphoria/responses.rs index b3b7e71..c638690 100644 --- a/crates/stemedb-api/src/dto/aphoria/responses.rs +++ b/crates/stemedb-api/src/dto/aphoria/responses.rs @@ -144,3 +144,39 @@ pub struct GetPatternsResponse { /// Total number of patterns matching (before limit applied). pub total_matching: usize, } + +// ============================================================================ +// Scan History Endpoint DTOs +// ============================================================================ + +/// A single scan from the scan history. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ScanListItem { + /// Unique scan ID. + pub scan_id: String, + + /// Project name. + pub project: String, + + /// Number of files scanned. + pub files_scanned: usize, + + /// Number of claims extracted. + pub claims_extracted: usize, + + /// Summary counts by verdict. + pub summary: ScanSummaryDto, + + /// Unix timestamp of when the scan was performed. + pub timestamp: u64, + + /// Findings (conflicts detected). + pub findings: Vec, +} + +/// Response containing recent scan history. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ListScansResponse { + /// Recent scans, newest first. + pub scans: Vec, +} diff --git a/crates/stemedb-api/src/handlers/aphoria/mod.rs b/crates/stemedb-api/src/handlers/aphoria/mod.rs index 74c13e6..1fe2d1e 100644 --- a/crates/stemedb-api/src/handlers/aphoria/mod.rs +++ b/crates/stemedb-api/src/handlers/aphoria/mod.rs @@ -13,4 +13,4 @@ pub(crate) mod scan; // Re-export all public handlers to preserve API pub use policy::{bless, export_policy, import_policy}; pub use report::{get_patterns, push_community_observations, push_observations}; -pub use scan::scan; +pub use scan::{list_scans, scan}; diff --git a/crates/stemedb-api/src/handlers/aphoria/scan.rs b/crates/stemedb-api/src/handlers/aphoria/scan.rs index f497b20..d6e4ca2 100644 --- a/crates/stemedb-api/src/handlers/aphoria/scan.rs +++ b/crates/stemedb-api/src/handlers/aphoria/scan.rs @@ -1,12 +1,13 @@ //! Project scanning handlers for conflict detection. -use axum::{http::StatusCode, Json}; +use axum::{extract::State, http::StatusCode, Json}; use std::path::PathBuf; use tracing::instrument; use crate::{ - dto::aphoria::{FindingDto, ScanRequest, ScanResponse, ScanSummaryDto}, + dto::aphoria::{FindingDto, ListScansResponse, ScanRequest, ScanResponse, ScanSummaryDto}, error::{ApiError, Result}, + state::AppState, }; use super::super::aphoria_helpers::conflict_result_to_dto; @@ -30,7 +31,10 @@ use super::super::aphoria_helpers::conflict_result_to_dto; tag = "aphoria" )] #[instrument(skip_all, fields(target_path = %req.target_path, format = %req.format))] -pub async fn scan(Json(req): Json) -> Result<(StatusCode, Json)> { +pub async fn scan( + State(state): State, + Json(req): Json, +) -> Result<(StatusCode, Json)> { let target_path = PathBuf::from(&req.target_path); // Check path exists @@ -87,6 +91,9 @@ pub async fn scan(Json(req): Json) -> Result<(StatusCode, Json) -> Result<(StatusCode, Json) -> Result> { + let response = state.scan_cache.list().await; + Ok(Json(response)) +} diff --git a/crates/stemedb-api/src/handlers/mod.rs b/crates/stemedb-api/src/handlers/mod.rs index 6b48bc8..64da7ae 100644 --- a/crates/stemedb-api/src/handlers/mod.rs +++ b/crates/stemedb-api/src/handlers/mod.rs @@ -77,6 +77,6 @@ pub use metrics::metrics_handler; #[cfg(feature = "aphoria")] pub use aphoria::{ - bless, export_policy, get_patterns, import_policy, push_community_observations, + bless, export_policy, get_patterns, import_policy, list_scans, push_community_observations, push_observations, scan, }; diff --git a/crates/stemedb-api/src/lib.rs b/crates/stemedb-api/src/lib.rs index 0c87bb2..9ac823e 100644 --- a/crates/stemedb-api/src/lib.rs +++ b/crates/stemedb-api/src/lib.rs @@ -36,6 +36,8 @@ pub mod handlers; pub mod hex; pub mod middleware; mod routers; +#[cfg(feature = "aphoria")] +pub mod scan_cache; pub mod services; pub mod state; diff --git a/crates/stemedb-api/src/routers.rs b/crates/stemedb-api/src/routers.rs index 75fbc73..d360df3 100644 --- a/crates/stemedb-api/src/routers.rs +++ b/crates/stemedb-api/src/routers.rs @@ -379,6 +379,7 @@ fn build_api_routes() -> Router { .route("/v1/aphoria/policy/export", post(handlers::export_policy)) .route("/v1/aphoria/policy/import", post(handlers::import_policy)) .route("/v1/aphoria/scan", post(handlers::scan)) + .route("/v1/aphoria/scans", get(handlers::list_scans)) .route("/v1/aphoria/observations", post(handlers::push_observations)) // Community corpus endpoints .route( diff --git a/crates/stemedb-api/src/scan_cache.rs b/crates/stemedb-api/src/scan_cache.rs new file mode 100644 index 0000000..3c537fe --- /dev/null +++ b/crates/stemedb-api/src/scan_cache.rs @@ -0,0 +1,138 @@ +//! In-memory scan result cache for the Aphoria scan history endpoint. +//! +//! Caches the last N scan results to provide a scan history API. + +use std::collections::VecDeque; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; +use tokio::sync::RwLock; + +use crate::dto::aphoria::{ListScansResponse, ScanListItem, ScanResponse, ScanSummaryDto}; + +/// Maximum number of scans to cache. +const MAX_CACHED_SCANS: usize = 100; + +/// Thread-safe scan cache that stores recent scan results. +#[derive(Clone)] +pub struct ScanCache { + inner: Arc>, +} + +struct ScanCacheInner { + scans: VecDeque, +} + +impl Default for ScanCache { + fn default() -> Self { + Self::new() + } +} + +impl ScanCache { + /// Create a new empty scan cache. + pub fn new() -> Self { + Self { + inner: Arc::new(RwLock::new(ScanCacheInner { + scans: VecDeque::with_capacity(MAX_CACHED_SCANS), + })), + } + } + + /// Record a new scan result. + /// + /// Adds the scan to the front of the cache, removing the oldest + /// scan if we exceed MAX_CACHED_SCANS. + pub async fn record(&self, response: &ScanResponse) { + let timestamp = + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + + let item = ScanListItem { + scan_id: response.scan_id.clone(), + project: response.project.clone(), + files_scanned: response.files_scanned, + claims_extracted: response.claims_extracted, + summary: ScanSummaryDto { + total: response.summary.total, + blocked: response.summary.blocked, + flagged: response.summary.flagged, + passed: response.summary.passed, + acknowledged: response.summary.acknowledged, + }, + timestamp, + findings: response.findings.clone(), + }; + + let mut inner = self.inner.write().await; + inner.scans.push_front(item); + if inner.scans.len() > MAX_CACHED_SCANS { + inner.scans.pop_back(); + } + } + + /// List all cached scans, newest first. + pub async fn list(&self) -> ListScansResponse { + let inner = self.inner.read().await; + ListScansResponse { scans: inner.scans.iter().cloned().collect() } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dto::aphoria::ScanSummaryDto; + + fn make_scan(id: &str) -> ScanResponse { + ScanResponse { + project: "test-project".to_string(), + scan_id: id.to_string(), + files_scanned: 10, + claims_extracted: 5, + findings: vec![], + summary: ScanSummaryDto { + total: 0, + blocked: 0, + flagged: 0, + passed: 0, + acknowledged: 0, + }, + } + } + + #[tokio::test] + async fn test_record_and_list() { + let cache = ScanCache::new(); + + cache.record(&make_scan("scan-1")).await; + cache.record(&make_scan("scan-2")).await; + + let list = cache.list().await; + assert_eq!(list.scans.len(), 2); + assert_eq!(list.scans[0].scan_id, "scan-2"); + assert_eq!(list.scans[1].scan_id, "scan-1"); + } + + #[tokio::test] + async fn test_cache_eviction() { + let cache = ScanCache::new(); + + // Fill cache beyond limit (MAX_CACHED_SCANS = 100) + for i in 0..105 { + cache.record(&make_scan(&format!("scan-{}", i))).await; + } + + let list = cache.list().await; + // Should be capped at MAX_CACHED_SCANS + assert_eq!(list.scans.len(), MAX_CACHED_SCANS); + // Newest first + assert_eq!(list.scans[0].scan_id, "scan-104"); + // Oldest retained (first 5 were evicted: scan-0 through scan-4) + assert_eq!(list.scans[99].scan_id, "scan-5"); + } + + #[tokio::test] + async fn test_empty_cache() { + let cache = ScanCache::new(); + let list = cache.list().await; + assert!(list.scans.is_empty()); + } +} diff --git a/crates/stemedb-api/src/state.rs b/crates/stemedb-api/src/state.rs index 109ddd7..51c97ce 100644 --- a/crates/stemedb-api/src/state.rs +++ b/crates/stemedb-api/src/state.rs @@ -12,6 +12,9 @@ use stemedb_storage::{ use stemedb_wal::group_commit::{GroupCommitBuffer, GroupCommitConfig}; use stemedb_wal::Journal; +#[cfg(feature = "aphoria")] +use crate::scan_cache::ScanCache; + /// Quota store type alias for convenience. pub type QuotaStoreImpl = GenericQuotaStore>; @@ -79,6 +82,10 @@ pub struct AppState { /// When GroupCommitBuffer successfully flushes a batch, it signals this /// Notify so IngestWorker can immediately refresh and process new records. pub flush_notify: Arc, + + /// Scan cache for Aphoria scan history (feature-gated). + #[cfg(feature = "aphoria")] + pub scan_cache: ScanCache, } impl AppState { @@ -141,6 +148,8 @@ impl AppState { circuit_breaker_store, api_key_store, flush_notify, + #[cfg(feature = "aphoria")] + scan_cache: ScanCache::new(), } }