833 lines
33 KiB
Rust
833 lines
33 KiB
Rust
use std::collections::HashMap;
|
|
use std::future::Future;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Duration;
|
|
|
|
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
|
|
use ai::index::full_source_code_embedding::manager::{
|
|
CodebaseIndexManager, CodebaseIndexManagerEvent,
|
|
};
|
|
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
|
|
use ai::index::local_project_index::{
|
|
LocalIndexStatus, LocalProjectIndexEvent, LocalProjectIndexManager,
|
|
};
|
|
use futures::channel::oneshot;
|
|
use futures::future::join_all;
|
|
use galaxy_cli::agent::Harness;
|
|
use galaxy_completer::completer::CommandExitStatus;
|
|
use galaxy_core::command::ExitCode;
|
|
use galaxy_core::{safe_info, safe_warn};
|
|
use galaxyui::r#async::FutureExt;
|
|
use galaxyui::{ModelContext, ModelSpawner, SingletonEntity};
|
|
use repo_metadata::repositories::{DetectedRepositories, RepoDetectionSource};
|
|
|
|
use super::terminal::TerminalDriver;
|
|
use super::AgentDriverError;
|
|
use crate::ai::agent_sdk::setup_observability::{SetupClientEventReporter, SetupStep};
|
|
use crate::ai::cloud_environments::{AmbientAgentEnvironment, SourceRepo};
|
|
use crate::terminal::model::session::command_executor::shell_escape_single_quotes;
|
|
use crate::terminal::shell::ShellType;
|
|
|
|
const CODEBASE_INDEX_SYNC_TIMEOUT: Duration = Duration::from_secs(60);
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum PrepareEnvironmentError {
|
|
#[error("Invalid runtime state - please file a bug report.")]
|
|
InvalidRuntimeState,
|
|
#[error("Failed to clone {repo_name}")]
|
|
CloneRepo { repo_name: String },
|
|
#[error("Failed to run setup command: {command}")]
|
|
SetupCommand { command: String },
|
|
#[error("Failed to change directory into {repo_name}")]
|
|
ChangeDirectory { repo_name: String },
|
|
#[error("Terminal driver error while preparing environment: {source}")]
|
|
TerminalDriver { source: AgentDriverError },
|
|
}
|
|
|
|
/// Prepare a cloud agent environment within a terminal session. This will:
|
|
/// 1. Clone all repositories, skipping any that are already cloned.
|
|
/// 2. Begin codebase indexing for all repositories (Oz harness only).
|
|
/// 3. Run any setup commands.
|
|
/// 4. If there is only one repository, navigate into it.
|
|
///
|
|
/// `is_sandbox` tells the preparer that `working_dir` only exists inside a
|
|
/// Docker sandbox container and therefore the host filesystem can't be used
|
|
/// for repo detection or indexing. This is an explicit signal from the
|
|
/// caller rather than a path-prefix inference, so non-sandbox callers that
|
|
/// happen to pass a path like `/home/agent/...` don't silently flip into
|
|
/// sandbox-only mode.
|
|
pub fn prepare_environment(
|
|
environment: AmbientAgentEnvironment,
|
|
working_dir: PathBuf,
|
|
is_sandbox: bool,
|
|
harness: Harness,
|
|
setup_events: SetupClientEventReporter,
|
|
ctx: &mut ModelContext<TerminalDriver>,
|
|
) -> impl Future<Output = Result<(), PrepareEnvironmentError>> {
|
|
let spawner = ctx.spawner();
|
|
async move {
|
|
let source_repos = environment.effective_repos();
|
|
let setup_commands = environment.setup_commands;
|
|
|
|
// Only index the codebase for the Oz harness; third-party harnesses (e.g. Claude)
|
|
// have their own methods for navigating a codebase.
|
|
let should_index_codebase = harness == Harness::Oz;
|
|
let should_subscribe_to_index_updates = should_index_codebase && !source_repos.is_empty();
|
|
let repo_channels = Arc::new(Mutex::new(HashMap::<PathBuf, oneshot::Sender<()>>::new()));
|
|
|
|
if should_subscribe_to_index_updates {
|
|
subscribe_to_codebase_index_events(&spawner, Arc::clone(&repo_channels)).await?;
|
|
}
|
|
|
|
let result = prepare_environment_impl(
|
|
&spawner,
|
|
working_dir.as_path(),
|
|
is_sandbox,
|
|
&source_repos,
|
|
setup_commands,
|
|
should_index_codebase,
|
|
Arc::clone(&repo_channels),
|
|
setup_events,
|
|
)
|
|
.await;
|
|
|
|
if should_subscribe_to_index_updates && result.is_err() {
|
|
let _ = spawner
|
|
.spawn(|_, ctx| {
|
|
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
|
|
ctx.unsubscribe_from_model(&LocalProjectIndexManager::handle(ctx));
|
|
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
|
|
ctx.unsubscribe_from_model(&CodebaseIndexManager::handle(ctx));
|
|
})
|
|
.await;
|
|
}
|
|
|
|
result
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn prepare_environment_impl(
|
|
spawner: &ModelSpawner<TerminalDriver>,
|
|
working_dir: &Path,
|
|
is_sandbox: bool,
|
|
source_repos: &[SourceRepo],
|
|
setup_commands: Vec<String>,
|
|
should_index_codebase: bool,
|
|
repo_channels: Arc<Mutex<HashMap<PathBuf, oneshot::Sender<()>>>>,
|
|
setup_events: SetupClientEventReporter,
|
|
) -> Result<(), PrepareEnvironmentError> {
|
|
let working_dir_string = working_dir.to_string_lossy().to_string();
|
|
|
|
// Position the session in `working_dir` before running any probes / clones.
|
|
// Routed through the silent executor so we don't add a user-visible `cd`
|
|
// block to the blocklist — in the common case (cloud agents) the session
|
|
// is already cd'd here by its startup dir, so this is a no-op re-cd and
|
|
// shouldn't appear in the user's terminal history.
|
|
if !cd_in_terminal_silent(working_dir_string.clone(), spawner).await? {
|
|
return Err(PrepareEnvironmentError::ChangeDirectory {
|
|
repo_name: working_dir_string,
|
|
});
|
|
}
|
|
let mut codebase_context_receivers = Vec::new();
|
|
|
|
if !source_repos.is_empty() {
|
|
setup_events
|
|
.record_result(SetupStep::EnvironmentRepoClone, async {
|
|
clone_repos(source_repos, working_dir, spawner).await?;
|
|
for repo in source_repos {
|
|
register_cloned_repo(repo, working_dir, is_sandbox, spawner).await?;
|
|
if !is_sandbox && should_index_codebase {
|
|
let receiver = index_repo_codebase(
|
|
&repo.repo,
|
|
working_dir,
|
|
Arc::clone(&repo_channels),
|
|
spawner,
|
|
)
|
|
.await?;
|
|
if let Some(receiver) = receiver {
|
|
codebase_context_receivers.push(receiver);
|
|
}
|
|
}
|
|
}
|
|
Ok::<(), PrepareEnvironmentError>(())
|
|
})
|
|
.await?;
|
|
|
|
if should_index_codebase {
|
|
record_codebase_indexing(
|
|
setup_events.clone(),
|
|
spawner.clone(),
|
|
codebase_context_receivers,
|
|
);
|
|
}
|
|
}
|
|
|
|
let has_setup_commands = !setup_commands.is_empty();
|
|
if has_setup_commands {
|
|
setup_events
|
|
.record_result(SetupStep::EnvironmentSetupCommands, async {
|
|
// Set CI=true so setup commands run in a CI-like environment. This should help us run
|
|
// non-interactive versions of setup commands, as many command line tools recognize the CI
|
|
// environment variable.
|
|
execute_command("export CI=true".to_string(), spawner).await?;
|
|
|
|
for command in setup_commands {
|
|
let command_for_error = command.clone();
|
|
safe_info!(
|
|
safe: ("Running setup command"),
|
|
full: ("Running setup command: {command}")
|
|
);
|
|
|
|
let exit_code = execute_command(command, spawner).await?;
|
|
if exit_code != 0.into() {
|
|
return Err(PrepareEnvironmentError::SetupCommand {
|
|
command: command_for_error,
|
|
});
|
|
}
|
|
|
|
let working_dir_string = working_dir.to_string_lossy().to_string();
|
|
if let Err(error) = cd_in_terminal(working_dir_string, spawner).await {
|
|
log::warn!(
|
|
"Failed to reset working directory after setup command: {error}"
|
|
);
|
|
}
|
|
|
|
safe_info!(
|
|
safe: ("Successfully completed setup command"),
|
|
full: ("Successfully completed setup command: {command_for_error}")
|
|
);
|
|
}
|
|
|
|
// Unset CI after setup commands complete so the agent session
|
|
// does not run with CI=true.
|
|
execute_command("unset CI".to_string(), spawner).await?;
|
|
Ok::<(), PrepareEnvironmentError>(())
|
|
})
|
|
.await?;
|
|
} else if should_index_codebase && source_repos.is_empty() {
|
|
let _ = spawner
|
|
.spawn(|_, ctx| {
|
|
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
|
|
ctx.unsubscribe_from_model(&LocalProjectIndexManager::handle(ctx));
|
|
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
|
|
ctx.unsubscribe_from_model(&CodebaseIndexManager::handle(ctx));
|
|
})
|
|
.await;
|
|
}
|
|
|
|
if should_index_codebase && source_repos.is_empty() {
|
|
log::info!("No repositories to index for codebase context");
|
|
}
|
|
|
|
// If there's only one repo in the environment, start the agent in that repo.
|
|
// This way, it doesn't have to locate the correct repo to work on.
|
|
if let Some(repo_name) = single_repo_name(source_repos) {
|
|
safe_info!(
|
|
safe: ("Changing directory into single repository"),
|
|
full: ("Changing directory into single repository: {repo_name}")
|
|
);
|
|
let exit_code = cd_in_terminal(repo_name.clone(), spawner).await?;
|
|
if exit_code != 0.into() {
|
|
return Err(PrepareEnvironmentError::ChangeDirectory { repo_name });
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn record_codebase_indexing(
|
|
setup_events: SetupClientEventReporter,
|
|
spawner: ModelSpawner<TerminalDriver>,
|
|
codebase_context_receivers: Vec<oneshot::Receiver<()>>,
|
|
) {
|
|
if codebase_context_receivers.is_empty() {
|
|
setup_events.record_value_detached(SetupStep::EnvironmentCodebaseIndexing, async move {
|
|
let _ = spawner
|
|
.spawn(|_, ctx| {
|
|
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
|
|
ctx.unsubscribe_from_model(&LocalProjectIndexManager::handle(ctx));
|
|
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
|
|
ctx.unsubscribe_from_model(&CodebaseIndexManager::handle(ctx));
|
|
})
|
|
.await;
|
|
});
|
|
return;
|
|
}
|
|
|
|
setup_events.record_value_detached(SetupStep::EnvironmentCodebaseIndexing, async move {
|
|
let repos_indexed = join_all(codebase_context_receivers);
|
|
if repos_indexed
|
|
.with_timeout(CODEBASE_INDEX_SYNC_TIMEOUT)
|
|
.await
|
|
.is_err()
|
|
{
|
|
log::warn!(
|
|
"Timed out waiting for codebase index sync; continuing without guaranteed codebase context",
|
|
);
|
|
}
|
|
let _ = spawner
|
|
.spawn(|_, ctx| {
|
|
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
|
|
ctx.unsubscribe_from_model(&LocalProjectIndexManager::handle(ctx));
|
|
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
|
|
ctx.unsubscribe_from_model(&CodebaseIndexManager::handle(ctx));
|
|
})
|
|
.await;
|
|
});
|
|
}
|
|
|
|
fn build_parallel_clone_command(repos: &[SourceRepo], shell_type: ShellType) -> String {
|
|
let mut script = String::from(
|
|
r#"set +e
|
|
failed=0
|
|
pids=""
|
|
tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/warp-clone-logs.XXXXXX")"
|
|
cleanup_clone_logs() {
|
|
rm -rf "$tmp_dir"
|
|
}
|
|
trap cleanup_clone_logs EXIT
|
|
clone_repo() {
|
|
repo_name="$1"
|
|
repo_url="$2"
|
|
target="$3"
|
|
if [ -d "$target" ]; then
|
|
printf '%s\n' "Repository directory $target already exists, skipping clone..."
|
|
return 0
|
|
fi
|
|
printf '%s\n' "Cloning repository $repo_name..."
|
|
git clone --filter=tree:0 "$repo_url" "$target"
|
|
}
|
|
"#,
|
|
);
|
|
|
|
let mut log_outputs = String::new();
|
|
for (index, repo) in repos.iter().enumerate() {
|
|
let repo_name = format!("{}/{}", repo.owner, repo.repo);
|
|
let repo_url = repo.https_clone_url();
|
|
let escaped_repo_name = shell_escape_single_quotes(&repo_name, ShellType::Bash);
|
|
let escaped_repo_url = shell_escape_single_quotes(&repo_url, ShellType::Bash);
|
|
let escaped_target = shell_escape_single_quotes(&repo.repo, ShellType::Bash);
|
|
let log_var = format!("log_file_{index}");
|
|
script.push_str(&format!(
|
|
"{log_var}=\"$tmp_dir/repo-{index}.log\"\n\
|
|
clone_repo '{escaped_repo_name}' '{escaped_repo_url}' '{escaped_target}' >\"${log_var}\" 2>&1 &\n"
|
|
));
|
|
script.push_str("pids=\"$pids $!\"\n");
|
|
log_outputs.push_str(&format!(
|
|
"printf '%s\\n' '===== {escaped_repo_name} ====='\n\
|
|
if [ -s \"${log_var}\" ]; then\n\
|
|
\tcat \"${log_var}\"\n\
|
|
else\n\
|
|
\tprintf '%s\\n' '(no output)'\n\
|
|
fi\n"
|
|
));
|
|
}
|
|
|
|
script.push_str(
|
|
r#"for pid in $pids; do
|
|
if ! wait "$pid"; then
|
|
failed=1
|
|
fi
|
|
done
|
|
"#,
|
|
);
|
|
script.push_str(&log_outputs);
|
|
script.push_str(
|
|
r#"
|
|
exit "$failed"
|
|
"#,
|
|
);
|
|
|
|
let escaped_script = shell_escape_single_quotes(&script, shell_type);
|
|
format!("sh -c '{escaped_script}'")
|
|
}
|
|
|
|
/// Clone all source repositories to `{working_dir}/{repo.repo}` if they do not already exist.
|
|
/// Multiple repositories are cloned in parallel to reduce environment setup time.
|
|
pub(super) async fn clone_repos(
|
|
repos: &[SourceRepo],
|
|
working_dir: &Path,
|
|
spawner: &ModelSpawner<TerminalDriver>,
|
|
) -> Result<(), PrepareEnvironmentError> {
|
|
match repos {
|
|
[] => Ok(()),
|
|
[repo] => clone_repo(repo, working_dir, spawner).await,
|
|
repos => {
|
|
let shell_type = spawner
|
|
.spawn(|driver, ctx| {
|
|
driver
|
|
.active_session_shell_type(ctx)
|
|
.unwrap_or(ShellType::Bash)
|
|
})
|
|
.await
|
|
.unwrap_or(ShellType::Bash);
|
|
|
|
let repo_names = repos
|
|
.iter()
|
|
.map(|repo| format!("{}/{}", repo.owner, repo.repo))
|
|
.collect::<Vec<_>>();
|
|
safe_info!(
|
|
safe: ("Cloning repositories via terminal"),
|
|
full: ("Cloning repositories via terminal: {}", repo_names.join(", "))
|
|
);
|
|
|
|
let command = build_parallel_clone_command(repos, shell_type);
|
|
let exit_code = execute_command(command, spawner).await?;
|
|
if exit_code != 0.into() {
|
|
return Err(PrepareEnvironmentError::CloneRepo {
|
|
repo_name: repo_names.join(", "),
|
|
});
|
|
}
|
|
|
|
safe_info!(
|
|
safe: ("Successfully cloned repositories"),
|
|
full: ("Successfully cloned repositories: {}", repo_names.join(", "))
|
|
);
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Clone a source repository to `{working_dir}/{repo.repo}` if it does not already exist.
|
|
/// This only performs the clone -- it does NOT register the repo with `DetectedRepositories`.
|
|
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true, repo = %repo))]
|
|
pub(super) async fn clone_repo(
|
|
repo: &SourceRepo,
|
|
working_dir: &Path,
|
|
spawner: &ModelSpawner<TerminalDriver>,
|
|
) -> Result<(), PrepareEnvironmentError> {
|
|
let repo_name = format!("{}/{}", repo.owner, repo.repo);
|
|
let repo_url = repo.https_clone_url();
|
|
// Get the session's shell type for proper escaping, falling back to Bash
|
|
// when the session is not yet bootstrapped or the spawn fails.
|
|
let shell_type = spawner
|
|
.spawn(|driver, ctx| {
|
|
driver
|
|
.active_session_shell_type(ctx)
|
|
.unwrap_or(ShellType::Bash)
|
|
})
|
|
.await
|
|
.unwrap_or(ShellType::Bash);
|
|
let escaped_url = shell_escape_single_quotes(&repo_url, shell_type);
|
|
// We do a partial clone here to speed up environment setup time.
|
|
let command = format!("git clone --filter=tree:0 '{escaped_url}'");
|
|
|
|
let repo_dir = working_dir.join(&repo.repo);
|
|
// Always ask the session whether the repo dir already exists, rather
|
|
// than stat'ing from the host. The session knows about sandbox-only
|
|
// paths, and this goes through the silent executor so `test -d` is
|
|
// not added to the user-visible blocklist. Pass the absolute path
|
|
// explicitly so the probe doesn't rely on the session's CWD.
|
|
let dir_exists = terminal_directory_exists(&repo_dir.to_string_lossy(), spawner).await?;
|
|
|
|
if dir_exists {
|
|
safe_warn!(
|
|
safe: ("We already have a directory with the same repository name in the terminal working directory, skipping clone..."),
|
|
full: (
|
|
"We already have a directory with the name {} in the terminal working directory, skipping clone...",
|
|
repo.repo)
|
|
);
|
|
} else {
|
|
safe_info!(
|
|
safe: ("Cloning repository via terminal"),
|
|
full: ("Cloning repository via terminal: {repo_name}")
|
|
);
|
|
|
|
let exit_code = execute_command(command, spawner).await?;
|
|
if exit_code != 0.into() {
|
|
return Err(PrepareEnvironmentError::CloneRepo {
|
|
repo_name: repo_name.clone(),
|
|
});
|
|
}
|
|
|
|
safe_info!(
|
|
safe: ("Successfully cloned repository"),
|
|
full: ("Successfully cloned: {repo_name}")
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Register a cloned source repository with `DetectedRepositories` so that the
|
|
/// skill watcher and other repo-aware subsystems can discover it.
|
|
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true, repo = %repo, is_sandbox = is_sandbox))]
|
|
pub(super) async fn register_cloned_repo(
|
|
repo: &SourceRepo,
|
|
working_dir: &Path,
|
|
is_sandbox: bool,
|
|
spawner: &ModelSpawner<TerminalDriver>,
|
|
) -> Result<(), PrepareEnvironmentError> {
|
|
let repo_dir = working_dir.join(&repo.repo);
|
|
|
|
// Register the repo with DetectedRepositories so that the skill watcher
|
|
// and other repo-aware subsystems can discover it before the first query.
|
|
//
|
|
// TODO(advait): When the remote code server lands for Docker sandboxes,
|
|
// sandbox-only working directories will be reachable from the host and
|
|
// we should register + index them here too (likely via a remote-aware
|
|
// path instead of `detect_possible_local_git_repo`/`index_directory`, which
|
|
// both assume a local filesystem). For now, skip so we don't try to
|
|
// stat paths that only exist inside the sandbox.
|
|
if is_sandbox {
|
|
safe_info!(
|
|
safe: ("Skipping local repo detection for sandbox-only working directory"),
|
|
full: (
|
|
"Skipping local repo detection and indexing for sandbox-only working directory {}",
|
|
working_dir.display()
|
|
)
|
|
);
|
|
} else {
|
|
let repo_dir_str = repo_dir.to_string_lossy().to_string();
|
|
let detect_future = spawner
|
|
.spawn(move |_, ctx| {
|
|
DetectedRepositories::handle(ctx).update(ctx, |repos, ctx| {
|
|
repos.detect_possible_local_git_repo(
|
|
&repo_dir_str,
|
|
RepoDetectionSource::CloudEnvironmentPrep,
|
|
ctx,
|
|
)
|
|
})
|
|
})
|
|
.await
|
|
.map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)?;
|
|
// Await detection so the repo is registered in DirectoryWatcher
|
|
// before the agent's first query.
|
|
if detect_future.await.is_none() {
|
|
safe_warn!(
|
|
safe: ("Repository detection returned no path"),
|
|
full: ("Repository detection returned no path for {}", repo_dir.display())
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
|
|
async fn subscribe_to_codebase_index_events(
|
|
spawner: &ModelSpawner<TerminalDriver>,
|
|
repo_channels: Arc<Mutex<HashMap<PathBuf, oneshot::Sender<()>>>>,
|
|
) -> Result<(), PrepareEnvironmentError> {
|
|
spawner
|
|
.spawn(move |_, ctx| {
|
|
let repo_channels = Arc::clone(&repo_channels);
|
|
ctx.subscribe_to_model(
|
|
&CodebaseIndexManager::handle(ctx),
|
|
move |_, _, event, ctx| {
|
|
if !matches!(event, CodebaseIndexManagerEvent::SyncStateUpdated { .. }) {
|
|
return;
|
|
}
|
|
|
|
let manager = CodebaseIndexManager::as_ref(ctx);
|
|
let mut repos_to_notify = Vec::new();
|
|
let mut channels = repo_channels
|
|
.lock()
|
|
.expect("repo channel map lock should not be poisoned");
|
|
|
|
for repo in channels.keys() {
|
|
let Some(status) = manager.get_codebase_index_status_for_path(repo, ctx)
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
if status.has_synced_version()
|
|
|| (!status.has_pending()
|
|
&& status.last_sync_successful() == Some(false))
|
|
{
|
|
repos_to_notify.push(repo.clone());
|
|
}
|
|
}
|
|
|
|
for repo in repos_to_notify {
|
|
if let Some(tx) = channels.remove(&repo) {
|
|
let _ = tx.send(());
|
|
}
|
|
}
|
|
},
|
|
);
|
|
})
|
|
.await
|
|
.map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)
|
|
}
|
|
|
|
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
|
|
async fn subscribe_to_codebase_index_events(
|
|
spawner: &ModelSpawner<TerminalDriver>,
|
|
repo_channels: Arc<Mutex<HashMap<PathBuf, oneshot::Sender<()>>>>,
|
|
) -> Result<(), PrepareEnvironmentError> {
|
|
spawner
|
|
.spawn(move |_, ctx| {
|
|
let repo_channels = Arc::clone(&repo_channels);
|
|
ctx.subscribe_to_model(
|
|
&LocalProjectIndexManager::handle(ctx),
|
|
move |_, _, event, _ctx| {
|
|
if !matches!(event, LocalProjectIndexEvent::StatusChanged { .. }) {
|
|
return;
|
|
}
|
|
|
|
let mut repos_to_notify = Vec::new();
|
|
let mut channels = repo_channels
|
|
.lock()
|
|
.expect("repo channel map lock should not be poisoned");
|
|
for repo in channels.keys() {
|
|
// The manager event identifies the changed root, while the channel map can
|
|
// contain nested paths. Re-check each path so a root-level completion wakes
|
|
// the exact environment request that owns it.
|
|
if LocalProjectIndexManager::as_ref(_ctx).is_ready_for_path(repo)
|
|
|| matches!(
|
|
LocalProjectIndexManager::as_ref(_ctx).status_for_path(repo),
|
|
Some((_, LocalIndexStatus::Failed { .. }))
|
|
)
|
|
{
|
|
repos_to_notify.push(repo.clone());
|
|
}
|
|
}
|
|
for repo in repos_to_notify {
|
|
if let Some(tx) = channels.remove(&repo) {
|
|
let _ = tx.send(());
|
|
}
|
|
}
|
|
},
|
|
);
|
|
})
|
|
.await
|
|
.map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)
|
|
}
|
|
|
|
#[cfg(any(not(feature = "local_fs"), target_family = "wasm"))]
|
|
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true, repo = %repo_name))]
|
|
async fn index_repo_codebase(
|
|
repo_name: &str,
|
|
working_dir: &Path,
|
|
repo_channels: Arc<Mutex<HashMap<PathBuf, oneshot::Sender<()>>>>,
|
|
spawner: &ModelSpawner<TerminalDriver>,
|
|
) -> Result<Option<oneshot::Receiver<()>>, PrepareEnvironmentError> {
|
|
let repo_path = working_dir.join(repo_name);
|
|
|
|
safe_info!(
|
|
safe: ("Trying to index repository for codebase context"),
|
|
full: ("Trying to index {:?} for codebase context", repo_path)
|
|
);
|
|
|
|
let repo_path_for_spawn = repo_path.clone();
|
|
spawner
|
|
.spawn(move |_, ctx| {
|
|
CodebaseIndexManager::handle(ctx).update(ctx, |manager, ctx| {
|
|
manager.index_directory(repo_path_for_spawn.clone(), ctx);
|
|
});
|
|
|
|
let status = CodebaseIndexManager::as_ref(ctx)
|
|
.get_codebase_index_status_for_path(&repo_path_for_spawn, ctx);
|
|
|
|
match status {
|
|
Some(status) if status.has_synced_version() => {
|
|
safe_info!(
|
|
safe: ("Not waiting on codebase index for repository; we have one already"),
|
|
full: ("Not waiting on codebase index for {:?}, we have one already", repo_path_for_spawn)
|
|
);
|
|
None
|
|
}
|
|
_ => {
|
|
safe_info!(
|
|
safe: ("Waiting on codebase index for repository"),
|
|
full: ("Waiting on codebase index for {:?}", repo_path_for_spawn)
|
|
);
|
|
let (tx, rx) = oneshot::channel::<()>();
|
|
repo_channels
|
|
.lock()
|
|
.expect("repo channel map lock should not be poisoned")
|
|
.insert(repo_path_for_spawn, tx);
|
|
Some(rx)
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)
|
|
}
|
|
|
|
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
|
|
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true, repo = %repo_name))]
|
|
async fn index_repo_codebase(
|
|
repo_name: &str,
|
|
working_dir: &Path,
|
|
repo_channels: Arc<Mutex<HashMap<PathBuf, oneshot::Sender<()>>>>,
|
|
spawner: &ModelSpawner<TerminalDriver>,
|
|
) -> Result<Option<oneshot::Receiver<()>>, PrepareEnvironmentError> {
|
|
let repo_path = working_dir.join(repo_name);
|
|
|
|
safe_info!(
|
|
safe: ("Trying to index repository for codebase context"),
|
|
full: ("Trying to index {:?} for codebase context", repo_path)
|
|
);
|
|
|
|
let repo_path_for_spawn = repo_path.clone();
|
|
spawner
|
|
.spawn(move |_, ctx| {
|
|
let (tx, rx) = oneshot::channel::<()>();
|
|
repo_channels
|
|
.lock()
|
|
.expect("repo channel map lock should not be poisoned")
|
|
.insert(repo_path_for_spawn.clone(), tx);
|
|
|
|
let indexing_result = LocalProjectIndexManager::handle(ctx)
|
|
.update(ctx, |manager, ctx| {
|
|
manager.index_directory(repo_path_for_spawn.clone(), ctx)
|
|
});
|
|
if let Err(error) = indexing_result {
|
|
log::warn!("Failed to start local project indexing: {error:#}");
|
|
repo_channels
|
|
.lock()
|
|
.expect("repo channel map lock should not be poisoned")
|
|
.remove(&repo_path_for_spawn);
|
|
return None;
|
|
}
|
|
|
|
if LocalProjectIndexManager::as_ref(ctx).is_ready_for_path(&repo_path_for_spawn) {
|
|
repo_channels
|
|
.lock()
|
|
.expect("repo channel map lock should not be poisoned")
|
|
.remove(&repo_path_for_spawn);
|
|
None
|
|
} else {
|
|
Some(rx)
|
|
}
|
|
})
|
|
.await
|
|
.map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)
|
|
}
|
|
|
|
/// Execute a command in the context of a terminal session.
|
|
async fn execute_command(
|
|
command: String,
|
|
spawner: &ModelSpawner<TerminalDriver>,
|
|
) -> Result<ExitCode, PrepareEnvironmentError> {
|
|
spawner
|
|
.spawn(move |terminal_driver, ctx| terminal_driver.execute_command(&command, ctx))
|
|
.await
|
|
.map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)?
|
|
.map_err(|error| match error {
|
|
AgentDriverError::InvalidRuntimeState => PrepareEnvironmentError::InvalidRuntimeState,
|
|
source => PrepareEnvironmentError::TerminalDriver { source },
|
|
})?
|
|
.await
|
|
.map_err(|error| match error {
|
|
AgentDriverError::InvalidRuntimeState => PrepareEnvironmentError::InvalidRuntimeState,
|
|
source => PrepareEnvironmentError::TerminalDriver { source },
|
|
})?
|
|
.await
|
|
.map_err(|error| match error {
|
|
AgentDriverError::InvalidRuntimeState => PrepareEnvironmentError::InvalidRuntimeState,
|
|
source => PrepareEnvironmentError::TerminalDriver { source },
|
|
})
|
|
}
|
|
|
|
/// Change the current directory in the context of a terminal session (using `cd {dir}`).
|
|
async fn cd_in_terminal(
|
|
target: String,
|
|
spawner: &ModelSpawner<TerminalDriver>,
|
|
) -> Result<ExitCode, PrepareEnvironmentError> {
|
|
spawner
|
|
.spawn(move |terminal_driver, ctx| terminal_driver.cd(&target, ctx))
|
|
.await
|
|
.map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)?
|
|
.map_err(|error| match error {
|
|
AgentDriverError::InvalidRuntimeState => PrepareEnvironmentError::InvalidRuntimeState,
|
|
source => PrepareEnvironmentError::TerminalDriver { source },
|
|
})?
|
|
.await
|
|
.map_err(|error| match error {
|
|
AgentDriverError::InvalidRuntimeState => PrepareEnvironmentError::InvalidRuntimeState,
|
|
source => PrepareEnvironmentError::TerminalDriver { source },
|
|
})?
|
|
.await
|
|
.map_err(|error| match error {
|
|
AgentDriverError::InvalidRuntimeState => PrepareEnvironmentError::InvalidRuntimeState,
|
|
source => PrepareEnvironmentError::TerminalDriver { source },
|
|
})
|
|
}
|
|
|
|
fn single_repo_name(repos: &[SourceRepo]) -> Option<String> {
|
|
if repos.len() != 1 {
|
|
return None;
|
|
}
|
|
Some(repos[0].repo.clone())
|
|
}
|
|
|
|
/// Change the active terminal session's working directory via `cd <target>`,
|
|
/// silently.
|
|
///
|
|
/// Thin wrapper around [`TerminalDriver::cd_silent`] so the call stays
|
|
/// consistent with the other `*_in_terminal` / `terminal_*` helpers in this
|
|
/// module. Uses the same [`ShellFamily::shell_escape`] logic as the visible
|
|
/// [`TerminalDriver::cd`] path, so it's safe across bash/zsh/fish/pwsh host
|
|
/// shells.
|
|
///
|
|
/// Returns `true` if the `cd` exited successfully.
|
|
async fn cd_in_terminal_silent(
|
|
target: String,
|
|
spawner: &ModelSpawner<TerminalDriver>,
|
|
) -> Result<bool, PrepareEnvironmentError> {
|
|
let output = spawner
|
|
.spawn(move |driver, ctx| driver.cd_silent(&target, ctx))
|
|
.await
|
|
.map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)?
|
|
.await
|
|
.map_err(|error| match error {
|
|
AgentDriverError::InvalidRuntimeState => PrepareEnvironmentError::InvalidRuntimeState,
|
|
source => PrepareEnvironmentError::TerminalDriver { source },
|
|
})?;
|
|
Ok(output.status == CommandExitStatus::Success)
|
|
}
|
|
|
|
/// Returns whether the given path resolves to an existing directory from the
|
|
/// perspective of the active terminal session.
|
|
///
|
|
/// Runs `test -d <path>` through the session's in-band command executor, so
|
|
/// the check is invisible in the user-facing blocklist and works for paths
|
|
/// that only exist inside a remote/sandbox filesystem. The path is escaped
|
|
/// using the *session's* actual shell type (bash/zsh use the `'"'"'` trick,
|
|
/// fish uses a backslash, PowerShell doubles the quote) rather than assuming
|
|
/// bash.
|
|
///
|
|
/// Prefer passing an absolute path: relative paths resolve against the
|
|
/// session's current working directory, which couples the caller to
|
|
/// whatever `cd` state the session happens to be in.
|
|
///
|
|
/// TODO(advait): `test -d ...` itself is POSIX-only. When we support
|
|
/// environment prep on Windows host shells (PowerShell / cmd.exe), also
|
|
/// branch on `ShellType` to emit the appropriate probe (e.g.
|
|
/// `Test-Path -PathType Container <path>` for PowerShell).
|
|
async fn terminal_directory_exists(
|
|
path: &str,
|
|
spawner: &ModelSpawner<TerminalDriver>,
|
|
) -> Result<bool, PrepareEnvironmentError> {
|
|
let path = path.to_owned();
|
|
let output = spawner
|
|
.spawn(move |driver, ctx| {
|
|
// Fall back to Bash if the session's shell type isn't known yet
|
|
// (e.g. pre-bootstrap). Bash-style escaping is a safe default for
|
|
// every POSIX shell we currently support.
|
|
let shell_type = driver
|
|
.active_session_shell_type(ctx)
|
|
.unwrap_or(ShellType::Bash);
|
|
let escaped = shell_escape_single_quotes(&path, shell_type);
|
|
let command = format!("test -d '{escaped}'");
|
|
driver.execute_silent_command(command, ctx)
|
|
})
|
|
.await
|
|
.map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)?
|
|
.await
|
|
.map_err(|error| match error {
|
|
AgentDriverError::InvalidRuntimeState => PrepareEnvironmentError::InvalidRuntimeState,
|
|
source => PrepareEnvironmentError::TerminalDriver { source },
|
|
})?;
|
|
Ok(output.status == CommandExitStatus::Success)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "environment_tests.rs"]
|
|
mod tests;
|