From 203d4a93e1aaa5e325e8e6999bf26fa092f3d424 Mon Sep 17 00:00:00 2001 From: main Date: Fri, 20 Mar 2026 20:17:39 -0400 Subject: Adopt typed MCP projections and polish UI --- crates/fidget-spinner-cli/src/mcp/projection.rs | 1145 +++++++++++++++++++++++ 1 file changed, 1145 insertions(+) create mode 100644 crates/fidget-spinner-cli/src/mcp/projection.rs (limited to 'crates/fidget-spinner-cli/src/mcp/projection.rs') diff --git a/crates/fidget-spinner-cli/src/mcp/projection.rs b/crates/fidget-spinner-cli/src/mcp/projection.rs new file mode 100644 index 0000000..ca89af0 --- /dev/null +++ b/crates/fidget-spinner-cli/src/mcp/projection.rs @@ -0,0 +1,1145 @@ +use std::collections::BTreeMap; + +use fidget_spinner_core::{ + AttachmentTargetRef, CommandRecipe, ExperimentAnalysis, ExperimentOutcome, FrontierBrief, + FrontierRecord, MetricValue, NonEmptyText, RunDimensionValue, +}; +use fidget_spinner_store_sqlite::{ + ArtifactDetail, ArtifactSummary, ExperimentDetail, ExperimentSummary, FrontierOpenProjection, + FrontierSummary, HypothesisCurrentState, HypothesisDetail, MetricBestEntry, MetricKeySummary, + MetricObservationSummary, ProjectStore, StoreError, VertexSummary, +}; +use libmcp::{ + ProjectionError, SelectorProjection, StructuredProjection, SurfaceKind, SurfacePolicy, +}; +use serde::Serialize; +use serde_json::Value; + +use crate::mcp::fault::{FaultKind, FaultRecord, FaultStage}; + +#[derive(Clone, Serialize, libmcp::SelectorProjection)] +pub(crate) struct HypothesisSelector { + pub(crate) slug: String, + pub(crate) title: String, +} + +#[derive(Clone, Serialize, libmcp::SelectorProjection)] +pub(crate) struct ExperimentSelector { + pub(crate) slug: String, + pub(crate) title: String, +} + +#[derive(Clone, Serialize, libmcp::SelectorProjection)] +pub(crate) struct FrontierSelector { + pub(crate) slug: String, + #[libmcp(title)] + pub(crate) label: String, +} + +#[derive(Clone, Serialize)] +pub(crate) struct FrontierSummaryProjection { + pub(crate) slug: String, + pub(crate) label: String, + pub(crate) objective: String, + pub(crate) status: String, + pub(crate) active_hypothesis_count: u64, + pub(crate) open_experiment_count: u64, + pub(crate) updated_at: String, +} + +#[derive(Clone, Serialize)] +pub(crate) struct FrontierBriefProjection { + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) situation: Option, + pub(crate) roadmap: Vec, + pub(crate) unknowns: Vec, + pub(crate) revision: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) updated_at: Option, +} + +#[derive(Clone, Serialize)] +pub(crate) struct RoadmapItemProjection { + pub(crate) rank: u32, + pub(crate) hypothesis: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) summary: Option, +} + +#[derive(Clone, Serialize)] +pub(crate) struct HypothesisRoadmapProjection { + pub(crate) slug: String, + pub(crate) title: String, + pub(crate) summary: String, +} + +#[derive(Clone, Serialize)] +pub(crate) struct FrontierRecordProjection { + pub(crate) slug: String, + pub(crate) label: String, + pub(crate) objective: String, + pub(crate) status: String, + pub(crate) revision: u64, + pub(crate) created_at: String, + pub(crate) updated_at: String, + pub(crate) brief: FrontierBriefProjection, +} + +#[derive(Clone, Serialize, libmcp::ToolProjection)] +#[libmcp(kind = "read")] +pub(crate) struct FrontierReadOutput { + pub(crate) record: FrontierRecordProjection, +} + +#[derive(Clone, Serialize, libmcp::ToolProjection)] +#[libmcp(kind = "list")] +pub(crate) struct FrontierListOutput { + pub(crate) count: usize, + pub(crate) frontiers: Vec, +} + +#[derive(Clone, Serialize, libmcp::ToolProjection)] +#[libmcp(kind = "overview")] +pub(crate) struct FrontierOpenOutput { + pub(crate) frontier: FrontierOpenFrontierProjection, + pub(crate) active_tags: Vec, + pub(crate) active_metric_keys: Vec, + pub(crate) active_hypotheses: Vec, + pub(crate) open_experiments: Vec, +} + +#[derive(Clone, Serialize)] +pub(crate) struct FrontierOpenFrontierProjection { + pub(crate) slug: String, + pub(crate) label: String, + pub(crate) objective: String, + pub(crate) status: String, + pub(crate) brief: FrontierBriefProjection, +} + +#[derive(Clone, Serialize)] +pub(crate) struct HypothesisSummaryProjection { + pub(crate) slug: String, + pub(crate) archived: bool, + pub(crate) title: String, + pub(crate) summary: String, + pub(crate) tags: Vec, + pub(crate) open_experiment_count: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) latest_verdict: Option, + pub(crate) updated_at: String, +} + +#[derive(Clone, Serialize)] +pub(crate) struct HypothesisRecordProjection { + pub(crate) slug: String, + pub(crate) archived: bool, + pub(crate) title: String, + pub(crate) summary: String, + pub(crate) body: String, + pub(crate) tags: Vec, + pub(crate) revision: u64, + pub(crate) created_at: String, + pub(crate) updated_at: String, +} + +#[derive(Clone, Serialize)] +pub(crate) struct HypothesisReadRecordProjection { + pub(crate) slug: String, + pub(crate) archived: bool, + pub(crate) title: String, + pub(crate) summary: String, + pub(crate) tags: Vec, + pub(crate) revision: u64, + pub(crate) updated_at: String, +} + +#[derive(Clone, Serialize)] +pub(crate) struct FrontierLinkProjection { + pub(crate) slug: String, + pub(crate) label: String, + pub(crate) status: String, +} + +#[derive(Clone, Serialize)] +pub(crate) struct HypothesisDetailConcise { + pub(crate) record: HypothesisReadRecordProjection, + pub(crate) frontier: FrontierLinkProjection, + pub(crate) parents: usize, + pub(crate) children: usize, + pub(crate) open_experiments: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) latest_closed_experiment: Option, + pub(crate) artifact_count: usize, +} + +#[derive(Clone, Serialize)] +pub(crate) struct HypothesisDetailFull { + pub(crate) record: HypothesisRecordProjection, + pub(crate) frontier: FrontierLinkProjection, + pub(crate) parents: Vec, + pub(crate) children: Vec, + pub(crate) open_experiments: Vec, + pub(crate) closed_experiments: Vec, + pub(crate) artifacts: Vec, +} + +pub(crate) struct HypothesisDetailOutput { + concise: HypothesisDetailConcise, + full: HypothesisDetailFull, +} + +impl StructuredProjection for HypothesisDetailOutput { + fn concise_projection(&self) -> Result { + Ok(serde_json::to_value(&self.concise)?) + } + + fn full_projection(&self) -> Result { + Ok(serde_json::to_value(&self.full)?) + } +} + +impl SurfacePolicy for HypothesisDetailOutput { + const KIND: SurfaceKind = SurfaceKind::Read; +} + +#[derive(Clone, Serialize, libmcp::ToolProjection)] +#[libmcp(kind = "mutation")] +pub(crate) struct HypothesisRecordOutput { + pub(crate) record: HypothesisRecordProjection, +} + +#[derive(Clone, Serialize, libmcp::ToolProjection)] +#[libmcp(kind = "list")] +pub(crate) struct HypothesisListOutput { + pub(crate) count: usize, + pub(crate) hypotheses: Vec, +} + +#[derive(Clone, Serialize)] +pub(crate) struct ExperimentSummaryProjection { + pub(crate) slug: String, + pub(crate) archived: bool, + pub(crate) title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) summary: Option, + pub(crate) tags: Vec, + pub(crate) status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) verdict: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) primary_metric: Option, + pub(crate) updated_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) closed_at: Option, +} + +#[derive(Clone, Serialize)] +pub(crate) struct ExperimentRecordProjection { + pub(crate) slug: String, + pub(crate) archived: bool, + pub(crate) title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) summary: Option, + pub(crate) tags: Vec, + pub(crate) status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) outcome: Option, + pub(crate) revision: u64, + pub(crate) created_at: String, + pub(crate) updated_at: String, +} + +#[derive(Clone, Serialize)] +pub(crate) struct ExperimentReadRecordProjection { + pub(crate) slug: String, + pub(crate) archived: bool, + pub(crate) title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) summary: Option, + pub(crate) tags: Vec, + pub(crate) status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) verdict: Option, + pub(crate) revision: u64, + pub(crate) updated_at: String, +} + +#[derive(Clone, Serialize)] +pub(crate) struct ExperimentDetailConcise { + pub(crate) record: ExperimentReadRecordProjection, + pub(crate) frontier: FrontierLinkProjection, + pub(crate) owning_hypothesis: HypothesisSummaryProjection, + pub(crate) parents: usize, + pub(crate) children: usize, + pub(crate) artifact_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) outcome: Option, +} + +#[derive(Clone, Serialize)] +pub(crate) struct ExperimentDetailFull { + pub(crate) record: ExperimentRecordProjection, + pub(crate) frontier: FrontierLinkProjection, + pub(crate) owning_hypothesis: HypothesisSummaryProjection, + pub(crate) parents: Vec, + pub(crate) children: Vec, + pub(crate) artifacts: Vec, +} + +pub(crate) struct ExperimentDetailOutput { + concise: ExperimentDetailConcise, + full: ExperimentDetailFull, +} + +impl StructuredProjection for ExperimentDetailOutput { + fn concise_projection(&self) -> Result { + Ok(serde_json::to_value(&self.concise)?) + } + + fn full_projection(&self) -> Result { + Ok(serde_json::to_value(&self.full)?) + } +} + +impl SurfacePolicy for ExperimentDetailOutput { + const KIND: SurfaceKind = SurfaceKind::Read; +} + +#[derive(Clone, Serialize, libmcp::ToolProjection)] +#[libmcp(kind = "mutation")] +pub(crate) struct ExperimentRecordOutput { + pub(crate) record: ExperimentRecordProjection, +} + +#[derive(Clone, Serialize, libmcp::ToolProjection)] +#[libmcp(kind = "list")] +pub(crate) struct ExperimentListOutput { + pub(crate) count: usize, + pub(crate) experiments: Vec, +} + +#[derive(Clone, Serialize)] +pub(crate) struct ArtifactSummaryProjection { + pub(crate) slug: String, + pub(crate) kind: String, + pub(crate) label: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) summary: Option, + pub(crate) locator: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) media_type: Option, + pub(crate) updated_at: String, +} + +#[derive(Clone, Serialize)] +pub(crate) struct ArtifactRecordProjection { + pub(crate) slug: String, + pub(crate) kind: String, + pub(crate) label: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) summary: Option, + pub(crate) locator: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) media_type: Option, + pub(crate) revision: u64, + pub(crate) created_at: String, + pub(crate) updated_at: String, +} + +#[derive(Clone, Serialize)] +pub(crate) struct ArtifactReadRecordProjection { + pub(crate) slug: String, + pub(crate) kind: String, + pub(crate) label: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) summary: Option, + pub(crate) locator: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) media_type: Option, + pub(crate) revision: u64, + pub(crate) updated_at: String, +} + +#[derive(Clone, Serialize)] +pub(crate) struct ArtifactDetailConcise { + pub(crate) record: ArtifactReadRecordProjection, + pub(crate) attachment_count: usize, +} + +#[derive(Clone, Serialize)] +pub(crate) struct ArtifactDetailFull { + pub(crate) record: ArtifactRecordProjection, + pub(crate) attachments: Vec, +} + +pub(crate) struct ArtifactDetailOutput { + concise: ArtifactDetailConcise, + full: ArtifactDetailFull, +} + +impl StructuredProjection for ArtifactDetailOutput { + fn concise_projection(&self) -> Result { + Ok(serde_json::to_value(&self.concise)?) + } + + fn full_projection(&self) -> Result { + Ok(serde_json::to_value(&self.full)?) + } +} + +impl SurfacePolicy for ArtifactDetailOutput { + const KIND: SurfaceKind = SurfaceKind::Read; + const REFERENCE_ONLY: bool = true; +} + +#[derive(Clone, Serialize, libmcp::ToolProjection)] +#[libmcp(kind = "mutation", reference_only)] +pub(crate) struct ArtifactRecordOutput { + pub(crate) record: ArtifactRecordProjection, +} + +#[derive(Clone, Serialize, libmcp::ToolProjection)] +#[libmcp(kind = "list", reference_only)] +pub(crate) struct ArtifactListOutput { + pub(crate) count: usize, + pub(crate) artifacts: Vec, +} + +#[derive(Clone, Serialize)] +pub(crate) struct HypothesisCurrentStateProjection { + pub(crate) hypothesis: HypothesisSummaryProjection, + pub(crate) open_experiments: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) latest_closed_experiment: Option, +} + +#[derive(Clone, Serialize)] +pub(crate) struct MetricKeySummaryProjection { + 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, + pub(crate) reference_count: u64, +} + +#[derive(Clone, Serialize)] +pub(crate) struct MetricBestEntryProjection { + pub(crate) experiment: ExperimentSummaryProjection, + pub(crate) hypothesis: HypothesisSummaryProjection, + pub(crate) value: f64, + pub(crate) dimensions: BTreeMap, +} + +#[derive(Clone, Serialize)] +pub(crate) struct MetricObservationSummaryProjection { + pub(crate) key: String, + pub(crate) value: f64, + pub(crate) unit: String, + pub(crate) objective: String, +} + +#[derive(Clone, Serialize)] +pub(crate) struct ExperimentOutcomeProjection { + pub(crate) backend: String, + pub(crate) command: CommandRecipeProjection, + pub(crate) dimensions: BTreeMap, + pub(crate) primary_metric: MetricValueProjection, + pub(crate) supporting_metrics: Vec, + pub(crate) verdict: String, + pub(crate) rationale: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) analysis: Option, + pub(crate) closed_at: String, +} + +#[derive(Clone, Serialize)] +pub(crate) struct ExperimentAnalysisProjection { + pub(crate) summary: String, + pub(crate) body: String, +} + +#[derive(Clone, Serialize)] +pub(crate) struct MetricValueProjection { + pub(crate) key: String, + pub(crate) value: f64, +} + +#[derive(Clone, Serialize)] +pub(crate) struct CommandRecipeProjection { + pub(crate) argv: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) working_directory: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) env: Option>, +} + +#[derive(Clone, Serialize)] +pub(crate) struct VertexSummaryProjection { + pub(crate) kind: String, + pub(crate) slug: String, + pub(crate) archived: bool, + pub(crate) title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) summary: Option, + pub(crate) updated_at: String, +} + +#[derive(Clone, Serialize)] +pub(crate) struct AttachmentTargetProjection { + pub(crate) kind: String, + pub(crate) slug: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) label: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) status: Option, +} + +#[derive(Clone, Serialize, libmcp::ToolProjection)] +#[libmcp(kind = "list")] +pub(crate) struct MetricKeysOutput { + pub(crate) count: usize, + pub(crate) metrics: Vec, +} + +#[derive(Clone, Serialize, libmcp::ToolProjection)] +#[libmcp(kind = "list")] +pub(crate) struct MetricBestOutput { + pub(crate) count: usize, + pub(crate) entries: Vec, +} + +pub(crate) fn frontier_list(frontiers: &[FrontierSummary]) -> FrontierListOutput { + FrontierListOutput { + count: frontiers.len(), + frontiers: frontiers.iter().map(frontier_summary).collect(), + } +} + +pub(crate) fn frontier_record( + store: &ProjectStore, + frontier: &FrontierRecord, + operation: &str, +) -> Result { + Ok(FrontierReadOutput { + record: frontier_record_projection(store, frontier, operation)?, + }) +} + +pub(crate) fn frontier_open(projection: &FrontierOpenProjection) -> FrontierOpenOutput { + FrontierOpenOutput { + frontier: FrontierOpenFrontierProjection { + slug: projection.frontier.slug.to_string(), + label: projection.frontier.label.to_string(), + objective: projection.frontier.objective.to_string(), + status: projection.frontier.status.as_str().to_owned(), + brief: frontier_brief_projection( + &projection.frontier.brief, + projection + .frontier + .brief + .roadmap + .iter() + .map(|item| { + let hypothesis = projection + .active_hypotheses + .iter() + .find(|state| state.hypothesis.id == item.hypothesis_id) + .map(|state| HypothesisRoadmapProjection { + slug: state.hypothesis.slug.to_string(), + title: state.hypothesis.title.to_string(), + summary: state.hypothesis.summary.to_string(), + }); + RoadmapItemProjection { + rank: item.rank, + hypothesis, + summary: item.summary.as_ref().map(ToString::to_string), + } + }) + .collect(), + ), + }, + active_tags: projection + .active_tags + .iter() + .map(ToString::to_string) + .collect(), + active_metric_keys: projection + .active_metric_keys + .iter() + .map(metric_key_summary) + .collect(), + active_hypotheses: projection + .active_hypotheses + .iter() + .map(hypothesis_current_state) + .collect(), + open_experiments: projection + .open_experiments + .iter() + .map(experiment_summary) + .collect(), + } +} + +pub(crate) fn hypothesis_record( + hypothesis: &fidget_spinner_core::HypothesisRecord, +) -> HypothesisRecordOutput { + HypothesisRecordOutput { + record: hypothesis_record_projection(hypothesis), + } +} + +pub(crate) fn hypothesis_list( + hypotheses: &[fidget_spinner_store_sqlite::HypothesisSummary], +) -> HypothesisListOutput { + HypothesisListOutput { + count: hypotheses.len(), + hypotheses: hypotheses.iter().map(hypothesis_summary).collect(), + } +} + +pub(crate) fn hypothesis_detail( + store: &ProjectStore, + detail: &HypothesisDetail, + operation: &str, +) -> Result { + let frontier = store + .read_frontier(&detail.record.frontier_id.to_string()) + .map_err(store_fault(operation))?; + let frontier = FrontierLinkProjection { + slug: frontier.slug.to_string(), + label: frontier.label.to_string(), + status: frontier.status.as_str().to_owned(), + }; + Ok(HypothesisDetailOutput { + concise: HypothesisDetailConcise { + record: HypothesisReadRecordProjection { + slug: detail.record.slug.to_string(), + archived: detail.record.archived, + title: detail.record.title.to_string(), + summary: detail.record.summary.to_string(), + tags: detail.record.tags.iter().map(ToString::to_string).collect(), + revision: detail.record.revision, + updated_at: timestamp_value(detail.record.updated_at), + }, + frontier: frontier.clone(), + parents: detail.parents.len(), + children: detail.children.len(), + open_experiments: detail + .open_experiments + .iter() + .map(experiment_summary) + .collect(), + latest_closed_experiment: detail.closed_experiments.first().map(experiment_summary), + artifact_count: detail.artifacts.len(), + }, + full: HypothesisDetailFull { + record: hypothesis_record_projection(&detail.record), + frontier, + parents: detail.parents.iter().map(vertex_summary).collect(), + children: detail.children.iter().map(vertex_summary).collect(), + open_experiments: detail + .open_experiments + .iter() + .map(experiment_summary) + .collect(), + closed_experiments: detail + .closed_experiments + .iter() + .map(experiment_summary) + .collect(), + artifacts: detail.artifacts.iter().map(artifact_summary).collect(), + }, + }) +} + +pub(crate) fn experiment_record( + experiment: &fidget_spinner_core::ExperimentRecord, +) -> ExperimentRecordOutput { + ExperimentRecordOutput { + record: experiment_record_projection(experiment), + } +} + +pub(crate) fn experiment_list(experiments: &[ExperimentSummary]) -> ExperimentListOutput { + ExperimentListOutput { + count: experiments.len(), + experiments: experiments.iter().map(experiment_summary).collect(), + } +} + +pub(crate) fn experiment_detail( + store: &ProjectStore, + detail: &ExperimentDetail, + operation: &str, +) -> Result { + let frontier = store + .read_frontier(&detail.record.frontier_id.to_string()) + .map_err(store_fault(operation))?; + let frontier = FrontierLinkProjection { + slug: frontier.slug.to_string(), + label: frontier.label.to_string(), + status: frontier.status.as_str().to_owned(), + }; + Ok(ExperimentDetailOutput { + concise: ExperimentDetailConcise { + record: ExperimentReadRecordProjection { + slug: detail.record.slug.to_string(), + archived: detail.record.archived, + title: detail.record.title.to_string(), + summary: detail.record.summary.as_ref().map(ToString::to_string), + tags: detail.record.tags.iter().map(ToString::to_string).collect(), + status: detail.record.status.as_str().to_owned(), + verdict: detail + .record + .outcome + .as_ref() + .map(|outcome| outcome.verdict.as_str().to_owned()), + revision: detail.record.revision, + updated_at: timestamp_value(detail.record.updated_at), + }, + frontier: frontier.clone(), + owning_hypothesis: hypothesis_summary(&detail.owning_hypothesis), + parents: detail.parents.len(), + children: detail.children.len(), + artifact_count: detail.artifacts.len(), + outcome: detail.record.outcome.as_ref().map(experiment_outcome), + }, + full: ExperimentDetailFull { + record: experiment_record_projection(&detail.record), + frontier, + owning_hypothesis: hypothesis_summary(&detail.owning_hypothesis), + parents: detail.parents.iter().map(vertex_summary).collect(), + children: detail.children.iter().map(vertex_summary).collect(), + artifacts: detail.artifacts.iter().map(artifact_summary).collect(), + }, + }) +} + +pub(crate) fn artifact_record( + artifact: &fidget_spinner_core::ArtifactRecord, +) -> ArtifactRecordOutput { + ArtifactRecordOutput { + record: artifact_record_projection(artifact), + } +} + +pub(crate) fn artifact_list(artifacts: &[ArtifactSummary]) -> ArtifactListOutput { + ArtifactListOutput { + count: artifacts.len(), + artifacts: artifacts.iter().map(artifact_summary).collect(), + } +} + +pub(crate) fn artifact_detail( + store: &ProjectStore, + detail: &ArtifactDetail, + operation: &str, +) -> Result { + let attachments = detail + .attachments + .iter() + .copied() + .map(|attachment| attachment_target(store, attachment, operation)) + .collect::, _>>()?; + Ok(ArtifactDetailOutput { + concise: ArtifactDetailConcise { + record: ArtifactReadRecordProjection { + slug: detail.record.slug.to_string(), + kind: detail.record.kind.as_str().to_owned(), + label: detail.record.label.to_string(), + summary: detail.record.summary.as_ref().map(ToString::to_string), + locator: detail.record.locator.to_string(), + media_type: detail.record.media_type.as_ref().map(ToString::to_string), + revision: detail.record.revision, + updated_at: timestamp_value(detail.record.updated_at), + }, + attachment_count: detail.attachments.len(), + }, + full: ArtifactDetailFull { + record: artifact_record_projection(&detail.record), + attachments, + }, + }) +} + +pub(crate) fn metric_keys(keys: &[MetricKeySummary]) -> MetricKeysOutput { + MetricKeysOutput { + count: keys.len(), + metrics: keys.iter().map(metric_key_summary).collect(), + } +} + +pub(crate) fn metric_best(entries: &[MetricBestEntry]) -> MetricBestOutput { + MetricBestOutput { + count: entries.len(), + entries: entries.iter().map(metric_best_entry).collect(), + } +} + +fn frontier_summary(frontier: &FrontierSummary) -> FrontierSummaryProjection { + FrontierSummaryProjection { + slug: frontier.slug.to_string(), + label: frontier.label.to_string(), + objective: frontier.objective.to_string(), + status: frontier.status.as_str().to_owned(), + active_hypothesis_count: frontier.active_hypothesis_count, + open_experiment_count: frontier.open_experiment_count, + updated_at: timestamp_value(frontier.updated_at), + } +} + +fn frontier_record_projection( + store: &ProjectStore, + frontier: &FrontierRecord, + operation: &str, +) -> Result { + let roadmap = frontier + .brief + .roadmap + .iter() + .map(|item| { + let hypothesis = store + .read_hypothesis(&item.hypothesis_id.to_string()) + .map_err(store_fault(operation))?; + Ok(RoadmapItemProjection { + rank: item.rank, + hypothesis: Some(HypothesisRoadmapProjection { + slug: hypothesis.record.slug.to_string(), + title: hypothesis.record.title.to_string(), + summary: hypothesis.record.summary.to_string(), + }), + summary: item.summary.as_ref().map(ToString::to_string), + }) + }) + .collect::, FaultRecord>>()?; + Ok(FrontierRecordProjection { + slug: frontier.slug.to_string(), + label: frontier.label.to_string(), + objective: frontier.objective.to_string(), + status: frontier.status.as_str().to_owned(), + revision: frontier.revision, + created_at: timestamp_value(frontier.created_at), + updated_at: timestamp_value(frontier.updated_at), + brief: frontier_brief_projection(&frontier.brief, roadmap), + }) +} + +fn frontier_brief_projection( + brief: &FrontierBrief, + roadmap: Vec, +) -> FrontierBriefProjection { + FrontierBriefProjection { + situation: brief.situation.as_ref().map(ToString::to_string), + roadmap, + unknowns: brief.unknowns.iter().map(ToString::to_string).collect(), + revision: brief.revision, + updated_at: brief.updated_at.map(timestamp_value), + } +} + +fn hypothesis_summary( + hypothesis: &fidget_spinner_store_sqlite::HypothesisSummary, +) -> HypothesisSummaryProjection { + HypothesisSummaryProjection { + slug: hypothesis.slug.to_string(), + archived: hypothesis.archived, + title: hypothesis.title.to_string(), + summary: hypothesis.summary.to_string(), + tags: hypothesis.tags.iter().map(ToString::to_string).collect(), + open_experiment_count: hypothesis.open_experiment_count, + latest_verdict: hypothesis + .latest_verdict + .map(|verdict| verdict.as_str().to_owned()), + updated_at: timestamp_value(hypothesis.updated_at), + } +} + +fn hypothesis_record_projection( + hypothesis: &fidget_spinner_core::HypothesisRecord, +) -> HypothesisRecordProjection { + HypothesisRecordProjection { + slug: hypothesis.slug.to_string(), + archived: hypothesis.archived, + title: hypothesis.title.to_string(), + summary: hypothesis.summary.to_string(), + body: hypothesis.body.to_string(), + tags: hypothesis.tags.iter().map(ToString::to_string).collect(), + revision: hypothesis.revision, + created_at: timestamp_value(hypothesis.created_at), + updated_at: timestamp_value(hypothesis.updated_at), + } +} + +fn experiment_summary(experiment: &ExperimentSummary) -> ExperimentSummaryProjection { + ExperimentSummaryProjection { + slug: experiment.slug.to_string(), + archived: experiment.archived, + title: experiment.title.to_string(), + summary: experiment.summary.as_ref().map(ToString::to_string), + tags: experiment.tags.iter().map(ToString::to_string).collect(), + status: experiment.status.as_str().to_owned(), + verdict: experiment + .verdict + .map(|verdict| verdict.as_str().to_owned()), + primary_metric: experiment + .primary_metric + .as_ref() + .map(metric_observation_summary), + updated_at: timestamp_value(experiment.updated_at), + closed_at: experiment.closed_at.map(timestamp_value), + } +} + +fn experiment_record_projection( + experiment: &fidget_spinner_core::ExperimentRecord, +) -> ExperimentRecordProjection { + ExperimentRecordProjection { + slug: experiment.slug.to_string(), + archived: experiment.archived, + title: experiment.title.to_string(), + summary: experiment.summary.as_ref().map(ToString::to_string), + tags: experiment.tags.iter().map(ToString::to_string).collect(), + status: experiment.status.as_str().to_owned(), + outcome: experiment.outcome.as_ref().map(experiment_outcome), + revision: experiment.revision, + created_at: timestamp_value(experiment.created_at), + updated_at: timestamp_value(experiment.updated_at), + } +} + +fn artifact_summary(artifact: &ArtifactSummary) -> ArtifactSummaryProjection { + ArtifactSummaryProjection { + slug: artifact.slug.to_string(), + kind: artifact.kind.as_str().to_owned(), + label: artifact.label.to_string(), + summary: artifact.summary.as_ref().map(ToString::to_string), + locator: artifact.locator.to_string(), + media_type: artifact.media_type.as_ref().map(ToString::to_string), + updated_at: timestamp_value(artifact.updated_at), + } +} + +fn artifact_record_projection( + artifact: &fidget_spinner_core::ArtifactRecord, +) -> ArtifactRecordProjection { + ArtifactRecordProjection { + slug: artifact.slug.to_string(), + kind: artifact.kind.as_str().to_owned(), + label: artifact.label.to_string(), + summary: artifact.summary.as_ref().map(ToString::to_string), + locator: artifact.locator.to_string(), + media_type: artifact.media_type.as_ref().map(ToString::to_string), + revision: artifact.revision, + created_at: timestamp_value(artifact.created_at), + updated_at: timestamp_value(artifact.updated_at), + } +} + +fn hypothesis_current_state(state: &HypothesisCurrentState) -> HypothesisCurrentStateProjection { + HypothesisCurrentStateProjection { + hypothesis: hypothesis_summary(&state.hypothesis), + open_experiments: state + .open_experiments + .iter() + .map(experiment_summary) + .collect(), + latest_closed_experiment: state + .latest_closed_experiment + .as_ref() + .map(experiment_summary), + } +} + +fn metric_key_summary(metric: &MetricKeySummary) -> MetricKeySummaryProjection { + MetricKeySummaryProjection { + 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), + reference_count: metric.reference_count, + } +} + +fn metric_best_entry(entry: &MetricBestEntry) -> MetricBestEntryProjection { + MetricBestEntryProjection { + experiment: experiment_summary(&entry.experiment), + hypothesis: hypothesis_summary(&entry.hypothesis), + value: entry.value, + dimensions: dimension_map(&entry.dimensions), + } +} + +fn metric_observation_summary( + metric: &MetricObservationSummary, +) -> MetricObservationSummaryProjection { + MetricObservationSummaryProjection { + key: metric.key.to_string(), + value: metric.value, + unit: metric.unit.as_str().to_owned(), + objective: metric.objective.as_str().to_owned(), + } +} + +fn experiment_outcome(outcome: &ExperimentOutcome) -> ExperimentOutcomeProjection { + ExperimentOutcomeProjection { + backend: outcome.backend.as_str().to_owned(), + command: command_recipe(&outcome.command), + dimensions: dimension_map(&outcome.dimensions), + primary_metric: metric_value(&outcome.primary_metric), + supporting_metrics: outcome + .supporting_metrics + .iter() + .map(metric_value) + .collect(), + verdict: outcome.verdict.as_str().to_owned(), + rationale: outcome.rationale.to_string(), + analysis: outcome.analysis.as_ref().map(experiment_analysis), + closed_at: timestamp_value(outcome.closed_at), + } +} + +fn experiment_analysis(analysis: &ExperimentAnalysis) -> ExperimentAnalysisProjection { + ExperimentAnalysisProjection { + summary: analysis.summary.to_string(), + body: analysis.body.to_string(), + } +} + +fn metric_value(metric: &MetricValue) -> MetricValueProjection { + MetricValueProjection { + key: metric.key.to_string(), + value: metric.value, + } +} + +fn command_recipe(command: &CommandRecipe) -> CommandRecipeProjection { + CommandRecipeProjection { + argv: command.argv.iter().map(ToString::to_string).collect(), + working_directory: command.working_directory.as_ref().map(ToString::to_string), + env: (!command.env.is_empty()).then(|| { + command + .env + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect() + }), + } +} + +fn dimension_map( + dimensions: &BTreeMap, +) -> BTreeMap { + dimensions + .iter() + .map(|(key, value)| (key.to_string(), run_dimension_value(value))) + .collect() +} + +fn run_dimension_value(value: &RunDimensionValue) -> Value { + match value { + RunDimensionValue::String(value) => Value::String(value.to_string()), + RunDimensionValue::Numeric(value) => serde_json::json!(value), + RunDimensionValue::Boolean(value) => serde_json::json!(value), + RunDimensionValue::Timestamp(value) => Value::String(value.to_string()), + } +} + +fn vertex_summary(vertex: &VertexSummary) -> VertexSummaryProjection { + VertexSummaryProjection { + kind: vertex.vertex.kind().as_str().to_owned(), + slug: vertex.slug.to_string(), + archived: vertex.archived, + title: vertex.title.to_string(), + summary: vertex.summary.as_ref().map(ToString::to_string), + updated_at: timestamp_value(vertex.updated_at), + } +} + +fn attachment_target( + store: &ProjectStore, + attachment: AttachmentTargetRef, + operation: &str, +) -> Result { + match attachment { + AttachmentTargetRef::Frontier(id) => { + let frontier = store + .read_frontier(&id.to_string()) + .map_err(store_fault(operation))?; + let reference = FrontierSelector { + slug: frontier.slug.to_string(), + label: frontier.label.to_string(), + }; + let selector = reference.selector_ref(); + Ok(AttachmentTargetProjection { + kind: "frontier".to_owned(), + slug: selector.slug, + title: None, + label: selector.title, + summary: None, + status: Some(frontier.status.as_str().to_owned()), + }) + } + AttachmentTargetRef::Hypothesis(id) => { + let hypothesis = store + .read_hypothesis(&id.to_string()) + .map_err(store_fault(operation))?; + let reference = HypothesisSelector { + slug: hypothesis.record.slug.to_string(), + title: hypothesis.record.title.to_string(), + }; + let selector = reference.selector_ref(); + Ok(AttachmentTargetProjection { + kind: "hypothesis".to_owned(), + slug: selector.slug, + title: selector.title, + label: None, + summary: Some(hypothesis.record.summary.to_string()), + status: None, + }) + } + AttachmentTargetRef::Experiment(id) => { + let experiment = store + .read_experiment(&id.to_string()) + .map_err(store_fault(operation))?; + let reference = ExperimentSelector { + slug: experiment.record.slug.to_string(), + title: experiment.record.title.to_string(), + }; + let selector = reference.selector_ref(); + Ok(AttachmentTargetProjection { + kind: "experiment".to_owned(), + slug: selector.slug, + title: selector.title, + label: None, + summary: experiment.record.summary.as_ref().map(ToString::to_string), + status: None, + }) + } + } +} + +fn timestamp_value(timestamp: time::OffsetDateTime) -> String { + timestamp + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_else(|_| timestamp.unix_timestamp().to_string()) +} + +fn store_fault(operation: &str) -> impl Fn(StoreError) -> FaultRecord + '_ { + move |error| { + FaultRecord::new( + FaultKind::Internal, + FaultStage::Store, + operation, + error.to_string(), + ) + } +} -- cgit v1.2.3