1use std::collections::BTreeSet;
4
5use sim_kernel::{Expr, Result, Symbol};
6use sim_lib_stream_core::{DevCassette, DevEvent, LatencyClass};
7
8#[derive(Clone, Debug, PartialEq, Eq)]
10pub struct ChangeCapsule {
11 pub id: Symbol,
13 pub scope: CapsuleScope,
15 pub patches: Vec<CapsulePatch>,
17 pub generated_artifacts: Vec<GeneratedArtifact>,
19 pub validations: Vec<CapsuleJob>,
21 pub docs_runs: Vec<CapsuleJob>,
23 pub commits: Vec<CapsuleCommit>,
25 pub pushes: Vec<CapsulePush>,
27 pub pin_plan: Vec<PinPlanEntry>,
29 pub site_changes: Vec<GeneratedArtifact>,
31 pub risks: Vec<String>,
33 pub rollback_notes: Vec<String>,
35 pub placement_plan: Vec<PlacedJob>,
37 pub cassette: DevCassette,
39 pub fairness: CapsuleFacet,
41}
42
43#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct CapsuleScope {
46 pub repos: Vec<String>,
48 pub targets: Vec<String>,
50}
51
52#[derive(Clone, Debug, PartialEq, Eq)]
54pub struct CapsulePatch {
55 pub repo: String,
57 pub path: String,
59 pub summary: String,
61}
62
63impl CapsulePatch {
64 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#[derive(Clone, Debug, PartialEq, Eq)]
80pub struct GeneratedArtifact {
81 pub repo: String,
83 pub path: String,
85 pub generator: String,
87 pub generated_public_doc: bool,
89 pub hand_edited: bool,
91}
92
93impl GeneratedArtifact {
94 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#[derive(Clone, Debug, PartialEq, Eq)]
112pub struct CapsuleJob {
113 pub kind: CapsuleJobKind,
115 pub label: String,
117 pub command: String,
119 pub site: JobSite,
121 pub outcome: CapsuleJobOutcome,
123 pub log_path: String,
125}
126
127impl CapsuleJob {
128 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 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 pub fn failed(mut self) -> Self {
156 self.outcome = CapsuleJobOutcome::Failed;
157 self
158 }
159}
160
161#[derive(Clone, Copy, Debug, PartialEq, Eq)]
163pub enum CapsuleJobKind {
164 Validation,
166 Docs,
168}
169
170impl CapsuleJobKind {
171 pub fn as_str(self) -> &'static str {
173 match self {
174 Self::Validation => "validation",
175 Self::Docs => "docs",
176 }
177 }
178}
179
180#[derive(Clone, Copy, Debug, PartialEq, Eq)]
182pub enum CapsuleJobOutcome {
183 Passed,
185 Failed,
187}
188
189#[derive(Clone, Copy, Debug, PartialEq, Eq)]
191pub enum JobSite {
192 LocalCoroutine,
194 Process,
196 Fabric,
198}
199
200impl JobSite {
201 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 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#[derive(Clone, Debug, PartialEq, Eq)]
225pub struct CapsuleCommit {
226 pub repo: String,
228 pub hash: String,
230}
231
232#[derive(Clone, Debug, PartialEq, Eq)]
234pub struct CapsulePush {
235 pub repo: String,
237 pub remote: String,
239 pub hash: String,
241}
242
243#[derive(Clone, Debug, PartialEq, Eq)]
245pub struct PinPlanEntry {
246 pub repo: String,
248 pub current_commit: String,
250 pub new_commit: String,
252 pub pushed_commit_exists: bool,
254}
255
256#[derive(Clone, Debug, PartialEq, Eq)]
258pub struct PlacedJob {
259 pub label: String,
261 pub site: JobSite,
263}
264
265impl PlacedJob {
266 pub fn new(label: impl Into<String>, site: JobSite) -> Self {
268 Self {
269 label: label.into(),
270 site,
271 }
272 }
273}
274
275#[derive(Clone, Debug, PartialEq, Eq)]
277pub struct CapsuleFacet {
278 pub label: String,
280 pub evidence: String,
282 pub confidence: String,
284}
285
286#[derive(Clone, Debug, PartialEq, Eq)]
288pub struct CapsuleReview {
289 pub accepted: bool,
291 pub content_hash: String,
293 pub replay_content_hash: String,
295 pub preview_repos: Vec<String>,
297 pub failure_reasons: Vec<String>,
299}
300
301pub 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
324pub 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}