Skip to main content

sim_lib_agent/atelier/
capsule.rs

1//! Change Capsule review model for agent-operated Atelier edits.
2
3use std::collections::BTreeSet;
4
5use sim_kernel::{Expr, Result, Symbol};
6use sim_lib_stream_core::{DevCassette, DevEvent, LatencyClass};
7
8/// Auditable package of patches, validation evidence, pins, and replay data.
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub struct ChangeCapsule {
11    /// Stable capsule id.
12    pub id: Symbol,
13    /// Repositories and files the capsule is allowed to touch.
14    pub scope: CapsuleScope,
15    /// Patch summaries included in the review.
16    pub patches: Vec<CapsulePatch>,
17    /// Generated artifacts produced by tooling.
18    pub generated_artifacts: Vec<GeneratedArtifact>,
19    /// Validation jobs and their placed execution sites.
20    pub validations: Vec<CapsuleJob>,
21    /// Documentation jobs and their placed execution sites.
22    pub docs_runs: Vec<CapsuleJob>,
23    /// Public repo commits produced by the capsule.
24    pub commits: Vec<CapsuleCommit>,
25    /// Push records for public commits.
26    pub pushes: Vec<CapsulePush>,
27    /// Planned `repos.toml` pin updates.
28    pub pin_plan: Vec<PinPlanEntry>,
29    /// Generated front-page changes.
30    pub site_changes: Vec<GeneratedArtifact>,
31    /// Human-review risk notes.
32    pub risks: Vec<String>,
33    /// Rollback notes for each affected repo.
34    pub rollback_notes: Vec<String>,
35    /// Placement plan for editing, validation, docs, pins, and capsule assembly.
36    pub placement_plan: Vec<PlacedJob>,
37    /// Recorded development cassette for replay.
38    pub cassette: DevCassette,
39    /// F6-style fairness and attribution facet.
40    pub fairness: CapsuleFacet,
41}
42
43/// Scope declared by a Change Capsule.
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct CapsuleScope {
46    /// Repository names in scope.
47    pub repos: Vec<String>,
48    /// File or directory targets in scope.
49    pub targets: Vec<String>,
50}
51
52/// One patch summarized for review.
53#[derive(Clone, Debug, PartialEq, Eq)]
54pub struct CapsulePatch {
55    /// Repository containing the patch.
56    pub repo: String,
57    /// Path changed by the patch.
58    pub path: String,
59    /// Review summary.
60    pub summary: String,
61}
62
63impl CapsulePatch {
64    /// Builds a patch summary.
65    pub fn new(
66        repo: impl Into<String>,
67        path: impl Into<String>,
68        summary: impl Into<String>,
69    ) -> Self {
70        Self {
71            repo: repo.into(),
72            path: path.into(),
73            summary: summary.into(),
74        }
75    }
76}
77
78/// Generated artifact or public front-page change.
79#[derive(Clone, Debug, PartialEq, Eq)]
80pub struct GeneratedArtifact {
81    /// Repository that owns the artifact.
82    pub repo: String,
83    /// Artifact path.
84    pub path: String,
85    /// Tool that generated or checked the artifact.
86    pub generator: String,
87    /// Whether the artifact is part of a generated public docs lane.
88    pub generated_public_doc: bool,
89    /// Whether the capsule attempted a hand edit.
90    pub hand_edited: bool,
91}
92
93impl GeneratedArtifact {
94    /// Builds a generated artifact record.
95    pub fn generated(
96        repo: impl Into<String>,
97        path: impl Into<String>,
98        generator: impl Into<String>,
99    ) -> Self {
100        Self {
101            repo: repo.into(),
102            path: path.into(),
103            generator: generator.into(),
104            generated_public_doc: true,
105            hand_edited: false,
106        }
107    }
108}
109
110/// Validation or docs job included in a capsule.
111#[derive(Clone, Debug, PartialEq, Eq)]
112pub struct CapsuleJob {
113    /// Job kind.
114    pub kind: CapsuleJobKind,
115    /// Stable job label.
116    pub label: String,
117    /// Command line or typed command template.
118    pub command: String,
119    /// Job placement.
120    pub site: JobSite,
121    /// Review outcome.
122    pub outcome: CapsuleJobOutcome,
123    /// Evidence log path.
124    pub log_path: String,
125}
126
127impl CapsuleJob {
128    /// Builds a passed validation job on a process site.
129    pub fn validation(label: impl Into<String>, command: impl Into<String>) -> Self {
130        let label = label.into();
131        Self {
132            kind: CapsuleJobKind::Validation,
133            label: label.clone(),
134            command: command.into(),
135            site: JobSite::Process,
136            outcome: CapsuleJobOutcome::Passed,
137            log_path: format!(".sim/atelier/logs/{label}.log"),
138        }
139    }
140
141    /// Builds a passed docs job on a process site.
142    pub fn docs(label: impl Into<String>, command: impl Into<String>) -> Self {
143        let label = label.into();
144        Self {
145            kind: CapsuleJobKind::Docs,
146            label: label.clone(),
147            command: command.into(),
148            site: JobSite::Process,
149            outcome: CapsuleJobOutcome::Passed,
150            log_path: format!(".sim/atelier/logs/{label}.log"),
151        }
152    }
153
154    /// Returns a copy marked as failed.
155    pub fn failed(mut self) -> Self {
156        self.outcome = CapsuleJobOutcome::Failed;
157        self
158    }
159}
160
161/// Capsule job kind.
162#[derive(Clone, Copy, Debug, PartialEq, Eq)]
163pub enum CapsuleJobKind {
164    /// Validation command.
165    Validation,
166    /// Documentation command.
167    Docs,
168}
169
170impl CapsuleJobKind {
171    /// Stable kind label.
172    pub fn as_str(self) -> &'static str {
173        match self {
174            Self::Validation => "validation",
175            Self::Docs => "docs",
176        }
177    }
178}
179
180/// Result of a capsule job.
181#[derive(Clone, Copy, Debug, PartialEq, Eq)]
182pub enum CapsuleJobOutcome {
183    /// Job passed.
184    Passed,
185    /// Job failed.
186    Failed,
187}
188
189/// Site class used for placed validation and docs jobs.
190#[derive(Clone, Copy, Debug, PartialEq, Eq)]
191pub enum JobSite {
192    /// Local coroutine site for edit and assembly work.
193    LocalCoroutine,
194    /// Process site reached through `realize`.
195    Process,
196    /// Fabric site reached through `realize`.
197    Fabric,
198}
199
200impl JobSite {
201    /// Stable site label.
202    pub fn as_str(self) -> &'static str {
203        match self {
204            Self::LocalCoroutine => "local-coroutine",
205            Self::Process => "process",
206            Self::Fabric => "fabric",
207        }
208    }
209
210    /// SUP.20 realization operation used for this placement.
211    pub fn realize_operation(self) -> &'static str {
212        match self {
213            Self::LocalCoroutine => "local-coroutine",
214            Self::Process | Self::Fabric => "realize",
215        }
216    }
217
218    fn can_run_validation(self) -> bool {
219        matches!(self, Self::Process | Self::Fabric)
220    }
221}
222
223/// One public commit referenced by a capsule.
224#[derive(Clone, Debug, PartialEq, Eq)]
225pub struct CapsuleCommit {
226    /// Repository name.
227    pub repo: String,
228    /// Commit hash.
229    pub hash: String,
230}
231
232/// One push record referenced by a capsule.
233#[derive(Clone, Debug, PartialEq, Eq)]
234pub struct CapsulePush {
235    /// Repository name.
236    pub repo: String,
237    /// Remote name or URL label.
238    pub remote: String,
239    /// Commit hash that was pushed.
240    pub hash: String,
241}
242
243/// Planned `repos.toml` pin update.
244#[derive(Clone, Debug, PartialEq, Eq)]
245pub struct PinPlanEntry {
246    /// Repository name.
247    pub repo: String,
248    /// Current pinned commit.
249    pub current_commit: String,
250    /// New commit to pin.
251    pub new_commit: String,
252    /// Whether the new commit exists on the upstream remote.
253    pub pushed_commit_exists: bool,
254}
255
256/// One placement-plan row.
257#[derive(Clone, Debug, PartialEq, Eq)]
258pub struct PlacedJob {
259    /// Job label such as `edit`, `validation`, `docs`, or `capsule-assembly`.
260    pub label: String,
261    /// Site used for the job.
262    pub site: JobSite,
263}
264
265impl PlacedJob {
266    /// Builds a placement row.
267    pub fn new(label: impl Into<String>, site: JobSite) -> Self {
268        Self {
269            label: label.into(),
270            site,
271        }
272    }
273}
274
275/// F6-style fairness facet attached to a capsule.
276#[derive(Clone, Debug, PartialEq, Eq)]
277pub struct CapsuleFacet {
278    /// Facet label.
279    pub label: String,
280    /// Evidence summary.
281    pub evidence: String,
282    /// Confidence token.
283    pub confidence: String,
284}
285
286/// Deterministic review output for a Change Capsule.
287#[derive(Clone, Debug, PartialEq, Eq)]
288pub struct CapsuleReview {
289    /// Whether the capsule is accepted.
290    pub accepted: bool,
291    /// Original cassette content hash.
292    pub content_hash: String,
293    /// Replay-computed content hash.
294    pub replay_content_hash: String,
295    /// Repositories previewed before pin edits.
296    pub preview_repos: Vec<String>,
297    /// Review failures.
298    pub failure_reasons: Vec<String>,
299}
300
301/// Reviews a capsule against replay, placement, conformance, docs, and pin rules.
302pub fn review_change_capsule(capsule: &ChangeCapsule) -> Result<CapsuleReview> {
303    let replay_content_hash = capsule.cassette.replay_content_hash()?;
304    let mut failure_reasons = Vec::new();
305    if capsule.cassette.content_hash() != replay_content_hash {
306        failure_reasons.push("cassette replay content hash mismatch".to_owned());
307    }
308    check_jobs(&capsule.validations, &mut failure_reasons);
309    check_jobs(&capsule.docs_runs, &mut failure_reasons);
310    check_placements(&capsule.placement_plan, &mut failure_reasons);
311    check_generated_docs(&capsule.generated_artifacts, &mut failure_reasons);
312    check_generated_docs(&capsule.site_changes, &mut failure_reasons);
313    check_pins(&capsule.pin_plan, &mut failure_reasons);
314
315    Ok(CapsuleReview {
316        accepted: failure_reasons.is_empty(),
317        content_hash: capsule.cassette.content_hash().to_owned(),
318        replay_content_hash,
319        preview_repos: preview_repos(capsule),
320        failure_reasons,
321    })
322}
323
324/// Builds a deterministic capsule fixture used by agents and view tests.
325pub fn fake_change_capsule() -> Result<ChangeCapsule> {
326    let node = Symbol::qualified("atelier/agent", "change-capsule");
327    let cassette = DevCassette::from_events(
328        Symbol::qualified("atelier/dev", "change-capsule-fixture"),
329        vec![
330            DevEvent::edit(node.clone(), summary("Patch capsule model"))?,
331            DevEvent::validate(node.clone(), summary("cargo test change_capsule"))?,
332            DevEvent::new(
333                "docs",
334                node.clone(),
335                LatencyClass::OfflineRender,
336                summary("simdoc check current"),
337            )?,
338            DevEvent::new(
339                "pin",
340                node.clone(),
341                LatencyClass::Interactive,
342                summary("pushed commit exists before pin"),
343            )?,
344            DevEvent::new(
345                "reflect",
346                node,
347                LatencyClass::OfflineRender,
348                summary("F6 facet records risk and rollback"),
349            )?,
350        ],
351    )?;
352
353    Ok(ChangeCapsule {
354        id: Symbol::qualified("atelier/capsule", "fixture"),
355        scope: CapsuleScope {
356            repos: vec!["sim-agent-net".to_owned(), "sim-tooling".to_owned()],
357            targets: vec![
358                "crates/sim-lib-agent/src/atelier/capsule.rs".to_owned(),
359                "src/atelier/capsule.rs".to_owned(),
360            ],
361        },
362        patches: vec![
363            CapsulePatch::new(
364                "sim-agent-net",
365                "crates/sim-lib-agent/src/atelier/capsule.rs",
366                "agent capsule model",
367            ),
368            CapsulePatch::new("sim-tooling", "src/atelier/capsule.rs", "capsule cache"),
369        ],
370        generated_artifacts: vec![GeneratedArtifact::generated(
371            "sim-tooling",
372            "docs/generated/contract.md",
373            "xtask simdoc",
374        )],
375        validations: vec![CapsuleJob::validation(
376            "agent-capsule-tests",
377            "cargo test -p sim-lib-agent change_capsule",
378        )],
379        docs_runs: vec![CapsuleJob::docs(
380            "simdoc-agent-net",
381            "cargo run -p xtask -- simdoc --check",
382        )],
383        commits: vec![CapsuleCommit {
384            repo: "sim-agent-net".to_owned(),
385            hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_owned(),
386        }],
387        pushes: vec![CapsulePush {
388            repo: "sim-agent-net".to_owned(),
389            remote: "origin".to_owned(),
390            hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_owned(),
391        }],
392        pin_plan: vec![PinPlanEntry {
393            repo: "sim-agent-net".to_owned(),
394            current_commit: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_owned(),
395            new_commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_owned(),
396            pushed_commit_exists: true,
397        }],
398        site_changes: vec![GeneratedArtifact::generated(
399            "repo-docs",
400            "docs/site/repos.md",
401            "simctl site",
402        )],
403        risks: vec!["review capsule scope before pinning".to_owned()],
404        rollback_notes: vec!["reset pin to previous commit and rerun site".to_owned()],
405        placement_plan: vec![
406            PlacedJob::new("edit", JobSite::LocalCoroutine),
407            PlacedJob::new("capsule-assembly", JobSite::LocalCoroutine),
408            PlacedJob::new("validation", JobSite::Process),
409            PlacedJob::new("docs", JobSite::Process),
410            PlacedJob::new("pin-plan", JobSite::LocalCoroutine),
411        ],
412        cassette,
413        fairness: CapsuleFacet {
414            label: "F6 trade-off".to_owned(),
415            evidence: "validation, docs, pin, and rollback evidence recorded".to_owned(),
416            confidence: "0.92".to_owned(),
417        },
418    })
419}
420
421fn check_jobs(jobs: &[CapsuleJob], failure_reasons: &mut Vec<String>) {
422    for job in jobs {
423        if !job.site.can_run_validation() {
424            failure_reasons.push(format!(
425                "{} job {} must run on process or fabric site",
426                job.kind.as_str(),
427                job.label
428            ));
429        }
430        if job.site.realize_operation() != "realize" {
431            failure_reasons.push(format!(
432                "{} job {} is not realized",
433                job.kind.as_str(),
434                job.label
435            ));
436        }
437        if job.outcome == CapsuleJobOutcome::Failed {
438            failure_reasons.push(format!("{} job {} failed", job.kind.as_str(), job.label));
439        }
440    }
441}
442
443fn check_placements(placements: &[PlacedJob], failure_reasons: &mut Vec<String>) {
444    for label in ["edit", "capsule-assembly"] {
445        let local = placements
446            .iter()
447            .any(|job| job.label == label && job.site == JobSite::LocalCoroutine);
448        if !local {
449            failure_reasons.push(format!("{label} must stay on the local coroutine site"));
450        }
451    }
452    for label in ["validation", "docs"] {
453        let realized = placements
454            .iter()
455            .any(|job| job.label == label && job.site.can_run_validation());
456        if !realized {
457            failure_reasons.push(format!("{label} must be placed on process or fabric site"));
458        }
459    }
460}
461
462fn check_generated_docs(artifacts: &[GeneratedArtifact], failure_reasons: &mut Vec<String>) {
463    for artifact in artifacts {
464        if artifact.generated_public_doc && artifact.hand_edited {
465            failure_reasons.push(format!(
466                "generated public doc {}:{} must be regenerated, not hand-edited",
467                artifact.repo, artifact.path
468            ));
469        }
470    }
471}
472
473fn check_pins(pins: &[PinPlanEntry], failure_reasons: &mut Vec<String>) {
474    for pin in pins {
475        if !pin.pushed_commit_exists {
476            failure_reasons.push(format!(
477                "pin plan for {} requires an existing pushed upstream commit",
478                pin.repo
479            ));
480        }
481    }
482}
483
484fn preview_repos(capsule: &ChangeCapsule) -> Vec<String> {
485    let mut repos = BTreeSet::new();
486    repos.extend(capsule.scope.repos.iter().cloned());
487    repos.extend(capsule.pin_plan.iter().map(|pin| pin.repo.clone()));
488    repos.into_iter().collect()
489}
490
491fn summary(text: &str) -> Expr {
492    Expr::Map(vec![(
493        Expr::Symbol(Symbol::new("summary")),
494        Expr::String(text.to_owned()),
495    )])
496}