use std::collections::HashMap;
use std::io::Cursor;
use std::path::Path;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use anyhow::Result;
use async_trait::async_trait;
use bollard::models::MountTypeEnum;
use bytes::Bytes;
use tempfile::tempdir;
use time::OffsetDateTime;
use crate::lfd::config::{CredentialMount, ExecutorConfig, ExecutorLimitsConfig, ExecutorType};
use crate::lfd::id::LfdId;
use crate::lfd::output::OutputHub;
use crate::lfd::store::{open_store, SharedStore, StorageConfig};
use crate::lfd::types::{
AgentRun, AgentStatus, Wave, WaveMode, WaveRun, WaveRunSnapshot, WaveRunStatus, WaveStatus,
};
use super::{
container_host_config, normalize_repo_url, DockerCredentialMount, DockerExecutor,
DockerRecoveryBackend, InspectedContainer, RepoIdentity, RepoMutationLocks, RepoVolumeIdentity,
CONTAINER_PREFIX_AGENT, CONTAINER_PREFIX_PREP, CONTAINER_WORKSPACE, LABEL_AGENT_ID, LABEL_KIND,
LABEL_KIND_REPO_VOLUME, LABEL_MANAGED, LABEL_WAVE_ID, LABEL_WAVE_RUN_ID, VOLUME_PREFIX,
};
#[test]
fn docker_mount_spec_resolves_allowlisted_credentials() {
let mounts = DockerCredentialMount::from_config(
&CredentialMount::try_from("claude".to_string()).expect("claude mount should parse"),
)
.expect("mount spec should parse");
assert_eq!(mounts.len(), 2);
assert!(mounts[0].host_path.ends_with(".claude"));
assert_eq!(mounts[0].container_path, "/home/agent/.claude");
assert!(!mounts[0].read_only);
assert!(mounts[1].host_path.ends_with(".claude.json"));
assert_eq!(mounts[1].container_path, "/home/agent/.claude.json");
assert!(DockerCredentialMount::from_config(
&CredentialMount::try_from("unknown".to_string())
.expect("unknown mount name is still valid syntax"),
)
.is_err());
let gh_mount = DockerCredentialMount::from_config(
&CredentialMount::try_from("gh".to_string()).expect("gh mount should parse"),
)
.expect("gh mount should resolve");
assert_eq!(gh_mount.len(), 1);
assert!(gh_mount[0].host_path.ends_with(".config/gh"));
assert_eq!(gh_mount[0].container_path, "/home/agent/.config/gh");
assert!(gh_mount[0].read_only);
}
#[test]
fn docker_rewrites_paths_into_workspace() {
let cmd = vec![
"claude".to_string(),
"--append-system-prompt-file".to_string(),
"/tmp/worktree/.lf/prompt.md".to_string(),
"-C".to_string(),
"/tmp/worktree".to_string(),
"--danger".to_string(),
"/tmp/worktree-copy".to_string(),
];
let rewritten = DockerExecutor::rewrite_command_paths(
cmd,
Path::new("/tmp/worktree"),
"/workspace/repos/repo/worktrees/wave",
);
assert_eq!(
rewritten,
vec![
"claude".to_string(),
"--append-system-prompt-file".to_string(),
"/workspace/repos/repo/worktrees/wave/.lf/prompt.md".to_string(),
"-C".to_string(),
"/workspace/repos/repo/worktrees/wave".to_string(),
"--danger".to_string(),
"/tmp/worktree-copy".to_string(),
]
);
}
#[test]
fn docker_workspace_mount_uses_volume() {
let mounts = DockerExecutor::build_mounts_for(&format!("{VOLUME_PREFIX}abc"), &[]);
assert_eq!(mounts.len(), 1);
assert_eq!(mounts[0].typ, Some(MountTypeEnum::VOLUME));
assert_eq!(mounts[0].source, Some(format!("{VOLUME_PREFIX}abc")));
assert_eq!(mounts[0].target, Some(CONTAINER_WORKSPACE.to_string()));
}
#[test]
fn docker_workspace_uses_host_worktree_name() {
let repo = Path::new("/tmp/repo");
let host_worktree = Path::new("/tmp/repo.wave-fork-1");
let workspace =
DockerExecutor::docker_workspace_for_host_worktree(repo, host_worktree, "branch");
assert!(workspace
.container_worktree
.ends_with("/worktrees/repo-wave-fork-1"));
}
#[test]
fn resolve_workspace_branch_prefers_explicit_context_branch() {
let cwd = Path::new("/tmp/repo.wave-fork-2");
let branch = DockerExecutor::resolve_workspace_branch(cwd, Some("feature-branch"), "main");
assert_eq!(branch, "feature-branch");
}
#[test]
fn resolve_workspace_branch_uses_fallback_without_context_or_git_branch() {
let cwd = Path::new("/tmp/repo.wave-fork-2");
let branch = DockerExecutor::resolve_workspace_branch(cwd, None, "main");
assert_eq!(branch, "main");
}
#[test]
fn resolve_workspace_branch_for_recovery_infers_fork_branch_from_path() {
let cwd = Path::new("/tmp/repo.wave-fork-2");
let branch = DockerExecutor::resolve_workspace_branch_for_recovery(cwd, "run-123", "main");
assert_eq!(branch, "run-123-fork-2");
}
#[test]
fn normalize_relative_workspace_path_rejects_traversal() {
assert!(DockerExecutor::normalize_relative_workspace_path("../etc/passwd").is_err());
assert!(DockerExecutor::normalize_relative_workspace_path("/absolute/path").is_err());
assert!(DockerExecutor::normalize_relative_workspace_path("").is_err());
assert_eq!(
DockerExecutor::normalize_relative_workspace_path(".lf/fork-manifest.json")
.unwrap()
.to_str()
.unwrap(),
".lf/fork-manifest.json"
);
assert_eq!(
DockerExecutor::normalize_relative_workspace_path("./file.txt")
.unwrap()
.to_str()
.unwrap(),
"file.txt"
);
assert!(DockerExecutor::normalize_relative_workspace_path("nested/../file.txt").is_err());
}
fn list_archive_entries(bytes: Bytes) -> Vec<String> {
let cursor = Cursor::new(bytes);
let mut archive = tar::Archive::new(cursor);
let mut entries = Vec::new();
for entry in archive.entries().expect("tar entries") {
let entry = entry.expect("tar entry");
let path = entry.path().expect("entry path");
let normalized = path
.to_string_lossy()
.trim_start_matches("./")
.trim_end_matches('/')
.to_string();
entries.push(normalized);
}
entries.sort();
entries
}
#[test]
fn build_image_context_respects_dockerignore_but_keeps_dockerfile() {
let repo = tempdir().expect("tempdir");
std::fs::write(repo.path().join(".dockerignore"), ".lf/\n*.log\n").expect("write dockerignore");
std::fs::create_dir_all(repo.path().join(".lf")).expect("create .lf");
std::fs::write(repo.path().join(".lf/Dockerfile"), "FROM scratch\n").expect("write dockerfile");
std::fs::write(repo.path().join(".lf/secret.txt"), "ignored").expect("write ignored file");
std::fs::write(repo.path().join("kept.txt"), "keep").expect("write kept");
std::fs::write(repo.path().join("ignored.log"), "ignore").expect("write ignored");
let context = DockerExecutor::build_image_context(repo.path()).expect("context");
let entries = list_archive_entries(context);
assert!(entries.contains(&".dockerignore".to_string()));
assert!(entries.contains(&".lf/Dockerfile".to_string()));
assert!(entries.contains(&"kept.txt".to_string()));
assert!(!entries.contains(&".lf/secret.txt".to_string()));
assert!(!entries.contains(&"ignored.log".to_string()));
}
#[test]
fn docker_host_config_applies_limits_and_no_new_privileges() {
let limits = ExecutorLimitsConfig::default();
let host_config = container_host_config(vec![], &limits);
assert_eq!(host_config.memory, Some(limits.memory));
assert_eq!(host_config.memory_swap, Some(limits.memory_swap));
assert_eq!(host_config.cpu_quota, Some(limits.cpu_quota));
assert_eq!(host_config.pids_limit, Some(limits.pids_limit));
assert_eq!(
host_config.security_opt,
Some(vec!["no-new-privileges:true".to_string()])
);
}
#[test]
fn repo_volume_identity_is_deterministic_and_safe() {
let repo = tempdir().expect("tempdir");
let first = RepoVolumeIdentity::from_identity(&RepoIdentity::from_repo(repo.path()));
let second = RepoVolumeIdentity::from_identity(&RepoIdentity::from_repo(repo.path()));
assert_eq!(first, second);
assert!(first.volume_name.starts_with(VOLUME_PREFIX));
assert!(first
.volume_name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.'));
}
#[test]
fn repo_identity_falls_back_to_path_hash() {
let repo = tempdir().expect("tempdir");
let identity_a = RepoIdentity::from_repo(repo.path());
let identity_b = RepoIdentity::from_repo(repo.path());
assert_eq!(identity_a, identity_b);
assert!(!identity_a.has_remote);
assert!(identity_a.canonical.starts_with("local:"));
}
#[test]
fn normalize_repo_url_handles_common_forms() {
assert_eq!(
normalize_repo_url("git@GitHub.com:LoopflowStudio/loopflow.git"),
"ssh://git@github.com/LoopflowStudio/loopflow"
);
assert_eq!(
normalize_repo_url("HTTPS://GITHUB.COM/loopflowstudio/loopflow.git/"),
"https://github.com/loopflowstudio/loopflow"
);
}
#[tokio::test]
async fn repo_mutation_locks_serialize_same_repo_key() {
let locks = RepoMutationLocks::default();
let events = Arc::new(StdMutex::new(Vec::new()));
let lock_a = locks.for_key("repo-1").await;
let events_a = events.clone();
let first = tokio::spawn(async move {
let _guard = lock_a.lock().await;
events_a.lock().expect("lock events").push("first-start");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
events_a.lock().expect("lock events").push("first-end");
});
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
let lock_b = locks.for_key("repo-1").await;
let events_b = events.clone();
let second = tokio::spawn(async move {
let _guard = lock_b.lock().await;
events_b.lock().expect("lock events").push("second");
});
first.await.expect("first task should finish");
second.await.expect("second task should finish");
assert_eq!(
events.lock().expect("events").as_slice(),
["first-start", "first-end", "second"]
);
}
#[derive(Debug, Clone)]
struct MockDockerRecoveryBackend {
inspected: Arc<StdMutex<HashMap<String, InspectedContainer>>>,
managed: Arc<StdMutex<Vec<String>>>,
stopped: Arc<StdMutex<Vec<String>>>,
removed: Arc<StdMutex<Vec<String>>>,
}
impl MockDockerRecoveryBackend {
fn new(inspected: HashMap<String, InspectedContainer>, managed: Vec<String>) -> Self {
Self {
inspected: Arc::new(StdMutex::new(inspected)),
managed: Arc::new(StdMutex::new(managed)),
stopped: Arc::new(StdMutex::new(Vec::new())),
removed: Arc::new(StdMutex::new(Vec::new())),
}
}
fn stopped(&self) -> Vec<String> {
self.stopped.lock().expect("stopped lock").clone()
}
fn removed(&self) -> Vec<String> {
self.removed.lock().expect("removed lock").clone()
}
}
#[async_trait]
impl DockerRecoveryBackend for MockDockerRecoveryBackend {
async fn inspect_container(&self, container_ref: &str) -> Result<Option<InspectedContainer>> {
Ok(self
.inspected
.lock()
.expect("inspected lock")
.get(container_ref)
.cloned())
}
async fn list_managed_containers(&self) -> Result<Vec<String>> {
Ok(self.managed.lock().expect("managed lock").clone())
}
async fn stop_container(&self, container_id: &str) -> Result<()> {
self.stopped
.lock()
.expect("stopped lock")
.push(container_id.to_string());
Ok(())
}
async fn remove_container(&self, container_id: &str) -> Result<()> {
self.removed
.lock()
.expect("removed lock")
.push(container_id.to_string());
Ok(())
}
}
async fn create_running_wave_and_run(
store: &SharedStore,
repo: &Path,
name: &str,
) -> (Wave, WaveRun) {
let wave = Wave {
id: LfdId::new(),
name: name.to_string(),
repo: repo.to_string_lossy().to_string(),
mode: WaveMode::Loop,
primary_flow: "ship-roadmap".to_string(),
crons: Vec::new(),
direction: vec![],
area: vec![],
status: WaveStatus::Running,
iteration: 0,
cycle_start_iteration: 0,
created_at: Some(OffsetDateTime::now_utc()),
workers: 1,
};
store
.create_wave(&wave)
.await
.expect("wave should be created");
let run = WaveRun {
id: LfdId::new(),
wave_id: wave.id().clone(),
snapshot: WaveRunSnapshot {
repo: repo.to_string_lossy().to_string(),
flow: "test-flow".to_string(),
direction: vec![],
area: vec![],
},
iteration: 0,
step_index: 0,
status: WaveRunStatus::Running,
worktree: repo.to_string_lossy().to_string(),
branch: "main".to_string(),
started_at: Some(OffsetDateTime::now_utc()),
ended_at: None,
error: None,
flow_parents: vec![],
execution_cursor: None,
activation_log_id: None,
parent_run_id: None,
parent_pr_number: None,
stack_position: 0,
stack_group_id: wave.id().to_string(),
stack_status: crate::lfd::types::WaveRunStackStatus::Active,
lineage_inferred: false,
target_branch: "main".to_string(),
repair_of: None,
pr: None,
};
store
.create_wave_run(&run)
.await
.expect("wave run should be created");
(wave, run)
}
fn make_running_agent(run: &WaveRun, container_id: Option<&str>, name: &str) -> AgentRun {
AgentRun {
id: LfdId::new(),
step: name.to_string(),
repo: run.snapshot.repo.clone(),
worktree: run.worktree.clone(),
wave_run_id: Some(run.id.clone()),
status: AgentStatus::Running,
started_at: Some(OffsetDateTime::now_utc()),
ended_at: None,
pid: None,
container_id: container_id.map(str::to_string),
agent: "claude-code".to_string(),
run_mode: "auto".to_string(),
}
}
#[test]
fn docker_agent_labels_include_rehydration_metadata() {
let labels = DockerExecutor::build_agent_labels("agent-1", "wave-1", "run-1");
assert_eq!(labels.get(LABEL_MANAGED).map(String::as_str), Some("true"));
assert_eq!(
labels.get(LABEL_AGENT_ID).map(String::as_str),
Some("agent-1")
);
assert_eq!(
labels.get(LABEL_WAVE_ID).map(String::as_str),
Some("wave-1")
);
assert_eq!(
labels.get(LABEL_WAVE_RUN_ID).map(String::as_str),
Some("run-1")
);
}
#[test]
fn docker_container_name_prefixes_match_contract_constants() {
let container_name = DockerExecutor::build_container_name("agent_1");
assert!(container_name.starts_with(CONTAINER_PREFIX_AGENT));
assert_eq!(container_name, format!("{CONTAINER_PREFIX_AGENT}agent-1"));
let helper_name = DockerExecutor::build_helper_container_name("sync-lfs");
assert!(helper_name.starts_with(CONTAINER_PREFIX_PREP));
}
#[test]
fn managed_container_filter_uses_managed_label_contract() {
assert_eq!(
super::managed_label_filter(),
format!("{LABEL_MANAGED}=true")
);
}
#[test]
fn repo_volume_labels_use_contract_keys_and_values() {
let labels = super::image::repo_volume_labels();
assert_eq!(labels.get(LABEL_MANAGED).map(String::as_str), Some("true"));
assert_eq!(
labels.get(LABEL_KIND).map(String::as_str),
Some(LABEL_KIND_REPO_VOLUME)
);
}
#[tokio::test]
async fn docker_startup_rehydrates_running_agents_and_cleans_orphans() {
let tmp = tempdir().expect("tempdir");
let db_path = tmp.path().join("test.db");
let store: SharedStore = Arc::new(
open_store(&StorageConfig::sqlite(db_path))
.await
.expect("db"),
);
let (rehydrated_wave, rehydrated_run) =
create_running_wave_and_run(&store, tmp.path(), "rehydrated-wave").await;
let (lost_wave, lost_run) = create_running_wave_and_run(&store, tmp.path(), "lost-wave").await;
let rehydrated_agent = make_running_agent(&rehydrated_run, Some("container-live"), "step-a");
let lost_agent = make_running_agent(&lost_run, Some("container-missing"), "step-b");
store
.start_agent(&rehydrated_agent)
.await
.expect("rehydrated agent should start");
store
.start_agent(&lost_agent)
.await
.expect("lost agent should start");
let config = ExecutorConfig {
r#type: ExecutorType::Docker,
image: "loopflow/agent:test".to_string(),
credentials: Default::default(),
agent_timeout: std::time::Duration::from_secs(45 * 60),
limits: ExecutorLimitsConfig::default(),
};
let executor = match DockerExecutor::new(store.clone(), &config) {
Ok(executor) => executor,
Err(err) => {
eprintln!("skipping test: docker unavailable ({err})");
return;
}
};
let output_dir = tmp.path().join("output");
std::fs::create_dir_all(&output_dir).expect("output dir");
let output = OutputHub::new(16, output_dir);
let backend = MockDockerRecoveryBackend::new(
HashMap::from([(
"container-live".to_string(),
InspectedContainer {
id: "container-live".to_string(),
running: true,
},
)]),
vec!["container-live".to_string(), "container-orphan".to_string()],
);
let recovery = executor
.recover_startup_with_backend(&backend, &output, false)
.await
.expect("startup recovery should succeed");
assert_eq!(recovery.rehydrated_agents, 1);
assert_eq!(recovery.lost_agents_failed, 1);
assert_eq!(recovery.orphaned_containers_removed, 1);
let active = executor.active.lock().await;
assert_eq!(
active.get(rehydrated_agent.id.as_str()).map(String::as_str),
Some("container-live")
);
assert!(active.get(lost_agent.id.as_str()).is_none());
drop(active);
let lost_agent_after = store
.get_agent(&lost_agent.id)
.await
.expect("get lost agent")
.expect("lost agent exists");
assert_eq!(lost_agent_after.status, AgentStatus::Failed);
assert!(lost_agent_after.ended_at.is_some());
let lost_run_after = store
.get_wave_run(&lost_run.id)
.await
.expect("get lost run")
.expect("lost run exists");
assert_eq!(lost_run_after.status, WaveRunStatus::Failed);
assert_eq!(
lost_run_after.error.as_deref(),
Some("container lost during lfd restart.")
);
let lost_wave_after = store
.get_wave(lost_wave.id())
.await
.expect("get lost wave")
.expect("lost wave exists");
assert_eq!(lost_wave_after.status(), WaveStatus::Failed);
let rehydrated_run_after = store
.get_wave_run(&rehydrated_run.id)
.await
.expect("get rehydrated run")
.expect("rehydrated run exists");
assert_eq!(rehydrated_run_after.status, WaveRunStatus::Running);
let rehydrated_wave_after = store
.get_wave(rehydrated_wave.id())
.await
.expect("get rehydrated wave")
.expect("rehydrated wave exists");
assert_eq!(rehydrated_wave_after.status(), WaveStatus::Running);
assert_eq!(backend.stopped(), vec!["container-orphan".to_string()]);
assert_eq!(backend.removed(), vec!["container-orphan".to_string()]);
}
#[tokio::test]
async fn docker_startup_lost_agent_does_not_flip_terminal_run_wave_status() {
let tmp = tempdir().expect("tempdir");
let db_path = tmp.path().join("test.db");
let store: SharedStore = Arc::new(
open_store(&StorageConfig::sqlite(db_path))
.await
.expect("db"),
);
let wave = Wave {
id: LfdId::new(),
name: "completed-wave".to_string(),
repo: tmp.path().to_string_lossy().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,
};
store
.create_wave(&wave)
.await
.expect("wave should be created");
let run = WaveRun {
id: LfdId::new(),
wave_id: wave.id().clone(),
snapshot: WaveRunSnapshot {
repo: tmp.path().to_string_lossy().to_string(),
flow: "test-flow".to_string(),
direction: vec![],
area: vec![],
},
iteration: 0,
step_index: 0,
status: WaveRunStatus::Completed,
worktree: tmp.path().to_string_lossy().to_string(),
branch: "main".to_string(),
started_at: Some(OffsetDateTime::now_utc()),
ended_at: Some(OffsetDateTime::now_utc()),
error: None,
flow_parents: vec![],
execution_cursor: None,
activation_log_id: None,
parent_run_id: None,
parent_pr_number: None,
stack_position: 0,
stack_group_id: wave.id().to_string(),
stack_status: crate::lfd::types::WaveRunStackStatus::Active,
lineage_inferred: false,
target_branch: "main".to_string(),
repair_of: None,
pr: None,
};
store
.create_wave_run(&run)
.await
.expect("wave run should be created");
let stale_agent = make_running_agent(&run, Some("container-missing"), "step-a");
store
.start_agent(&stale_agent)
.await
.expect("stale agent should start");
let config = ExecutorConfig {
r#type: ExecutorType::Docker,
image: "loopflow/agent:test".to_string(),
credentials: Default::default(),
agent_timeout: std::time::Duration::from_secs(45 * 60),
limits: ExecutorLimitsConfig::default(),
};
let executor = match DockerExecutor::new(store.clone(), &config) {
Ok(executor) => executor,
Err(err) => {
eprintln!("skipping test: docker unavailable ({err})");
return;
}
};
let output_dir = tmp.path().join("output");
std::fs::create_dir_all(&output_dir).expect("output dir");
let output = OutputHub::new(16, output_dir);
let backend = MockDockerRecoveryBackend::new(HashMap::new(), Vec::new());
let recovery = executor
.recover_startup_with_backend(&backend, &output, false)
.await
.expect("startup recovery should succeed");
assert_eq!(recovery.rehydrated_agents, 0);
assert_eq!(recovery.lost_agents_failed, 1);
assert_eq!(recovery.orphaned_containers_removed, 0);
let agent_after = store
.get_agent(&stale_agent.id)
.await
.expect("get agent")
.expect("agent exists");
assert_eq!(agent_after.status, AgentStatus::Failed);
assert!(agent_after.ended_at.is_some());
let run_after = store
.get_wave_run(&run.id)
.await
.expect("get run")
.expect("run exists");
assert_eq!(run_after.status, WaveRunStatus::Completed);
let wave_after = store
.get_wave(wave.id())
.await
.expect("get wave")
.expect("wave exists");
assert_eq!(wave_after.status(), WaveStatus::Idle);
}