pub mod attention;
pub mod auth;
pub mod catalog;
pub mod flows;
pub mod hooks;
pub mod providers;
pub mod repos;
pub mod secrets;
pub mod sessions;
pub mod system;
pub mod terminal_sessions;
pub mod usage;
pub mod wave_config;
pub mod wave_runs;
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, trigger_dto, wave_cron_dto, wave_run_dto, CommitEntryDto, ErrorResponse,
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::store::{SharedStore, StoreError};
use crate::lfd::types::Wave;
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 latest = store.get_latest_wave_run(wave.id()).await?;
let stack_runs = store.list_stack_runs(wave.id()).await?;
let live_snapshot = build_live_pr_snapshot(store, github_config, &stack_runs).await?;
let queue_views = build_wave_queue_views(store, wave.id(), &live_snapshot).await?;
let repo = wave.repo().clone();
let name = wave.name().clone();
let flow_name = wave.primary_flow().clone();
let flow_repo = wave.repo().clone();
let (git_state, flow_steps) = tokio::join!(
async {
tokio::task::spawn_blocking(move || infer_wave_git_state(&repo, &name))
.await
.ok()
.flatten()
},
async {
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 triggers_list = store
.list_triggers(Some(wave.id()))
.await
.unwrap_or_default();
let triggers = triggers_list.into_iter().map(trigger_dto).collect();
let crons_list = store.list_wave_crons(wave.id()).await.unwrap_or_default();
let crons = crons_list.into_iter().map(wave_cron_dto).collect();
let wave_config = wave_config::read_wave_config(std::path::Path::new(wave.repo()), wave.name());
let active_run = if include_active_run {
latest.map(|run| {
let live_pr_state = live_snapshot.state_for_run(&run);
let pr_state_stale = live_snapshot.stale_for_run(&run);
let queue_view = queue_views.get(&run.id);
wave_run_dto(run, live_pr_state, pr_state_stale, queue_view)
})
} else {
None
};
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),
};
Ok(WaveDto {
id: wave.id().to_string(),
object: "wave".to_string(),
name: wave.name().clone(),
repo: wave.repo().clone(),
mode: wave.mode().as_str().to_string(),
primary_flow: wave.primary_flow().to_string(),
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(),
iteration: wave.iteration(),
local_worktree,
remote_branch,
commits,
diff_stat,
flow_steps,
open_pr_count: live_snapshot.open_pr_count(),
stack_count: stack_runs.len() as u32,
has_stale_pr_state: live_snapshot.has_stale_pr_state(),
workers: wave.workers(),
triggers,
crons,
active_run,
})
}
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,
})
}
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::store::SharedStore;
use crate::lfd::types::{
LivePrState, LivePullRequestState, PullRequest, Wave, WaveMode, WaveRun, WaveRunSnapshot,
WaveRunStackStatus, WaveRunStatus, WaveStatus,
};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use time::OffsetDateTime;
fn wave_run_with_pr(pr_number: Option<u32>, pr_state: Option<&str>) -> WaveRun {
WaveRun {
id: LfdId::new(),
wave_id: LfdId::new(),
snapshot: WaveRunSnapshot {
repo: ".".to_string(),
flow: "build".to_string(),
direction: Vec::new(),
area: Vec::new(),
},
iteration: 0,
step_index: 0,
status: WaveRunStatus::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,
activation_log_id: None,
parent_run_id: None,
parent_pr_number: None,
stack_position: 0,
stack_group_id: "wave-group".to_string(),
stack_status: WaveRunStackStatus::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::lfd::store::StorageConfig::sqlite(db_path);
Arc::new(
crate::lfd::store::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(),
repo: repo.to_string(),
mode: WaveMode::Loop,
primary_flow: "ship-roadmap".to_string(),
crons: Vec::new(),
direction: vec![],
area: vec![],
status: WaveStatus::Idle,
iteration: 0,
cycle_start_iteration: 0,
created_at: Some(OffsetDateTime::now_utc()),
workers: 1,
}
}
fn make_wave_run(wave: &Wave, pr_number: u32) -> WaveRun {
WaveRun {
id: LfdId::new(),
wave_id: wave.id().clone(),
snapshot: WaveRunSnapshot {
repo: wave.repo().clone(),
flow: wave.primary_flow().clone(),
direction: wave.direction().clone(),
area: wave.area().clone(),
},
iteration: pr_number,
step_index: 0,
status: WaveRunStatus::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,
activation_log_id: None,
parent_run_id: None,
parent_pr_number: None,
stack_position: pr_number,
stack_group_id: wave.id().to_string(),
stack_status: WaveRunStackStatus::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, WaveRun) {
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_wave_run(&wave, pr_number);
store
.create_wave_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 = wave_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.open_pr_count, 0,
"closed live PRs should not count as open even if snapshot says open"
);
assert_eq!(dto.stack_count, 1);
assert!(dto.has_stale_pr_state);
}
#[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"
);
}
}
}