use std::collections::BTreeSet;
use sim_kernel::{Expr, Result, Symbol};
use sim_lib_stream_core::{DevCassette, DevEvent, LatencyClass};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChangeCapsule {
pub id: Symbol,
pub scope: CapsuleScope,
pub patches: Vec<CapsulePatch>,
pub generated_artifacts: Vec<GeneratedArtifact>,
pub validations: Vec<CapsuleJob>,
pub docs_runs: Vec<CapsuleJob>,
pub commits: Vec<CapsuleCommit>,
pub pushes: Vec<CapsulePush>,
pub pin_plan: Vec<PinPlanEntry>,
pub site_changes: Vec<GeneratedArtifact>,
pub risks: Vec<String>,
pub rollback_notes: Vec<String>,
pub placement_plan: Vec<PlacedJob>,
pub cassette: DevCassette,
pub fairness: CapsuleFacet,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CapsuleScope {
pub repos: Vec<String>,
pub targets: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CapsulePatch {
pub repo: String,
pub path: String,
pub summary: String,
}
impl CapsulePatch {
pub fn new(
repo: impl Into<String>,
path: impl Into<String>,
summary: impl Into<String>,
) -> Self {
Self {
repo: repo.into(),
path: path.into(),
summary: summary.into(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GeneratedArtifact {
pub repo: String,
pub path: String,
pub generator: String,
pub generated_public_doc: bool,
pub hand_edited: bool,
}
impl GeneratedArtifact {
pub fn generated(
repo: impl Into<String>,
path: impl Into<String>,
generator: impl Into<String>,
) -> Self {
Self {
repo: repo.into(),
path: path.into(),
generator: generator.into(),
generated_public_doc: true,
hand_edited: false,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CapsuleJob {
pub kind: CapsuleJobKind,
pub label: String,
pub command: String,
pub site: JobSite,
pub outcome: CapsuleJobOutcome,
pub log_path: String,
}
impl CapsuleJob {
pub fn validation(label: impl Into<String>, command: impl Into<String>) -> Self {
let label = label.into();
Self {
kind: CapsuleJobKind::Validation,
label: label.clone(),
command: command.into(),
site: JobSite::Process,
outcome: CapsuleJobOutcome::Passed,
log_path: format!(".sim/atelier/logs/{label}.log"),
}
}
pub fn docs(label: impl Into<String>, command: impl Into<String>) -> Self {
let label = label.into();
Self {
kind: CapsuleJobKind::Docs,
label: label.clone(),
command: command.into(),
site: JobSite::Process,
outcome: CapsuleJobOutcome::Passed,
log_path: format!(".sim/atelier/logs/{label}.log"),
}
}
pub fn failed(mut self) -> Self {
self.outcome = CapsuleJobOutcome::Failed;
self
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CapsuleJobKind {
Validation,
Docs,
}
impl CapsuleJobKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Validation => "validation",
Self::Docs => "docs",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CapsuleJobOutcome {
Passed,
Failed,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum JobSite {
LocalCoroutine,
Process,
Fabric,
}
impl JobSite {
pub fn as_str(self) -> &'static str {
match self {
Self::LocalCoroutine => "local-coroutine",
Self::Process => "process",
Self::Fabric => "fabric",
}
}
pub fn realize_operation(self) -> &'static str {
match self {
Self::LocalCoroutine => "local-coroutine",
Self::Process | Self::Fabric => "realize",
}
}
fn can_run_validation(self) -> bool {
matches!(self, Self::Process | Self::Fabric)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CapsuleCommit {
pub repo: String,
pub hash: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CapsulePush {
pub repo: String,
pub remote: String,
pub hash: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PinPlanEntry {
pub repo: String,
pub current_commit: String,
pub new_commit: String,
pub pushed_commit_exists: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PlacedJob {
pub label: String,
pub site: JobSite,
}
impl PlacedJob {
pub fn new(label: impl Into<String>, site: JobSite) -> Self {
Self {
label: label.into(),
site,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CapsuleFacet {
pub label: String,
pub evidence: String,
pub confidence: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CapsuleReview {
pub accepted: bool,
pub content_hash: String,
pub replay_content_hash: String,
pub preview_repos: Vec<String>,
pub failure_reasons: Vec<String>,
}
pub fn review_change_capsule(capsule: &ChangeCapsule) -> Result<CapsuleReview> {
let replay_content_hash = capsule.cassette.replay_content_hash()?;
let mut failure_reasons = Vec::new();
if capsule.cassette.content_hash() != replay_content_hash {
failure_reasons.push("cassette replay content hash mismatch".to_owned());
}
check_jobs(&capsule.validations, &mut failure_reasons);
check_jobs(&capsule.docs_runs, &mut failure_reasons);
check_placements(&capsule.placement_plan, &mut failure_reasons);
check_generated_docs(&capsule.generated_artifacts, &mut failure_reasons);
check_generated_docs(&capsule.site_changes, &mut failure_reasons);
check_pins(&capsule.pin_plan, &mut failure_reasons);
Ok(CapsuleReview {
accepted: failure_reasons.is_empty(),
content_hash: capsule.cassette.content_hash().to_owned(),
replay_content_hash,
preview_repos: preview_repos(capsule),
failure_reasons,
})
}
pub fn fake_change_capsule() -> Result<ChangeCapsule> {
let node = Symbol::qualified("atelier/agent", "change-capsule");
let cassette = DevCassette::from_events(
Symbol::qualified("atelier/dev", "change-capsule-fixture"),
vec![
DevEvent::edit(node.clone(), summary("Patch capsule model"))?,
DevEvent::validate(node.clone(), summary("cargo test change_capsule"))?,
DevEvent::new(
"docs",
node.clone(),
LatencyClass::OfflineRender,
summary("simdoc check current"),
)?,
DevEvent::new(
"pin",
node.clone(),
LatencyClass::Interactive,
summary("pushed commit exists before pin"),
)?,
DevEvent::new(
"reflect",
node,
LatencyClass::OfflineRender,
summary("F6 facet records risk and rollback"),
)?,
],
)?;
Ok(ChangeCapsule {
id: Symbol::qualified("atelier/capsule", "fixture"),
scope: CapsuleScope {
repos: vec!["sim-agent-net".to_owned(), "sim-tooling".to_owned()],
targets: vec![
"crates/sim-lib-agent/src/atelier/capsule.rs".to_owned(),
"src/atelier/capsule.rs".to_owned(),
],
},
patches: vec![
CapsulePatch::new(
"sim-agent-net",
"crates/sim-lib-agent/src/atelier/capsule.rs",
"agent capsule model",
),
CapsulePatch::new("sim-tooling", "src/atelier/capsule.rs", "capsule cache"),
],
generated_artifacts: vec![GeneratedArtifact::generated(
"sim-tooling",
"docs/generated/contract.md",
"xtask simdoc",
)],
validations: vec![CapsuleJob::validation(
"agent-capsule-tests",
"cargo test -p sim-lib-agent change_capsule",
)],
docs_runs: vec![CapsuleJob::docs(
"simdoc-agent-net",
"cargo run -p xtask -- simdoc --check",
)],
commits: vec![CapsuleCommit {
repo: "sim-agent-net".to_owned(),
hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_owned(),
}],
pushes: vec![CapsulePush {
repo: "sim-agent-net".to_owned(),
remote: "origin".to_owned(),
hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_owned(),
}],
pin_plan: vec![PinPlanEntry {
repo: "sim-agent-net".to_owned(),
current_commit: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_owned(),
new_commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_owned(),
pushed_commit_exists: true,
}],
site_changes: vec![GeneratedArtifact::generated(
"repo-docs",
"docs/site/repos.md",
"simctl site",
)],
risks: vec!["review capsule scope before pinning".to_owned()],
rollback_notes: vec!["reset pin to previous commit and rerun site".to_owned()],
placement_plan: vec![
PlacedJob::new("edit", JobSite::LocalCoroutine),
PlacedJob::new("capsule-assembly", JobSite::LocalCoroutine),
PlacedJob::new("validation", JobSite::Process),
PlacedJob::new("docs", JobSite::Process),
PlacedJob::new("pin-plan", JobSite::LocalCoroutine),
],
cassette,
fairness: CapsuleFacet {
label: "F6 trade-off".to_owned(),
evidence: "validation, docs, pin, and rollback evidence recorded".to_owned(),
confidence: "0.92".to_owned(),
},
})
}
fn check_jobs(jobs: &[CapsuleJob], failure_reasons: &mut Vec<String>) {
for job in jobs {
if !job.site.can_run_validation() {
failure_reasons.push(format!(
"{} job {} must run on process or fabric site",
job.kind.as_str(),
job.label
));
}
if job.site.realize_operation() != "realize" {
failure_reasons.push(format!(
"{} job {} is not realized",
job.kind.as_str(),
job.label
));
}
if job.outcome == CapsuleJobOutcome::Failed {
failure_reasons.push(format!("{} job {} failed", job.kind.as_str(), job.label));
}
}
}
fn check_placements(placements: &[PlacedJob], failure_reasons: &mut Vec<String>) {
for label in ["edit", "capsule-assembly"] {
let local = placements
.iter()
.any(|job| job.label == label && job.site == JobSite::LocalCoroutine);
if !local {
failure_reasons.push(format!("{label} must stay on the local coroutine site"));
}
}
for label in ["validation", "docs"] {
let realized = placements
.iter()
.any(|job| job.label == label && job.site.can_run_validation());
if !realized {
failure_reasons.push(format!("{label} must be placed on process or fabric site"));
}
}
}
fn check_generated_docs(artifacts: &[GeneratedArtifact], failure_reasons: &mut Vec<String>) {
for artifact in artifacts {
if artifact.generated_public_doc && artifact.hand_edited {
failure_reasons.push(format!(
"generated public doc {}:{} must be regenerated, not hand-edited",
artifact.repo, artifact.path
));
}
}
}
fn check_pins(pins: &[PinPlanEntry], failure_reasons: &mut Vec<String>) {
for pin in pins {
if !pin.pushed_commit_exists {
failure_reasons.push(format!(
"pin plan for {} requires an existing pushed upstream commit",
pin.repo
));
}
}
}
fn preview_repos(capsule: &ChangeCapsule) -> Vec<String> {
let mut repos = BTreeSet::new();
repos.extend(capsule.scope.repos.iter().cloned());
repos.extend(capsule.pin_plan.iter().map(|pin| pin.repo.clone()));
repos.into_iter().collect()
}
fn summary(text: &str) -> Expr {
Expr::Map(vec![(
Expr::Symbol(Symbol::new("summary")),
Expr::String(text.to_owned()),
)])
}