Files
huskies/server/examples/pipeline_state_sketch_statig.rs
T
Timmy f7d69cde50 sketch(520): typed pipeline state machine — bare and statig versions
Two parallel scratch experiments under server/examples/ exploring the
typed Rust state machine that should replace huskies's current
stringly-typed CRDT representation (story 520).

  - pipeline_state_sketch_bare.rs   — hand-rolled, plain enums + match
  - pipeline_state_sketch_statig.rs — using the statig crate

Both sketches:
  - Define the same Stage enum (Backlog, Coding, Qa, Merge, Done, Archived)
  - Define ArchiveReason (subsumes refactor 436's blocked/merge_failure/review_hold)
  - Define ExecutionState (per-node, separate from synced Stage) — bare only
  - Define PipelineEvent and the valid transitions
  - Make bug 519 unrepresentable: Stage::Merge requires NonZeroU32 commits_ahead
  - Make bug 502 unrepresentable: Coder agents can't be assigned to Merge state
  - Have happy-path tests, retry-loop tests, and invalid-transition tests

Differences:
  - Bare uses pure pattern matching, no framework. ~720 lines.
  - Statig uses #[state_machine] proc macro and gets free hierarchical
    states via the `active` superstate that factors out the cross-cutting
    Block / ReviewHold / Abandon / Supersede transitions across the four
    active stages. ~440 lines, 11 passing tests.

Run with:
  cargo run  --example pipeline_state_sketch_bare   -p huskies
  cargo run  --example pipeline_state_sketch_statig -p huskies
  cargo test --example pipeline_state_sketch_bare   -p huskies
  cargo test --example pipeline_state_sketch_statig -p huskies

Adds statig 0.3 as a dev-dependency in server/Cargo.toml. Cargo.lock
updated to include statig + statig-macro and their transitive deps.

Not wired into the main codebase. Once we agree on which version to
adopt, story 520 promotes the chosen sketch into a real
server/src/pipeline_state.rs module.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:03:07 +01:00

533 lines
18 KiB
Rust

//! Pipeline state machine — design sketch (story 520) — STATIG version.
//!
//! Parallel to `pipeline_state_sketch_bare.rs`. Same domain types, same
//! transitions, same event semantics — but the state machine is built using
//! the `statig` crate (https://crates.io/crates/statig) instead of being
//! hand-rolled.
//!
//! Run with:
//! cargo run --example pipeline_state_sketch_statig -p huskies
//! Test with:
//! cargo test --example pipeline_state_sketch_statig -p huskies
//!
//! Why both versions?
//!
//! - The **bare** version shows that plain Rust enums + a transition function
//! are *enough* to make impossible states unrepresentable. No framework.
//! - The **statig** version shows what we'd gain by adopting a state-machine
//! crate: hierarchical states (the `active` superstate factors out the
//! cross-cutting Block/ReviewHold/Abandon/Supersede transitions, which the
//! bare version had to duplicate inline with `|` patterns), generated
//! `State` enum with type-safe data-carrying constructors, and stateful
//! `handle(&event)` dispatch. Type safety is preserved either way:
//! `State::merge(BranchName, NonZeroU32)` requires both args at the
//! constructor, just like `Stage::Merge { feature_branch, commits_ahead }`
//! in the bare version.
//!
//! Trade-off: statig adds a dependency and a proc-macro layer, which makes
//! the code harder to read for someone unfamiliar with the crate. The
//! framework-free version is more transparent but requires manual
//! pattern-matching and inline duplication for cross-cutting transitions.
use chrono::{DateTime, Utc};
use statig::prelude::*;
use std::num::NonZeroU32;
// ── Newtypes (same as bare version) ──────────────────────────────────────────
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct StoryId(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct BranchName(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct GitSha(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AgentName(pub String);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct NodePubkey(pub [u8; 32]);
// ── Archive reason (same as bare version) ────────────────────────────────────
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ArchiveReason {
Completed,
Abandoned,
Superseded { by: StoryId },
Blocked { reason: String },
MergeFailed { reason: String },
ReviewHeld { reason: String },
}
// ── Pipeline events (same as bare version) ───────────────────────────────────
#[derive(Debug, Clone)]
pub enum PipelineEvent {
DepsMet,
GatesStarted,
GatesPassed {
feature_branch: BranchName,
commits_ahead: NonZeroU32,
},
GatesFailed {
reason: String,
},
QaSkipped {
feature_branch: BranchName,
commits_ahead: NonZeroU32,
},
MergeSucceeded {
merge_commit: GitSha,
},
MergeFailedFinal {
reason: String,
},
Accepted,
Block {
reason: String,
},
Unblock,
Abandon,
Supersede {
by: StoryId,
},
ReviewHold {
reason: String,
},
}
// ── The state machine ────────────────────────────────────────────────────────
//
// statig requires a "context" struct (the `Self` of the impl block). For us
// it's empty — all per-state data lives ON the state itself, carried forward
// by the auto-generated `State::xxx(...)` constructors.
#[derive(Default)]
pub struct PipelineMachine;
#[state_machine(
initial = "State::backlog()",
state(derive(Debug, Clone, PartialEq, Eq))
)]
impl PipelineMachine {
// ── Active stages: backlog, coding, qa, merge ────────────────────────
//
// Each is a child of the `active` superstate, which handles the
// cross-cutting transitions (Block / ReviewHold / Abandon / Supersede)
// exactly once instead of being duplicated per state.
#[state(superstate = "active")]
fn backlog(event: &PipelineEvent) -> Response<State> {
match event {
PipelineEvent::DepsMet => Transition(State::coding()),
_ => Super, // defer to `active` (and ultimately to "unhandled")
}
}
#[state(superstate = "active")]
fn coding(event: &PipelineEvent) -> Response<State> {
match event {
PipelineEvent::GatesStarted => Transition(State::qa()),
PipelineEvent::QaSkipped {
feature_branch,
commits_ahead,
} => Transition(State::merge(feature_branch.clone(), *commits_ahead)),
_ => Super,
}
}
#[state(superstate = "active")]
fn qa(event: &PipelineEvent) -> Response<State> {
match event {
PipelineEvent::GatesPassed {
feature_branch,
commits_ahead,
} => Transition(State::merge(feature_branch.clone(), *commits_ahead)),
PipelineEvent::GatesFailed { .. } => Transition(State::coding()),
_ => Super,
}
}
#[state(superstate = "active")]
fn merge(
_feature_branch: &mut BranchName,
_commits_ahead: &mut NonZeroU32,
event: &PipelineEvent,
) -> Response<State> {
// Note: the type signature of this state function REQUIRES both
// _feature_branch and _commits_ahead. There is no way to construct
// a Merge state without them. NonZeroU32 makes "merge with zero
// commits ahead" structurally unrepresentable (bug 519 fixed by
// construction, same as the bare version).
//
// The fields are prefixed with `_` because this state function only
// transitions forward and doesn't read them — but they're available
// to inspect via the State::Merge variant generated by the macro.
match event {
PipelineEvent::MergeSucceeded { merge_commit } => Transition(State::done(
Utc::now(),
merge_commit.clone(),
)),
PipelineEvent::MergeFailedFinal { reason } => Transition(State::archived(
Utc::now(),
ArchiveReason::MergeFailed {
reason: reason.clone(),
},
)),
_ => Super,
}
}
// ── Cross-cutting superstate ─────────────────────────────────────────
//
// This is the statig payoff: ONE place defines what Block/ReviewHold/
// Abandon/Supersede do across all four active stages. The bare version
// had to duplicate this with `|` patterns. Adding a new active stage
// here means just adding it as a child of `active`; the cross-cutting
// transitions come for free.
#[superstate]
fn active(event: &PipelineEvent) -> Response<State> {
let now = Utc::now();
match event {
PipelineEvent::Block { reason } => Transition(State::archived(
now,
ArchiveReason::Blocked {
reason: reason.clone(),
},
)),
PipelineEvent::ReviewHold { reason } => Transition(State::archived(
now,
ArchiveReason::ReviewHeld {
reason: reason.clone(),
},
)),
PipelineEvent::Abandon => {
Transition(State::archived(now, ArchiveReason::Abandoned))
}
PipelineEvent::Supersede { by } => Transition(State::archived(
now,
ArchiveReason::Superseded { by: by.clone() },
)),
_ => Handled, // unhandled events are silently ignored
}
}
// ── Done is special: it's not a child of `active` because Block and ──
// ── ReviewHold are NOT valid from Done (per the bare version's rules).
// ── Abandon and Supersede ARE valid, so we have to handle them inline.
#[state]
fn done(
merged_at: &mut DateTime<Utc>,
merge_commit: &mut GitSha,
event: &PipelineEvent,
) -> Response<State> {
let now = Utc::now();
let _ = merged_at; // currently unused; available for queries
let _ = merge_commit;
match event {
PipelineEvent::Accepted => {
Transition(State::archived(now, ArchiveReason::Completed))
}
PipelineEvent::Abandon => {
Transition(State::archived(now, ArchiveReason::Abandoned))
}
PipelineEvent::Supersede { by } => Transition(State::archived(
now,
ArchiveReason::Superseded { by: by.clone() },
)),
_ => Handled,
}
}
// ── Archived is terminal except for Unblock from Blocked → Backlog ───
#[state]
fn archived(
archived_at: &mut DateTime<Utc>,
reason: &mut ArchiveReason,
event: &PipelineEvent,
) -> Response<State> {
let _ = archived_at;
match event {
PipelineEvent::Unblock => {
if matches!(reason, ArchiveReason::Blocked { .. }) {
Transition(State::backlog())
} else {
Handled // unblock only valid from Blocked
}
}
_ => Handled,
}
}
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn nz(n: u32) -> NonZeroU32 {
NonZeroU32::new(n).unwrap()
}
fn fb(name: &str) -> BranchName {
BranchName(name.to_string())
}
fn sha(s: &str) -> GitSha {
GitSha(s.to_string())
}
// ── Happy path ─────────────────────────────────────────────────────────
#[test]
fn happy_path_backlog_through_done() {
let mut sm = PipelineMachine.state_machine();
assert!(matches!(sm.state(), State::Backlog {}));
sm.handle(&PipelineEvent::DepsMet);
assert!(matches!(sm.state(), State::Coding {}));
sm.handle(&PipelineEvent::QaSkipped {
feature_branch: fb("feature/story-1"),
commits_ahead: nz(3),
});
assert!(matches!(sm.state(), State::Merge { .. }));
sm.handle(&PipelineEvent::MergeSucceeded {
merge_commit: sha("abc123"),
});
assert!(matches!(sm.state(), State::Done { .. }));
sm.handle(&PipelineEvent::Accepted);
assert!(matches!(
sm.state(),
State::Archived {
reason: ArchiveReason::Completed,
..
}
));
}
#[test]
fn qa_retry_loop() {
let mut sm = PipelineMachine.state_machine();
sm.handle(&PipelineEvent::DepsMet);
sm.handle(&PipelineEvent::GatesStarted);
assert!(matches!(sm.state(), State::Qa {}));
sm.handle(&PipelineEvent::GatesFailed {
reason: "tests failed".into(),
});
assert!(matches!(sm.state(), State::Coding {}));
}
// ── Bug 519 unrepresentability: Merge with zero commits ahead ──────────
#[test]
fn merge_with_zero_commits_is_unrepresentable() {
// Identical to the bare version: NonZeroU32::new(0) returns None,
// so a State::merge(branch, ZERO) literally cannot be constructed.
assert!(NonZeroU32::new(0).is_none());
}
// ── Cross-cutting Block from any active stage (superstate proves it) ───
#[test]
fn block_from_backlog_via_superstate() {
let mut sm = PipelineMachine.state_machine();
sm.handle(&PipelineEvent::Block {
reason: "stuck".into(),
});
assert!(matches!(
sm.state(),
State::Archived {
reason: ArchiveReason::Blocked { .. },
..
}
));
}
#[test]
fn block_from_coding_via_superstate() {
let mut sm = PipelineMachine.state_machine();
sm.handle(&PipelineEvent::DepsMet);
sm.handle(&PipelineEvent::Block {
reason: "stuck".into(),
});
assert!(matches!(
sm.state(),
State::Archived {
reason: ArchiveReason::Blocked { .. },
..
}
));
}
#[test]
fn block_from_qa_via_superstate() {
let mut sm = PipelineMachine.state_machine();
sm.handle(&PipelineEvent::DepsMet);
sm.handle(&PipelineEvent::GatesStarted);
sm.handle(&PipelineEvent::Block {
reason: "stuck".into(),
});
assert!(matches!(
sm.state(),
State::Archived {
reason: ArchiveReason::Blocked { .. },
..
}
));
}
#[test]
fn block_from_merge_via_superstate() {
let mut sm = PipelineMachine.state_machine();
sm.handle(&PipelineEvent::DepsMet);
sm.handle(&PipelineEvent::QaSkipped {
feature_branch: fb("f"),
commits_ahead: nz(1),
});
sm.handle(&PipelineEvent::Block {
reason: "stuck".into(),
});
assert!(matches!(
sm.state(),
State::Archived {
reason: ArchiveReason::Blocked { .. },
..
}
));
}
// ── Block from Done is NOT valid (Done isn't a child of `active`) ──────
#[test]
fn block_from_done_is_ignored() {
let mut sm = PipelineMachine.state_machine();
sm.handle(&PipelineEvent::DepsMet);
sm.handle(&PipelineEvent::QaSkipped {
feature_branch: fb("f"),
commits_ahead: nz(1),
});
sm.handle(&PipelineEvent::MergeSucceeded {
merge_commit: sha("abc"),
});
// Now in Done. Block should NOT transition us anywhere.
sm.handle(&PipelineEvent::Block {
reason: "stuck".into(),
});
assert!(matches!(sm.state(), State::Done { .. }));
}
// ── Abandon from Done IS valid (handled inline in done()) ──────────────
#[test]
fn abandon_from_done_works() {
let mut sm = PipelineMachine.state_machine();
sm.handle(&PipelineEvent::DepsMet);
sm.handle(&PipelineEvent::QaSkipped {
feature_branch: fb("f"),
commits_ahead: nz(1),
});
sm.handle(&PipelineEvent::MergeSucceeded {
merge_commit: sha("abc"),
});
sm.handle(&PipelineEvent::Abandon);
assert!(matches!(
sm.state(),
State::Archived {
reason: ArchiveReason::Abandoned,
..
}
));
}
// ── Unblock from Archived(Blocked) → Backlog ───────────────────────────
#[test]
fn unblock_returns_to_backlog() {
let mut sm = PipelineMachine.state_machine();
sm.handle(&PipelineEvent::Block {
reason: "test".into(),
});
assert!(matches!(
sm.state(),
State::Archived {
reason: ArchiveReason::Blocked { .. },
..
}
));
sm.handle(&PipelineEvent::Unblock);
assert!(matches!(sm.state(), State::Backlog {}));
}
#[test]
fn unblock_from_review_held_does_nothing() {
// Unblock is specifically for Blocked, not for any Archived variant.
let mut sm = PipelineMachine.state_machine();
sm.handle(&PipelineEvent::ReviewHold {
reason: "TBD".into(),
});
// Now in Archived(ReviewHeld). Unblock should NOT transition.
sm.handle(&PipelineEvent::Unblock);
assert!(matches!(
sm.state(),
State::Archived {
reason: ArchiveReason::ReviewHeld { .. },
..
}
));
}
}
// ── main: a quick interactive demo ───────────────────────────────────────────
fn main() {
println!("─── Pipeline state machine sketch (story 520) — STATIG version ───\n");
let mut sm = PipelineMachine.state_machine();
println!("Initial: {:?}\n", sm.state());
println!("→ DepsMet");
sm.handle(&PipelineEvent::DepsMet);
println!(" state: {:?}\n", sm.state());
println!("→ QaSkipped");
sm.handle(&PipelineEvent::QaSkipped {
feature_branch: BranchName("feature/story-100".into()),
commits_ahead: NonZeroU32::new(3).unwrap(),
});
println!(" state: {:?}\n", sm.state());
println!("→ MergeSucceeded");
sm.handle(&PipelineEvent::MergeSucceeded {
merge_commit: GitSha("abc1234".into()),
});
println!(" state: {:?}\n", sm.state());
println!("→ Accepted");
sm.handle(&PipelineEvent::Accepted);
println!(" state: {:?}\n", sm.state());
println!("─── Trying invalid transition: Done → Unblock ───");
let mut sm2 = PipelineMachine.state_machine();
sm2.handle(&PipelineEvent::DepsMet);
sm2.handle(&PipelineEvent::QaSkipped {
feature_branch: BranchName("feature/story-101".into()),
commits_ahead: NonZeroU32::new(2).unwrap(),
});
sm2.handle(&PipelineEvent::MergeSucceeded {
merge_commit: GitSha("def5678".into()),
});
println!(" before Unblock: {:?}", sm2.state());
sm2.handle(&PipelineEvent::Unblock); // silently ignored — no transition
println!(" after Unblock: {:?} (no change — Unblock is a no-op from Done)", sm2.state());
}