swactor/crates/simulation/src/ci/local_sim.rs
Zachery Aaron Shores-Chmielewski 2a6e47bf4b feat: mvp ci workflow (#42)
Not working too well and difficult to track, but has a skeleton there to work from.


Signed-off-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
2026-02-16 15:57:55 +00:00

555 lines
20 KiB
Rust

//! Local CI simulation: deterministic, round-based execution of the local
//! coordinator's queue + scheduling logic.
//!
//! No actors, no IO. Models the one-at-a-time scheduling with supersede.
//! Follows the same pattern as `sim.rs`.
use std::collections::{HashMap, VecDeque};
use swactor_ci::pipeline::PipelineExecution;
use swactor_ci::yaml::{self, CiYaml};
use swactor_ci::{JobId, JobStatus, PipelineId, PipelineStatus, StatusUpdate, WebhookEvent};
// ─── Simulation Config ──────────────────────────────────────────────────────
/// Configuration for a local CI simulation run.
#[derive(Debug, Clone)]
pub struct LocalSimConfig {
pub name: String,
pub num_rounds: usize,
pub ci_yaml: String,
pub webhook_schedule: Vec<(usize, WebhookEvent)>,
/// Rounds a job takes to execute.
pub job_duration: usize,
/// Force specific jobs to fail: (round, job_name_substring).
pub job_failure_schedule: Vec<(usize, String)>,
}
impl Default for LocalSimConfig {
fn default() -> Self {
Self {
name: "local-sim".into(),
num_rounds: 50,
ci_yaml: String::new(),
webhook_schedule: Vec::new(),
job_duration: 3,
job_failure_schedule: Vec::new(),
}
}
}
// ─── Simulation Trace ───────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub enum LocalSimEvent {
WebhookReceived { commit_sha: String },
PipelineCreated { pipeline_id: PipelineId, name: String },
PipelineSuperseded { pipeline_id: PipelineId },
PipelineCompleted { pipeline_id: PipelineId, status: PipelineStatus },
JobStarted { job_id: JobId },
JobCompleted { job_id: JobId, passed: bool },
JobSkipped { job_id: JobId },
}
#[derive(Debug, Clone)]
pub struct LocalSimSnapshot {
pub queued_pipelines: usize,
pub active_pipeline: Option<PipelineId>,
pub running_job: Option<JobId>,
pub completed_pipelines: usize,
}
#[derive(Debug, Clone)]
pub struct LocalSimTrace {
pub name: String,
pub events: Vec<(usize, LocalSimEvent)>,
pub snapshots: Vec<LocalSimSnapshot>,
pub status_updates: Vec<StatusUpdate>,
pub num_rounds: usize,
pub final_pipelines: Vec<PipelineExecution>,
}
// ─── Simulation State ───────────────────────────────────────────────────────
struct RunningJob {
job_id: JobId,
started_round: usize,
}
/// Run a local CI simulation and return the trace.
pub fn run_simulation(config: LocalSimConfig) -> LocalSimTrace {
let ci_yaml: CiYaml =
yaml::parse_ci_yaml(&config.ci_yaml).expect("LocalSimConfig.ci_yaml must be valid YAML");
let mut events: Vec<(usize, LocalSimEvent)> = Vec::new();
let mut snapshots: Vec<LocalSimSnapshot> = Vec::new();
let mut all_status_updates: Vec<StatusUpdate> = Vec::new();
// Coordinator state.
let mut pipelines: HashMap<PipelineId, PipelineExecution> = HashMap::new();
let mut next_pipeline_id: u64 = 1;
let mut queue: VecDeque<PipelineId> = VecDeque::new();
let mut active_pipeline: Option<PipelineId> = None;
let mut running_job: Option<RunningJob> = None;
let mut completed_pipelines: Vec<PipelineExecution> = Vec::new();
for round in 1..=config.num_rounds {
// 1. Inject webhook events for this round.
for (sched_round, event) in &config.webhook_schedule {
if *sched_round == round {
events.push((
round,
LocalSimEvent::WebhookReceived {
commit_sha: event.commit_sha.clone(),
},
));
let matched = yaml::matching_pipelines(&ci_yaml, event);
for pipeline_name in matched {
let pipeline_id = PipelineId(next_pipeline_id);
next_pipeline_id += 1;
let pipeline_def = &ci_yaml.pipelines[&pipeline_name];
let job_defs: Vec<_> = pipeline_def
.jobs
.iter()
.map(|(name, def)| yaml::to_job_definition(name, def))
.collect();
let pipeline = PipelineExecution::new(
pipeline_id,
pipeline_name.clone(),
event.repo_owner.clone(),
event.repo_name.clone(),
event.commit_sha.clone(),
event.branch.clone(),
job_defs,
);
events.push((
round,
LocalSimEvent::PipelineCreated {
pipeline_id,
name: pipeline_name.clone(),
},
));
all_status_updates.push(StatusUpdate {
repo_owner: event.repo_owner.clone(),
repo_name: event.repo_name.clone(),
commit_sha: event.commit_sha.clone(),
state: "pending".into(),
context: format!("ci/{pipeline_name}"),
description: format!("Pipeline '{pipeline_name}' is pending"),
target_url: None,
});
pipelines.insert(pipeline_id, pipeline);
// Enqueue with supersede logic.
let supersede_idx = queue.iter().position(|&qid| {
pipelines
.get(&qid)
.map(|p| p.branch == event.branch)
.unwrap_or(false)
});
if let Some(idx) = supersede_idx {
let old_id = queue[idx];
if let Some(old_pipeline) = pipelines.get_mut(&old_id) {
old_pipeline.status = PipelineStatus::Error {
reason: "superseded".into(),
};
let job_names: Vec<String> =
old_pipeline.jobs.keys().cloned().collect();
for name in job_names {
if old_pipeline.jobs[&name].status == JobStatus::Pending {
old_pipeline.set_job_status(&name, JobStatus::Skipped);
}
}
}
events.push((
round,
LocalSimEvent::PipelineSuperseded {
pipeline_id: old_id,
},
));
if let Some(old_pipeline) = pipelines.remove(&old_id) {
// Emit terminal status for superseded pipeline.
all_status_updates.push(StatusUpdate {
repo_owner: old_pipeline.repo_owner.clone(),
repo_name: old_pipeline.repo_name.clone(),
commit_sha: old_pipeline.commit_sha.clone(),
state: "error".into(),
context: format!("ci/{}", old_pipeline.pipeline_name),
description: "superseded".into(),
target_url: None,
});
completed_pipelines.push(old_pipeline);
}
queue[idx] = pipeline_id;
} else {
queue.push_back(pipeline_id);
}
}
}
}
// 2. Complete running job if it has reached duration.
if let Some(ref rj) = running_job {
if round - rj.started_round >= config.job_duration {
let job_id = rj.job_id.clone();
let should_fail = config
.job_failure_schedule
.iter()
.any(|(r, name_sub)| *r <= round && job_id.job_name.contains(name_sub.as_str()));
let passed = !should_fail;
if passed {
if let Some(pipeline) = pipelines.get_mut(&job_id.pipeline_id) {
pipeline.set_job_status(&job_id.job_name, JobStatus::Passed);
}
} else {
if let Some(pipeline) = pipelines.get_mut(&job_id.pipeline_id) {
pipeline.set_job_status(
&job_id.job_name,
JobStatus::Failed {
reason: "command failed".into(),
},
);
}
}
events.push((
round,
LocalSimEvent::JobCompleted {
job_id: job_id.clone(),
passed,
},
));
// Emit skipped events for any jobs that were skipped due to failure.
if !passed {
if let Some(pipeline) = pipelines.get(&job_id.pipeline_id) {
for (_name, job) in &pipeline.jobs {
if job.status == JobStatus::Skipped {
events.push((
round,
LocalSimEvent::JobSkipped {
job_id: job.job_id.clone(),
},
));
}
}
}
}
running_job = None;
}
}
// 3. Schedule next (one-at-a-time).
schedule_next(
&mut pipelines,
&mut queue,
&mut active_pipeline,
&mut running_job,
&mut completed_pipelines,
&mut events,
&mut all_status_updates,
round,
);
// 4. Snapshot.
snapshots.push(LocalSimSnapshot {
queued_pipelines: queue.len(),
active_pipeline,
running_job: running_job.as_ref().map(|rj| rj.job_id.clone()),
completed_pipelines: completed_pipelines.len(),
});
}
// Collect remaining active pipelines into final output.
let mut final_pipelines: Vec<PipelineExecution> = pipelines.into_values().collect();
final_pipelines.extend(completed_pipelines);
LocalSimTrace {
name: config.name,
events,
snapshots,
status_updates: all_status_updates,
num_rounds: config.num_rounds,
final_pipelines,
}
}
#[allow(clippy::too_many_arguments)]
fn schedule_next(
pipelines: &mut HashMap<PipelineId, PipelineExecution>,
queue: &mut VecDeque<PipelineId>,
active_pipeline: &mut Option<PipelineId>,
running_job: &mut Option<RunningJob>,
completed_pipelines: &mut Vec<PipelineExecution>,
events: &mut Vec<(usize, LocalSimEvent)>,
status_updates: &mut Vec<StatusUpdate>,
round: usize,
) {
// If a job is running, nothing to do.
if running_job.is_some() {
return;
}
// If we have an active pipeline, try eligible jobs.
if let Some(active_id) = *active_pipeline {
if let Some(pipeline) = pipelines.get(&active_id) {
let eligible = pipeline.eligible_jobs();
if !eligible.is_empty() {
let job_name = eligible[0].clone();
let job_id = JobId {
pipeline_id: active_id,
job_name: job_name.clone(),
};
// Mark as running.
if let Some(pipeline) = pipelines.get_mut(&active_id) {
if let Some(job) = pipeline.jobs.get_mut(&job_name) {
job.status = JobStatus::Running;
}
}
events.push((round, LocalSimEvent::JobStarted { job_id: job_id.clone() }));
*running_job = Some(RunningJob {
job_id,
started_round: round,
});
return;
}
// No eligible jobs — check terminal.
if pipeline.status.is_terminal() {
let pipeline = pipelines.remove(&active_id).unwrap();
let status = pipeline.status.clone();
status_updates.push(StatusUpdate {
repo_owner: pipeline.repo_owner.clone(),
repo_name: pipeline.repo_name.clone(),
commit_sha: pipeline.commit_sha.clone(),
state: pipeline.status.forgejo_state().into(),
context: format!("ci/{}", pipeline.pipeline_name),
description: format!(
"Pipeline '{}' {}",
pipeline.pipeline_name,
pipeline.status.forgejo_state()
),
target_url: None,
});
events.push((
round,
LocalSimEvent::PipelineCompleted {
pipeline_id: active_id,
status,
},
));
completed_pipelines.push(pipeline);
*active_pipeline = None;
// Recurse.
schedule_next(
pipelines,
queue,
active_pipeline,
running_job,
completed_pipelines,
events,
status_updates,
round,
);
return;
}
}
// Pipeline exists but no eligible jobs and not terminal — waiting.
return;
}
// No active pipeline — pop from queue.
if let Some(next_id) = queue.pop_front() {
*active_pipeline = Some(next_id);
schedule_next(
pipelines,
queue,
active_pipeline,
running_job,
completed_pipelines,
events,
status_updates,
round,
);
}
}
// ─── Properties ─────────────────────────────────────────────────────────────
/// At most one job running in any snapshot.
pub fn check_one_at_a_time(trace: &LocalSimTrace) -> bool {
trace
.snapshots
.iter()
.all(|s| s.running_job.is_some() as usize <= 1)
}
/// Superseded pipelines never have a Running job.
pub fn check_superseded_no_running(trace: &LocalSimTrace) -> bool {
let superseded: Vec<PipelineId> = trace
.events
.iter()
.filter_map(|(_, e)| match e {
LocalSimEvent::PipelineSuperseded { pipeline_id } => Some(*pipeline_id),
_ => None,
})
.collect();
let started_jobs: Vec<&JobId> = trace
.events
.iter()
.filter_map(|(_, e)| match e {
LocalSimEvent::JobStarted { job_id } => Some(job_id),
_ => None,
})
.collect();
for pid in &superseded {
if started_jobs
.iter()
.any(|jid| jid.pipeline_id == *pid)
{
return false;
}
}
true
}
/// All non-superseded pipelines reach terminal status.
pub fn check_termination(trace: &LocalSimTrace) -> bool {
let superseded: Vec<PipelineId> = trace
.events
.iter()
.filter_map(|(_, e)| match e {
LocalSimEvent::PipelineSuperseded { pipeline_id } => Some(*pipeline_id),
_ => None,
})
.collect();
for pipeline in &trace.final_pipelines {
if superseded.contains(&pipeline.pipeline_id) {
continue;
}
if !pipeline.status.is_terminal() {
return false;
}
}
true
}
/// Within a pipeline, jobs respect dependency order.
pub fn check_dag_ordering(trace: &LocalSimTrace) -> bool {
let mut started: HashMap<(u64, &str), usize> = HashMap::new();
let mut completed: HashMap<(u64, &str), usize> = HashMap::new();
for (round, event) in &trace.events {
match event {
LocalSimEvent::JobStarted { job_id } => {
started.insert(
(job_id.pipeline_id.0, job_id.job_name.as_str()),
*round,
);
}
LocalSimEvent::JobCompleted { job_id, .. } => {
completed.insert(
(job_id.pipeline_id.0, job_id.job_name.as_str()),
*round,
);
}
_ => {}
}
}
for pipeline in &trace.final_pipelines {
for (name, job) in &pipeline.jobs {
if let Some(&start_round) = started.get(&(pipeline.pipeline_id.0, name.as_str())) {
for dep in &job.definition.needs {
if let Some(&dep_complete_round) =
completed.get(&(pipeline.pipeline_id.0, dep.as_str()))
{
if dep_complete_round > start_round {
return false;
}
}
}
}
}
}
true
}
/// Different branches execute in queue order (FIFO).
pub fn check_fifo_order(trace: &LocalSimTrace) -> bool {
// Collect pipeline creation order and first job start per pipeline.
let mut creation_order: Vec<PipelineId> = Vec::new();
let mut first_start: HashMap<PipelineId, usize> = HashMap::new();
for (round, event) in &trace.events {
if let LocalSimEvent::PipelineCreated { pipeline_id, .. } = event {
creation_order.push(*pipeline_id);
}
if let LocalSimEvent::JobStarted { job_id } = event {
first_start
.entry(job_id.pipeline_id)
.or_insert(*round);
}
}
// For each pair of pipelines created in order, if both started, the earlier-created
// one should have started no later.
for i in 0..creation_order.len() {
for j in (i + 1)..creation_order.len() {
let pid_a = creation_order[i];
let pid_b = creation_order[j];
if let (Some(&start_a), Some(&start_b)) =
(first_start.get(&pid_a), first_start.get(&pid_b))
{
if start_a > start_b {
return false;
}
}
}
}
true
}
/// Every webhook produces a terminal status (success/failure/error).
pub fn check_all_webhooks_terminate(trace: &LocalSimTrace) -> bool {
let webhook_commits: Vec<&str> = trace
.events
.iter()
.filter_map(|(_, e)| match e {
LocalSimEvent::WebhookReceived { commit_sha } => Some(commit_sha.as_str()),
_ => None,
})
.collect();
for sha in webhook_commits {
let has_terminal = trace.status_updates.iter().any(|u| {
u.commit_sha == sha
&& (u.state == "success" || u.state == "failure" || u.state == "error")
});
if !has_terminal {
return false;
}
}
true
}