diff options
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/fidget-spinner-cli/Cargo.toml | 4 | ||||
| -rw-r--r-- | crates/fidget-spinner-cli/src/mcp/output.rs | 15 | ||||
| -rw-r--r-- | crates/fidget-spinner-cli/src/mcp/projection.rs | 216 | ||||
| -rw-r--r-- | crates/fidget-spinner-cli/src/mcp/service.rs | 139 | ||||
| -rw-r--r-- | crates/fidget-spinner-cli/src/ui.rs | 476 | ||||
| -rw-r--r-- | crates/fidget-spinner-cli/tests/mcp_hardening.rs | 61 | ||||
| -rw-r--r-- | crates/fidget-spinner-store-sqlite/src/lib.rs | 2 |
7 files changed, 721 insertions, 192 deletions
diff --git a/crates/fidget-spinner-cli/Cargo.toml b/crates/fidget-spinner-cli/Cargo.toml index 69f72b2..6499904 100644 --- a/crates/fidget-spinner-cli/Cargo.toml +++ b/crates/fidget-spinner-cli/Cargo.toml @@ -18,7 +18,7 @@ clap.workspace = true dirs.workspace = true fidget-spinner-core = { path = "../fidget-spinner-core" } fidget-spinner-store-sqlite = { path = "../fidget-spinner-store-sqlite" } -libmcp = { git = "https://git.swarm.moe/libmcp.git", rev = "bb92a05eb5446e07c6288e266bd06d7b5899eee5" } +libmcp = { git = "https://git.swarm.moe/libmcp.git", rev = "e325cd23f19378f543981071673c1d03be438fa5" } maud.workspace = true percent-encoding.workspace = true plotters.workspace = true @@ -28,7 +28,7 @@ time.workspace = true tokio.workspace = true [dev-dependencies] -libmcp-testkit = { git = "https://git.swarm.moe/libmcp.git", rev = "bb92a05eb5446e07c6288e266bd06d7b5899eee5", package = "libmcp-testkit" } +libmcp-testkit = { git = "https://git.swarm.moe/libmcp.git", rev = "e325cd23f19378f543981071673c1d03be438fa5", package = "libmcp-testkit" } [lints] workspace = true diff --git a/crates/fidget-spinner-cli/src/mcp/output.rs b/crates/fidget-spinner-cli/src/mcp/output.rs index 2e11e20..494fe23 100644 --- a/crates/fidget-spinner-cli/src/mcp/output.rs +++ b/crates/fidget-spinner-cli/src/mcp/output.rs @@ -118,21 +118,6 @@ pub(crate) fn projected_tool_output( )) } -pub(crate) fn fallback_tool_output( - concise: &impl Serialize, - full: &impl Serialize, - kind: SurfaceKind, - stage: FaultStage, - operation: &str, -) -> Result<ToolOutput, FaultRecord> { - let projection = FallbackJsonProjection::new(concise, full, kind) - .map_err(|error| projection_fault(error, stage, operation))?; - let concise_text = projection - .porcelain_projection(DetailLevel::Concise) - .map_err(|error| projection_fault(error, stage, operation))?; - projected_tool_output(&projection, concise_text, None, stage, operation) -} - pub(crate) fn fallback_detailed_tool_output( concise: &impl Serialize, full: &impl Serialize, diff --git a/crates/fidget-spinner-cli/src/mcp/projection.rs b/crates/fidget-spinner-cli/src/mcp/projection.rs index ca89af0..a36e915 100644 --- a/crates/fidget-spinner-cli/src/mcp/projection.rs +++ b/crates/fidget-spinner-cli/src/mcp/projection.rs @@ -2,15 +2,18 @@ use std::collections::BTreeMap; use fidget_spinner_core::{ AttachmentTargetRef, CommandRecipe, ExperimentAnalysis, ExperimentOutcome, FrontierBrief, - FrontierRecord, MetricValue, NonEmptyText, RunDimensionValue, + FrontierRecord, MetricDefinition, MetricValue, NonEmptyText, RunDimensionDefinition, + RunDimensionValue, TagRecord, }; use fidget_spinner_store_sqlite::{ - ArtifactDetail, ArtifactSummary, ExperimentDetail, ExperimentSummary, FrontierOpenProjection, - FrontierSummary, HypothesisCurrentState, HypothesisDetail, MetricBestEntry, MetricKeySummary, - MetricObservationSummary, ProjectStore, StoreError, VertexSummary, + ArtifactDetail, ArtifactSummary, EntityHistoryEntry, ExperimentDetail, ExperimentSummary, + FrontierOpenProjection, FrontierSummary, HypothesisCurrentState, HypothesisDetail, + MetricBestEntry, MetricKeySummary, MetricObservationSummary, ProjectStore, StoreError, + VertexSummary, }; use libmcp::{ ProjectionError, SelectorProjection, StructuredProjection, SurfaceKind, SurfacePolicy, + TimestampText, }; use serde::Serialize; use serde_json::Value; @@ -44,7 +47,7 @@ pub(crate) struct FrontierSummaryProjection { pub(crate) status: String, pub(crate) active_hypothesis_count: u64, pub(crate) open_experiment_count: u64, - pub(crate) updated_at: String, + pub(crate) updated_at: TimestampText, } #[derive(Clone, Serialize)] @@ -55,7 +58,7 @@ pub(crate) struct FrontierBriefProjection { pub(crate) unknowns: Vec<String>, pub(crate) revision: u64, #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) updated_at: Option<String>, + pub(crate) updated_at: Option<TimestampText>, } #[derive(Clone, Serialize)] @@ -80,8 +83,8 @@ pub(crate) struct FrontierRecordProjection { pub(crate) objective: String, pub(crate) status: String, pub(crate) revision: u64, - pub(crate) created_at: String, - pub(crate) updated_at: String, + pub(crate) created_at: TimestampText, + pub(crate) updated_at: TimestampText, pub(crate) brief: FrontierBriefProjection, } @@ -127,7 +130,7 @@ pub(crate) struct HypothesisSummaryProjection { pub(crate) open_experiment_count: u64, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) latest_verdict: Option<String>, - pub(crate) updated_at: String, + pub(crate) updated_at: TimestampText, } #[derive(Clone, Serialize)] @@ -139,8 +142,8 @@ pub(crate) struct HypothesisRecordProjection { pub(crate) body: String, pub(crate) tags: Vec<String>, pub(crate) revision: u64, - pub(crate) created_at: String, - pub(crate) updated_at: String, + pub(crate) created_at: TimestampText, + pub(crate) updated_at: TimestampText, } #[derive(Clone, Serialize)] @@ -151,7 +154,7 @@ pub(crate) struct HypothesisReadRecordProjection { pub(crate) summary: String, pub(crate) tags: Vec<String>, pub(crate) revision: u64, - pub(crate) updated_at: String, + pub(crate) updated_at: TimestampText, } #[derive(Clone, Serialize)] @@ -229,9 +232,9 @@ pub(crate) struct ExperimentSummaryProjection { pub(crate) verdict: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) primary_metric: Option<MetricObservationSummaryProjection>, - pub(crate) updated_at: String, + pub(crate) updated_at: TimestampText, #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) closed_at: Option<String>, + pub(crate) closed_at: Option<TimestampText>, } #[derive(Clone, Serialize)] @@ -246,8 +249,8 @@ pub(crate) struct ExperimentRecordProjection { #[serde(skip_serializing_if = "Option::is_none")] pub(crate) outcome: Option<ExperimentOutcomeProjection>, pub(crate) revision: u64, - pub(crate) created_at: String, - pub(crate) updated_at: String, + pub(crate) created_at: TimestampText, + pub(crate) updated_at: TimestampText, } #[derive(Clone, Serialize)] @@ -262,7 +265,7 @@ pub(crate) struct ExperimentReadRecordProjection { #[serde(skip_serializing_if = "Option::is_none")] pub(crate) verdict: Option<String>, pub(crate) revision: u64, - pub(crate) updated_at: String, + pub(crate) updated_at: TimestampText, } #[derive(Clone, Serialize)] @@ -329,7 +332,7 @@ pub(crate) struct ArtifactSummaryProjection { pub(crate) locator: String, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) media_type: Option<String>, - pub(crate) updated_at: String, + pub(crate) updated_at: TimestampText, } #[derive(Clone, Serialize)] @@ -343,8 +346,8 @@ pub(crate) struct ArtifactRecordProjection { #[serde(skip_serializing_if = "Option::is_none")] pub(crate) media_type: Option<String>, pub(crate) revision: u64, - pub(crate) created_at: String, - pub(crate) updated_at: String, + pub(crate) created_at: TimestampText, + pub(crate) updated_at: TimestampText, } #[derive(Clone, Serialize)] @@ -358,7 +361,7 @@ pub(crate) struct ArtifactReadRecordProjection { #[serde(skip_serializing_if = "Option::is_none")] pub(crate) media_type: Option<String>, pub(crate) revision: u64, - pub(crate) updated_at: String, + pub(crate) updated_at: TimestampText, } #[derive(Clone, Serialize)] @@ -452,7 +455,7 @@ pub(crate) struct ExperimentOutcomeProjection { pub(crate) rationale: String, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) analysis: Option<ExperimentAnalysisProjection>, - pub(crate) closed_at: String, + pub(crate) closed_at: TimestampText, } #[derive(Clone, Serialize)] @@ -484,7 +487,7 @@ pub(crate) struct VertexSummaryProjection { pub(crate) title: String, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) summary: Option<String>, - pub(crate) updated_at: String, + pub(crate) updated_at: TimestampText, } #[derive(Clone, Serialize)] @@ -515,6 +518,82 @@ pub(crate) struct MetricBestOutput { pub(crate) entries: Vec<MetricBestEntryProjection>, } +#[derive(Clone, Serialize)] +pub(crate) struct TagRecordProjection { + pub(crate) name: String, + pub(crate) description: String, + pub(crate) created_at: TimestampText, +} + +#[derive(Clone, Serialize, libmcp::ToolProjection)] +#[libmcp(kind = "mutation")] +pub(crate) struct TagRecordOutput { + pub(crate) record: TagRecordProjection, +} + +#[derive(Clone, Serialize, libmcp::ToolProjection)] +#[libmcp(kind = "list")] +pub(crate) struct TagListOutput { + pub(crate) count: usize, + pub(crate) tags: Vec<TagRecordProjection>, +} + +#[derive(Clone, Serialize)] +pub(crate) struct MetricDefinitionProjection { + pub(crate) key: String, + pub(crate) unit: String, + pub(crate) objective: String, + pub(crate) visibility: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) description: Option<String>, + pub(crate) created_at: TimestampText, + pub(crate) updated_at: TimestampText, +} + +#[derive(Clone, Serialize, libmcp::ToolProjection)] +#[libmcp(kind = "mutation")] +pub(crate) struct MetricDefinitionOutput { + pub(crate) record: MetricDefinitionProjection, +} + +#[derive(Clone, Serialize)] +pub(crate) struct RunDimensionDefinitionProjection { + pub(crate) key: String, + pub(crate) value_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) description: Option<String>, + pub(crate) created_at: TimestampText, + pub(crate) updated_at: TimestampText, +} + +#[derive(Clone, Serialize, libmcp::ToolProjection)] +#[libmcp(kind = "mutation")] +pub(crate) struct RunDimensionDefinitionOutput { + pub(crate) record: RunDimensionDefinitionProjection, +} + +#[derive(Clone, Serialize, libmcp::ToolProjection)] +#[libmcp(kind = "list")] +pub(crate) struct RunDimensionListOutput { + pub(crate) count: usize, + pub(crate) dimensions: Vec<RunDimensionDefinitionProjection>, +} + +#[derive(Clone, Serialize)] +pub(crate) struct HistoryEntryProjection { + pub(crate) revision: u64, + pub(crate) event_kind: String, + pub(crate) occurred_at: TimestampText, + pub(crate) snapshot: Value, +} + +#[derive(Clone, Serialize, libmcp::ToolProjection)] +#[libmcp(kind = "list")] +pub(crate) struct HistoryOutput { + pub(crate) count: usize, + pub(crate) history: Vec<HistoryEntryProjection>, +} + pub(crate) fn frontier_list(frontiers: &[FrontierSummary]) -> FrontierListOutput { FrontierListOutput { count: frontiers.len(), @@ -784,6 +863,50 @@ pub(crate) fn metric_best(entries: &[MetricBestEntry]) -> MetricBestOutput { } } +pub(crate) fn tag_record(tag: &TagRecord) -> TagRecordOutput { + TagRecordOutput { + record: tag_record_projection(tag), + } +} + +pub(crate) fn tag_list(tags: &[TagRecord]) -> TagListOutput { + TagListOutput { + count: tags.len(), + tags: tags.iter().map(tag_record_projection).collect(), + } +} + +pub(crate) fn metric_definition(metric: &MetricDefinition) -> MetricDefinitionOutput { + MetricDefinitionOutput { + record: metric_definition_projection(metric), + } +} + +pub(crate) fn run_dimension_definition( + dimension: &RunDimensionDefinition, +) -> RunDimensionDefinitionOutput { + RunDimensionDefinitionOutput { + record: run_dimension_definition_projection(dimension), + } +} + +pub(crate) fn run_dimension_list(dimensions: &[RunDimensionDefinition]) -> RunDimensionListOutput { + RunDimensionListOutput { + count: dimensions.len(), + dimensions: dimensions + .iter() + .map(run_dimension_definition_projection) + .collect(), + } +} + +pub(crate) fn history(history: &[EntityHistoryEntry]) -> HistoryOutput { + HistoryOutput { + count: history.len(), + history: history.iter().map(history_entry_projection).collect(), + } +} + fn frontier_summary(frontier: &FrontierSummary) -> FrontierSummaryProjection { FrontierSummaryProjection { slug: frontier.slug.to_string(), @@ -969,6 +1092,47 @@ fn metric_key_summary(metric: &MetricKeySummary) -> MetricKeySummaryProjection { } } +fn tag_record_projection(tag: &TagRecord) -> TagRecordProjection { + TagRecordProjection { + name: tag.name.to_string(), + description: tag.description.to_string(), + created_at: timestamp_value(tag.created_at), + } +} + +fn metric_definition_projection(metric: &MetricDefinition) -> MetricDefinitionProjection { + MetricDefinitionProjection { + key: metric.key.to_string(), + unit: metric.unit.as_str().to_owned(), + objective: metric.objective.as_str().to_owned(), + visibility: metric.visibility.as_str().to_owned(), + description: metric.description.as_ref().map(ToString::to_string), + created_at: timestamp_value(metric.created_at), + updated_at: timestamp_value(metric.updated_at), + } +} + +fn run_dimension_definition_projection( + dimension: &RunDimensionDefinition, +) -> RunDimensionDefinitionProjection { + RunDimensionDefinitionProjection { + key: dimension.key.to_string(), + value_type: dimension.value_type.as_str().to_owned(), + description: dimension.description.as_ref().map(ToString::to_string), + created_at: timestamp_value(dimension.created_at), + updated_at: timestamp_value(dimension.updated_at), + } +} + +fn history_entry_projection(entry: &EntityHistoryEntry) -> HistoryEntryProjection { + HistoryEntryProjection { + revision: entry.revision, + event_kind: entry.event_kind.to_string(), + occurred_at: timestamp_value(entry.occurred_at), + snapshot: entry.snapshot.clone(), + } +} + fn metric_best_entry(entry: &MetricBestEntry) -> MetricBestEntryProjection { MetricBestEntryProjection { experiment: experiment_summary(&entry.experiment), @@ -1127,10 +1291,8 @@ fn attachment_target( } } -fn timestamp_value(timestamp: time::OffsetDateTime) -> String { - timestamp - .format(&time::format_description::well_known::Rfc3339) - .unwrap_or_else(|_| timestamp.unix_timestamp().to_string()) +fn timestamp_value(timestamp: time::OffsetDateTime) -> TimestampText { + TimestampText::from(timestamp) } fn store_fault(operation: &str) -> impl Fn(StoreError) -> FaultRecord + '_ { diff --git a/crates/fidget-spinner-cli/src/mcp/service.rs b/crates/fidget-spinner-cli/src/mcp/service.rs index 3ce68ae..7c649aa 100644 --- a/crates/fidget-spinner-cli/src/mcp/service.rs +++ b/crates/fidget-spinner-cli/src/mcp/service.rs @@ -26,8 +26,8 @@ use serde_json::{Map, Value, json}; use crate::mcp::fault::{FaultKind, FaultRecord, FaultStage}; use crate::mcp::output::{ - ToolOutput, fallback_detailed_tool_output, fallback_tool_output, projected_tool_output, - split_presentation, tool_success, + ToolOutput, fallback_detailed_tool_output, projected_tool_output, split_presentation, + tool_success, }; use crate::mcp::projection; use crate::mcp::protocol::{TRANSIENT_ONCE_ENV, TRANSIENT_ONCE_MARKER_ENV, WorkerOperation}; @@ -103,13 +103,7 @@ impl WorkerService { TagName::new(args.name).map_err(store_fault(&operation))?, NonEmptyText::new(args.description).map_err(store_fault(&operation))?, )); - fallback_tool_output( - &tag, - &tag, - libmcp::SurfaceKind::Mutation, - FaultStage::Worker, - &operation, - )? + tag_record_output(&tag, &operation)? } "tag.list" => tag_list_output(&lift!(self.store.list_tags()), &operation)?, "frontier.create" => { @@ -471,13 +465,7 @@ impl WorkerService { .map_err(store_fault(&operation))?, }) ); - fallback_tool_output( - &metric, - &metric, - libmcp::SurfaceKind::Mutation, - FaultStage::Worker, - &operation, - )? + metric_definition_output(&metric, &operation)? } "metric.keys" => { let args = deserialize::<MetricKeysArgs>(arguments)?; @@ -517,23 +505,11 @@ impl WorkerService { .map_err(store_fault(&operation))?, }) ); - fallback_tool_output( - &dimension, - &dimension, - libmcp::SurfaceKind::Mutation, - FaultStage::Worker, - &operation, - )? + run_dimension_definition_output(&dimension, &operation)? } "run.dimension.list" => { let dimensions = lift!(self.store.list_run_dimensions()); - fallback_tool_output( - &dimensions, - &dimensions, - libmcp::SurfaceKind::List, - FaultStage::Worker, - &operation, - )? + run_dimension_list_output(&dimensions, &operation)? } other => { return Err(FaultRecord::new( @@ -1064,17 +1040,27 @@ fn project_status_output( ) } +fn tag_record_output( + tag: &fidget_spinner_core::TagRecord, + operation: &str, +) -> Result<ToolOutput, FaultRecord> { + let projection = projection::tag_record(tag); + projected_tool_output( + &projection, + format!("tag {} — {}", tag.name, tag.description), + None, + FaultStage::Worker, + operation, + ) +} + fn tag_list_output( tags: &[fidget_spinner_core::TagRecord], operation: &str, ) -> Result<ToolOutput, FaultRecord> { - let concise = json!({ - "count": tags.len(), - "tags": tags, - }); - fallback_detailed_tool_output( - &concise, - &concise, + let projection = projection::tag_list(tags); + projected_tool_output( + &projection, if tags.is_empty() { "no tags".to_owned() } else { @@ -1084,7 +1070,6 @@ fn tag_list_output( .join("\n") }, None, - libmcp::SurfaceKind::List, FaultStage::Worker, operation, ) @@ -1527,6 +1512,26 @@ fn metric_keys_output( ) } +fn metric_definition_output( + metric: &fidget_spinner_core::MetricDefinition, + operation: &str, +) -> Result<ToolOutput, FaultRecord> { + let projection = projection::metric_definition(metric); + projected_tool_output( + &projection, + format!( + "metric {} [{} {} {}]", + metric.key, + metric.unit.as_str(), + metric.objective.as_str(), + metric.visibility.as_str() + ), + None, + FaultStage::Worker, + operation, + ) +} + fn metric_best_output( entries: &[MetricBestEntry], operation: &str, @@ -1562,14 +1567,63 @@ fn metric_best_output( ) } +fn run_dimension_definition_output( + dimension: &fidget_spinner_core::RunDimensionDefinition, + operation: &str, +) -> Result<ToolOutput, FaultRecord> { + let projection = projection::run_dimension_definition(dimension); + projected_tool_output( + &projection, + format!( + "dimension {} [{}]", + dimension.key, + dimension.value_type.as_str() + ), + None, + FaultStage::Worker, + operation, + ) +} + +fn run_dimension_list_output( + dimensions: &[fidget_spinner_core::RunDimensionDefinition], + operation: &str, +) -> Result<ToolOutput, FaultRecord> { + let projection = projection::run_dimension_list(dimensions); + projected_tool_output( + &projection, + if dimensions.is_empty() { + "no run dimensions".to_owned() + } else { + dimensions + .iter() + .map(|dimension| { + format!( + "{} [{}]{}", + dimension.key, + dimension.value_type.as_str(), + dimension + .description + .as_ref() + .map_or_else(String::new, |description| format!(" — {description}")) + ) + }) + .collect::<Vec<_>>() + .join("\n") + }, + None, + FaultStage::Worker, + operation, + ) +} + fn history_output( history: &[EntityHistoryEntry], operation: &str, ) -> Result<ToolOutput, FaultRecord> { - let concise = json!({ "count": history.len(), "history": history }); - fallback_detailed_tool_output( - &concise, - &concise, + let projection = projection::history(history); + projected_tool_output( + &projection, if history.is_empty() { "no history".to_owned() } else { @@ -1585,7 +1639,6 @@ fn history_output( .join("\n") }, None, - libmcp::SurfaceKind::List, FaultStage::Worker, operation, ) diff --git a/crates/fidget-spinner-cli/src/ui.rs b/crates/fidget-spinner-cli/src/ui.rs index d88bab0..9a42411 100644 --- a/crates/fidget-spinner-cli/src/ui.rs +++ b/crates/fidget-spinner-cli/src/ui.rs @@ -1,3 +1,4 @@ +use std::collections::{BTreeMap, BTreeSet}; use std::io; use std::net::SocketAddr; @@ -55,6 +56,14 @@ enum FrontierTab { struct FrontierPageQuery { metric: Option<String>, tab: Option<String>, + #[serde(flatten)] + extra: BTreeMap<String, String>, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +struct DimensionFacet { + key: String, + values: Vec<String>, } struct AttachmentDisplay { @@ -93,6 +102,23 @@ impl FrontierTab { } } +impl FrontierPageQuery { + fn dimension_filters(&self) -> BTreeMap<String, String> { + self.extra + .iter() + .filter_map(|(key, value)| { + let value = value.trim(); + (!value.is_empty()) + .then(|| { + key.strip_prefix("dim.") + .map(|dimension| (dimension.to_owned(), value.to_owned())) + }) + .flatten() + }) + .collect() + } +} + pub(crate) fn serve( project_root: Utf8PathBuf, bind: SocketAddr, @@ -208,13 +234,7 @@ fn render_frontier_detail( projection.active_hypotheses.len(), projection.open_experiments.len() ); - let content = render_frontier_tab_content( - &store, - &projection, - tab, - query.metric.as_deref(), - state.limit, - )?; + let content = render_frontier_tab_content(&store, &projection, tab, &query, state.limit)?; Ok(render_shell( &title, &shell, @@ -225,6 +245,7 @@ fn render_frontier_detail( &projection.frontier.slug, tab, query.metric.as_deref(), + &query.dimension_filters(), )), content, )) @@ -370,7 +391,7 @@ fn render_frontier_tab_content( store: &fidget_spinner_store_sqlite::ProjectStore, projection: &FrontierOpenProjection, tab: FrontierTab, - metric_selector: Option<&str>, + query: &FrontierPageQuery, limit: Option<u32>, ) -> Result<Markup, StoreError> { match tab { @@ -415,7 +436,9 @@ fn render_frontier_tab_content( } else { projection.active_metric_keys.clone() }; - let selected_metric = metric_selector + let selected_metric = query + .metric + .as_deref() .and_then(|selector| NonEmptyText::new(selector.to_owned()).ok()) .or_else(|| metric_keys.first().map(|metric| metric.key.clone())); let series = selected_metric @@ -424,6 +447,7 @@ fn render_frontier_tab_content( store.frontier_metric_series(projection.frontier.slug.as_str(), metric, true) }) .transpose()?; + let dimension_filters = query.dimension_filters(); Ok(html! { (render_frontier_header(&projection.frontier)) (render_metric_series_section( @@ -431,6 +455,7 @@ fn render_frontier_tab_content( &metric_keys, selected_metric.as_ref(), series.as_ref(), + &dimension_filters, limit, )) }) @@ -442,6 +467,7 @@ fn render_frontier_tab_bar( frontier_slug: &Slug, active_tab: FrontierTab, metric: Option<&str>, + dimension_filters: &BTreeMap<String, String>, ) -> Markup { const TABS: [FrontierTab; 4] = [ FrontierTab::Brief, @@ -452,7 +478,7 @@ fn render_frontier_tab_bar( html! { nav.tab-row aria-label="Frontier tabs" { @for tab in TABS { - @let href = frontier_tab_href(frontier_slug, tab, metric); + @let href = frontier_tab_href_with_filters(frontier_slug, tab, metric, dimension_filters); a href=(href) class={(if tab == active_tab { "tab-chip active" } else { "tab-chip" })} @@ -511,8 +537,15 @@ fn render_metric_series_section( metric_keys: &[fidget_spinner_store_sqlite::MetricKeySummary], selected_metric: Option<&NonEmptyText>, series: Option<&FrontierMetricSeries>, + dimension_filters: &BTreeMap<String, String>, limit: Option<u32>, ) -> Markup { + let facets = series + .map(|series| collect_dimension_facets(&series.points)) + .unwrap_or_default(); + let filtered_points = series + .map(|series| filter_metric_points(&series.points, dimension_filters)) + .unwrap_or_default(); html! { section.card { h2 { "Metrics" } @@ -556,47 +589,59 @@ fn render_metric_series_section( @if let Some(description) = series.metric.description.as_ref() { p.muted { (description) } } - @if series.points.is_empty() { + @if !facets.is_empty() { + (render_metric_filter_panel( + frontier_slug, + &series.metric.key, + &facets, + dimension_filters, + )) + } + @if filtered_points.is_empty() { + p.muted { "No closed experiments match the current filters." } + } @else if series.points.is_empty() { p.muted { "No closed experiments for this metric yet." } } @else { div.chart-frame { - (PreEscaped(render_metric_chart_svg(series))) + (PreEscaped(render_metric_chart_svg(&series.metric, &filtered_points))) } p.muted { "x = close order, y = metric value. Point color tracks verdict." } - table.metric-table { - thead { - tr { - th { "#" } - th { "Experiment" } - th { "Hypothesis" } - th { "Closed" } - th { "Verdict" } - th { "Value" } - } - } - tbody { - @for (index, point) in limit_items(&series.points, limit).iter().enumerate() { + div.table-scroll { + table.metric-table { + thead { tr { - td { ((index + 1).to_string()) } - td { - a href=(experiment_href(&point.experiment.slug)) { - (point.experiment.title) + th { "#" } + th { "Experiment" } + th { "Hypothesis" } + th { "Closed" } + th { "Verdict" } + th { "Value" } + } + } + tbody { + @for (index, point) in limit_items(&filtered_points, limit).iter().copied().enumerate() { + tr { + td { ((index + 1).to_string()) } + td { + a href=(experiment_href(&point.experiment.slug)) { + (point.experiment.title) + } } - } - td { - a href=(hypothesis_href(&point.hypothesis.slug)) { - (point.hypothesis.title) + td { + a href=(hypothesis_href(&point.hypothesis.slug)) { + (point.hypothesis.title) + } } - } - td { (format_timestamp(point.closed_at)) } - td { - span class=(status_chip_classes(verdict_class(point.verdict))) { - (point.verdict.as_str()) + td.nowrap { (format_timestamp(point.closed_at)) } + td { + span class=(status_chip_classes(verdict_class(point.verdict))) { + (point.verdict.as_str()) + } } + td.nowrap { (format_metric_value(point.value, series.metric.unit)) } } - td { (format_metric_value(point.value, series.metric.unit)) } } } } @@ -607,18 +652,80 @@ fn render_metric_series_section( } } -fn render_metric_chart_svg(series: &FrontierMetricSeries) -> String { +fn render_metric_filter_panel( + frontier_slug: &Slug, + metric_key: &NonEmptyText, + facets: &[DimensionFacet], + active_filters: &BTreeMap<String, String>, +) -> Markup { + let clear_href = frontier_tab_href_with_filters( + frontier_slug, + FrontierTab::Metrics, + Some(metric_key.as_str()), + &BTreeMap::new(), + ); + html! { + section.subcard { + h3 id="slice-filters" { "Slice Filters" } + form.filter-form method="get" action=(frontier_href(frontier_slug)) { + input type="hidden" name="tab" value="metrics"; + input type="hidden" name="metric" value=(metric_key.as_str()); + div.filter-form-grid { + @for facet in facets { + label.filter-control id=(metric_filter_anchor_id(&facet.key)) { + span.filter-label { (&facet.key) } + select.filter-select name=(format!("dim.{}", facet.key)) { + option + value="" + selected[active_filters.get(&facet.key).is_none()] + { "all" } + @for value in &facet.values { + option + value=(value) + selected[active_filters.get(&facet.key) == Some(value)] + { (value) } + } + } + } + } + } + div.filter-actions { + button.filter-apply type="submit" { "Apply" } + a.clear-filter href=(clear_href) { "Clear all" } + } + } + @if active_filters.is_empty() { + p.muted { "No slice filters active." } + } @else { + div.chip-row { + @for (key, value) in active_filters { + @let href = frontier_tab_href_with_filters( + frontier_slug, + FrontierTab::Metrics, + Some(metric_key.as_str()), + &remove_dimension_filter(active_filters, key), + ); + a.metric-filter-chip.active href=(href) { + (key) "=" (value) " ×" + } + } + } + } + } + } +} + +fn render_metric_chart_svg( + metric: &fidget_spinner_store_sqlite::MetricKeySummary, + points: &[&fidget_spinner_store_sqlite::FrontierMetricPoint], +) -> String { let mut svg = String::new(); { let root = SVGBackend::with_string(&mut svg, (960, 360)).into_drawing_area(); if root.fill(&RGBColor(255, 250, 242)).is_err() { return chart_error_markup("chart fill failed"); } - let values = series - .points - .iter() - .map(|point| point.value) - .collect::<Vec<_>>(); + let values = points.iter().map(|point| point.value).collect::<Vec<_>>(); let (mut min_value, mut max_value) = values .iter() .copied() @@ -641,7 +748,7 @@ fn render_metric_chart_svg(series: &FrontierMetricSeries) -> String { min_value -= pad; max_value += pad; } - let x_end = i32::try_from(series.points.len().saturating_sub(1)) + let x_end = i32::try_from(points.len().saturating_sub(1)) .unwrap_or(0) .max(1); let mut chart = match ChartBuilder::on(&root) @@ -649,7 +756,7 @@ fn render_metric_chart_svg(series: &FrontierMetricSeries) -> String { .x_label_area_size(32) .y_label_area_size(72) .caption( - format!("{} over closed experiments", series.metric.key), + format!("{} over closed experiments", metric.key), ("Iosevka Web", 18).into_font().color(&BLACK), ) .build_cartesian_2d(0_i32..x_end, min_value..max_value) @@ -664,7 +771,7 @@ fn render_metric_chart_svg(series: &FrontierMetricSeries) -> String { .axis_style(RGBColor(103, 86, 63)) .label_style(("Iosevka Web", 12).into_font().color(&RGBColor(79, 71, 58))) .x_desc("close order") - .y_desc(series.metric.unit.as_str()) + .y_desc(metric.unit.as_str()) .x_label_formatter(&|value| format!("{}", value + 1)) .draw() .is_err() @@ -672,8 +779,7 @@ fn render_metric_chart_svg(series: &FrontierMetricSeries) -> String { return chart_error_markup("mesh draw failed"); } - let line_points = series - .points + let line_points = points .iter() .enumerate() .filter_map(|(index, point)| i32::try_from(index).ok().map(|x| (x, point.value))) @@ -690,14 +796,13 @@ fn render_metric_chart_svg(series: &FrontierMetricSeries) -> String { return chart_error_markup("line draw failed"); } - let points = series - .points + let plotted_points = points .iter() .enumerate() - .filter_map(|(index, point)| i32::try_from(index).ok().map(|x| (x, point))) + .filter_map(|(index, point)| i32::try_from(index).ok().map(|x| (x, *point))) .collect::<Vec<_>>(); if chart - .draw_series(points.iter().map(|(x, point)| { + .draw_series(plotted_points.iter().map(|(x, point)| { Circle::new( (*x, point.value), 4, @@ -709,7 +814,7 @@ fn render_metric_chart_svg(series: &FrontierMetricSeries) -> String { return chart_error_markup("point draw failed"); } if chart - .draw_series(points.iter().map(|(x, point)| { + .draw_series(plotted_points.iter().map(|(x, point)| { Text::new( format!("{}", x + 1), (*x, point.value), @@ -892,30 +997,32 @@ fn render_frontier_active_sets(projection: &FrontierOpenProjection) -> Markup { @if projection.active_metric_keys.is_empty() { p.muted { "No live metrics." } } @else { - table.metric-table { - thead { - tr { - th { "Key" } - th { "Unit" } - th { "Objective" } - th { "Refs" } - } - } - tbody { - @for metric in &projection.active_metric_keys { + div.table-scroll { + table.metric-table { + thead { tr { - td { - a href=(frontier_tab_href( - &projection.frontier.slug, - FrontierTab::Metrics, - Some(metric.key.as_str()), - )) { - (metric.key) + th { "Key" } + th { "Unit" } + th { "Objective" } + th { "Refs" } + } + } + tbody { + @for metric in &projection.active_metric_keys { + tr { + td { + a href=(frontier_tab_href( + &projection.frontier.slug, + FrontierTab::Metrics, + Some(metric.key.as_str()), + )) { + (metric.key) + } } + td { (metric.unit.as_str()) } + td { (metric.objective.as_str()) } + td { (metric.reference_count) } } - td { (metric.unit.as_str()) } - td { (metric.objective.as_str()) } - td { (metric.reference_count) } } } } @@ -1093,13 +1200,15 @@ fn render_experiment_outcome(outcome: &ExperimentOutcome) -> Markup { @if !outcome.dimensions.is_empty() { section.subcard { h3 { "Dimensions" } - table.metric-table { - thead { tr { th { "Key" } th { "Value" } } } - tbody { - @for (key, value) in &outcome.dimensions { - tr { - td { (key) } - td { (render_dimension_value(value)) } + div.table-scroll { + table.metric-table { + thead { tr { th { "Key" } th { "Value" } } } + tbody { + @for (key, value) in &outcome.dimensions { + tr { + td { (key) } + td { (render_dimension_value(value)) } + } } } } @@ -1148,13 +1257,15 @@ fn render_command_recipe(command: &fidget_spinner_core::CommandRecipe) -> Markup } } @if !command.env.is_empty() { - table.metric-table { - thead { tr { th { "Env" } th { "Value" } } } - tbody { - @for (key, value) in &command.env { - tr { - td { (key) } - td { (value) } + div.table-scroll { + table.metric-table { + thead { tr { th { "Env" } th { "Value" } } } + tbody { + @for (key, value) in &command.env { + tr { + td { (key) } + td { (value) } + } } } } @@ -1172,18 +1283,20 @@ fn render_metric_panel( html! { section.subcard { h3 { (title) } - table.metric-table { - thead { - tr { - th { "Key" } - th { "Value" } - } - } - tbody { - @for metric in metrics { + div.table-scroll { + table.metric-table { + thead { tr { - td { (metric.key) } - td { (format_metric_value(metric.value, metric_unit_for(metric, outcome))) } + th { "Key" } + th { "Value" } + } + } + tbody { + @for metric in metrics { + tr { + td { (metric.key) } + td { (format_metric_value(metric.value, metric_unit_for(metric, outcome))) } + } } } } @@ -1565,6 +1678,15 @@ fn frontier_href(slug: &Slug) -> String { } fn frontier_tab_href(slug: &Slug, tab: FrontierTab, metric: Option<&str>) -> String { + frontier_tab_href_with_filters(slug, tab, metric, &BTreeMap::new()) +} + +fn frontier_tab_href_with_filters( + slug: &Slug, + tab: FrontierTab, + metric: Option<&str>, + dimension_filters: &BTreeMap<String, String>, +) -> String { let mut href = format!( "/frontier/{}?tab={}", encode_path_segment(slug.as_str()), @@ -1574,6 +1696,12 @@ fn frontier_tab_href(slug: &Slug, tab: FrontierTab, metric: Option<&str>) -> Str href.push_str("&metric="); href.push_str(&encode_path_segment(metric)); } + for (key, value) in dimension_filters { + href.push_str("&dim."); + href.push_str(&encode_path_segment(key)); + href.push('='); + href.push_str(&encode_path_segment(value)); + } href } @@ -1684,6 +1812,73 @@ fn limit_items<T>(items: &[T], limit: Option<u32>) -> &[T] { &items[..end] } +fn collect_dimension_facets( + points: &[fidget_spinner_store_sqlite::FrontierMetricPoint], +) -> Vec<DimensionFacet> { + let mut values_by_key: BTreeMap<String, BTreeSet<String>> = BTreeMap::new(); + for point in points { + for (key, value) in &point.dimensions { + let _ = values_by_key + .entry(key.to_string()) + .or_default() + .insert(render_dimension_value(value)); + } + } + values_by_key + .into_iter() + .map(|(key, values)| DimensionFacet { + key, + values: values.into_iter().collect(), + }) + .collect() +} + +fn filter_metric_points<'a>( + points: &'a [fidget_spinner_store_sqlite::FrontierMetricPoint], + dimension_filters: &BTreeMap<String, String>, +) -> Vec<&'a fidget_spinner_store_sqlite::FrontierMetricPoint> { + points + .iter() + .filter(|point| point_matches_dimension_filters(point, dimension_filters)) + .collect() +} + +fn point_matches_dimension_filters( + point: &fidget_spinner_store_sqlite::FrontierMetricPoint, + dimension_filters: &BTreeMap<String, String>, +) -> bool { + dimension_filters.iter().all(|(key, expected)| { + point.dimensions.iter().any(|(point_key, point_value)| { + point_key.as_str() == key && render_dimension_value(point_value) == *expected + }) + }) +} + +fn remove_dimension_filter( + filters: &BTreeMap<String, String>, + key: &str, +) -> BTreeMap<String, String> { + let mut next = filters.clone(); + let _ = next.remove(key); + next +} + +fn metric_filter_anchor_id(key: &str) -> String { + format!("filter-{}", sanitize_fragment_id(key)) +} + +fn sanitize_fragment_id(raw: &str) -> String { + raw.chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character.to_ascii_lowercase() + } else { + '-' + } + }) + .collect() +} + fn styles() -> &'static str { r#" :root { @@ -1718,7 +1913,8 @@ fn styles() -> &'static str { } a:hover { text-decoration: underline; } .shell { - width: min(1360px, 100%); + width: 100%; + max-width: none; margin: 0 auto; padding: 24px 24px 40px; display: grid; @@ -1995,6 +2191,71 @@ fn styles() -> &'static str { text-transform: uppercase; letter-spacing: 0.05em; } + .filter-form { + display: grid; + gap: 12px; + } + .filter-form-grid { + display: grid; + gap: 10px 12px; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + } + .filter-control { + display: grid; + gap: 6px; + min-width: 0; + } + .filter-label { + color: var(--muted); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + } + .filter-select { + width: 100%; + min-width: 0; + padding: 7px 9px; + border: 1px solid var(--border); + background: var(--panel); + color: var(--text); + font: inherit; + } + .filter-actions { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + } + .filter-apply { + padding: 7px 11px; + border: 1px solid var(--border-strong); + background: var(--accent-soft); + color: var(--text); + font: inherit; + cursor: pointer; + } + .metric-filter-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 5px 9px; + border: 1px solid var(--border); + background: var(--panel); + color: var(--text); + font-size: 12px; + white-space: nowrap; + } + .metric-filter-chip.active { + border-color: var(--border-strong); + background: var(--accent-soft); + font-weight: 700; + } + .clear-filter { + color: var(--muted); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + } .link-chip { display: inline-grid; gap: 4px; @@ -2039,10 +2300,13 @@ fn styles() -> &'static str { .status-neutral, .classless { color: #5f584d; border-color: var(--border-strong); background: var(--panel); } .status-archived { color: #7a756d; border-color: var(--border); background: var(--panel); } .metric-table { - width: 100%; + width: max-content; + min-width: 100%; border-collapse: collapse; font-size: 13px; - display: block; + } + .table-scroll { + width: 100%; overflow-x: auto; } .metric-table th, @@ -2051,7 +2315,9 @@ fn styles() -> &'static str { border-top: 1px solid var(--border); text-align: left; vertical-align: top; - overflow-wrap: anywhere; + white-space: nowrap; + overflow-wrap: normal; + word-break: normal; } .metric-table th { color: var(--muted); diff --git a/crates/fidget-spinner-cli/tests/mcp_hardening.rs b/crates/fidget-spinner-cli/tests/mcp_hardening.rs index c4ee002..c086a45 100644 --- a/crates/fidget-spinner-cli/tests/mcp_hardening.rs +++ b/crates/fidget-spinner-cli/tests/mcp_hardening.rs @@ -393,6 +393,67 @@ fn frontier_open_is_the_grounding_surface_for_live_state() -> TestResult { } #[test] +fn registry_and_history_surfaces_render_timestamps_as_strings() -> TestResult { + let project_root = temp_project_root("timestamp_text")?; + init_project(&project_root)?; + + let mut harness = McpHarness::spawn(Some(&project_root))?; + let _ = harness.initialize()?; + harness.notify_initialized()?; + + let dimension = harness.call_tool_full( + 19, + "run.dimension.define", + json!({ + "key": "duration_s", + "value_type": "numeric", + "description": "Wallclock timeout in seconds.", + }), + )?; + assert_tool_ok(&dimension); + assert!(tool_content(&dimension)["record"]["created_at"].is_string()); + assert!(tool_content(&dimension)["record"]["updated_at"].is_string()); + + let dimensions = harness.call_tool_full(20, "run.dimension.list", json!({}))?; + assert_tool_ok(&dimensions); + let listed = must_some( + tool_content(&dimensions)["dimensions"] + .as_array() + .and_then(|items| items.first()), + "defined run dimension in list", + )?; + assert!(listed["created_at"].is_string()); + assert!(listed["updated_at"].is_string()); + + let frontier = harness.call_tool_full( + 21, + "frontier.create", + json!({ + "label": "alpha", + "objective": "Trace timestamp presentation discipline", + }), + )?; + assert_tool_ok(&frontier); + let frontier_slug = must_some( + tool_content(&frontier)["record"]["slug"].as_str(), + "frontier slug", + )?; + + let history = + harness.call_tool_full(22, "frontier.history", json!({ "frontier": frontier_slug }))?; + assert_tool_ok(&history); + let history_entry = must_some( + tool_content(&history)["history"] + .as_array() + .and_then(|items| items.first()), + "frontier history entry", + )?; + assert!(history_entry["occurred_at"].is_string()); + + Ok(()) +} + +#[test] fn hypothesis_body_discipline_is_enforced_over_mcp() -> TestResult { let project_root = temp_project_root("single_paragraph")?; init_project(&project_root)?; diff --git a/crates/fidget-spinner-store-sqlite/src/lib.rs b/crates/fidget-spinner-store-sqlite/src/lib.rs index 283f5d3..fbdbb32 100644 --- a/crates/fidget-spinner-store-sqlite/src/lib.rs +++ b/crates/fidget-spinner-store-sqlite/src/lib.rs @@ -497,6 +497,7 @@ pub struct FrontierMetricPoint { pub value: f64, pub verdict: FrontierVerdict, pub closed_at: OffsetDateTime, + pub dimensions: BTreeMap<NonEmptyText, RunDimensionValue>, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] @@ -1424,6 +1425,7 @@ impl ProjectStore { .map(|(record, outcome, value)| { Ok(FrontierMetricPoint { closed_at: outcome.closed_at, + dimensions: outcome.dimensions.clone(), experiment: self.experiment_summary_from_record(record.clone())?, hypothesis: self.hypothesis_summary_from_record( self.hypothesis_by_id(record.hypothesis_id)?, |