pub mod attention;
pub mod auth;
pub mod catalog;
pub mod flows;
pub mod hooks;
pub mod providers;
pub mod repos;
pub mod runs;
pub mod session_controls;
pub mod system;
pub mod usage;
pub mod waves;
pub mod worktrees;
pub mod ws;
#[cfg(test)]
pub(crate) mod test_helpers;
use crate::lfd::config::GitHubConfig;
use crate::lfd::http::dto::{
format_datetime, run_dto, CommitEntryDto, ErrorResponse, PullRequestDto, RepoWorkDto, WaveDto,
};
use crate::lfd::id::LfdId;
use crate::lfd::live_pr::{build_live_pr_snapshot, LivePrSnapshot};
use crate::lfd::queue::{project_queue_views, QueueRunView};
use crate::lfd::types::{Run, Wave};
use crate::lfdb::{SharedStore, StoreError};
use axum::http::StatusCode;
use axum::Json;
use std::collections::HashMap;
pub type ApiError = (StatusCode, Json<ErrorResponse>);
pub fn parse_lfd_id(value: &str, error_message: &'static str) -> Result<LfdId, ApiError> {
value
.parse::<LfdId>()
.map_err(|_| crate::lfd::http::api_error(StatusCode::BAD_REQUEST, error_message))
}
pub async fn resolve_wave_id(
state: &crate::lfd::http::HttpState,
value: &str,
) -> Result<crate::lfd::id::LfdId, (StatusCode, Json<ErrorResponse>)> {
if let Ok(id) = value.parse::<crate::lfd::id::LfdId>() {
return Ok(id);
}
let name = value.to_string();
let wave = state
.store
.get_wave_by_name(&name)
.await
.map_err(crate::lfd::http::map_store_error)?;
wave.map(|wave| wave.id().clone())
.ok_or_else(|| crate::lfd::http::api_error(StatusCode::NOT_FOUND, "wave not found"))
}
pub async fn build_wave_dtos(
store: &SharedStore,
github_config: &GitHubConfig,
waves: Vec<Wave>,
include_active_run: bool,
) -> Result<Vec<WaveDto>, StoreError> {
let mut views = Vec::with_capacity(waves.len());
for wave in waves {
views.push(build_wave_dto(store, github_config, wave, include_active_run).await?);
}
Ok(views)
}
pub async fn build_wave_dto(
store: &SharedStore,
github_config: &GitHubConfig,
wave: Wave,
include_active_run: bool,
) -> Result<WaveDto, StoreError> {
let stack_runs = store.list_stack_runs(wave.id()).await?;
let blocks = store.list_queue_blocks(wave.id()).await?;
let blocks_by_run = blocks
.into_iter()
.map(|block| (block.run_id.clone(), block))
.collect::<HashMap<_, _>>();
let flow_name = wave.primary_flow().clone();
let flow_repo = wave.repo().to_string();
let flow_steps = tokio::task::spawn_blocking(move || {
flows::load_flow_steps(&flow_name, std::path::Path::new(&flow_repo)).unwrap_or_default()
})
.await
.unwrap_or_default();
let mut repos = Vec::with_capacity(wave.repos.len());
let mut has_stale_pr_state = false;
for repo_work in &wave.repos {
let repo_runs: Vec<Run> = stack_runs
.iter()
.filter(|run| run.repo == repo_work.repo)
.cloned()
.collect();
let snapshot = build_live_pr_snapshot(store, github_config, &repo_runs).await?;
has_stale_pr_state |= snapshot.has_stale_pr_state();
let queue_views = project_queue_views(
&repo_runs,
|run| snapshot.state_for_run(run).cloned(),
&blocks_by_run,
);
let repo = repo_work.repo.clone();
let name = wave.name().clone();
let git_state = tokio::task::spawn_blocking(move || infer_wave_git_state(&repo, &name))
.await
.ok()
.flatten();
let (local_worktree, remote_branch, commits, diff_stat) = match git_state {
Some(state) => (
Some(state.worktree),
state.branch,
state.commits,
state.diff_stat,
),
None => (None, None, Vec::new(), None),
};
let latest_for_repo = repo_runs.iter().max_by_key(|run| run.started_at);
let pr = latest_for_repo.and_then(|run| {
run.pr.as_ref().map(|pr| PullRequestDto {
url: pr.url.clone(),
number: pr.number,
state: pr.state.clone(),
title: pr.title.clone(),
branch: pr.branch.clone(),
})
});
let active_run = if include_active_run {
latest_for_repo.map(|run| {
let live_pr_state = snapshot.state_for_run(run);
let pr_state_stale = snapshot.stale_for_run(run);
let queue_view = queue_views.get(&run.id);
run_dto(run.clone(), live_pr_state, pr_state_stale, queue_view)
})
} else {
None
};
repos.push(RepoWorkDto {
repo: repo_work.repo.clone(),
status: repo_work.status.as_str().to_string(),
iteration: repo_work.iteration,
local_worktree,
remote_branch,
commits,
diff_stat,
open_pr_count: snapshot.open_pr_count(),
stack_count: repo_runs.len() as u32,
active_run,
pr,
});
}
let wave_config = crate::engine::wave_config::read_wave_config(
std::path::Path::new(wave.repo()),
wave.name(),
);
Ok(WaveDto {
id: wave.id().to_string(),
object: "wave".to_string(),
name: wave.name().clone(),
primary_flow: wave.primary_flow().to_string(),
goal: wave.goal().to_string(),
metrics: wave.metrics().clone(),
direction: wave.direction().clone(),
area: wave.area().clone(),
agent: wave_config.as_ref().and_then(|config| config.agent.clone()),
step_agents: wave_config.and_then(|config| config.step_agents),
created_at: format_datetime(wave.created_at()),
status: wave.status().as_str().to_string(),
flow_steps,
has_stale_pr_state,
workers: wave.workers(),
repos,
parent_wave_id: wave.parent_wave_id().map(|id| id.to_string()),
})
}
pub(crate) async fn build_wave_queue_views(
store: &SharedStore,
wave_id: &LfdId,
snapshot: &LivePrSnapshot,
) -> Result<HashMap<LfdId, QueueRunView>, StoreError> {
let stack_runs = store.list_stack_runs(wave_id).await?;
let blocks = store.list_queue_blocks(wave_id).await?;
let blocks_by_run = blocks
.into_iter()
.map(|block| (block.run_id.clone(), block))
.collect::<HashMap<_, _>>();
Ok(project_queue_views(
&stack_runs,
|run| snapshot.state_for_run(run).cloned(),
&blocks_by_run,
))
}
pub fn paginate<T>(
mut items: Vec<T>,
limit: Option<u32>,
starting_after: Option<&str>,
ending_before: Option<&str>,
id: fn(&T) -> &LfdId,
) -> (Vec<T>, bool) {
if let Some(cursor) = starting_after {
if let Some(pos) = items.iter().position(|item| id(item).as_str() == cursor) {
items = items.split_off(pos + 1);
}
}
if let Some(cursor) = ending_before {
if let Some(pos) = items.iter().position(|item| id(item).as_str() == cursor) {
items.truncate(pos);
}
}
let mut has_more = false;
if let Some(limit) = limit {
let limit = limit as usize;
if items.len() > limit {
items.truncate(limit);
has_more = true;
}
}
(items, has_more)
}
#[derive(Debug)]
pub(crate) struct WaveGitState {
pub(crate) worktree: String,
pub(crate) branch: Option<String>,
pub(crate) commits: Vec<CommitEntryDto>,
pub(crate) diff_stat: Option<String>,
}
pub(crate) fn infer_wave_git_state(repo: &str, wave_name: &str) -> Option<WaveGitState> {
let repo_path = std::path::Path::new(repo);
let worktree = crate::engine::worktrees::worktree_path(repo_path, wave_name);
infer_wave_git_state_for_worktree(&worktree, wave_name)
}
pub(crate) fn infer_wave_git_state_for_worktree(
worktree: &std::path::Path,
wave_name: &str,
) -> Option<WaveGitState> {
if !worktree.exists() {
return None;
}
let branch = crate::engine::git::current_branch(worktree).ok().flatten();
let diff_ref = nearest_base_ref(worktree, wave_name);
let commits = git_commit_log(worktree, &diff_ref);
let diff_stat = git_diff_stat(worktree, &diff_ref);
Some(WaveGitState {
worktree: worktree.to_string_lossy().to_string(),
branch,
commits,
diff_stat,
})
}
pub(crate) fn is_open_pr_state(state: Option<&str>) -> bool {
match state {
Some(state) => state.eq_ignore_ascii_case("open") || state.eq_ignore_ascii_case("draft"),
None => false,
}
}
fn nearest_base_ref(worktree: &std::path::Path, wave_name: &str) -> String {
let main_repo = crate::engine::worktrees::main_repo_root(worktree)
.unwrap_or_else(|_| worktree.to_path_buf());
let default_branch =
crate::engine::git::get_default_branch(&main_repo).unwrap_or_else(|_| "main".to_string());
let current = crate::engine::git::current_branch(worktree)
.ok()
.flatten()
.unwrap_or_default();
let wave_slug = crate::engine::naming::sanitize_for_branch(wave_name);
let mut candidates: Vec<String> = vec![default_branch.clone()];
if let Some(sibling_branches) = wave_remote_branches(worktree, &wave_slug) {
for branch in sibling_branches {
if !is_current_or_tracking_branch(&branch, ¤t) && !candidates.contains(&branch) {
candidates.push(branch);
}
}
}
let mut best_ref = default_branch;
let mut best_distance = u64::MAX;
for candidate in &candidates {
let mb = match crate::engine::git::merge_base(worktree, "HEAD", candidate) {
Ok(sha) => sha,
Err(_) => continue,
};
let distance = commit_count(worktree, &mb);
if distance < best_distance {
best_distance = distance;
best_ref = mb;
}
}
best_ref
}
fn is_current_or_tracking_branch(candidate: &str, current: &str) -> bool {
if candidate == current {
return true;
}
candidate
.split_once('/')
.is_some_and(|(_, branch)| branch == current)
}
fn wave_remote_branches(worktree: &std::path::Path, wave_slug: &str) -> Option<Vec<String>> {
let pattern = format!(".{}.", wave_slug);
let output = std::process::Command::new("git")
.args(["branch", "-r", "--format=%(refname:short)"])
.current_dir(worktree)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let branches = String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|line| line.contains(&pattern))
.map(|line| line.trim().to_string())
.collect();
Some(branches)
}
fn commit_count(worktree: &std::path::Path, from_ref: &str) -> u64 {
std::process::Command::new("git")
.args(["rev-list", "--count", &format!("{from_ref}..HEAD")])
.current_dir(worktree)
.output()
.ok()
.and_then(|o| {
if o.status.success() {
String::from_utf8_lossy(&o.stdout).trim().parse().ok()
} else {
None
}
})
.unwrap_or(u64::MAX)
}
fn git_commit_log(worktree: &std::path::Path, diff_ref: &str) -> Vec<CommitEntryDto> {
let output = std::process::Command::new("git")
.args(["log", "--oneline", &format!("{diff_ref}..HEAD")])
.current_dir(worktree)
.output();
let output = match output {
Ok(o) if o.status.success() => o,
_ => return Vec::new(),
};
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| {
let (sha, message) = line.split_once(' ')?;
Some(CommitEntryDto {
sha: sha.to_string(),
message: message.to_string(),
})
})
.collect()
}
fn git_file_diff(worktree: &std::path::Path, diff_ref: &str, file_path: &str) -> String {
let output = std::process::Command::new("git")
.args(["diff", diff_ref, "--", file_path])
.current_dir(worktree)
.output();
let output = match output {
Ok(o) if o.status.success() => o,
_ => return String::new(),
};
let diff = String::from_utf8_lossy(&output.stdout).to_string();
let lines: Vec<&str> = diff.lines().collect();
if lines.len() > 500 {
let truncated: String = lines[..500].join("\n");
let remaining = lines.len() - 500;
format!("{truncated}\n... (truncated, {remaining} more lines)")
} else {
diff
}
}
fn git_diff_stat(worktree: &std::path::Path, diff_ref: &str) -> Option<String> {
let output = std::process::Command::new("git")
.args(["diff", "--stat", diff_ref])
.current_dir(worktree)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stat = String::from_utf8_lossy(&output.stdout).trim().to_string();
if stat.is_empty() {
None
} else {
Some(stat)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lfd::id::LfdId;
use crate::lfd::types::{
LivePrState, LivePullRequestState, PullRequest, RepoWork, Run, RunStackStatus, RunStatus,
Wave, WaveStatus,
};
use crate::lfdb::SharedStore;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use time::OffsetDateTime;
fn run_with_pr(pr_number: Option<u32>, pr_state: Option<&str>) -> Run {
Run {
id: LfdId::new(),
wave_id: LfdId::new(),
repo: ".".to_string(),
flow: "build".to_string(),
task: None,
direction: Vec::new(),
area: Vec::new(),
iteration: 0,
step_index: 0,
status: RunStatus::Running,
worktree: "/tmp/worktree".to_string(),
branch: "feature".to_string(),
started_at: None,
ended_at: None,
error: None,
flow_parents: Vec::new(),
execution_cursor: None,
parent_run_id: None,
parent_pr_number: None,
stack_position: 0,
stack_group_id: "wave-group".to_string(),
stack_status: RunStackStatus::Active,
lineage_inferred: false,
target_branch: "main".to_string(),
repair_of: None,
pr: Some(PullRequest {
url: "https://example.test/pr/1".to_string(),
number: pr_number,
state: pr_state.map(ToString::to_string),
title: Some("test".to_string()),
branch: Some("feature".to_string()),
}),
}
}
async fn sqlite_store() -> SharedStore {
let db_path = std::env::temp_dir().join(format!("lfd-routes-test-{}.db", LfdId::new()));
let config = crate::lfdb::StorageConfig::sqlite(db_path);
Arc::new(
crate::lfdb::open_store(&config)
.await
.expect("sqlite store should initialize"),
)
}
fn make_wave(repo: &str) -> Wave {
Wave {
id: LfdId::new(),
name: "wave-live-pr".to_string(),
primary_flow: "ship-roadmap".to_string(),
goal: "ship-roadmap".to_string(),
metrics: Vec::new(),
repos: vec![RepoWork {
repo: repo.to_string(),
worktree: String::new(),
branch: String::new(),
status: WaveStatus::Idle,
iteration: 0,
cycle_start_iteration: 0,
position: 0,
}],
direction: vec![],
area: vec![],
paused: false,
created_at: Some(OffsetDateTime::now_utc()),
workers: 1,
parent_wave_id: None,
}
}
fn make_run(wave: &Wave, pr_number: u32) -> Run {
Run {
id: LfdId::new(),
wave_id: wave.id().clone(),
repo: wave.repo().to_string(),
flow: wave.primary_flow().clone(),
task: None,
direction: wave.direction().clone(),
area: wave.area().clone(),
iteration: pr_number,
step_index: 0,
status: RunStatus::Completed,
worktree: "/tmp/worktree".to_string(),
branch: format!("feature-{pr_number}"),
started_at: Some(OffsetDateTime::now_utc()),
ended_at: Some(OffsetDateTime::now_utc()),
error: None,
flow_parents: Vec::new(),
execution_cursor: None,
parent_run_id: None,
parent_pr_number: None,
stack_position: pr_number,
stack_group_id: wave.id().to_string(),
stack_status: RunStackStatus::Active,
lineage_inferred: false,
target_branch: "main".to_string(),
repair_of: None,
pr: Some(PullRequest {
url: format!("https://example.test/pr/{pr_number}"),
number: Some(pr_number),
state: Some("open".to_string()),
title: Some("title".to_string()),
branch: Some(format!("feature-{pr_number}")),
}),
}
}
async fn setup_wave_with_run(pr_number: u32) -> (SharedStore, tempfile::TempDir, Wave, Run) {
let store = sqlite_store().await;
let repo_dir = tempfile::tempdir().expect("tempdir should be created");
let wave = make_wave(
repo_dir
.path()
.to_str()
.expect("tempdir path should be valid UTF-8"),
);
store
.create_wave(&wave)
.await
.expect("wave should be created in store");
let run = make_run(&wave, pr_number);
store
.create_run(&run)
.await
.expect("wave run should be created in store");
(store, repo_dir, wave, run)
}
fn live_state(repo_id: &str, pr_number: u32, state: LivePrState) -> LivePullRequestState {
LivePullRequestState {
repo_id: repo_id.to_string(),
pr_number,
state,
is_draft: false,
head_ref: format!("feature-{pr_number}"),
head_sha: "abc123".to_string(),
base_ref: "main".to_string(),
updated_at: OffsetDateTime::now_utc(),
merged_at: (state == LivePrState::Merged).then(OffsetDateTime::now_utc),
synced_at: OffsetDateTime::now_utc(),
}
}
#[test]
fn current_tracking_branch_matches_local_branch_name() {
assert!(is_current_or_tracking_branch(
"origin/jack.wave.20260209_1000",
"jack.wave.20260209_1000"
));
}
#[test]
fn sibling_branch_does_not_match_current_branch_name() {
assert!(!is_current_or_tracking_branch(
"origin/jack.wave.20260209_1001",
"jack.wave.20260209_1000"
));
}
#[test]
fn unknown_pr_state_is_not_open() {
assert!(!is_open_pr_state(None));
assert!(!is_open_pr_state(Some("closed")));
assert!(!is_open_pr_state(Some("merged")));
assert!(is_open_pr_state(Some("open")));
assert!(is_open_pr_state(Some("draft")));
}
#[test]
fn snapshot_returns_state_and_stale_for_run() {
let run = run_with_pr(Some(101), Some("open"));
let key = crate::lfd::live_pr::run_live_pr_key(&run).expect("key");
let snapshot = LivePrSnapshot {
live_states: HashMap::from([(
key.clone(),
LivePullRequestState {
repo_id: ".".to_string(),
pr_number: 101,
state: LivePrState::Open,
is_draft: false,
head_ref: "feature".to_string(),
head_sha: "abc123".to_string(),
base_ref: "main".to_string(),
updated_at: OffsetDateTime::now_utc(),
merged_at: None,
synced_at: OffsetDateTime::now_utc(),
},
)]),
stale_keys: HashSet::from([key]),
};
assert_eq!(snapshot.state_for_run(&run).map(|s| s.pr_number), Some(101));
assert!(snapshot.stale_for_run(&run));
}
#[tokio::test]
async fn build_wave_dto_uses_live_pr_state_for_open_counts() {
let (store, _repo_dir, wave, _) = setup_wave_with_run(17).await;
store
.upsert_live_pr_state(&live_state(wave.repo(), 17, LivePrState::Closed))
.await
.expect("live PR state should be upserted");
let dto = build_wave_dto(
&store,
&crate::lfd::config::GitHubConfig::default(),
wave,
false,
)
.await
.expect("wave dto should be built");
assert_eq!(dto.repos.len(), 1);
assert_eq!(
dto.repos[0].open_pr_count, 0,
"closed live PRs should not count as open even if snapshot says open"
);
assert_eq!(dto.repos[0].stack_count, 1);
assert!(dto.has_stale_pr_state);
}
#[tokio::test]
async fn build_wave_dto_selects_latest_run_per_repo() {
let store = sqlite_store().await;
let repo_a = tempfile::tempdir().expect("repo a");
let repo_b = tempfile::tempdir().expect("repo b");
let repo_a_path = repo_a.path().to_str().expect("repo a path").to_string();
let repo_b_path = repo_b.path().to_str().expect("repo b path").to_string();
let started = OffsetDateTime::now_utc();
let mut wave = make_wave(&repo_a_path);
wave.repos.push(RepoWork {
repo: repo_b_path.clone(),
worktree: String::new(),
branch: String::new(),
status: WaveStatus::Idle,
iteration: 0,
cycle_start_iteration: 0,
position: 1,
});
store
.create_wave(&wave)
.await
.expect("wave should be created in store");
let mut run_a = make_run(&wave, 11);
run_a.repo = repo_a_path.clone();
run_a.started_at = Some(started);
let mut run_b = make_run(&wave, 22);
run_b.repo = repo_b_path;
run_b.started_at = Some(started + time::Duration::seconds(1));
store
.create_run(&run_a)
.await
.expect("repo a run should be created");
store
.create_run(&run_b)
.await
.expect("repo b run should be created");
let dto = build_wave_dto(
&store,
&crate::lfd::config::GitHubConfig::default(),
wave,
true,
)
.await
.expect("wave dto should be built");
assert_eq!(dto.repos.len(), 2);
assert_eq!(
dto.repos[0]
.active_run
.as_ref()
.and_then(|run| run.pr.as_ref())
.and_then(|pr| pr.number),
Some(11)
);
assert_eq!(
dto.repos[1]
.active_run
.as_ref()
.and_then(|run| run.pr.as_ref())
.and_then(|pr| pr.number),
Some(22)
);
}
#[tokio::test]
async fn build_snapshot_tracks_live_pr_state_transitions() {
let (store, _repo_dir, wave, run) = setup_wave_with_run(21).await;
let key =
crate::lfd::live_pr::run_live_pr_key(&run).expect("run should have a live PR key");
let github = crate::lfd::config::GitHubConfig::default();
for (state, expected_open_count) in [
(LivePrState::Open, 1_u32),
(LivePrState::Merged, 0_u32),
(LivePrState::Closed, 0_u32),
(LivePrState::Unknown, 0_u32),
] {
store
.upsert_live_pr_state(&live_state(wave.repo(), 21, state))
.await
.expect("live PR state should be upserted");
let snapshot = build_live_pr_snapshot(&store, &github, std::slice::from_ref(&run))
.await
.expect("snapshot should build");
assert_eq!(snapshot.open_pr_count(), expected_open_count);
assert_eq!(
snapshot.live_states.get(&key).map(|value| value.state),
Some(state)
);
assert!(
snapshot.has_stale_pr_state(),
"missing GitHub token should keep stale visibility explicit"
);
}
}
}