Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
//! General-purpose administrative commands in the Warp CLI.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use warp_cli::agent::OutputFormat;
|
||||
use warpui::{platform::TerminationMode, AppContext, SingletonEntity};
|
||||
|
||||
use crate::auth::auth_manager::{AuthManager, AuthManagerEvent};
|
||||
use crate::auth::user::PrincipalType;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
|
||||
/// Kick off a device authorization login flow and handle auth events.
|
||||
pub fn login(ctx: &mut AppContext) -> Result<()> {
|
||||
let auth_state = AuthStateProvider::as_ref(ctx).get();
|
||||
let has_cached_credentials = auth_state.is_logged_in();
|
||||
|
||||
// If the user is already logged in, we require that the user log out before logging
|
||||
// back in to ensure their existing state isn't replaced (especially if using both the CLI
|
||||
// and the desktop app). In this case, try refreshing their credentials first. If the user
|
||||
// is trying to log in because the cached credentials are invalid, we should let them do so.
|
||||
// Track whether we've started the device auth flow. Failure events
|
||||
// that arrive before device auth has started are leftover refresh
|
||||
// errors and should be ignored rather than treated as terminal.
|
||||
let mut started_device_auth = !has_cached_credentials;
|
||||
ctx.subscribe_to_model(
|
||||
&AuthManager::handle(ctx),
|
||||
move |_, event, ctx| match event {
|
||||
AuthManagerEvent::AuthComplete => {
|
||||
if !started_device_auth {
|
||||
// Refresh succeeded - credentials are still valid.
|
||||
let auth_state = AuthStateProvider::as_ref(ctx).get();
|
||||
match (auth_state.username_for_display(), auth_state.user_email()) {
|
||||
(Some(username), Some(email)) if username != email => {
|
||||
println!("You are already logged in as {username} ({email}).")
|
||||
}
|
||||
(Some(name), _) | (None, Some(name)) => {
|
||||
println!("You are already logged in as {name}.")
|
||||
}
|
||||
(None, None) => {
|
||||
println!("You are already logged in.")
|
||||
}
|
||||
}
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
} else {
|
||||
// Device auth succeeded.
|
||||
println!("Logged in successfully");
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
}
|
||||
}
|
||||
AuthManagerEvent::AuthFailed(_) => {
|
||||
if !started_device_auth {
|
||||
// Refresh failed - start a fresh device auth flow.
|
||||
started_device_auth = true;
|
||||
AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| {
|
||||
auth_manager.authorize_device(ctx);
|
||||
});
|
||||
} else {
|
||||
// Device auth failed.
|
||||
let err_msg = match event {
|
||||
AuthManagerEvent::AuthFailed(err) => {
|
||||
format!("Authentication failed: {err:#}")
|
||||
}
|
||||
_ => "Authentication failed".to_string(),
|
||||
};
|
||||
ctx.terminate_app(
|
||||
TerminationMode::ForceTerminate,
|
||||
Some(Err(anyhow::anyhow!(err_msg))),
|
||||
);
|
||||
}
|
||||
}
|
||||
AuthManagerEvent::ReceivedDeviceAuthorizationCode {
|
||||
verification_url,
|
||||
verification_url_complete,
|
||||
user_code,
|
||||
} => {
|
||||
if let Some(url) = verification_url_complete {
|
||||
println!("To log in, open this URL in your browser:\n{url}");
|
||||
} else {
|
||||
println!(
|
||||
"To log in, visit {verification_url} and enter this code: {user_code}"
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
);
|
||||
|
||||
// Either refresh existing credentials or start device auth from scratch.
|
||||
AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| {
|
||||
if has_cached_credentials {
|
||||
auth_manager.refresh_user(ctx);
|
||||
} else {
|
||||
auth_manager.authorize_device(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct WhoamiOutput {
|
||||
uid: String,
|
||||
#[serde(rename = "type")]
|
||||
principal_type: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
display_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
email: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
team_uid: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
team_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Singleton model that provides a `ModelContext` for the `whoami` command's async work.
|
||||
struct WhoamiRunner;
|
||||
|
||||
impl warpui::Entity for WhoamiRunner {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for WhoamiRunner {}
|
||||
|
||||
/// Print information about the currently authenticated principal.
|
||||
pub fn whoami(ctx: &mut AppContext, output_format: OutputFormat) -> Result<()> {
|
||||
let auth_state = AuthStateProvider::as_ref(ctx).get();
|
||||
let principal_type = auth_state.principal_type().unwrap_or_default();
|
||||
|
||||
let uid = auth_state
|
||||
.user_id()
|
||||
.map(|id| {
|
||||
let s = id.as_string();
|
||||
s.strip_prefix("serviceAccount:")
|
||||
.map(String::from)
|
||||
.unwrap_or(s)
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("Could not determine user ID. Are you logged in?"))?;
|
||||
|
||||
let mut info = WhoamiOutput {
|
||||
uid,
|
||||
principal_type: match principal_type {
|
||||
PrincipalType::User => "user",
|
||||
PrincipalType::ServiceAccount => "service_account",
|
||||
},
|
||||
display_name: auth_state.display_name(),
|
||||
email: match principal_type {
|
||||
PrincipalType::User => auth_state.user_email().filter(|e| !e.is_empty()),
|
||||
PrincipalType::ServiceAccount => None,
|
||||
},
|
||||
team_uid: None,
|
||||
team_name: None,
|
||||
};
|
||||
|
||||
// Refresh workspace metadata before reading team info, so we don't print
|
||||
// stale or missing team data if the metadata hasn't been fetched yet.
|
||||
let runner = ctx.add_singleton_model(|_| WhoamiRunner);
|
||||
runner.update(ctx, move |_, ctx| {
|
||||
let refresh_future = super::common::refresh_workspace_metadata(ctx);
|
||||
ctx.spawn(refresh_future, move |_, result, ctx| {
|
||||
if let Err(err) = result {
|
||||
// Do not prevent showing user info if fetching team metadata fails.
|
||||
log::warn!("Failed to refresh team metadata for whoami: {err:#}");
|
||||
}
|
||||
|
||||
let current_team = UserWorkspaces::as_ref(ctx).current_team();
|
||||
info.team_uid = current_team.map(|t| t.uid.to_string());
|
||||
info.team_name = current_team
|
||||
.map(|t| t.name.clone())
|
||||
.filter(|n| !n.is_empty());
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json => {
|
||||
match serde_json::to_string(&info).context("whoami output should serialize") {
|
||||
Ok(json) => println!("{json}"),
|
||||
Err(err) => {
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, Some(Err(err)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
OutputFormat::Pretty => {
|
||||
match principal_type {
|
||||
PrincipalType::User => println!("User ID: {}", info.uid),
|
||||
PrincipalType::ServiceAccount => {
|
||||
println!("Service account ID: {}", info.uid)
|
||||
}
|
||||
}
|
||||
if let Some(name) = &info.display_name {
|
||||
println!("Display Name: {name}");
|
||||
}
|
||||
if let Some(email) = &info.email {
|
||||
println!("Email: {email}");
|
||||
}
|
||||
if let Some(team_uid) = &info.team_uid {
|
||||
println!("Team ID: {team_uid}");
|
||||
}
|
||||
if let Some(team_name) = &info.team_name {
|
||||
println!("Team Name: {team_name}");
|
||||
}
|
||||
}
|
||||
OutputFormat::Text => {
|
||||
println!("{}:{}", info.principal_type, info.uid);
|
||||
}
|
||||
OutputFormat::Ndjson => {
|
||||
ctx.terminate_app(
|
||||
TerminationMode::ForceTerminate,
|
||||
Some(Err(anyhow::anyhow!(
|
||||
"`whoami` does not support `--output-format ndjson`"
|
||||
))),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
});
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Log out of Warp using the same logic as the app.
|
||||
pub fn logout(ctx: &mut AppContext) -> Result<()> {
|
||||
let auth_state = AuthStateProvider::as_ref(ctx).get();
|
||||
if !auth_state.is_logged_in() {
|
||||
println!("You are not logged in.");
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
crate::auth::log_out(ctx);
|
||||
println!("Logged out successfully.");
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
//! Commands to interact with available agents via the public API.
|
||||
|
||||
use crate::ai::agent_sdk::oauth_flow::poll_oauth_until_terminal;
|
||||
use crate::ai::cloud_environments::GithubRepo;
|
||||
use crate::server::server_api::ai::AgentListItem;
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use warp_cli::agent::ListAgentConfigsArgs;
|
||||
use warp_graphql::queries::get_oauth_connect_tx_status::OauthConnectTxStatus;
|
||||
use warp_graphql::queries::user_repo_auth_status::UserRepoAuthStatusEnum;
|
||||
use warpui::{platform::TerminationMode, AppContext, ModelContext, SingletonEntity};
|
||||
|
||||
const MAX_LINE_WIDTH: usize = 90;
|
||||
const MAX_AUTH_ATTEMPTS: u32 = 8;
|
||||
|
||||
/// Singleton model that runs async work for agent CLI commands.
|
||||
struct AgentConfigRunner;
|
||||
|
||||
/// List all available agents.
|
||||
pub fn list_agents(ctx: &mut AppContext, args: ListAgentConfigsArgs) -> anyhow::Result<()> {
|
||||
let runner = ctx.add_singleton_model(|_ctx| AgentConfigRunner);
|
||||
runner.update(ctx, |runner, ctx| runner.list(args.repo.clone(), ctx))
|
||||
}
|
||||
|
||||
/// Parse a repo spec string (owner/repo or GitHub URL) into a GithubRepo.
|
||||
fn parse_repo_spec(spec: &str) -> anyhow::Result<GithubRepo> {
|
||||
let spec = spec.trim();
|
||||
|
||||
// Try URL format: https://github.com/owner/repo or https://github.com/owner/repo.git
|
||||
if spec.starts_with("https://github.com/") || spec.starts_with("http://github.com/") {
|
||||
let path = spec
|
||||
.trim_start_matches("https://github.com/")
|
||||
.trim_start_matches("http://github.com/")
|
||||
.trim_end_matches(".git")
|
||||
.trim_end_matches('/');
|
||||
|
||||
let parts: Vec<&str> = path.split('/').collect();
|
||||
if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
|
||||
return Ok(GithubRepo::new(parts[0].to_string(), parts[1].to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// Try slug format: owner/repo
|
||||
let parts: Vec<&str> = spec.split('/').collect();
|
||||
if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
|
||||
return Ok(GithubRepo::new(parts[0].to_string(), parts[1].to_string()));
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"Invalid repo format: '{}'. Expected 'owner/repo' or 'https://github.com/owner/repo'",
|
||||
spec
|
||||
))
|
||||
}
|
||||
|
||||
impl AgentConfigRunner {
|
||||
fn list(&self, repo: Option<String>, ctx: &mut ModelContext<Self>) -> anyhow::Result<()> {
|
||||
// If a repo is specified, check auth first
|
||||
if let Some(ref repo_spec) = repo {
|
||||
let github_repo = parse_repo_spec(repo_spec)?;
|
||||
self.auth_then_list(vec![github_repo], 1, repo, ctx);
|
||||
} else {
|
||||
// No repo specified - just list from environments
|
||||
self.fetch_and_display_agents(repo, ctx);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check GitHub auth for repos, then list agents.
|
||||
fn auth_then_list(
|
||||
&self,
|
||||
repos: Vec<GithubRepo>,
|
||||
attempt: u32,
|
||||
repo_spec: Option<String>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if attempt > MAX_AUTH_ATTEMPTS {
|
||||
ctx.terminate_app(
|
||||
TerminationMode::ForceTerminate,
|
||||
Some(Err(anyhow::anyhow!(
|
||||
"Exceeded maximum number of authorization attempts ({}). Please try again later.",
|
||||
MAX_AUTH_ATTEMPTS
|
||||
))),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let integrations_client = ServerApiProvider::handle(ctx)
|
||||
.as_ref(ctx)
|
||||
.get_integrations_client();
|
||||
|
||||
let repo_tuples: Vec<(String, String)> = repos
|
||||
.iter()
|
||||
.map(|repo| (repo.owner.clone(), repo.repo.clone()))
|
||||
.collect();
|
||||
|
||||
let auth_check_future = async move {
|
||||
integrations_client
|
||||
.check_user_repo_auth_status(repo_tuples)
|
||||
.await
|
||||
};
|
||||
|
||||
ctx.spawn(auth_check_future, move |runner, auth_result, ctx| {
|
||||
match auth_result {
|
||||
Ok(response) => {
|
||||
let mut has_blocking_private_issues = false;
|
||||
|
||||
for status in &response.statuses {
|
||||
match status.status {
|
||||
UserRepoAuthStatusEnum::Success => {}
|
||||
UserRepoAuthStatusEnum::NoInstallationOrAccessForRepo => {
|
||||
if !status.is_public {
|
||||
eprintln!(
|
||||
"Cannot access private repo {}/{}",
|
||||
status.owner, status.repo,
|
||||
);
|
||||
has_blocking_private_issues = true;
|
||||
}
|
||||
// Public repos without auth are fine - no warning needed
|
||||
}
|
||||
UserRepoAuthStatusEnum::UserNotConnectedToGithub => {
|
||||
eprintln!("User not connected to GitHub");
|
||||
has_blocking_private_issues = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !has_blocking_private_issues {
|
||||
// No blocking issues - proceed with listing
|
||||
runner.fetch_and_display_agents(repo_spec, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle OAuth flow if server provides auth_url + tx_id
|
||||
match (response.auth_url, response.tx_id) {
|
||||
(Some(auth_url), Some(tx_id)) => {
|
||||
println!("\nAuthorization required for private repository access.");
|
||||
println!("Opening browser for GitHub authorization: {auth_url}\n");
|
||||
ctx.open_url(&auth_url);
|
||||
|
||||
let integrations_client = ServerApiProvider::handle(ctx)
|
||||
.as_ref(ctx)
|
||||
.get_integrations_client();
|
||||
let tx_id = tx_id.into_inner();
|
||||
let poll_future = poll_oauth_until_terminal(integrations_client, tx_id);
|
||||
|
||||
let next_attempt = attempt + 1;
|
||||
|
||||
ctx.spawn(poll_future, move |runner, poll_result, ctx| {
|
||||
match poll_result {
|
||||
Ok(OauthConnectTxStatus::Completed) => {
|
||||
// OAuth completed, retry
|
||||
runner.auth_then_list(repos, next_attempt, repo_spec, ctx);
|
||||
}
|
||||
Ok(OauthConnectTxStatus::Failed) => {
|
||||
ctx.terminate_app(
|
||||
TerminationMode::ForceTerminate,
|
||||
Some(Err(anyhow::anyhow!(
|
||||
"GitHub authorization failed. Please try again."
|
||||
))),
|
||||
);
|
||||
}
|
||||
Ok(OauthConnectTxStatus::Expired) => {
|
||||
ctx.terminate_app(
|
||||
TerminationMode::ForceTerminate,
|
||||
Some(Err(anyhow::anyhow!(
|
||||
"GitHub authorization expired. Please try again."
|
||||
))),
|
||||
);
|
||||
}
|
||||
Ok(_) => {
|
||||
ctx.terminate_app(
|
||||
TerminationMode::ForceTerminate,
|
||||
Some(Err(anyhow::anyhow!(
|
||||
"Unexpected OAuth status"
|
||||
))),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
ctx.terminate_app(
|
||||
TerminationMode::ForceTerminate,
|
||||
Some(Err(anyhow::anyhow!(
|
||||
"Error polling OAuth status: {err}"
|
||||
))),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
(Some(auth_url), None) => {
|
||||
println!("\nAuthorize access here: {auth_url}\n");
|
||||
println!("After authorizing, please re-run this command.");
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
}
|
||||
_ => {
|
||||
ctx.terminate_app(
|
||||
TerminationMode::ForceTerminate,
|
||||
Some(Err(anyhow::anyhow!(
|
||||
"Cannot list agents: authorization required but no auth flow provided"
|
||||
))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
ctx.terminate_app(
|
||||
TerminationMode::ForceTerminate,
|
||||
Some(Err(e.context("Failed to check GitHub auth status"))),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn fetch_and_display_agents(&self, repo: Option<String>, ctx: &mut ModelContext<Self>) {
|
||||
let ai_client = ServerApiProvider::handle(ctx).as_ref(ctx).get_ai_client();
|
||||
|
||||
if repo.is_some() {
|
||||
println!("Fetching agent skills from the specified repository...");
|
||||
} else {
|
||||
println!("Fetching agent skills from your Warp environments...");
|
||||
}
|
||||
|
||||
let list_future = async move { ai_client.list_agents(repo).await };
|
||||
|
||||
ctx.spawn(list_future, |_, result, ctx| match result {
|
||||
Ok(agents) => {
|
||||
Self::print_agents_table(&agents);
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
}
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Print a list of agents in a card-style format.
|
||||
fn print_agents_table(agents: &[AgentListItem]) {
|
||||
if agents.is_empty() {
|
||||
println!("No agents found.");
|
||||
return;
|
||||
}
|
||||
|
||||
if agents.len() == 1 {
|
||||
println!("\nAgent:");
|
||||
} else {
|
||||
println!("\nAgents ({}):", agents.len());
|
||||
}
|
||||
|
||||
for agent in agents {
|
||||
println!("\n{}", agent.name);
|
||||
|
||||
for variant in &agent.variants {
|
||||
let mut table = super::output::standard_table();
|
||||
|
||||
// ID
|
||||
table.add_row(vec![format!("ID: {}", variant.id)]);
|
||||
|
||||
// Description
|
||||
if !variant.description.is_empty() {
|
||||
let description_cell = super::text_layout::render_labeled_wrapped_field(
|
||||
"Description",
|
||||
&variant.description,
|
||||
MAX_LINE_WIDTH,
|
||||
);
|
||||
table.add_row(vec![description_cell]);
|
||||
}
|
||||
|
||||
// Base prompt (truncated)
|
||||
if !variant.base_prompt.is_empty() {
|
||||
let mut chars = variant.base_prompt.chars();
|
||||
let truncated: String = chars.by_ref().take(100).collect();
|
||||
let truncated_prompt = if chars.next().is_some() {
|
||||
format!("{truncated}...")
|
||||
} else {
|
||||
truncated
|
||||
};
|
||||
let prompt_cell = super::text_layout::render_labeled_wrapped_field(
|
||||
"Base Prompt",
|
||||
&truncated_prompt,
|
||||
MAX_LINE_WIDTH,
|
||||
);
|
||||
table.add_row(vec![prompt_cell]);
|
||||
}
|
||||
|
||||
// Source
|
||||
table.add_row(vec![format!(
|
||||
"Source: {}/{}",
|
||||
variant.source.owner, variant.source.name
|
||||
)]);
|
||||
|
||||
// Environments
|
||||
if !variant.environments.is_empty() {
|
||||
let env_entries: Vec<_> = variant
|
||||
.environments
|
||||
.iter()
|
||||
.map(|e| format!("{} ({})", e.name, e.uid))
|
||||
.collect();
|
||||
table.add_row(vec![format!("Environments: {}", env_entries.join(", "))]);
|
||||
}
|
||||
|
||||
println!("{table}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl warpui::Entity for AgentConfigRunner {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for AgentConfigRunner {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,169 @@
|
||||
//! Unit tests for `filter_from_args`. Verifies the clap enums are faithfully translated into
|
||||
//! `TaskListFilter` without dropping any fields.
|
||||
|
||||
use chrono::{TimeZone, Utc};
|
||||
|
||||
use warp_cli::json_filter::JsonOutput;
|
||||
use warp_cli::task::{
|
||||
ArtifactTypeArg, ExecutionLocationArg, ListTasksArgs, RunSortByArg, RunSortOrderArg,
|
||||
RunSourceArg, RunStateArg,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::server::server_api::ai::{ArtifactType, ExecutionLocation, RunSortBy, RunSortOrder};
|
||||
|
||||
/// A `ListTasksArgs` whose fields are all at their defaults.
|
||||
fn empty_args() -> ListTasksArgs {
|
||||
ListTasksArgs {
|
||||
limit: 10,
|
||||
state: vec![],
|
||||
source: None,
|
||||
execution_location: None,
|
||||
creator: None,
|
||||
environment: None,
|
||||
skill: None,
|
||||
schedule: None,
|
||||
ancestor_run: None,
|
||||
name: None,
|
||||
model: None,
|
||||
artifact_type: None,
|
||||
created_after: None,
|
||||
created_before: None,
|
||||
updated_after: None,
|
||||
query: None,
|
||||
sort_by: None,
|
||||
sort_order: None,
|
||||
cursor: None,
|
||||
json_output: JsonOutput::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_args_yields_default_filter() {
|
||||
let filter = filter_from_args(&empty_args());
|
||||
assert!(filter.creator_uid.is_none());
|
||||
assert!(filter.updated_after.is_none());
|
||||
assert!(filter.created_after.is_none());
|
||||
assert!(filter.created_before.is_none());
|
||||
assert!(filter.states.is_none());
|
||||
assert!(filter.source.is_none());
|
||||
assert!(filter.execution_location.is_none());
|
||||
assert!(filter.environment_id.is_none());
|
||||
assert!(filter.skill_spec.is_none());
|
||||
assert!(filter.schedule_id.is_none());
|
||||
assert!(filter.ancestor_run_id.is_none());
|
||||
assert!(filter.config_name.is_none());
|
||||
assert!(filter.model_id.is_none());
|
||||
assert!(filter.artifact_type.is_none());
|
||||
assert!(filter.search_query.is_none());
|
||||
assert!(filter.sort_by.is_none());
|
||||
assert!(filter.sort_order.is_none());
|
||||
assert!(filter.cursor.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_flags_map_to_filter() {
|
||||
let args = ListTasksArgs {
|
||||
state: vec![
|
||||
RunStateArg::Failed,
|
||||
RunStateArg::Error,
|
||||
RunStateArg::Cancelled,
|
||||
],
|
||||
..empty_args()
|
||||
};
|
||||
let filter = filter_from_args(&args);
|
||||
assert_eq!(
|
||||
filter.states.as_deref(),
|
||||
Some(
|
||||
[
|
||||
AmbientAgentTaskState::Failed,
|
||||
AmbientAgentTaskState::Error,
|
||||
AmbientAgentTaskState::Cancelled,
|
||||
]
|
||||
.as_slice()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_cli_maps_to_cli() {
|
||||
let args = ListTasksArgs {
|
||||
source: Some(RunSourceArg::Cli),
|
||||
..empty_args()
|
||||
};
|
||||
let filter = filter_from_args(&args);
|
||||
assert_eq!(filter.source, Some(AgentSource::Cli));
|
||||
// Sanity-check the wire value: `--source CLI` must send `source=CLI`.
|
||||
assert_eq!(filter.source.as_ref().map(AgentSource::as_str), Some("CLI"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_interactive_maps_to_local() {
|
||||
// The public API uses `LOCAL` as the source value for local interactive
|
||||
// tasks. The CLI exposes this as `--source INTERACTIVE` for readability,
|
||||
// but the request sent to the server must use `LOCAL`.
|
||||
let args = ListTasksArgs {
|
||||
source: Some(RunSourceArg::Interactive),
|
||||
..empty_args()
|
||||
};
|
||||
let filter = filter_from_args(&args);
|
||||
assert_eq!(filter.source, Some(AgentSource::Interactive));
|
||||
assert_eq!(
|
||||
filter.source.as_ref().map(AgentSource::as_str),
|
||||
Some("LOCAL")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_field_maps_through() {
|
||||
let created_after = Utc.with_ymd_and_hms(2026, 4, 1, 0, 0, 0).unwrap();
|
||||
let created_before = Utc.with_ymd_and_hms(2026, 4, 2, 0, 0, 0).unwrap();
|
||||
let updated_after = Utc.with_ymd_and_hms(2026, 4, 3, 12, 30, 0).unwrap();
|
||||
|
||||
let args = ListTasksArgs {
|
||||
limit: 20,
|
||||
state: vec![RunStateArg::InProgress],
|
||||
source: Some(RunSourceArg::Api),
|
||||
execution_location: Some(ExecutionLocationArg::Remote),
|
||||
creator: Some("user-uid".to_string()),
|
||||
environment: Some("env-123".to_string()),
|
||||
skill: Some("owner/repo:SKILL.md".to_string()),
|
||||
schedule: Some("sched-1".to_string()),
|
||||
ancestor_run: Some("run-parent".to_string()),
|
||||
name: Some("nightly".to_string()),
|
||||
model: Some("claude-4-5".to_string()),
|
||||
artifact_type: Some(ArtifactTypeArg::PullRequest),
|
||||
created_after: Some(created_after),
|
||||
created_before: Some(created_before),
|
||||
updated_after: Some(updated_after),
|
||||
query: Some("oz run".to_string()),
|
||||
sort_by: Some(RunSortByArg::CreatedAt),
|
||||
sort_order: Some(RunSortOrderArg::Asc),
|
||||
cursor: Some("abcd==".to_string()),
|
||||
json_output: JsonOutput::default(),
|
||||
};
|
||||
|
||||
let filter = filter_from_args(&args);
|
||||
|
||||
assert_eq!(filter.creator_uid.as_deref(), Some("user-uid"));
|
||||
assert_eq!(filter.updated_after, Some(updated_after));
|
||||
assert_eq!(filter.created_after, Some(created_after));
|
||||
assert_eq!(filter.created_before, Some(created_before));
|
||||
assert_eq!(
|
||||
filter.states.as_deref(),
|
||||
Some([AmbientAgentTaskState::InProgress].as_slice())
|
||||
);
|
||||
assert_eq!(filter.source, Some(AgentSource::AgentWebhook));
|
||||
assert_eq!(filter.execution_location, Some(ExecutionLocation::Remote));
|
||||
assert_eq!(filter.environment_id.as_deref(), Some("env-123"));
|
||||
assert_eq!(filter.skill_spec.as_deref(), Some("owner/repo:SKILL.md"));
|
||||
assert_eq!(filter.schedule_id.as_deref(), Some("sched-1"));
|
||||
assert_eq!(filter.ancestor_run_id.as_deref(), Some("run-parent"));
|
||||
assert_eq!(filter.config_name.as_deref(), Some("nightly"));
|
||||
assert_eq!(filter.model_id.as_deref(), Some("claude-4-5"));
|
||||
assert_eq!(filter.artifact_type, Some(ArtifactType::PullRequest));
|
||||
assert_eq!(filter.search_query.as_deref(), Some("oz run"));
|
||||
assert_eq!(filter.sort_by, Some(RunSortBy::CreatedAt));
|
||||
assert_eq!(filter.sort_order, Some(RunSortOrder::Asc));
|
||||
assert_eq!(filter.cursor.as_deref(), Some("abcd=="));
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use warp_cli::agent::OutputFormat;
|
||||
use warp_cli::artifact::{
|
||||
ArtifactCommand, DownloadArtifactArgs, GetArtifactArgs, UploadArtifactArgs,
|
||||
};
|
||||
use warp_cli::GlobalOptions;
|
||||
use warpui::{platform::TerminationMode, AppContext, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::ai::artifact_download::{download_artifact_bytes, download_destination};
|
||||
#[cfg(test)]
|
||||
use crate::server::server_api::ai::FileArtifactRecord;
|
||||
use crate::server::server_api::ai::{AIClient, ArtifactDownloadResponse};
|
||||
use crate::server::server_api::{ServerApi, ServerApiProvider};
|
||||
|
||||
use super::artifact_upload::{
|
||||
CompletedFileArtifactUpload, FileArtifactUploadRequest, FileArtifactUploader,
|
||||
};
|
||||
|
||||
/// Run artifact-related commands.
|
||||
pub fn run(
|
||||
ctx: &mut AppContext,
|
||||
global_options: GlobalOptions,
|
||||
command: ArtifactCommand,
|
||||
) -> Result<()> {
|
||||
let runner = ctx.add_singleton_model(|_| ArtifactCommandRunner);
|
||||
match command {
|
||||
ArtifactCommand::Upload(args) => {
|
||||
runner.update(ctx, |runner, ctx| {
|
||||
runner.upload(args, global_options.output_format, ctx);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
ArtifactCommand::Get(args) => {
|
||||
runner.update(ctx, |runner, ctx| {
|
||||
runner.get(args, global_options.output_format, ctx);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
ArtifactCommand::Download(args) => {
|
||||
runner.update(ctx, |runner, ctx| {
|
||||
runner.download(args, global_options.output_format, ctx);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ArtifactCommandRunner;
|
||||
|
||||
impl ArtifactCommandRunner {
|
||||
fn get(
|
||||
&self,
|
||||
args: GetArtifactArgs,
|
||||
output_format: OutputFormat,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
|
||||
|
||||
ctx.spawn(
|
||||
async move { get_artifact(ai_client, &args.artifact_uid).await },
|
||||
move |_, result, ctx| match result {
|
||||
Ok(artifact) => {
|
||||
if let Err(err) = write_get_output(&artifact, output_format) {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
}
|
||||
Err(err) => super::report_fatal_error(err, ctx),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn download(
|
||||
&self,
|
||||
args: DownloadArtifactArgs,
|
||||
output_format: OutputFormat,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
|
||||
let server_api = ServerApiProvider::as_ref(ctx).get();
|
||||
|
||||
ctx.spawn(
|
||||
async move { download_artifact(ai_client, server_api, args).await },
|
||||
move |_, result, ctx| match result {
|
||||
Ok(output) => {
|
||||
if let Err(err) = write_download_output(&output, output_format) {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
}
|
||||
Err(err) => super::report_fatal_error(err, ctx),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn upload(
|
||||
&self,
|
||||
args: UploadArtifactArgs,
|
||||
output_format: OutputFormat,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let server_api = ServerApiProvider::as_ref(ctx).get();
|
||||
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
|
||||
let uploader = FileArtifactUploader::new(ai_client, server_api.clone());
|
||||
|
||||
ctx.spawn(
|
||||
async move {
|
||||
let request = FileArtifactUploadRequest::try_from(args)?;
|
||||
let association = uploader.resolve_upload_association(&request).await?;
|
||||
server_api.set_ambient_agent_task_id(Some(association.ambient_task_id));
|
||||
uploader.upload_with_association(request, association).await
|
||||
},
|
||||
move |_, result, ctx| match result {
|
||||
Ok(artifact) => {
|
||||
if let Err(err) = write_upload_output(&artifact, output_format) {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
}
|
||||
Err(err) => super::report_fatal_error(err, ctx),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl warpui::Entity for ArtifactCommandRunner {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for ArtifactCommandRunner {}
|
||||
|
||||
async fn get_artifact(
|
||||
ai_client: Arc<dyn AIClient>,
|
||||
artifact_uid: &str,
|
||||
) -> Result<ArtifactDownloadResponse> {
|
||||
ai_client
|
||||
.get_artifact_download(artifact_uid)
|
||||
.await
|
||||
.with_context(|| format!("Failed to get artifact '{artifact_uid}'"))
|
||||
}
|
||||
|
||||
async fn download_artifact(
|
||||
ai_client: Arc<dyn AIClient>,
|
||||
server_api: Arc<ServerApi>,
|
||||
args: DownloadArtifactArgs,
|
||||
) -> Result<DownloadArtifactOutput> {
|
||||
let artifact = get_artifact(ai_client, &args.artifact_uid).await?;
|
||||
let path = download_destination(&artifact, args.out);
|
||||
download_artifact_bytes(server_api.http_client(), &artifact, &path).await?;
|
||||
let path = std::path::absolute(&path).unwrap_or(path);
|
||||
Ok(DownloadArtifactOutput::new(&artifact, path))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ArtifactMetadataOutput {
|
||||
artifact_uid: String,
|
||||
artifact_type: String,
|
||||
created_at: String,
|
||||
download_url: String,
|
||||
expires_at: String,
|
||||
content_type: String,
|
||||
filepath: Option<String>,
|
||||
filename: Option<String>,
|
||||
description: Option<String>,
|
||||
size_bytes: Option<i64>,
|
||||
}
|
||||
|
||||
impl ArtifactMetadataOutput {
|
||||
fn new(artifact: &ArtifactDownloadResponse) -> Self {
|
||||
Self {
|
||||
artifact_uid: artifact.artifact_uid().to_string(),
|
||||
artifact_type: artifact.artifact_type().to_string(),
|
||||
created_at: artifact.created_at().to_rfc3339(),
|
||||
download_url: artifact.download_url().to_string(),
|
||||
expires_at: artifact.expires_at().to_rfc3339(),
|
||||
content_type: artifact.content_type().to_string(),
|
||||
filepath: artifact.filepath().map(ToString::to_string),
|
||||
filename: artifact.filename().map(ToString::to_string),
|
||||
description: artifact.description().map(ToString::to_string),
|
||||
size_bytes: artifact.size_bytes(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DownloadArtifactOutput {
|
||||
artifact_uid: String,
|
||||
artifact_type: String,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl DownloadArtifactOutput {
|
||||
fn new(artifact: &ArtifactDownloadResponse, path: PathBuf) -> Self {
|
||||
Self {
|
||||
artifact_uid: artifact.artifact_uid().to_string(),
|
||||
artifact_type: artifact.artifact_type().to_string(),
|
||||
path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UploadArtifactOutput {
|
||||
artifact_uid: String,
|
||||
filepath: String,
|
||||
description: Option<String>,
|
||||
mime_type: String,
|
||||
size_bytes: Option<i64>,
|
||||
}
|
||||
|
||||
fn write_get_output(
|
||||
artifact: &ArtifactDownloadResponse,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let mut stdout = std::io::stdout();
|
||||
write_get_output_to(&mut stdout, artifact, output_format)
|
||||
}
|
||||
|
||||
fn write_get_output_to<W: std::io::Write>(
|
||||
output: &mut W,
|
||||
artifact: &ArtifactDownloadResponse,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let output_record = ArtifactMetadataOutput::new(artifact);
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Ndjson => {
|
||||
serde_json::to_writer(&mut *output, &output_record)
|
||||
.context("unable to write JSON output")?;
|
||||
writeln!(&mut *output)?;
|
||||
}
|
||||
OutputFormat::Pretty => {
|
||||
writeln!(&mut *output, "Artifact UID: {}", output_record.artifact_uid)?;
|
||||
writeln!(
|
||||
&mut *output,
|
||||
"Artifact type: {}",
|
||||
output_record.artifact_type
|
||||
)?;
|
||||
writeln!(&mut *output, "Created at: {}", output_record.created_at)?;
|
||||
writeln!(&mut *output, "Download URL: {}", output_record.download_url)?;
|
||||
writeln!(&mut *output, "Expires at: {}", output_record.expires_at)?;
|
||||
writeln!(&mut *output, "Content type: {}", output_record.content_type)?;
|
||||
if let Some(filepath) = output_record.filepath {
|
||||
writeln!(&mut *output, "Filepath: {filepath}")?;
|
||||
}
|
||||
if let Some(filename) = output_record.filename {
|
||||
writeln!(&mut *output, "Filename: {filename}")?;
|
||||
}
|
||||
if let Some(description) = output_record.description {
|
||||
writeln!(&mut *output, "Description: {description}")?;
|
||||
}
|
||||
if let Some(size_bytes) = output_record.size_bytes {
|
||||
writeln!(&mut *output, "Size bytes: {size_bytes}")?;
|
||||
}
|
||||
}
|
||||
OutputFormat::Text => {
|
||||
writeln!(
|
||||
&mut *output,
|
||||
"Artifact UID\tArtifact type\tCreated at\tDownload URL\tExpires at\tContent type\tFilepath\tFilename\tDescription\tSize bytes"
|
||||
)?;
|
||||
writeln!(
|
||||
&mut *output,
|
||||
"{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
|
||||
output_record.artifact_uid,
|
||||
output_record.artifact_type,
|
||||
output_record.created_at,
|
||||
output_record.download_url,
|
||||
output_record.expires_at,
|
||||
output_record.content_type,
|
||||
output_record.filepath.unwrap_or_default(),
|
||||
output_record.filename.unwrap_or_default(),
|
||||
output_record.description.unwrap_or_default(),
|
||||
output_record
|
||||
.size_bytes
|
||||
.map(|size| size.to_string())
|
||||
.unwrap_or_default()
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_download_output(
|
||||
output_record: &DownloadArtifactOutput,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let mut stdout = std::io::stdout();
|
||||
write_download_output_to(&mut stdout, output_record, output_format)
|
||||
}
|
||||
|
||||
fn write_download_output_to<W: std::io::Write>(
|
||||
output: &mut W,
|
||||
output_record: &DownloadArtifactOutput,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Ndjson => {
|
||||
serde_json::to_writer(&mut *output, output_record)
|
||||
.context("unable to write JSON output")?;
|
||||
writeln!(&mut *output)?;
|
||||
}
|
||||
OutputFormat::Pretty => {
|
||||
writeln!(&mut *output, "Artifact downloaded")?;
|
||||
writeln!(&mut *output, "Artifact UID: {}", output_record.artifact_uid)?;
|
||||
writeln!(
|
||||
&mut *output,
|
||||
"Artifact type: {}",
|
||||
output_record.artifact_type
|
||||
)?;
|
||||
writeln!(&mut *output, "Path: {}", output_record.path.display())?;
|
||||
}
|
||||
OutputFormat::Text => {
|
||||
writeln!(&mut *output, "Artifact UID\tArtifact type\tPath")?;
|
||||
writeln!(
|
||||
&mut *output,
|
||||
"{}\t{}\t{}",
|
||||
output_record.artifact_uid,
|
||||
output_record.artifact_type,
|
||||
output_record.path.display()
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_upload_output(
|
||||
artifact: &CompletedFileArtifactUpload,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let mut stdout = std::io::stdout();
|
||||
write_upload_output_to(&mut stdout, artifact, output_format)
|
||||
}
|
||||
|
||||
fn write_upload_output_to<W: std::io::Write>(
|
||||
output: &mut W,
|
||||
artifact: &CompletedFileArtifactUpload,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let output_record = UploadArtifactOutput {
|
||||
artifact_uid: artifact.artifact.artifact_uid.clone(),
|
||||
filepath: artifact.artifact.filepath.clone(),
|
||||
description: artifact.artifact.description.clone(),
|
||||
mime_type: artifact.artifact.mime_type.clone(),
|
||||
size_bytes: Some(artifact.size_bytes),
|
||||
};
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Ndjson => {
|
||||
serde_json::to_writer(&mut *output, &output_record)
|
||||
.context("unable to write JSON output")?;
|
||||
writeln!(&mut *output)?;
|
||||
}
|
||||
OutputFormat::Pretty => {
|
||||
writeln!(&mut *output, "Artifact uploaded")?;
|
||||
writeln!(&mut *output, "Artifact UID: {}", output_record.artifact_uid)?;
|
||||
writeln!(&mut *output, "Filepath: {}", output_record.filepath)?;
|
||||
writeln!(
|
||||
&mut *output,
|
||||
"Description: {}",
|
||||
output_record.description.as_deref().unwrap_or("")
|
||||
)?;
|
||||
writeln!(&mut *output, "MIME type: {}", output_record.mime_type)?;
|
||||
writeln!(
|
||||
&mut *output,
|
||||
"Size bytes: {}",
|
||||
output_record
|
||||
.size_bytes
|
||||
.map(|size| size.to_string())
|
||||
.unwrap_or_default()
|
||||
)?;
|
||||
}
|
||||
OutputFormat::Text => {
|
||||
writeln!(
|
||||
&mut *output,
|
||||
"Artifact UID\tFilepath\tDescription\tMIME type\tSize bytes"
|
||||
)?;
|
||||
writeln!(
|
||||
&mut *output,
|
||||
"{}\t{}\t{}\t{}\t{}",
|
||||
output_record.artifact_uid,
|
||||
output_record.filepath,
|
||||
output_record.description.unwrap_or_default(),
|
||||
output_record.mime_type,
|
||||
output_record
|
||||
.size_bytes
|
||||
.map(|size| size.to_string())
|
||||
.unwrap_or_default()
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "artifact_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,255 @@
|
||||
use std::path::PathBuf;
|
||||
use warp_cli::agent::OutputFormat;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn sample_completed_upload() -> CompletedFileArtifactUpload {
|
||||
CompletedFileArtifactUpload {
|
||||
artifact: sample_artifact_record(),
|
||||
size_bytes: 42,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_artifact_record() -> FileArtifactRecord {
|
||||
FileArtifactRecord {
|
||||
artifact_uid: "artifact-123".to_string(),
|
||||
filepath: "outputs/report.txt".to_string(),
|
||||
description: Some("daily summary".to_string()),
|
||||
mime_type: "text/plain".to_string(),
|
||||
size_bytes: Some(42),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_file_download_response() -> ArtifactDownloadResponse {
|
||||
serde_json::from_str(
|
||||
r#"{
|
||||
"artifact_uid": "artifact-123",
|
||||
"artifact_type": "FILE",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"data": {
|
||||
"download_url": "https://storage.example.com/report.txt",
|
||||
"expires_at": "2024-01-15T11:30:00Z",
|
||||
"content_type": "text/plain",
|
||||
"filepath": "outputs/report.txt",
|
||||
"filename": "report.txt",
|
||||
"description": "daily summary",
|
||||
"size_bytes": 42
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn sample_screenshot_download_response() -> ArtifactDownloadResponse {
|
||||
serde_json::from_str(
|
||||
r#"{
|
||||
"artifact_uid": "screenshot-123",
|
||||
"artifact_type": "SCREENSHOT",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"data": {
|
||||
"download_url": "https://storage.example.com/screenshot.png",
|
||||
"expires_at": "2024-01-15T11:30:00Z",
|
||||
"content_type": "image/png",
|
||||
"description": "dashboard screenshot"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_get_output_to_writes_json_output() {
|
||||
let mut output = Vec::new();
|
||||
|
||||
write_get_output_to(
|
||||
&mut output,
|
||||
&sample_file_download_response(),
|
||||
OutputFormat::Json,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
String::from_utf8(output).unwrap(),
|
||||
"{\"artifact_uid\":\"artifact-123\",\"artifact_type\":\"FILE\",\"created_at\":\"2024-01-15T10:30:00+00:00\",\"download_url\":\"https://storage.example.com/report.txt\",\"expires_at\":\"2024-01-15T11:30:00+00:00\",\"content_type\":\"text/plain\",\"filepath\":\"outputs/report.txt\",\"filename\":\"report.txt\",\"description\":\"daily summary\",\"size_bytes\":42}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_get_output_to_writes_ndjson_output() {
|
||||
let mut output = Vec::new();
|
||||
|
||||
write_get_output_to(
|
||||
&mut output,
|
||||
&sample_file_download_response(),
|
||||
OutputFormat::Ndjson,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
String::from_utf8(output).unwrap(),
|
||||
"{\"artifact_uid\":\"artifact-123\",\"artifact_type\":\"FILE\",\"created_at\":\"2024-01-15T10:30:00+00:00\",\"download_url\":\"https://storage.example.com/report.txt\",\"expires_at\":\"2024-01-15T11:30:00+00:00\",\"content_type\":\"text/plain\",\"filepath\":\"outputs/report.txt\",\"filename\":\"report.txt\",\"description\":\"daily summary\",\"size_bytes\":42}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_get_output_to_writes_pretty_output() {
|
||||
let mut output = Vec::new();
|
||||
|
||||
write_get_output_to(
|
||||
&mut output,
|
||||
&sample_file_download_response(),
|
||||
OutputFormat::Pretty,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
String::from_utf8(output).unwrap(),
|
||||
"Artifact UID: artifact-123\nArtifact type: FILE\nCreated at: 2024-01-15T10:30:00+00:00\nDownload URL: https://storage.example.com/report.txt\nExpires at: 2024-01-15T11:30:00+00:00\nContent type: text/plain\nFilepath: outputs/report.txt\nFilename: report.txt\nDescription: daily summary\nSize bytes: 42\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_get_output_to_writes_text_output() {
|
||||
let mut output = Vec::new();
|
||||
|
||||
write_get_output_to(
|
||||
&mut output,
|
||||
&sample_file_download_response(),
|
||||
OutputFormat::Text,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
String::from_utf8(output).unwrap(),
|
||||
"Artifact UID\tArtifact type\tCreated at\tDownload URL\tExpires at\tContent type\tFilepath\tFilename\tDescription\tSize bytes\nartifact-123\tFILE\t2024-01-15T10:30:00+00:00\thttps://storage.example.com/report.txt\t2024-01-15T11:30:00+00:00\ttext/plain\toutputs/report.txt\treport.txt\tdaily summary\t42\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_download_output_to_writes_pretty_output() {
|
||||
let artifact = sample_file_download_response();
|
||||
let path = std::path::absolute("report.txt").unwrap();
|
||||
let output_record = DownloadArtifactOutput::new(&artifact, path.clone());
|
||||
let mut output = Vec::new();
|
||||
|
||||
write_download_output_to(&mut output, &output_record, OutputFormat::Pretty).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
String::from_utf8(output).unwrap(),
|
||||
format!(
|
||||
"Artifact downloaded\nArtifact UID: artifact-123\nArtifact type: FILE\nPath: {}\n",
|
||||
path.display()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_destination_uses_explicit_path() {
|
||||
assert_eq!(
|
||||
download_destination(
|
||||
&sample_file_download_response(),
|
||||
Some(PathBuf::from("downloads/report.txt"))
|
||||
),
|
||||
PathBuf::from("downloads/report.txt")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_destination_defaults_to_file_artifact_filename() {
|
||||
assert_eq!(
|
||||
download_destination(&sample_file_download_response(), None),
|
||||
PathBuf::from("report.txt")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_destination_defaults_screenshot_to_artifact_uid_with_extension() {
|
||||
assert_eq!(
|
||||
download_destination(&sample_screenshot_download_response(), None),
|
||||
PathBuf::from("artifact-screenshot-123.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_destination_defaults_pdf_to_artifact_uid_with_extension() {
|
||||
let artifact: ArtifactDownloadResponse = serde_json::from_str(
|
||||
r#"{
|
||||
"artifact_uid": "artifact-pdf-123",
|
||||
"artifact_type": "FILE",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"data": {
|
||||
"download_url": "https://storage.example.com/report.pdf",
|
||||
"expires_at": "2024-01-15T11:30:00Z",
|
||||
"content_type": "application/pdf",
|
||||
"filepath": "outputs/report.pdf",
|
||||
"filename": "",
|
||||
"description": "pdf report",
|
||||
"size_bytes": 42
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
download_destination(&artifact, None),
|
||||
PathBuf::from("artifact-artifact-pdf-123.pdf")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_upload_output_to_writes_json_output() {
|
||||
let mut output = Vec::new();
|
||||
|
||||
write_upload_output_to(&mut output, &sample_completed_upload(), OutputFormat::Json).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
String::from_utf8(output).unwrap(),
|
||||
"{\"artifact_uid\":\"artifact-123\",\"filepath\":\"outputs/report.txt\",\"description\":\"daily summary\",\"mime_type\":\"text/plain\",\"size_bytes\":42}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_upload_output_to_writes_ndjson_output() {
|
||||
let mut output = Vec::new();
|
||||
|
||||
write_upload_output_to(
|
||||
&mut output,
|
||||
&sample_completed_upload(),
|
||||
OutputFormat::Ndjson,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
String::from_utf8(output).unwrap(),
|
||||
"{\"artifact_uid\":\"artifact-123\",\"filepath\":\"outputs/report.txt\",\"description\":\"daily summary\",\"mime_type\":\"text/plain\",\"size_bytes\":42}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_upload_output_to_writes_pretty_output() {
|
||||
let mut output = Vec::new();
|
||||
|
||||
write_upload_output_to(
|
||||
&mut output,
|
||||
&sample_completed_upload(),
|
||||
OutputFormat::Pretty,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
String::from_utf8(output).unwrap(),
|
||||
"Artifact uploaded\nArtifact UID: artifact-123\nFilepath: outputs/report.txt\nDescription: daily summary\nMIME type: text/plain\nSize bytes: 42\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_upload_output_to_writes_text_output() {
|
||||
let mut output = Vec::new();
|
||||
|
||||
write_upload_output_to(&mut output, &sample_completed_upload(), OutputFormat::Text).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
String::from_utf8(output).unwrap(),
|
||||
"Artifact UID\tFilepath\tDescription\tMIME type\tSize bytes\nartifact-123\toutputs/report.txt\tdaily summary\ttext/plain\t42\n"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
use std::env;
|
||||
use std::fs::File;
|
||||
use std::io::Read as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use blocking::unblock;
|
||||
use mime_guess::from_path;
|
||||
use warp_cli::artifact::UploadArtifactArgs;
|
||||
|
||||
use super::common::parse_ambient_task_id;
|
||||
use crate::ai::agent::api::ServerConversationToken;
|
||||
use crate::ai::agent::conversation::ServerAIConversationMetadata;
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::server::server_api::ai::{
|
||||
AIClient, CreateFileArtifactUploadRequest, CreateFileArtifactUploadResponse,
|
||||
FileArtifactRecord, FileArtifactUploadTargetInfo,
|
||||
};
|
||||
use crate::server::server_api::presigned_upload::upload_file_to_target;
|
||||
use crate::server::server_api::ServerApi;
|
||||
|
||||
const MIME_SNIFF_BYTES: usize = 8 * 1024;
|
||||
const OZ_RUN_ID_ENV_VAR: &str = "OZ_RUN_ID";
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub(crate) struct FileArtifactUploadRequest {
|
||||
pub(crate) path: PathBuf,
|
||||
pub(crate) run_id: Option<AmbientAgentTaskId>,
|
||||
pub(crate) conversation_id: Option<ServerConversationToken>,
|
||||
pub(crate) description: Option<String>,
|
||||
}
|
||||
|
||||
impl TryFrom<UploadArtifactArgs> for FileArtifactUploadRequest {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: UploadArtifactArgs) -> Result<Self> {
|
||||
let run_id = match value.run_id {
|
||||
Some(run_id) => Some(parse_run_id(&run_id, "Invalid run ID")?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
path: value.path,
|
||||
run_id,
|
||||
conversation_id: value.conversation_id.map(ServerConversationToken::new),
|
||||
description: value.description,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct CompletedFileArtifactUpload {
|
||||
pub(crate) artifact: FileArtifactRecord,
|
||||
pub(crate) size_bytes: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub(crate) struct ResolvedUploadAssociation {
|
||||
conversation_id: Option<ServerConversationToken>,
|
||||
run_id: Option<AmbientAgentTaskId>,
|
||||
pub(crate) ambient_task_id: AmbientAgentTaskId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PreparedUploadArtifact {
|
||||
path: PathBuf,
|
||||
filepath: String,
|
||||
mime_type: String,
|
||||
file_size: u64,
|
||||
}
|
||||
|
||||
impl PreparedUploadArtifact {
|
||||
fn from_path(path: PathBuf) -> Result<Self> {
|
||||
// `infer` only needs leading signature bytes, so avoid buffering the whole artifact
|
||||
// before we stream the file body to the upload target.
|
||||
let (file_size, mime_sniff_bytes) = file_size_and_prefix_for_path(&path, MIME_SNIFF_BYTES)?;
|
||||
|
||||
Ok(Self {
|
||||
filepath: normalize_artifact_filepath(&path),
|
||||
mime_type: infer_mime_type(&path, &mime_sniff_bytes),
|
||||
file_size,
|
||||
path,
|
||||
})
|
||||
}
|
||||
|
||||
fn graphql_size_bytes(&self) -> Option<i32> {
|
||||
checked_graphql_size_bytes_for_upload(&self.path, self.file_size)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct FileArtifactUploader {
|
||||
ai_client: Arc<dyn AIClient>,
|
||||
server_api: Arc<ServerApi>,
|
||||
}
|
||||
|
||||
impl FileArtifactUploader {
|
||||
pub(crate) fn new(ai_client: Arc<dyn AIClient>, server_api: Arc<ServerApi>) -> Self {
|
||||
Self {
|
||||
ai_client,
|
||||
server_api,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn upload_with_association(
|
||||
&self,
|
||||
request: FileArtifactUploadRequest,
|
||||
association: ResolvedUploadAssociation,
|
||||
) -> Result<CompletedFileArtifactUpload> {
|
||||
let FileArtifactUploadRequest {
|
||||
path, description, ..
|
||||
} = request;
|
||||
|
||||
let artifact = self.prepare_upload_artifact(path).await?;
|
||||
let create_response = self
|
||||
.create_upload_target(association, description, &artifact)
|
||||
.await?;
|
||||
|
||||
let checksum = self
|
||||
.upload_artifact_bytes(&create_response.upload_target, &artifact)
|
||||
.await?;
|
||||
let uploaded_artifact = self
|
||||
.confirm_upload(create_response.artifact.artifact_uid, checksum)
|
||||
.await?;
|
||||
let size_bytes = i64::try_from(artifact.file_size)
|
||||
.context("Artifact file size exceeds supported range")?;
|
||||
|
||||
Ok(CompletedFileArtifactUpload {
|
||||
artifact: uploaded_artifact,
|
||||
size_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
async fn prepare_upload_artifact(&self, path: PathBuf) -> Result<PreparedUploadArtifact> {
|
||||
unblock(move || PreparedUploadArtifact::from_path(path)).await
|
||||
}
|
||||
|
||||
async fn create_upload_target(
|
||||
&self,
|
||||
association: ResolvedUploadAssociation,
|
||||
description: Option<String>,
|
||||
artifact: &PreparedUploadArtifact,
|
||||
) -> Result<CreateFileArtifactUploadResponse> {
|
||||
self.ai_client
|
||||
.create_file_artifact_upload_target(CreateFileArtifactUploadRequest {
|
||||
conversation_id: association
|
||||
.conversation_id
|
||||
.as_ref()
|
||||
.map(|token| token.as_str().to_string()),
|
||||
run_id: association.run_id.as_ref().map(ToString::to_string),
|
||||
filepath: artifact.filepath.clone(),
|
||||
description,
|
||||
mime_type: Some(artifact.mime_type.clone()),
|
||||
size_bytes: artifact.graphql_size_bytes(),
|
||||
})
|
||||
.await
|
||||
.context("Failed to create file artifact upload target")
|
||||
}
|
||||
|
||||
async fn upload_artifact_bytes(
|
||||
&self,
|
||||
target: &FileArtifactUploadTargetInfo,
|
||||
artifact: &PreparedUploadArtifact,
|
||||
) -> Result<String> {
|
||||
upload_file_to_target(
|
||||
self.server_api.http_client(),
|
||||
target,
|
||||
&artifact.path,
|
||||
artifact.file_size,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn confirm_upload(
|
||||
&self,
|
||||
artifact_uid: String,
|
||||
checksum: String,
|
||||
) -> Result<FileArtifactRecord> {
|
||||
self.ai_client
|
||||
.confirm_file_artifact_upload(artifact_uid, checksum)
|
||||
.await
|
||||
.context("Failed to confirm file artifact upload")
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_upload_association(
|
||||
&self,
|
||||
request: &FileArtifactUploadRequest,
|
||||
) -> Result<ResolvedUploadAssociation> {
|
||||
let conversation_task_id = match (request.run_id.as_ref(), request.conversation_id.as_ref())
|
||||
{
|
||||
// we were given a conversation id, so we need to resolve the task id from the conversation via the api
|
||||
(None, Some(conversation_id)) => {
|
||||
Some(self.resolve_conversation_task_id(conversation_id).await)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
resolve_upload_association_from_sources(
|
||||
request.run_id,
|
||||
request.conversation_id.clone(),
|
||||
conversation_task_id,
|
||||
load_env_run_id()?,
|
||||
)
|
||||
}
|
||||
|
||||
async fn resolve_conversation_task_id(
|
||||
&self,
|
||||
conversation_id: &ServerConversationToken,
|
||||
) -> Result<AmbientAgentTaskId> {
|
||||
let metadata = self
|
||||
.ai_client
|
||||
.list_ai_conversation_metadata(Some(vec![conversation_id.as_str().to_string()]))
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to load conversation '{}' to resolve artifact upload headers",
|
||||
conversation_id.as_str()
|
||||
)
|
||||
})?;
|
||||
|
||||
let metadata = single_conversation_metadata(conversation_id.as_str(), metadata)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to load conversation '{}' to resolve artifact upload headers",
|
||||
conversation_id.as_str()
|
||||
)
|
||||
})?;
|
||||
|
||||
ambient_task_id_from_conversation_metadata(conversation_id.as_str(), metadata)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_artifact_filepath(path: &Path) -> String {
|
||||
path.to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
|
||||
fn infer_mime_type(path: &Path, file_bytes: &[u8]) -> String {
|
||||
infer::get(file_bytes)
|
||||
.map(|kind| kind.mime_type().to_string())
|
||||
.unwrap_or_else(|| from_path(path).first_or_octet_stream().to_string())
|
||||
}
|
||||
|
||||
fn file_size_and_prefix_for_path(path: &Path, max_bytes: usize) -> Result<(u64, Vec<u8>)> {
|
||||
let mut file = File::open(path)
|
||||
.with_context(|| format!("Failed to open artifact file '{}'", path.display()))?;
|
||||
let file_size = file
|
||||
.metadata()
|
||||
.with_context(|| format!("Failed to stat artifact file '{}'", path.display()))?
|
||||
.len();
|
||||
let mut bytes = vec![0; max_bytes];
|
||||
let bytes_read = file
|
||||
.read(&mut bytes)
|
||||
.with_context(|| format!("Failed to read artifact file '{}'", path.display()))?;
|
||||
bytes.truncate(bytes_read);
|
||||
Ok((file_size, bytes))
|
||||
}
|
||||
|
||||
fn checked_graphql_size_bytes_for_upload(path: &Path, size_bytes: u64) -> Option<i32> {
|
||||
let graphql_size_bytes = i32::try_from(size_bytes).ok();
|
||||
if graphql_size_bytes.is_none() {
|
||||
// The backing upload can handle large files, but the GraphQL field is still `Int`.
|
||||
// Dropping `size_bytes` preserves the upload request instead of failing on conversion.
|
||||
log::warn!(
|
||||
"Artifact file '{}' is {} bytes, which exceeds the GraphQL size_bytes limit of {} bytes; omitting size_bytes from the upload target request",
|
||||
path.display(),
|
||||
size_bytes,
|
||||
i32::MAX,
|
||||
);
|
||||
}
|
||||
|
||||
graphql_size_bytes
|
||||
}
|
||||
|
||||
fn single_conversation_metadata(
|
||||
conversation_id: &str,
|
||||
mut metadata: Vec<ServerAIConversationMetadata>,
|
||||
) -> Result<ServerAIConversationMetadata> {
|
||||
match metadata.len() {
|
||||
0 => bail!("Conversation not found"),
|
||||
1 => Ok(metadata.pop().expect("metadata length checked")),
|
||||
_ => bail!("Multiple conversations found for '{conversation_id}'"),
|
||||
}
|
||||
}
|
||||
|
||||
fn ambient_task_id_from_conversation_metadata(
|
||||
conversation_id: &str,
|
||||
metadata: ServerAIConversationMetadata,
|
||||
) -> Result<AmbientAgentTaskId> {
|
||||
metadata.ambient_agent_task_id.ok_or_else(|| {
|
||||
anyhow!("Conversation '{conversation_id}' is not backed by a cloud agent task")
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_run_id(run_id: &str, error_prefix: &str) -> Result<AmbientAgentTaskId> {
|
||||
parse_ambient_task_id(run_id, error_prefix)
|
||||
}
|
||||
|
||||
fn load_env_run_id() -> Result<Option<String>> {
|
||||
match env::var(OZ_RUN_ID_ENV_VAR) {
|
||||
Ok(run_id) => Ok(Some(run_id)),
|
||||
Err(env::VarError::NotPresent) => Ok(None),
|
||||
Err(env::VarError::NotUnicode(_)) => Err(anyhow!(
|
||||
"{OZ_RUN_ID_ENV_VAR} is set but is not valid Unicode"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_env_run_id(env_run_id: Option<String>) -> Result<AmbientAgentTaskId> {
|
||||
let Some(run_id) = env_run_id else {
|
||||
bail!("{OZ_RUN_ID_ENV_VAR} is not set");
|
||||
};
|
||||
|
||||
parse_run_id(&run_id, "Invalid OZ_RUN_ID")
|
||||
}
|
||||
|
||||
fn resolve_upload_association_from_sources(
|
||||
explicit_run_id: Option<AmbientAgentTaskId>,
|
||||
explicit_conversation_id: Option<ServerConversationToken>,
|
||||
conversation_task_id: Option<Result<AmbientAgentTaskId>>,
|
||||
env_run_id: Option<String>,
|
||||
) -> Result<ResolvedUploadAssociation> {
|
||||
// Precedence is deliberate:
|
||||
// 1. An explicit run ID is authoritative and must not silently fall back.
|
||||
// 2. A conversation ID stays attached to the artifact even if we have to borrow the ambient
|
||||
// task ID from `OZ_RUN_ID` because the conversation lacks cloud-task metadata.
|
||||
// 3. `OZ_RUN_ID` becomes the sole source of truth only when the caller supplied nothing else.
|
||||
if let Some(run_id) = explicit_run_id {
|
||||
let ambient_task_id = run_id;
|
||||
return Ok(ResolvedUploadAssociation {
|
||||
conversation_id: None,
|
||||
run_id: Some(run_id),
|
||||
ambient_task_id,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(conversation_id) = explicit_conversation_id {
|
||||
match conversation_task_id
|
||||
.ok_or_else(|| anyhow!("conversation resolution should be provided"))?
|
||||
{
|
||||
Ok(ambient_task_id) => {
|
||||
return Ok(ResolvedUploadAssociation {
|
||||
conversation_id: Some(conversation_id),
|
||||
run_id: None,
|
||||
ambient_task_id,
|
||||
});
|
||||
}
|
||||
Err(conversation_err) => {
|
||||
let env_err = match resolve_env_run_id(env_run_id) {
|
||||
Ok(ambient_task_id) => {
|
||||
log::warn!(
|
||||
"Conversation '{}' task resolution failed ({conversation_err}); falling back to {OZ_RUN_ID_ENV_VAR} for ambient task context",
|
||||
conversation_id.as_str()
|
||||
);
|
||||
return Ok(ResolvedUploadAssociation {
|
||||
conversation_id: Some(conversation_id),
|
||||
run_id: None,
|
||||
ambient_task_id,
|
||||
});
|
||||
}
|
||||
Err(env_err) => env_err,
|
||||
};
|
||||
|
||||
return Err(anyhow!(
|
||||
"Failed to resolve artifact upload association for conversation '{}': {conversation_err}; also failed to use {OZ_RUN_ID_ENV_VAR}: {env_err}",
|
||||
conversation_id.as_str()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ambient_task_id = resolve_env_run_id(env_run_id).map_err(|env_err| {
|
||||
anyhow!(
|
||||
"Failed to resolve artifact upload association: no usable --run-id or --conversation-id was provided, and {OZ_RUN_ID_ENV_VAR}: {env_err}"
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(ResolvedUploadAssociation {
|
||||
conversation_id: None,
|
||||
run_id: Some(ambient_task_id),
|
||||
ambient_task_id,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "artifact_upload_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,269 @@
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::Utc;
|
||||
use tempfile::tempdir;
|
||||
use warp_cli::artifact::UploadArtifactArgs;
|
||||
|
||||
use super::*;
|
||||
use crate::ai::agent::api::ServerConversationToken;
|
||||
use crate::ai::agent::conversation::{AIAgentHarness, ServerAIConversationMetadata};
|
||||
use crate::cloud_object::{Revision, ServerMetadata, ServerPermissions};
|
||||
use crate::persistence::model::ConversationUsageMetadata;
|
||||
use crate::server::ids::ServerId;
|
||||
|
||||
fn create_mock_server_metadata() -> ServerMetadata {
|
||||
ServerMetadata {
|
||||
uid: ServerId::default(),
|
||||
revision: Revision::now(),
|
||||
metadata_last_updated_ts: Utc::now().into(),
|
||||
trashed_ts: None,
|
||||
folder_id: None,
|
||||
is_welcome_object: false,
|
||||
creator_uid: None,
|
||||
last_editor_uid: None,
|
||||
current_editor_uid: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_conversation_metadata(
|
||||
conversation_id: &str,
|
||||
ambient_task_id: Option<&str>,
|
||||
) -> ServerAIConversationMetadata {
|
||||
ServerAIConversationMetadata {
|
||||
title: "Artifact upload".to_string(),
|
||||
working_directory: None,
|
||||
harness: AIAgentHarness::Oz,
|
||||
usage: ConversationUsageMetadata {
|
||||
was_summarized: false,
|
||||
context_window_usage: 0.0,
|
||||
credits_spent: 0.0,
|
||||
credits_spent_for_last_block: None,
|
||||
token_usage: vec![],
|
||||
tool_usage_metadata: Default::default(),
|
||||
},
|
||||
metadata: create_mock_server_metadata(),
|
||||
permissions: ServerPermissions::mock_personal(),
|
||||
ambient_agent_task_id: ambient_task_id.map(|task_id| task_id.parse().unwrap()),
|
||||
server_conversation_token: ServerConversationToken::new(conversation_id.to_string()),
|
||||
artifacts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_artifact_filepath_preserves_shape_and_normalizes_separators() {
|
||||
let path = PathBuf::from(r"outputs\reports/final.txt");
|
||||
assert_eq!(
|
||||
normalize_artifact_filepath(&path),
|
||||
"outputs/reports/final.txt"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_graphql_size_bytes_for_upload_returns_none_for_overflow() {
|
||||
let path = PathBuf::from("outputs/large-artifact.bin");
|
||||
|
||||
assert_eq!(
|
||||
checked_graphql_size_bytes_for_upload(&path, i32::MAX as u64),
|
||||
Some(i32::MAX)
|
||||
);
|
||||
assert_eq!(
|
||||
checked_graphql_size_bytes_for_upload(&path, i32::MAX as u64 + 1),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_size_and_prefix_for_path_returns_truncated_prefix() {
|
||||
let tempdir = tempdir().unwrap();
|
||||
let path = tempdir.path().join("artifact.bin");
|
||||
fs::write(&path, b"0123456789").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
file_size_and_prefix_for_path(&path, 4).unwrap(),
|
||||
(10, b"0123".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_size_and_prefix_for_path_returns_full_contents_when_prefix_exceeds_file() {
|
||||
let tempdir = tempdir().unwrap();
|
||||
let path = tempdir.path().join("artifact.bin");
|
||||
fs::write(&path, b"0123456789").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
file_size_and_prefix_for_path(&path, 32).unwrap(),
|
||||
(10, b"0123456789".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_conversation_metadata_returns_the_only_metadata_record() {
|
||||
let metadata = single_conversation_metadata(
|
||||
"conversation-123",
|
||||
vec![create_conversation_metadata(
|
||||
"conversation-123",
|
||||
Some("550e8400-e29b-41d4-a716-446655440000"),
|
||||
)],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let task_id = ambient_task_id_from_conversation_metadata("conversation-123", metadata).unwrap();
|
||||
assert_eq!(
|
||||
task_id,
|
||||
"550e8400-e29b-41d4-a716-446655440000".parse().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_conversation_metadata_errors_when_no_metadata_is_returned() {
|
||||
let err = single_conversation_metadata("conversation-123", Vec::new()).unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("Conversation not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_task_id_from_conversation_metadata_requires_cloud_task_metadata() {
|
||||
let err = ambient_task_id_from_conversation_metadata(
|
||||
"conversation-123",
|
||||
create_conversation_metadata("conversation-123", None),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("Conversation 'conversation-123' is not backed by a cloud agent task"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_run_id_wins_over_env_fallback() {
|
||||
let resolved = resolve_upload_association_from_sources(
|
||||
Some("550e8400-e29b-41d4-a716-446655440000".parse().unwrap()),
|
||||
None,
|
||||
None,
|
||||
Some("11111111-1111-1111-1111-111111111111".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ResolvedUploadAssociation {
|
||||
conversation_id: None,
|
||||
run_id: Some("550e8400-e29b-41d4-a716-446655440000".parse().unwrap()),
|
||||
ambient_task_id: "550e8400-e29b-41d4-a716-446655440000".parse().unwrap(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_explicit_run_id_errors_even_if_env_fallback_exists() {
|
||||
let err = FileArtifactUploadRequest::try_from(UploadArtifactArgs {
|
||||
path: PathBuf::from("outputs/report.txt"),
|
||||
run_id: Some("not-a-run-id".to_string()),
|
||||
conversation_id: None,
|
||||
description: None,
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("Invalid run ID 'not-a-run-id'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_conversation_resolution_ignores_env_fallback() {
|
||||
let resolved = resolve_upload_association_from_sources(
|
||||
None,
|
||||
Some(ServerConversationToken::new("conversation-123".to_string())),
|
||||
Some(Ok("550e8400-e29b-41d4-a716-446655440000".parse().unwrap())),
|
||||
Some("11111111-1111-1111-1111-111111111111".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ResolvedUploadAssociation {
|
||||
conversation_id: Some(ServerConversationToken::new("conversation-123".to_string())),
|
||||
run_id: None,
|
||||
ambient_task_id: "550e8400-e29b-41d4-a716-446655440000".parse().unwrap(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_conversation_resolution_falls_back_to_env_run_id() {
|
||||
let resolved = resolve_upload_association_from_sources(
|
||||
None,
|
||||
Some(ServerConversationToken::new("conversation-123".to_string())),
|
||||
Some(Err(anyhow!(
|
||||
"Conversation 'conversation-123' is not backed by a cloud agent task"
|
||||
))),
|
||||
Some("550e8400-e29b-41d4-a716-446655440000".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ResolvedUploadAssociation {
|
||||
conversation_id: Some(ServerConversationToken::new("conversation-123".to_string())),
|
||||
run_id: None,
|
||||
ambient_task_id: "550e8400-e29b-41d4-a716-446655440000".parse().unwrap(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_args_fall_back_to_env_run_id_for_request_association() {
|
||||
let resolved = resolve_upload_association_from_sources(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("550e8400-e29b-41d4-a716-446655440000".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ResolvedUploadAssociation {
|
||||
conversation_id: None,
|
||||
run_id: Some("550e8400-e29b-41d4-a716-446655440000".parse().unwrap()),
|
||||
ambient_task_id: "550e8400-e29b-41d4-a716-446655440000".parse().unwrap(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_args_and_missing_env_return_clear_error() {
|
||||
let err = resolve_upload_association_from_sources(None, None, None, None).unwrap_err();
|
||||
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("no usable --run-id or --conversation-id was provided"));
|
||||
assert!(err.to_string().contains("OZ_RUN_ID"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_env_run_id_returns_clear_error() {
|
||||
let err =
|
||||
resolve_upload_association_from_sources(None, None, None, Some("not-a-run-id".to_string()))
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("Invalid OZ_RUN_ID 'not-a-run-id'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_env_run_id_reads_variable() {
|
||||
let previous = env::var_os(OZ_RUN_ID_ENV_VAR);
|
||||
env::set_var(OZ_RUN_ID_ENV_VAR, "550e8400-e29b-41d4-a716-446655440000");
|
||||
|
||||
let loaded = load_env_run_id().unwrap();
|
||||
|
||||
match previous {
|
||||
Some(value) => env::set_var(OZ_RUN_ID_ENV_VAR, value),
|
||||
None => env::remove_var(OZ_RUN_ID_ENV_VAR),
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
loaded.as_deref(),
|
||||
Some("550e8400-e29b-41d4-a716-446655440000")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
//! Common utilities for agent SDK commands.
|
||||
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::TryFutureExt;
|
||||
use inquire::{InquireError, Select};
|
||||
use warp_cli::agent::Harness;
|
||||
use warp_cli::environment::{EnvironmentCreateArgs, EnvironmentUpdateArgs};
|
||||
use warpui::r#async::FutureExt;
|
||||
use warpui::{AppContext, GetSingletonModelHandle, SingletonEntity as _, UpdateModel};
|
||||
|
||||
use crate::ai::agent::conversation::ServerAIConversationMetadata;
|
||||
use crate::ai::agent_sdk::driver::{AgentDriverError, WARP_DRIVE_SYNC_TIMEOUT};
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::ai::cloud_environments::CloudAmbientAgentEnvironment;
|
||||
use crate::ai::llms::{LLMId, LLMPreferences};
|
||||
use crate::auth::auth_state::AuthStateProvider;
|
||||
use crate::cloud_object::{CloudObject, Owner};
|
||||
use crate::server::cloud_objects::update_manager::UpdateManager;
|
||||
use crate::server::ids::{ServerId, SyncId};
|
||||
use crate::server::server_api::ai::AIClient;
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::workspaces::update_manager::TeamUpdateManager;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
|
||||
/// How long to wait for workspace metadata to refresh.
|
||||
pub const WORKSPACE_METADATA_REFRESH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
pub fn validate_agent_mode_base_model_id(
|
||||
model_id: &str,
|
||||
ctx: &AppContext,
|
||||
) -> anyhow::Result<LLMId> {
|
||||
let llm_prefs = LLMPreferences::as_ref(ctx);
|
||||
|
||||
let llm_id: LLMId = model_id.into();
|
||||
let valid_ids = llm_prefs
|
||||
.get_base_llm_choices_for_agent_mode()
|
||||
.map(|info| info.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if valid_ids.contains(&llm_id) {
|
||||
Ok(llm_id)
|
||||
} else {
|
||||
let suggestions = valid_ids
|
||||
.into_iter()
|
||||
.map(|id| id.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
Err(anyhow::anyhow!(
|
||||
"Unknown model id '{model_id}'. Try one of: {suggestions}"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn parse_ambient_task_id(
|
||||
run_id: &str,
|
||||
error_prefix: &str,
|
||||
) -> anyhow::Result<AmbientAgentTaskId> {
|
||||
run_id
|
||||
.parse()
|
||||
.map_err(|err| anyhow::anyhow!("{error_prefix} '{run_id}': {err}"))
|
||||
}
|
||||
|
||||
pub(super) fn set_ambient_task_context_from_run_id(
|
||||
ctx: &AppContext,
|
||||
run_id: &str,
|
||||
) -> anyhow::Result<AmbientAgentTaskId> {
|
||||
let task_id = parse_ambient_task_id(run_id, "Invalid run ID")?;
|
||||
ServerApiProvider::handle(ctx)
|
||||
.as_ref(ctx)
|
||||
.get()
|
||||
.set_ambient_agent_task_id(Some(task_id));
|
||||
Ok(task_id)
|
||||
}
|
||||
|
||||
/// Resolve the owner of a new cloud object. This resolution is based on the CLI `--team` and `--personal` flags.
|
||||
///
|
||||
/// If `team_flag` is true, attempts to get the current team UID (errors if not on a team).
|
||||
/// If `user_flag` is true, gets the current user's UID.
|
||||
/// Otherwise, defaults to team if available, falling back to user.
|
||||
pub fn resolve_owner(team_flag: bool, user_flag: bool, ctx: &AppContext) -> anyhow::Result<Owner> {
|
||||
if team_flag {
|
||||
let team_id = UserWorkspaces::as_ref(ctx)
|
||||
.current_team_uid()
|
||||
.ok_or_else(|| anyhow::anyhow!("User is not on a team"))?;
|
||||
return Ok(Owner::Team { team_uid: team_id });
|
||||
}
|
||||
|
||||
if user_flag {
|
||||
let user_id = AuthStateProvider::as_ref(ctx)
|
||||
.get()
|
||||
.user_id()
|
||||
.ok_or_else(|| anyhow::anyhow!("User should be logged in"))?;
|
||||
return Ok(Owner::User { user_uid: user_id });
|
||||
}
|
||||
|
||||
// Default: try team first, fall back to user
|
||||
if let Some(team_uid) = UserWorkspaces::as_ref(ctx).current_team_uid() {
|
||||
return Ok(Owner::Team { team_uid });
|
||||
}
|
||||
|
||||
log::warn!("Tried to default to creating team object, team could not be found.");
|
||||
let user_id = AuthStateProvider::as_ref(ctx)
|
||||
.get()
|
||||
.user_id()
|
||||
.ok_or_else(|| anyhow::anyhow!("User should be logged in"))?;
|
||||
Ok(Owner::User { user_uid: user_id })
|
||||
}
|
||||
|
||||
/// Refresh workspace metadata before executing an operation.
|
||||
///
|
||||
/// This ensures that team state is up-to-date before creating cloud objects or performing
|
||||
/// other operations that depend on team membership.
|
||||
pub fn refresh_workspace_metadata<C>(
|
||||
ctx: &mut C,
|
||||
) -> impl Future<Output = anyhow::Result<()>> + Send + 'static
|
||||
where
|
||||
C: GetSingletonModelHandle + UpdateModel,
|
||||
{
|
||||
let refresh_future = TeamUpdateManager::handle(ctx).update(ctx, |manager, ctx| {
|
||||
manager
|
||||
.refresh_workspace_metadata(ctx)
|
||||
.with_timeout(WORKSPACE_METADATA_REFRESH_TIMEOUT)
|
||||
});
|
||||
|
||||
async move {
|
||||
let _ = refresh_future
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Timed out refreshing team metadata"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh Warp Drive before executing an operation.
|
||||
pub fn refresh_warp_drive(
|
||||
ctx: &AppContext,
|
||||
) -> impl Future<Output = anyhow::Result<()>> + Send + 'static {
|
||||
UpdateManager::as_ref(ctx)
|
||||
.initial_load_complete()
|
||||
.with_timeout(WARP_DRIVE_SYNC_TIMEOUT)
|
||||
.map_err(|_| anyhow::anyhow!("Timed out waiting for Warp Drive to sync"))
|
||||
}
|
||||
|
||||
/// Fetch the conversation's server metadata and validate that its harness matches the caller's
|
||||
/// `--harness` choice. Returns the metadata on success so the caller can reuse it (e.g. for the
|
||||
/// server conversation token).
|
||||
///
|
||||
/// Called up-front before any task/config-build logic consumes `args.harness`, so a mismatch
|
||||
/// error surfaces before side effects like task creation. We deliberately do NOT auto-upgrade
|
||||
/// the harness: `Harness::Oz` default with a Claude conversation id is treated as a mismatch
|
||||
/// and errors out.
|
||||
pub(super) async fn fetch_and_validate_conversation_harness(
|
||||
ai_client: Arc<dyn AIClient>,
|
||||
conversation_id: &str,
|
||||
args_harness: Harness,
|
||||
) -> Result<ServerAIConversationMetadata, AgentDriverError> {
|
||||
let metadata = ai_client
|
||||
.list_ai_conversation_metadata(Some(vec![conversation_id.to_string()]))
|
||||
.await
|
||||
.map_err(|e| AgentDriverError::ConversationLoadFailed(format!("{e:#}")))?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| {
|
||||
AgentDriverError::ConversationLoadFailed(format!(
|
||||
"conversation {conversation_id} not found or not accessible"
|
||||
))
|
||||
})?;
|
||||
|
||||
if metadata.harness != args_harness {
|
||||
return Err(AgentDriverError::ConversationHarnessMismatch {
|
||||
conversation_id: conversation_id.to_string(),
|
||||
expected: Harness::from(metadata.harness).to_string(),
|
||||
got: args_harness.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
/// Format an object owner for display in the CLI.
|
||||
pub fn format_owner(owner: &Owner) -> &'static str {
|
||||
// TODO: For potentially-shared objects, consider looking up the particular user/team name.
|
||||
match owner {
|
||||
Owner::User { .. } => "Personal",
|
||||
Owner::Team { .. } => "Team",
|
||||
}
|
||||
}
|
||||
|
||||
/// An error resolving an agent option, which we may have prompted the user for.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ResolveConfigurationError {
|
||||
/// The user canceled the operation, and we should exit.
|
||||
#[error("Operation canceled")]
|
||||
Canceled,
|
||||
#[error("{id} is not a valid {kind} identifier")]
|
||||
InvalidId { id: String, kind: &'static str },
|
||||
#[error("{kind} {id} not found")]
|
||||
ObjectNotFound { id: String, kind: &'static str },
|
||||
#[error(transparent)]
|
||||
Other(anyhow::Error),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum EnvironmentChoice {
|
||||
/// The user explicitly chose not to use an environment.
|
||||
None,
|
||||
/// The user chose a specific environment.
|
||||
Environment { id: String, name: String },
|
||||
}
|
||||
|
||||
impl EnvironmentChoice {
|
||||
/// Resolve the environment to use when creating an agent integration.
|
||||
/// Warp Drive *must* have been synced first.
|
||||
pub fn resolve_for_create(
|
||||
args: EnvironmentCreateArgs,
|
||||
ctx: &AppContext,
|
||||
) -> Result<Self, ResolveConfigurationError> {
|
||||
if args.no_environment {
|
||||
Ok(EnvironmentChoice::None)
|
||||
} else if let Some(id) = args.environment {
|
||||
Self::get_by_id(id, ctx)
|
||||
} else {
|
||||
let all_environments = CloudAmbientAgentEnvironment::get_all(ctx);
|
||||
let mut synced_environments: Vec<(ServerId, &CloudAmbientAgentEnvironment)> =
|
||||
all_environments
|
||||
.iter()
|
||||
.filter_map(|env| {
|
||||
if let SyncId::ServerId(server_id) = env.sync_id() {
|
||||
Some((server_id, env))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
synced_environments
|
||||
.sort_by_key(|(_, env)| env.model().string_model.name.to_lowercase());
|
||||
|
||||
let environments: Vec<EnvironmentChoice> = synced_environments
|
||||
.into_iter()
|
||||
.map(|(server_id, env)| EnvironmentChoice::Environment {
|
||||
id: server_id.to_string(),
|
||||
name: env.model().string_model.name.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut options = vec![EnvironmentChoice::None];
|
||||
options.extend(environments);
|
||||
|
||||
// If there are no synced environments, require the user to create one or use --no-environment.
|
||||
if options.len() == 1 {
|
||||
let cli_name = warp_cli::binary_name().unwrap_or_else(|| "warp".to_string());
|
||||
return Err(ResolveConfigurationError::Other(anyhow::anyhow!(
|
||||
"No environments are configured for this account.\n\
|
||||
You can create an environment with `{cli_name} environment create`.\n\
|
||||
Or, re-run this command with `--no-environment` to not use an environment.\n\
|
||||
Without an environment, the agent will not be able to access private repositories or create pull requests.",
|
||||
)));
|
||||
}
|
||||
|
||||
let prompt = "Select an environment to run the agent in (or 'No environment'):";
|
||||
|
||||
let choice = Select::new(prompt, options).prompt();
|
||||
|
||||
match choice {
|
||||
Ok(choice) => Ok(choice),
|
||||
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
|
||||
Err(ResolveConfigurationError::Canceled)
|
||||
}
|
||||
Err(err) => Err(ResolveConfigurationError::Other(anyhow::anyhow!(
|
||||
"Error selecting environment: {err}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the environment to use when updating an agent integration. If the user did not
|
||||
/// request any changes to the environment, this returns `Ok(None)`.
|
||||
/// Warp Drive *must* have been synced first.
|
||||
pub fn resolve_for_update(
|
||||
args: EnvironmentUpdateArgs,
|
||||
ctx: &AppContext,
|
||||
) -> Result<Option<Self>, ResolveConfigurationError> {
|
||||
if args.remove_environment {
|
||||
Ok(Some(EnvironmentChoice::None))
|
||||
} else if let Some(id) = args.environment {
|
||||
Self::get_by_id(id, ctx).map(Some)
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_by_id(id: String, ctx: &AppContext) -> Result<Self, ResolveConfigurationError> {
|
||||
let sync_id = SyncId::ServerId(ServerId::try_from(id.as_str()).map_err(|_| {
|
||||
ResolveConfigurationError::InvalidId {
|
||||
id: id.clone(),
|
||||
kind: "environment",
|
||||
}
|
||||
})?);
|
||||
|
||||
let environment =
|
||||
CloudAmbientAgentEnvironment::get_by_id(&sync_id, ctx).ok_or_else(|| {
|
||||
ResolveConfigurationError::ObjectNotFound {
|
||||
id: id.clone(),
|
||||
kind: "environment",
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(EnvironmentChoice::Environment {
|
||||
id,
|
||||
name: environment.model().string_model.name.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for EnvironmentChoice {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
EnvironmentChoice::None => write!(
|
||||
f,
|
||||
"No environment (agent will not be able to access private repositories or create pull requests)",
|
||||
),
|
||||
EnvironmentChoice::Environment { id, name } => write!(f, "{name} ({id})"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_ambient_task_id;
|
||||
|
||||
#[test]
|
||||
fn parse_ambient_task_id_accepts_valid_ids() {
|
||||
let task_id =
|
||||
parse_ambient_task_id("550e8400-e29b-41d4-a716-446655440000", "Invalid run ID")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(task_id.to_string(), "550e8400-e29b-41d4-a716-446655440000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ambient_task_id_preserves_error_prefix() {
|
||||
let err = parse_ambient_task_id("not-a-run-id", "Invalid run ID").unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("Invalid run ID 'not-a-run-id'"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use serde_json::{Map, Value};
|
||||
use warp_cli::mcp::MCPSpec;
|
||||
|
||||
use crate::ai::ambient_agents::AgentConfigSnapshot;
|
||||
|
||||
/// A strict, file-based representation of `AgentConfigSnapshot`.
|
||||
///
|
||||
/// Notes:
|
||||
/// - Keys are snake_case and unknown keys are rejected.
|
||||
/// - MCP configuration must be provided only under the `mcp_servers` key and must be the
|
||||
/// unwrapped server map `{ <server_name>: <server_config>, ... }`.
|
||||
#[derive(Debug, Clone, Default, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AgentConfigSnapshotFile {
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub environment_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub model_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub base_prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
pub mcp_servers: Option<Map<String, Value>>,
|
||||
#[serde(default)]
|
||||
pub host: Option<String>,
|
||||
#[serde(default)]
|
||||
pub computer_use_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoadedAgentConfigSnapshotFile {
|
||||
pub file: AgentConfigSnapshotFile,
|
||||
}
|
||||
|
||||
/// Load an `AgentConfigSnapshotFile` from disk.
|
||||
///
|
||||
/// Parsing rules:
|
||||
/// - `.json` => JSON
|
||||
/// - `.yml` / `.yaml` => YAML
|
||||
/// - otherwise: try JSON, then YAML
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn load_config_file(path: &Path) -> anyhow::Result<LoadedAgentConfigSnapshotFile> {
|
||||
let contents = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read config file '{}'", path.display()))?;
|
||||
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_ascii_lowercase());
|
||||
|
||||
let file = match ext.as_deref() {
|
||||
Some("json") => parse_json(&contents)
|
||||
.with_context(|| format!("Invalid JSON in config file '{}'", path.display()))?,
|
||||
Some("yml") | Some("yaml") => parse_yaml(&contents)
|
||||
.with_context(|| format!("Invalid YAML in config file '{}'", path.display()))?,
|
||||
_ => parse_json(&contents)
|
||||
.or_else(|_| parse_yaml(&contents))
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to parse config file '{}' as JSON or YAML",
|
||||
path.display()
|
||||
)
|
||||
})?,
|
||||
};
|
||||
|
||||
if let Some(mcp_servers) = &file.mcp_servers {
|
||||
super::mcp_config::validate_mcp_servers(mcp_servers)
|
||||
.with_context(|| format!("Invalid mcp_servers in '{}'", path.display()))?;
|
||||
}
|
||||
|
||||
Ok(LoadedAgentConfigSnapshotFile { file })
|
||||
}
|
||||
|
||||
/// WASM builds don't use CLI command execution / local file access.
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub fn load_config_file(_path: &Path) -> anyhow::Result<LoadedAgentConfigSnapshotFile> {
|
||||
Err(anyhow::anyhow!(
|
||||
"Config files are not supported in WASM builds"
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_json(input: &str) -> anyhow::Result<AgentConfigSnapshotFile> {
|
||||
serde_json::from_str::<AgentConfigSnapshotFile>(input).with_context(supported_keys_context)
|
||||
}
|
||||
|
||||
fn parse_yaml(input: &str) -> anyhow::Result<AgentConfigSnapshotFile> {
|
||||
// `serde_yaml` can deserialize into `serde_json::Value` directly.
|
||||
serde_yaml::from_str::<AgentConfigSnapshotFile>(input).with_context(supported_keys_context)
|
||||
}
|
||||
|
||||
fn supported_keys_context() -> String {
|
||||
"Supported keys: name, environment_id, model_id, base_prompt, mcp_servers, host, computer_use_enabled".to_string()
|
||||
}
|
||||
|
||||
/// Convert an unwrapped `mcp_servers` map into runtime MCP specs for AgentDriver.
|
||||
///
|
||||
/// Behavior:
|
||||
/// - Entries with `warp_id` become `MCPSpec::Uuid`.
|
||||
/// - Entries with `command`/`url` remain as inline JSON (`MCPSpec::Json`) containing the unwrapped server map.
|
||||
pub fn mcp_specs_from_mcp_servers(
|
||||
mcp_servers: &Map<String, Value>,
|
||||
) -> anyhow::Result<Vec<MCPSpec>> {
|
||||
let mut uuids: Vec<uuid::Uuid> = Vec::new();
|
||||
let mut json_map: Map<String, Value> = Map::new();
|
||||
|
||||
for (name, config) in mcp_servers {
|
||||
let obj = config
|
||||
.as_object()
|
||||
.ok_or_else(|| anyhow::anyhow!("MCP server '{name}' config must be a JSON object"))?;
|
||||
|
||||
if let Some(warp_id) = obj.get("warp_id").and_then(Value::as_str) {
|
||||
let uuid = uuid::Uuid::parse_str(warp_id).map_err(|_| {
|
||||
anyhow::anyhow!("MCP server '{name}' field 'warp_id' must be a UUID")
|
||||
})?;
|
||||
uuids.push(uuid);
|
||||
} else {
|
||||
json_map.insert(name.clone(), config.clone());
|
||||
}
|
||||
}
|
||||
|
||||
uuids.sort();
|
||||
uuids.dedup();
|
||||
|
||||
let mut specs: Vec<MCPSpec> = uuids.into_iter().map(MCPSpec::Uuid).collect();
|
||||
|
||||
if !json_map.is_empty() {
|
||||
let json =
|
||||
serde_json::to_string(&json_map).context("Failed to serialize MCP server map")?;
|
||||
specs.push(MCPSpec::Json(json));
|
||||
}
|
||||
|
||||
Ok(specs)
|
||||
}
|
||||
|
||||
/// Merge config file settings with CLI-provided overrides.
|
||||
///
|
||||
/// Precedence: CLI > file > default.
|
||||
pub fn merge_with_precedence(
|
||||
file: Option<&LoadedAgentConfigSnapshotFile>,
|
||||
cli: AgentConfigSnapshot,
|
||||
) -> AgentConfigSnapshot {
|
||||
let default_file = AgentConfigSnapshotFile::default();
|
||||
let file = file.map(|loaded| &loaded.file).unwrap_or(&default_file);
|
||||
|
||||
let name = cli.name.or_else(|| file.name.clone());
|
||||
let environment_id = cli.environment_id.or_else(|| file.environment_id.clone());
|
||||
let model_id = cli.model_id.or_else(|| file.model_id.clone());
|
||||
let base_prompt = cli.base_prompt.or_else(|| file.base_prompt.clone());
|
||||
|
||||
let mcp_servers = merge_mcp_servers(file.mcp_servers.clone(), cli.mcp_servers);
|
||||
let worker_host = cli.worker_host.or_else(|| file.host.clone());
|
||||
let computer_use_enabled = cli.computer_use_enabled.or(file.computer_use_enabled);
|
||||
|
||||
AgentConfigSnapshot {
|
||||
name,
|
||||
environment_id,
|
||||
model_id,
|
||||
base_prompt,
|
||||
mcp_servers,
|
||||
profile_id: None,
|
||||
worker_host,
|
||||
skill_spec: cli.skill_spec,
|
||||
computer_use_enabled,
|
||||
harness: cli.harness,
|
||||
harness_auth_secrets: cli.harness_auth_secrets,
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge MCP servers from two sources.
|
||||
///
|
||||
/// Returns the merged map, or None if both inputs are None/empty.
|
||||
pub fn merge_mcp_servers(
|
||||
file_mcp: Option<Map<String, Value>>,
|
||||
cli_mcp: Option<Map<String, Value>>,
|
||||
) -> Option<Map<String, Value>> {
|
||||
match (file_mcp, cli_mcp) {
|
||||
(None, None) => None,
|
||||
(Some(map), None) => {
|
||||
if map.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(map)
|
||||
}
|
||||
}
|
||||
(None, Some(map)) => {
|
||||
if map.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(map)
|
||||
}
|
||||
}
|
||||
(Some(mut file_map), Some(cli_map)) => {
|
||||
for (k, v) in cli_map {
|
||||
file_map.insert(k, v);
|
||||
}
|
||||
if file_map.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(file_map)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "config_file_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,214 @@
|
||||
#![cfg(not(target_family = "wasm"))]
|
||||
|
||||
use std::io::Write as _;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::ai::ambient_agents::AgentConfigSnapshot;
|
||||
use warp_cli::mcp::MCPSpec;
|
||||
|
||||
fn write_temp(suffix: &str, contents: &str) -> tempfile::NamedTempFile {
|
||||
let mut file = tempfile::Builder::new().suffix(suffix).tempfile().unwrap();
|
||||
file.write_all(contents.as_bytes()).unwrap();
|
||||
file
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_json_and_validates_mcp_servers() {
|
||||
let contents = json!({
|
||||
"model_id": "gpt-4o",
|
||||
"environment_id": "env-123",
|
||||
"base_prompt": "be helpful",
|
||||
"mcp_servers": {
|
||||
"s": { "command": "npx", "args": [] }
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let file = write_temp(".json", &contents);
|
||||
let loaded = super::load_config_file(file.path()).unwrap();
|
||||
|
||||
assert_eq!(loaded.file.model_id.as_deref(), Some("gpt-4o"));
|
||||
assert_eq!(loaded.file.environment_id.as_deref(), Some("env-123"));
|
||||
assert_eq!(loaded.file.base_prompt.as_deref(), Some("be helpful"));
|
||||
assert!(loaded.file.mcp_servers.is_some());
|
||||
assert!(loaded.file.mcp_servers.as_ref().unwrap().contains_key("s"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_yaml() {
|
||||
let contents = r#"
|
||||
model_id: gpt-4o
|
||||
mcp_servers:
|
||||
s:
|
||||
command: npx
|
||||
args: []
|
||||
"#;
|
||||
|
||||
let file = write_temp(".yaml", contents);
|
||||
let loaded = super::load_config_file(file.path()).unwrap();
|
||||
|
||||
assert_eq!(loaded.file.model_id.as_deref(), Some("gpt-4o"));
|
||||
assert!(loaded.file.mcp_servers.as_ref().unwrap().contains_key("s"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_keys_are_rejected() {
|
||||
let contents = json!({
|
||||
"model_id": "gpt-4o",
|
||||
"typo_model": "oops"
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let file = write_temp(".json", &contents);
|
||||
let err = super::load_config_file(file.path()).unwrap_err();
|
||||
let err_str = format!("{err:#}");
|
||||
assert!(err_str.contains("Supported keys"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_must_be_under_mcp_servers_key() {
|
||||
let contents = json!({
|
||||
"model_id": "gpt-4o",
|
||||
"mcpServers": { "s": { "command": "npx", "args": [] } }
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let file = write_temp(".json", &contents);
|
||||
let err = super::load_config_file(file.path()).unwrap_err();
|
||||
let err_str = format!("{err:#}");
|
||||
assert!(err_str.contains("Supported keys"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_precedence_cli_over_file_and_merges_mcp() {
|
||||
let contents = json!({
|
||||
"model_id": "file-model",
|
||||
"mcp_servers": {
|
||||
"a": { "url": "https://example.com/mcp" }
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let file = write_temp(".json", &contents);
|
||||
let loaded = super::load_config_file(file.path()).unwrap();
|
||||
|
||||
let cli = AgentConfigSnapshot {
|
||||
name: Some("cli-name".to_string()),
|
||||
environment_id: None,
|
||||
model_id: Some("cli-model".to_string()),
|
||||
base_prompt: None,
|
||||
mcp_servers: Some(serde_json::Map::from_iter([(
|
||||
"a".to_string(),
|
||||
json!({"command": "npx", "args": []}),
|
||||
)])),
|
||||
profile_id: None,
|
||||
worker_host: None,
|
||||
skill_spec: None,
|
||||
computer_use_enabled: None,
|
||||
harness: None,
|
||||
harness_auth_secrets: None,
|
||||
};
|
||||
|
||||
let merged = super::merge_with_precedence(Some(&loaded), cli);
|
||||
|
||||
assert_eq!(merged.name.as_deref(), Some("cli-name"));
|
||||
assert_eq!(merged.model_id.as_deref(), Some("cli-model"));
|
||||
|
||||
let a = merged.mcp_servers.as_ref().unwrap().get("a").unwrap();
|
||||
assert_eq!(a.get("command").and_then(|v| v.as_str()), Some("npx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_empty_mcp_servers_is_loaded_as_empty_map() {
|
||||
let contents = json!({
|
||||
"mcp_servers": {}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let file = write_temp(".json", &contents);
|
||||
let loaded = super::load_config_file(file.path()).unwrap();
|
||||
|
||||
assert!(loaded.file.mcp_servers.is_some());
|
||||
assert!(loaded.file.mcp_servers.as_ref().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_servers_map_converts_to_runtime_specs() {
|
||||
let contents = json!({
|
||||
"mcp_servers": {
|
||||
"existing": { "warp_id": "550e8400-e29b-41d4-a716-446655440000" },
|
||||
"ephemeral": { "command": "npx", "args": [] }
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let file = write_temp(".json", &contents);
|
||||
let loaded = super::load_config_file(file.path()).unwrap();
|
||||
|
||||
let map = loaded.file.mcp_servers.as_ref().unwrap();
|
||||
let specs = super::mcp_specs_from_mcp_servers(map).unwrap();
|
||||
|
||||
assert!(specs.iter().any(|s| matches!(s, MCPSpec::Uuid(_))));
|
||||
assert!(specs.iter().any(|s| matches!(s, MCPSpec::Json(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_computer_use_enabled_from_json() {
|
||||
let contents = json!({
|
||||
"computer_use_enabled": true
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let file = write_temp(".json", &contents);
|
||||
let loaded = super::load_config_file(file.path()).unwrap();
|
||||
|
||||
assert_eq!(loaded.file.computer_use_enabled, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_computer_use_enabled_from_yaml() {
|
||||
let contents = "computer_use_enabled: false\n";
|
||||
|
||||
let file = write_temp(".yaml", contents);
|
||||
let loaded = super::load_config_file(file.path()).unwrap();
|
||||
|
||||
assert_eq!(loaded.file.computer_use_enabled, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_precedence_cli_computer_use_enabled_over_file() {
|
||||
let contents = json!({
|
||||
"computer_use_enabled": false
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let file = write_temp(".json", &contents);
|
||||
let loaded = super::load_config_file(file.path()).unwrap();
|
||||
|
||||
let cli = AgentConfigSnapshot {
|
||||
computer_use_enabled: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let merged = super::merge_with_precedence(Some(&loaded), cli);
|
||||
|
||||
assert_eq!(merged.computer_use_enabled, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_precedence_file_computer_use_enabled_when_cli_none() {
|
||||
let contents = json!({
|
||||
"computer_use_enabled": true
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let file = write_temp(".json", &contents);
|
||||
let loaded = super::load_config_file(file.path()).unwrap();
|
||||
|
||||
let cli = AgentConfigSnapshot::default();
|
||||
|
||||
let merged = super::merge_with_precedence(Some(&loaded), cli);
|
||||
|
||||
assert_eq!(merged.computer_use_enabled, Some(true));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,313 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use base64::{engine::general_purpose, Engine};
|
||||
use futures::future::join_all;
|
||||
use futures::TryStreamExt as _;
|
||||
use mime_guess::from_path;
|
||||
use tokio::fs;
|
||||
use tokio_util::io::StreamReader;
|
||||
use warp_core::features::FeatureFlag;
|
||||
|
||||
use crate::ai::agent_sdk::retry::with_bounded_retry;
|
||||
use crate::ai::ambient_agents::task::{AttachmentInput, TaskAttachment};
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::ai::attachment_utils::MAX_ATTACHMENT_SIZE_BYTES;
|
||||
use crate::server::server_api::ai::AIClient;
|
||||
use crate::server::server_api::presigned_upload::HttpStatusError;
|
||||
use crate::server::server_api::ServerApi;
|
||||
use crate::util::image::MIN_IMAGE_HEADER_SIZE;
|
||||
|
||||
/// Maximum number of file attachments for a cloud agent task.
|
||||
pub const MAX_ATTACHMENT_COUNT_FOR_CLOUD_QUERY: usize = 25;
|
||||
|
||||
/// Fetches task attachments via GraphQL and downloads them to the filesystem.
|
||||
/// Returns the attachments directory path if any attachments were downloaded,
|
||||
/// so the caller can pass it to the server via `StartFromAmbientRunPrompt`.
|
||||
///
|
||||
/// `attachments_dir` is the per-session directory where files should be downloaded.
|
||||
///
|
||||
/// Makes a best-effort attempt to download all attachments.
|
||||
/// Individual download failures are logged but don't cause the entire function to fail.
|
||||
pub(crate) async fn fetch_and_download_attachments(
|
||||
ai_client: Arc<dyn AIClient>,
|
||||
http_client: Arc<ServerApi>,
|
||||
task_id: String,
|
||||
attachments_dir: PathBuf,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
if !FeatureFlag::AmbientAgentsImageUpload.is_enabled() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let attachments = ai_client
|
||||
.get_task_attachments(task_id.clone())
|
||||
.await
|
||||
.context("Failed to fetch task attachments")?;
|
||||
|
||||
log::info!("Fetched {} task attachments", attachments.len());
|
||||
|
||||
if attachments.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
download_and_write_attachments(attachments, &attachments_dir, &http_client).await?;
|
||||
|
||||
Ok(Some(attachments_dir.to_string_lossy().into_owned()))
|
||||
}
|
||||
|
||||
/// Fetches handoff snapshot attachments for the active execution and downloads
|
||||
/// them into `{attachments_dir}/handoff/{attachment_uuid}` so the runtime's
|
||||
/// rehydration prompt references always point at a file that exists on disk.
|
||||
///
|
||||
/// Returns `Some(attachments_dir)` when at least one attachment wrote to disk, mirroring
|
||||
/// the contract of the sibling [`fetch_and_download_attachments`]. Partial failures are
|
||||
/// logged at WARN level inside this function; per-file errors are not surfaced to callers.
|
||||
///
|
||||
/// Fatal failures (listing the attachments, creating the handoff dir) return `Err`.
|
||||
pub(crate) async fn fetch_and_download_handoff_snapshot_attachments(
|
||||
ai_client: Arc<dyn AIClient>,
|
||||
http_client: &http_client::Client,
|
||||
task_id: AmbientAgentTaskId,
|
||||
attachments_dir: PathBuf,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
if !FeatureFlag::OzHandoff.is_enabled() {
|
||||
log::error!(
|
||||
"fetch_and_download_handoff_snapshot_attachments called with OzHandoff disabled; \
|
||||
call sites should gate on the flag before invoking"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let attachments = ai_client
|
||||
.get_handoff_snapshot_attachments(&task_id)
|
||||
.await
|
||||
.context("Failed to fetch handoff snapshot attachments")?;
|
||||
|
||||
if attachments.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let handoff_dir = attachments_dir.join("handoff");
|
||||
fs::create_dir_all(&handoff_dir)
|
||||
.await
|
||||
.context("Failed to create handoff attachments directory")?;
|
||||
|
||||
let attempts = attachments.len();
|
||||
let download_futures = attachments.into_iter().map(|attachment| {
|
||||
let file_path = handoff_dir.join(&attachment.file_id);
|
||||
download_handoff_entry(attachment, file_path, http_client)
|
||||
});
|
||||
let results = join_all(download_futures).await;
|
||||
|
||||
let mut succeeded: usize = 0;
|
||||
let mut failures: Vec<(String, String)> = Vec::new();
|
||||
for result in results {
|
||||
match result {
|
||||
Ok(()) => succeeded += 1,
|
||||
Err((filename, err)) => failures.push((filename, err)),
|
||||
}
|
||||
}
|
||||
|
||||
if failures.is_empty() {
|
||||
log::info!("Handoff snapshot attachments: {succeeded}/{attempts} downloaded");
|
||||
} else {
|
||||
let detail = failures
|
||||
.iter()
|
||||
.map(|(filename, err)| format!("{filename}: {err}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
log::warn!(
|
||||
"Handoff snapshot attachments: {succeeded}/{attempts} downloaded; {} failed ({detail})",
|
||||
failures.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Only surface the attachments dir if at least one file made it to disk. Passing a dir
|
||||
// with zero usable entries downstream would make the rehydration prompt reference a
|
||||
// phantom path.
|
||||
if succeeded == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(attachments_dir.to_string_lossy().into_owned()))
|
||||
}
|
||||
|
||||
/// Downloads task attachments from presigned URLs and writes them to the filesystem.
|
||||
/// Downloads are performed concurrently using `join_all`.
|
||||
/// Makes a best-effort attempt to download all attachments, logging warnings for failures.
|
||||
/// The filename is already formatted by the server with UUID prefix (e.g., "uuid_filename.png").
|
||||
async fn download_and_write_attachments(
|
||||
attachments: Vec<TaskAttachment>,
|
||||
attachment_dir: &Path,
|
||||
http_client: &ServerApi,
|
||||
) -> anyhow::Result<()> {
|
||||
fs::create_dir_all(attachment_dir)
|
||||
.await
|
||||
.context("Failed to create attachments directory")?;
|
||||
log::info!(
|
||||
"Created attachments directory at: {}",
|
||||
attachment_dir.display()
|
||||
);
|
||||
|
||||
let http = http_client.http_client();
|
||||
let download_futures = attachments
|
||||
.into_iter()
|
||||
.map(|attachment| download_task_attachment(attachment, attachment_dir, http));
|
||||
let results = join_all(download_futures).await;
|
||||
|
||||
let mut successful = 0;
|
||||
let mut failed = 0;
|
||||
for result in results {
|
||||
match result {
|
||||
Ok(()) => successful += 1,
|
||||
Err(_) => failed += 1,
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Attachment download complete: {successful} successful, {failed} failed");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download a single task attachment into `attachment_dir/<sanitized filename>`.
|
||||
///
|
||||
/// Delegates to [`download_attachment`] so transient failures retry on the shared schedule.
|
||||
async fn download_task_attachment(
|
||||
attachment: TaskAttachment,
|
||||
attachment_dir: &Path,
|
||||
http_client: &http_client::Client,
|
||||
) -> anyhow::Result<()> {
|
||||
let safe_filename = Path::new(&attachment.filename)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid filename for file_id={}", attachment.file_id))?
|
||||
.to_string();
|
||||
|
||||
let file_path = attachment_dir.join(&safe_filename);
|
||||
log::info!(
|
||||
"Downloading attachment: {} -> {}",
|
||||
attachment.filename,
|
||||
file_path.display()
|
||||
);
|
||||
|
||||
download_attachment(http_client, &attachment.download_url, &file_path).await?;
|
||||
|
||||
log::info!("Successfully wrote attachment to: {}", file_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download a single handoff attachment into `file_path`, mapping failure to
|
||||
/// `(filename, error_message)` so the aggregator in
|
||||
/// [`fetch_and_download_handoff_snapshot_attachments`] can log and count per-file outcomes.
|
||||
async fn download_handoff_entry(
|
||||
attachment: TaskAttachment,
|
||||
file_path: PathBuf,
|
||||
http_client: &http_client::Client,
|
||||
) -> Result<(), (String, String)> {
|
||||
// Factor `file_id` and `download_url` out before the retry closure so `attachment` is fully
|
||||
// consumed up-front. The closure borrows the two fields it needs as references.
|
||||
let TaskAttachment {
|
||||
file_id,
|
||||
download_url,
|
||||
..
|
||||
} = attachment;
|
||||
download_attachment(http_client, &download_url, &file_path)
|
||||
.await
|
||||
.map_err(|e| (file_id, format!("{e:#}")))
|
||||
}
|
||||
|
||||
/// Shared download primitive: GET `download_url`, write the body to `file_path`, and retry
|
||||
/// transient HTTP failures on the shared bounded-backoff schedule. Non-2xx responses surface
|
||||
/// an [`HttpStatusError`] so the retry classifier can decide whether to retry.
|
||||
async fn download_attachment(
|
||||
http_client: &http_client::Client,
|
||||
download_url: &str,
|
||||
file_path: &Path,
|
||||
) -> anyhow::Result<()> {
|
||||
let operation = format!("download attachment '{}'", file_path.display());
|
||||
with_bounded_retry(&operation, || async {
|
||||
let response = http_client
|
||||
.get(download_url)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send download request")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(anyhow::Error::new(HttpStatusError {
|
||||
status: status.as_u16(),
|
||||
body: body.clone(),
|
||||
})
|
||||
.context(format!("Download failed with status {status}: {body}")));
|
||||
}
|
||||
|
||||
// Stream the response body directly to disk instead of buffering the full payload
|
||||
// in memory.
|
||||
let mut file = fs::File::create(file_path)
|
||||
.await
|
||||
.context("Failed to create file")?;
|
||||
let mut response_stream =
|
||||
StreamReader::new(response.bytes_stream().map_err(std::io::Error::other));
|
||||
tokio::io::copy(&mut response_stream, &mut file)
|
||||
.await
|
||||
.context("Failed to write file")?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Process a file attachment for ambient agent upload.
|
||||
/// Returns AttachmentInput with base64-encoded data.
|
||||
/// All file types share the same 10MB size limit.
|
||||
pub fn process_attachment(
|
||||
attachment_path: &PathBuf,
|
||||
index: usize,
|
||||
) -> anyhow::Result<AttachmentInput> {
|
||||
let file_bytes = std::fs::read(attachment_path).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to read attachment file '{}': {e}",
|
||||
attachment_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
// Detect MIME type from file data using infer crate, fall back to file extension
|
||||
let mime_type = if file_bytes.len() >= MIN_IMAGE_HEADER_SIZE {
|
||||
infer::get(&file_bytes).map(|kind| kind.mime_type().to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// If infer couldn't detect, fall back to file extension
|
||||
let mime_type = mime_type.unwrap_or_else(|| {
|
||||
from_path(attachment_path)
|
||||
.first_or_octet_stream()
|
||||
.to_string()
|
||||
});
|
||||
|
||||
if file_bytes.len() > MAX_ATTACHMENT_SIZE_BYTES {
|
||||
return Err(anyhow::anyhow!(
|
||||
"File is too large ({}MB). Maximum size is 10MB.",
|
||||
file_bytes.len() / (1024 * 1024)
|
||||
));
|
||||
}
|
||||
|
||||
let base64_data = general_purpose::STANDARD.encode(&file_bytes);
|
||||
|
||||
let file_name = attachment_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("task_attachment_{index}"));
|
||||
|
||||
Ok(AttachmentInput {
|
||||
file_name,
|
||||
mime_type,
|
||||
data: base64_data,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "attachments_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,389 @@
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
|
||||
use mockito::{Matcher, Server};
|
||||
use tempfile::{Builder as TempDirBuilder, NamedTempFile, TempDir};
|
||||
|
||||
use super::*;
|
||||
use crate::ai::agent_sdk::test_support::build_test_http_client;
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::server::server_api::ai::MockAIClient;
|
||||
|
||||
#[test]
|
||||
fn process_attachment_text_file() {
|
||||
let mut f = NamedTempFile::with_suffix(".txt").unwrap();
|
||||
write!(f, "hello world").unwrap();
|
||||
|
||||
let result = process_attachment(&f.path().to_path_buf(), 0).unwrap();
|
||||
assert_eq!(
|
||||
result.file_name,
|
||||
f.path().file_name().unwrap().to_str().unwrap()
|
||||
);
|
||||
assert_eq!(result.mime_type, "text/plain");
|
||||
assert_eq!(
|
||||
general_purpose::STANDARD.decode(&result.data).unwrap(),
|
||||
b"hello world"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_attachment_too_large() {
|
||||
let mut f = NamedTempFile::with_suffix(".bin").unwrap();
|
||||
let data = vec![0u8; MAX_ATTACHMENT_SIZE_BYTES + 1];
|
||||
f.write_all(&data).unwrap();
|
||||
|
||||
let err = process_attachment(&f.path().to_path_buf(), 0).unwrap_err();
|
||||
assert!(err.to_string().contains("too large"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_attachment_nonexistent_file() {
|
||||
let path = std::path::PathBuf::from("/tmp/nonexistent_attachment_test_file.xyz");
|
||||
let err = process_attachment(&path, 0).unwrap_err();
|
||||
assert!(err.to_string().contains("Failed to read"));
|
||||
}
|
||||
|
||||
// End-to-end handoff snapshot download tests. Each test drives the real
|
||||
// `fetch_and_download_handoff_snapshot_attachments` pipeline (including the shared
|
||||
// `with_bounded_retry` helper) against a `mockito::Server` + a real `http_client::Client`,
|
||||
// with `MockAIClient` stubbing only the listing call.
|
||||
|
||||
fn handoff_tempdir() -> TempDir {
|
||||
TempDirBuilder::new()
|
||||
.prefix("handoff-test")
|
||||
.tempdir()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Construct a `TaskAttachment` pointing at `{server_base_url}/download/{file_id}` so each test
|
||||
/// can register matching mocks without fishing for URLs.
|
||||
fn make_attachment(server_base_url: &str, file_id: &str, filename: &str) -> TaskAttachment {
|
||||
TaskAttachment {
|
||||
file_id: file_id.to_string(),
|
||||
filename: filename.to_string(),
|
||||
download_url: format!("{server_base_url}/download/{file_id}"),
|
||||
mime_type: "application/octet-stream".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Regex path matcher for `/download/<file_id>`.
|
||||
fn download_path(file_id: &str) -> Matcher {
|
||||
Matcher::Regex(format!("^/download/{file_id}$"))
|
||||
}
|
||||
|
||||
/// Pre-parsed task id for tests that exercise the outer function. Any valid UUID works; the
|
||||
/// mocked `AIClient` consumes this opaquely.
|
||||
fn fake_task_id() -> AmbientAgentTaskId {
|
||||
"550e8400-e29b-41d4-a716-446655440000".parse().unwrap()
|
||||
}
|
||||
|
||||
/// Build a `MockAIClient` whose `get_handoff_snapshot_attachments` returns `attachments`.
|
||||
fn mock_client_returning(attachments: Vec<TaskAttachment>) -> Arc<MockAIClient> {
|
||||
let mut mock = MockAIClient::new();
|
||||
mock.expect_get_handoff_snapshot_attachments()
|
||||
.times(1)
|
||||
.returning(move |_task_id| Ok(attachments.clone()));
|
||||
Arc::new(mock)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn e2e_happy_path_downloads_all_and_writes_to_disk() {
|
||||
// Two attachments, both served 200 with distinct payloads. The pipeline must write each
|
||||
// byte stream to `{attachments_dir}/handoff/{file_id}` and report the dir back.
|
||||
let _guard = FeatureFlag::OzHandoff.override_enabled(true);
|
||||
let tempdir = handoff_tempdir();
|
||||
let attachments_dir = tempdir.path().to_path_buf();
|
||||
let http = build_test_http_client();
|
||||
let mut server = Server::new_async().await;
|
||||
|
||||
let first_mock = server
|
||||
.mock("GET", download_path("alpha-uuid"))
|
||||
.with_status(200)
|
||||
.with_body("alpha-body")
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let second_mock = server
|
||||
.mock("GET", download_path("beta-uuid"))
|
||||
.with_status(200)
|
||||
.with_body("beta-body")
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let attachments = vec![
|
||||
make_attachment(&server.url(), "alpha-uuid", "alpha.patch"),
|
||||
make_attachment(&server.url(), "beta-uuid", "beta.patch"),
|
||||
];
|
||||
|
||||
let result = fetch_and_download_handoff_snapshot_attachments(
|
||||
mock_client_returning(attachments),
|
||||
&http,
|
||||
fake_task_id(),
|
||||
attachments_dir.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("should not be fatal");
|
||||
|
||||
assert_eq!(result.as_deref(), Some(&*attachments_dir.to_string_lossy()));
|
||||
assert_eq!(
|
||||
fs::read(attachments_dir.join("handoff").join("alpha-uuid")).unwrap(),
|
||||
b"alpha-body"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(attachments_dir.join("handoff").join("beta-uuid")).unwrap(),
|
||||
b"beta-body"
|
||||
);
|
||||
first_mock.assert();
|
||||
second_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn e2e_transient_5xx_retried_then_succeeds() {
|
||||
// Declare the failing 503 mock first, then the success mock. Mockito serves them in
|
||||
// registration order, so attempt #1 hits 503 and attempt #2 hits 200.
|
||||
let _guard = FeatureFlag::OzHandoff.override_enabled(true);
|
||||
let tempdir = handoff_tempdir();
|
||||
let attachments_dir = tempdir.path().to_path_buf();
|
||||
let http = build_test_http_client();
|
||||
let mut server = Server::new_async().await;
|
||||
|
||||
let flaky_fail = server
|
||||
.mock("GET", download_path("flaky-uuid"))
|
||||
.with_status(503)
|
||||
.with_body("temporarily unavailable")
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let flaky_ok = server
|
||||
.mock("GET", download_path("flaky-uuid"))
|
||||
.with_status(200)
|
||||
.with_body("finally-here")
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let attachments = vec![make_attachment(&server.url(), "flaky-uuid", "flaky.patch")];
|
||||
let result = fetch_and_download_handoff_snapshot_attachments(
|
||||
mock_client_returning(attachments),
|
||||
&http,
|
||||
fake_task_id(),
|
||||
attachments_dir.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.as_deref(), Some(&*attachments_dir.to_string_lossy()));
|
||||
assert_eq!(
|
||||
fs::read(attachments_dir.join("handoff").join("flaky-uuid")).unwrap(),
|
||||
b"finally-here"
|
||||
);
|
||||
flaky_fail.assert_async().await;
|
||||
flaky_ok.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn e2e_permanent_4xx_fails_fast_without_retries() {
|
||||
// 404 is a permanent error; the retry loop must NOT retry. Exactly one GET is expected.
|
||||
let _guard = FeatureFlag::OzHandoff.override_enabled(true);
|
||||
let tempdir = handoff_tempdir();
|
||||
let attachments_dir = tempdir.path().to_path_buf();
|
||||
let http = build_test_http_client();
|
||||
let mut server = Server::new_async().await;
|
||||
|
||||
let missing = server
|
||||
.mock("GET", download_path("missing-uuid"))
|
||||
.with_status(404)
|
||||
.with_body("not found")
|
||||
.expect(1) // exactly one attempt
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let attachments = vec![make_attachment(&server.url(), "missing-uuid", "gone.patch")];
|
||||
let result = fetch_and_download_handoff_snapshot_attachments(
|
||||
mock_client_returning(attachments),
|
||||
&http,
|
||||
fake_task_id(),
|
||||
attachments_dir.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_none(), "no file landed, dir should be None");
|
||||
assert!(
|
||||
!attachments_dir
|
||||
.join("handoff")
|
||||
.join("missing-uuid")
|
||||
.exists(),
|
||||
"no file should be written on permanent failure"
|
||||
);
|
||||
missing.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn e2e_retry_exhaustion_marks_failed() {
|
||||
// Three persistent 5xxs: the retry loop bails out after MAX_ATTEMPTS (3); no file lands.
|
||||
let _guard = FeatureFlag::OzHandoff.override_enabled(true);
|
||||
let tempdir = handoff_tempdir();
|
||||
let attachments_dir = tempdir.path().to_path_buf();
|
||||
let http = build_test_http_client();
|
||||
let mut server = Server::new_async().await;
|
||||
|
||||
let persistent = server
|
||||
.mock("GET", download_path("dead-uuid"))
|
||||
.with_status(502)
|
||||
.with_body("bad gateway")
|
||||
.expect(3) // exactly MAX_ATTEMPTS
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let attachments = vec![make_attachment(&server.url(), "dead-uuid", "dead.patch")];
|
||||
let result = fetch_and_download_handoff_snapshot_attachments(
|
||||
mock_client_returning(attachments),
|
||||
&http,
|
||||
fake_task_id(),
|
||||
attachments_dir.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_none());
|
||||
assert!(!attachments_dir.join("handoff").join("dead-uuid").exists());
|
||||
persistent.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn e2e_partial_success_returns_dir_with_downloaded_subset() {
|
||||
// One attachment succeeds, one fails permanently. The dir is returned so the caller can
|
||||
// still see `{handoff_dir}/ok-uuid` downstream; the failed sibling's file is absent.
|
||||
let _guard = FeatureFlag::OzHandoff.override_enabled(true);
|
||||
let tempdir = handoff_tempdir();
|
||||
let attachments_dir = tempdir.path().to_path_buf();
|
||||
let http = build_test_http_client();
|
||||
let mut server = Server::new_async().await;
|
||||
|
||||
let ok_mock = server
|
||||
.mock("GET", download_path("ok-uuid"))
|
||||
.with_status(200)
|
||||
.with_body("present")
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
let bad_mock = server
|
||||
.mock("GET", download_path("bad-uuid"))
|
||||
.with_status(403)
|
||||
.with_body("forbidden")
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let attachments = vec![
|
||||
make_attachment(&server.url(), "ok-uuid", "ok.patch"),
|
||||
make_attachment(&server.url(), "bad-uuid", "bad.patch"),
|
||||
];
|
||||
let result = fetch_and_download_handoff_snapshot_attachments(
|
||||
mock_client_returning(attachments),
|
||||
&http,
|
||||
fake_task_id(),
|
||||
attachments_dir.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.as_deref(), Some(&*attachments_dir.to_string_lossy()));
|
||||
assert_eq!(
|
||||
fs::read(attachments_dir.join("handoff").join("ok-uuid")).unwrap(),
|
||||
b"present"
|
||||
);
|
||||
assert!(!attachments_dir.join("handoff").join("bad-uuid").exists());
|
||||
ok_mock.assert_async().await;
|
||||
bad_mock.assert_async().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn e2e_empty_attachment_list_returns_none_without_creating_dir() {
|
||||
// With zero attachments listed, the function returns None early and does NOT create the
|
||||
// handoff dir — nothing to land there.
|
||||
let _guard = FeatureFlag::OzHandoff.override_enabled(true);
|
||||
let tempdir = handoff_tempdir();
|
||||
let attachments_dir = tempdir.path().to_path_buf();
|
||||
let http = build_test_http_client();
|
||||
|
||||
let result = fetch_and_download_handoff_snapshot_attachments(
|
||||
mock_client_returning(Vec::new()),
|
||||
&http,
|
||||
fake_task_id(),
|
||||
attachments_dir.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("empty list should not be a fatal error");
|
||||
|
||||
assert!(result.is_none());
|
||||
assert!(
|
||||
!attachments_dir.join("handoff").exists(),
|
||||
"handoff dir should not be created when there are no attachments"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn e2e_get_handoff_snapshot_attachments_failure_is_fatal() {
|
||||
// When the listing call errors, the function must return Err wrapping the underlying
|
||||
// message with a context describing where it happened.
|
||||
let _guard = FeatureFlag::OzHandoff.override_enabled(true);
|
||||
let tempdir = handoff_tempdir();
|
||||
let http = build_test_http_client();
|
||||
|
||||
let mut mock = MockAIClient::new();
|
||||
mock.expect_get_handoff_snapshot_attachments()
|
||||
.times(1)
|
||||
.returning(|_task_id| Err(anyhow::anyhow!("simulated listing failure")));
|
||||
|
||||
let err = fetch_and_download_handoff_snapshot_attachments(
|
||||
Arc::new(mock),
|
||||
&http,
|
||||
fake_task_id(),
|
||||
tempdir.path().to_path_buf(),
|
||||
)
|
||||
.await
|
||||
.expect_err("listing failure must be fatal");
|
||||
|
||||
let chain: Vec<String> = err.chain().map(|c| c.to_string()).collect();
|
||||
assert!(
|
||||
chain
|
||||
.iter()
|
||||
.any(|s| s.contains("Failed to fetch handoff snapshot attachments")),
|
||||
"expected context-wrapped error in chain: {chain:?}"
|
||||
);
|
||||
assert!(
|
||||
chain
|
||||
.iter()
|
||||
.any(|s| s.contains("simulated listing failure")),
|
||||
"expected underlying error in chain: {chain:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn e2e_returns_none_when_oz_handoff_flag_is_disabled() {
|
||||
// With the feature flag off, the function short-circuits to None without calling the
|
||||
// AIClient. Any call site that forgot to gate on the flag would log an error; here we
|
||||
// just verify the return value.
|
||||
let _guard = FeatureFlag::OzHandoff.override_enabled(false);
|
||||
let tempdir = handoff_tempdir();
|
||||
let attachments_dir = tempdir.path().to_path_buf();
|
||||
let http = build_test_http_client();
|
||||
|
||||
// No expect_get_handoff_snapshot_attachments: if the function calls it, the mock panics.
|
||||
let mock = MockAIClient::new();
|
||||
let result = fetch_and_download_handoff_snapshot_attachments(
|
||||
Arc::new(mock),
|
||||
&http,
|
||||
fake_task_id(),
|
||||
attachments_dir.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("flag-disabled path should not be fatal");
|
||||
|
||||
assert!(result.is_none());
|
||||
assert!(!attachments_dir.join("handoff").exists());
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use std::{collections::HashMap, ffi::OsString, future::Future, pin::Pin};
|
||||
|
||||
use anyhow::Error;
|
||||
use warpui::ModelSpawner;
|
||||
|
||||
use super::terminal::TerminalDriver;
|
||||
use crate::ai::cloud_environments::ProvidersConfig;
|
||||
|
||||
mod aws;
|
||||
mod gcp;
|
||||
|
||||
pub(crate) type Result<T> = std::result::Result<T, CloudProviderSetupError>;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("{provider_name} setup failed")]
|
||||
pub(crate) struct CloudProviderSetupError {
|
||||
provider_name: &'static str,
|
||||
#[source]
|
||||
source: Error,
|
||||
}
|
||||
|
||||
impl CloudProviderSetupError {
|
||||
pub(crate) fn new(provider_name: &'static str, source: impl Into<Error>) -> Self {
|
||||
Self {
|
||||
provider_name,
|
||||
source: source.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A cloud provider that we configure automatic Oz access to.
|
||||
pub(crate) trait CloudProvider: Send {
|
||||
/// Return environment variables that should be injected into the terminal
|
||||
/// session.
|
||||
fn env_vars(&self) -> Result<HashMap<OsString, OsString>>;
|
||||
|
||||
/// Perform any async setup that requires the terminal session to be running.
|
||||
fn setup(
|
||||
&mut self,
|
||||
_spawner: ModelSpawner<TerminalDriver>,
|
||||
) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
|
||||
/// Best-effort cleanup of any resources created during setup.
|
||||
///
|
||||
/// The default implementation is a no-op.
|
||||
fn cleanup(self: Box<Self>) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the set of cloud providers from an environment's provider configuration.
|
||||
pub(crate) fn load_providers(
|
||||
providers: &ProvidersConfig,
|
||||
run_id: &str,
|
||||
) -> Result<Vec<Box<dyn CloudProvider>>> {
|
||||
let mut result: Vec<Box<dyn CloudProvider>> = Vec::new();
|
||||
|
||||
if let Some(aws) = &providers.aws {
|
||||
result.push(Box::new(aws::AwsCloudProvider::new(aws, run_id)?));
|
||||
}
|
||||
|
||||
if let Some(gcp) = &providers.gcp {
|
||||
result.push(Box::new(gcp::GcpCloudProvider::new(gcp, run_id)?));
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Collect all environment variables from a list of providers.
|
||||
pub(crate) fn collect_env_vars(
|
||||
providers: &[Box<dyn CloudProvider>],
|
||||
vars: &mut HashMap<OsString, OsString>,
|
||||
) -> Result<()> {
|
||||
for provider in providers {
|
||||
vars.extend(provider.env_vars()?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "cloud_provider_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,130 @@
|
||||
use std::{collections::HashMap, ffi::OsString, future::Future, pin::Pin, time::Duration};
|
||||
|
||||
use anyhow::Context;
|
||||
use tempfile::{Builder, NamedTempFile};
|
||||
use vec1::Vec1;
|
||||
use warp_core::safe_info;
|
||||
use warp_managed_secrets::ManagedSecretManager;
|
||||
use warpui::{ModelSpawner, SingletonEntity};
|
||||
|
||||
use crate::ai::aws_credentials::aws_role_session_name;
|
||||
use crate::ai::cloud_environments::AwsProviderConfig;
|
||||
|
||||
use super::super::terminal::TerminalDriver;
|
||||
use super::{CloudProvider, CloudProviderSetupError, Result};
|
||||
|
||||
/// Default duration for OIDC identity tokens issued for cloud provider auth.
|
||||
/// The AWS CLI doesn't offer a mechanism for refreshing web identity tokens, so we
|
||||
/// set this to the current maximum task duration.
|
||||
const IDENTITY_TOKEN_DURATION: Duration = Duration::from_hours(3);
|
||||
|
||||
/// AWS STS audience for Warp Oz OIDC federation.
|
||||
const AWS_AUDIENCE: &str = "sts.amazonaws.com";
|
||||
|
||||
/// Provides AWS Web Identity credentials for the agent session.
|
||||
pub(crate) struct AwsCloudProvider {
|
||||
/// ARN of the role to assume.
|
||||
role_arn: String,
|
||||
session_name: String,
|
||||
/// File containing the OIDC token that the AWS CLI will use to assume the role.
|
||||
token_file: NamedTempFile,
|
||||
}
|
||||
|
||||
impl AwsCloudProvider {
|
||||
const PROVIDER_NAME: &'static str = "aws";
|
||||
|
||||
pub fn new(config: &AwsProviderConfig, run_id: &str) -> Result<Self> {
|
||||
// The `tempfile` crate defaults to creating temporary files with user-only permissions.
|
||||
let token_file = Builder::new()
|
||||
.prefix(&format!("oz_aws_oidc_{run_id}_"))
|
||||
.suffix(".token")
|
||||
.tempfile()
|
||||
.context("Failed to create temporary AWS OIDC token file")
|
||||
.map_err(|error| CloudProviderSetupError::new(Self::PROVIDER_NAME, error))?;
|
||||
|
||||
Ok(Self {
|
||||
role_arn: config.role_arn.clone(),
|
||||
session_name: aws_role_session_name(run_id),
|
||||
token_file,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl CloudProvider for AwsCloudProvider {
|
||||
fn env_vars(&self) -> Result<HashMap<OsString, OsString>> {
|
||||
// Set variables that the AWS CLI and SDKs check for assuming a role with web identity:
|
||||
// https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-role.html#cli-configure-role-oidc
|
||||
let mut vars = HashMap::new();
|
||||
vars.insert(
|
||||
OsString::from("AWS_ROLE_ARN"),
|
||||
OsString::from(&self.role_arn),
|
||||
);
|
||||
vars.insert(
|
||||
OsString::from("AWS_ROLE_SESSION_NAME"),
|
||||
OsString::from(&self.session_name),
|
||||
);
|
||||
vars.insert(
|
||||
OsString::from("AWS_WEB_IDENTITY_TOKEN_FILE"),
|
||||
self.token_file.path().as_os_str().to_owned(),
|
||||
);
|
||||
Ok(vars)
|
||||
}
|
||||
|
||||
fn setup(
|
||||
&mut self,
|
||||
spawner: ModelSpawner<TerminalDriver>,
|
||||
) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let token_file_path = self.token_file.path();
|
||||
safe_info!(
|
||||
safe: ("Setting up AWS cloud provider credentials"),
|
||||
full: ("Setting up AWS cloud provider: role_arn={}, session={}", self.role_arn, self.session_name)
|
||||
);
|
||||
|
||||
// 1. Issue an OIDC identity token.
|
||||
let audience = AWS_AUDIENCE.to_string();
|
||||
let duration = IDENTITY_TOKEN_DURATION;
|
||||
|
||||
// Use the scoped principal as the subject, since AWS can't match directly
|
||||
// on the team claim.
|
||||
let subject_template = Vec1::new("scoped_principal".into());
|
||||
let token = spawner
|
||||
.spawn(move |_, ctx| {
|
||||
ManagedSecretManager::handle(ctx)
|
||||
.as_ref(ctx)
|
||||
.issue_task_identity_token(
|
||||
warp_managed_secrets::client::IdentityTokenOptions {
|
||||
audience,
|
||||
requested_duration: duration,
|
||||
subject_template,
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| CloudProviderSetupError::new(Self::PROVIDER_NAME, err))?
|
||||
.await
|
||||
.map_err(|err| CloudProviderSetupError::new(Self::PROVIDER_NAME, err))?;
|
||||
|
||||
// 2. Write the token to the pre-created temporary file.
|
||||
async_fs::write(&token_file_path, token.token.as_bytes())
|
||||
.await
|
||||
.map_err(|err| CloudProviderSetupError::new(Self::PROVIDER_NAME, err))?;
|
||||
|
||||
safe_info!(
|
||||
safe: ("AWS cloud provider setup complete"),
|
||||
full: ("AWS cloud provider setup complete: token_file={}", token_file_path.display())
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn cleanup(self: Box<Self>) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
|
||||
Box::pin(async move {
|
||||
let Self { token_file, .. } = *self;
|
||||
token_file
|
||||
.close()
|
||||
.context("Failed to remove AWS OIDC token file")
|
||||
.map_err(|err| CloudProviderSetupError::new(Self::PROVIDER_NAME, err))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
use std::{collections::HashMap, ffi::OsString, future::Future, pin::Pin, time::Duration};
|
||||
|
||||
use anyhow::Context as _;
|
||||
use warp_managed_secrets::{GcpCredentials, GcpFederationConfig};
|
||||
|
||||
use crate::ai::cloud_environments::GcpProviderConfig;
|
||||
|
||||
use super::{CloudProvider, CloudProviderSetupError, Result};
|
||||
|
||||
/// Token lifetime for GCP executable-sourced credentials. The GCP client
|
||||
/// libraries handle refreshing automatically, so we keep this short.
|
||||
const TOKEN_LIFETIME: Duration = Duration::from_secs(30 * 60);
|
||||
|
||||
/// Provides GCP Workload Identity Federation credentials for the agent session.
|
||||
///
|
||||
/// The credential config file is written eagerly during construction. GCP SDKs
|
||||
/// discover it via `GOOGLE_APPLICATION_CREDENTIALS` and invoke the embedded
|
||||
/// executable to obtain tokens on demand.
|
||||
pub(crate) struct GcpCloudProvider {
|
||||
credentials: GcpCredentials,
|
||||
}
|
||||
|
||||
impl GcpCloudProvider {
|
||||
const PROVIDER_NAME: &'static str = "gcp";
|
||||
|
||||
pub fn new(config: &GcpProviderConfig, run_id: &str) -> Result<Self> {
|
||||
let federation_config = GcpFederationConfig {
|
||||
project_number: config.project_number.clone(),
|
||||
pool_id: config.workload_identity_federation_pool_id.clone(),
|
||||
provider_id: config.workload_identity_federation_provider_id.clone(),
|
||||
service_account_email: config.service_account_email.clone(),
|
||||
token_lifetime: Some(TOKEN_LIFETIME),
|
||||
};
|
||||
|
||||
let credentials = GcpCredentials::federated(run_id, &federation_config)
|
||||
.context("Failed to prepare GCP federation credentials")
|
||||
.map_err(|error| CloudProviderSetupError::new(Self::PROVIDER_NAME, error))?;
|
||||
|
||||
Ok(Self { credentials })
|
||||
}
|
||||
}
|
||||
|
||||
impl CloudProvider for GcpCloudProvider {
|
||||
fn env_vars(&self) -> Result<HashMap<OsString, OsString>> {
|
||||
Ok(self.credentials.env_vars())
|
||||
}
|
||||
|
||||
fn cleanup(self: Box<Self>) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
|
||||
Box::pin(async move {
|
||||
self.credentials
|
||||
.cleanup()
|
||||
.context("Failed to remove GCP credential files")
|
||||
.map_err(|err| CloudProviderSetupError::new(Self::PROVIDER_NAME, err))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
use std::{collections::HashMap, ffi::OsString, path::PathBuf};
|
||||
|
||||
use crate::ai::cloud_environments::{AwsProviderConfig, GcpProviderConfig, ProvidersConfig};
|
||||
|
||||
use super::{
|
||||
aws::AwsCloudProvider, collect_env_vars, gcp::GcpCloudProvider, load_providers, CloudProvider,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn aws_provider_env_vars_before_setup() {
|
||||
let config = AwsProviderConfig {
|
||||
role_arn: "arn:aws:iam::123456789012:role/MyRole".to_string(),
|
||||
};
|
||||
let provider = AwsCloudProvider::new(&config, "abc-123").unwrap();
|
||||
let vars = provider.env_vars().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
vars.get(&OsString::from("AWS_ROLE_ARN")),
|
||||
Some(&OsString::from("arn:aws:iam::123456789012:role/MyRole"))
|
||||
);
|
||||
assert_eq!(
|
||||
vars.get(&OsString::from("AWS_ROLE_SESSION_NAME")),
|
||||
Some(&OsString::from("Oz_Run_abc-123"))
|
||||
);
|
||||
let token_file = PathBuf::from(
|
||||
vars.get(&OsString::from("AWS_WEB_IDENTITY_TOKEN_FILE"))
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(token_file
|
||||
.extension()
|
||||
.is_some_and(|extension| extension == "token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_cloud_providers_empty_when_no_providers() {
|
||||
let config = ProvidersConfig {
|
||||
gcp: None,
|
||||
aws: None,
|
||||
};
|
||||
let providers = load_providers(&config, "run-1").unwrap();
|
||||
assert!(providers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_cloud_providers_creates_aws_provider() {
|
||||
let config = ProvidersConfig {
|
||||
gcp: None,
|
||||
aws: Some(AwsProviderConfig {
|
||||
role_arn: "arn:aws:iam::111111111111:role/TestRole".to_string(),
|
||||
}),
|
||||
};
|
||||
let providers = load_providers(&config, "run-42").unwrap();
|
||||
assert_eq!(providers.len(), 1);
|
||||
|
||||
let vars = providers[0].env_vars().unwrap();
|
||||
assert_eq!(
|
||||
vars.get(&OsString::from("AWS_ROLE_ARN")),
|
||||
Some(&OsString::from("arn:aws:iam::111111111111:role/TestRole"))
|
||||
);
|
||||
assert_eq!(
|
||||
vars.get(&OsString::from("AWS_ROLE_SESSION_NAME")),
|
||||
Some(&OsString::from("Oz_Run_run-42"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gcp_provider_env_vars() {
|
||||
let config = GcpProviderConfig {
|
||||
project_number: "123456789".to_string(),
|
||||
workload_identity_federation_pool_id: "my-pool".to_string(),
|
||||
workload_identity_federation_provider_id: "my-provider".to_string(),
|
||||
service_account_email: None,
|
||||
};
|
||||
let provider = GcpCloudProvider::new(&config, "run-99").unwrap();
|
||||
let vars = provider.env_vars().unwrap();
|
||||
|
||||
assert!(vars.contains_key(&OsString::from("GOOGLE_APPLICATION_CREDENTIALS")));
|
||||
assert_eq!(
|
||||
vars.get(&OsString::from("GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES")),
|
||||
Some(&OsString::from("1"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_cloud_providers_creates_gcp_provider() {
|
||||
let config = ProvidersConfig {
|
||||
gcp: Some(GcpProviderConfig {
|
||||
project_number: "111".to_string(),
|
||||
workload_identity_federation_pool_id: "pool".to_string(),
|
||||
workload_identity_federation_provider_id: "prov".to_string(),
|
||||
service_account_email: None,
|
||||
}),
|
||||
aws: None,
|
||||
};
|
||||
let providers = load_providers(&config, "run-1").unwrap();
|
||||
assert_eq!(providers.len(), 1);
|
||||
|
||||
let vars = providers[0].env_vars().unwrap();
|
||||
assert!(vars.contains_key(&OsString::from("GOOGLE_APPLICATION_CREDENTIALS")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_provider_env_vars_merges_all_providers() {
|
||||
let config = ProvidersConfig {
|
||||
gcp: Some(GcpProviderConfig {
|
||||
project_number: "222".to_string(),
|
||||
workload_identity_federation_pool_id: "pool".to_string(),
|
||||
workload_identity_federation_provider_id: "prov".to_string(),
|
||||
service_account_email: None,
|
||||
}),
|
||||
aws: Some(AwsProviderConfig {
|
||||
role_arn: "arn:aws:iam::999:role/R".to_string(),
|
||||
}),
|
||||
};
|
||||
let providers = load_providers(&config, "id-7").unwrap();
|
||||
assert_eq!(providers.len(), 2);
|
||||
|
||||
let mut vars = HashMap::new();
|
||||
collect_env_vars(&providers, &mut vars).unwrap();
|
||||
|
||||
// AWS variables.
|
||||
assert!(vars.contains_key(&OsString::from("AWS_ROLE_ARN")));
|
||||
assert!(vars.contains_key(&OsString::from("AWS_ROLE_SESSION_NAME")));
|
||||
// GCP variables.
|
||||
assert!(vars.contains_key(&OsString::from("GOOGLE_APPLICATION_CREDENTIALS")));
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
future::Future,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crate::ai::cloud_environments::{AmbientAgentEnvironment, GithubRepo};
|
||||
use crate::terminal::model::session::command_executor::shell_escape_single_quotes;
|
||||
use crate::terminal::shell::ShellType;
|
||||
use ai::index::full_source_code_embedding::manager::{
|
||||
CodebaseIndexManager, CodebaseIndexManagerEvent,
|
||||
};
|
||||
use futures::{channel::oneshot, future::join_all};
|
||||
use repo_metadata::repositories::{DetectedRepositories, RepoDetectionSource};
|
||||
use warp_completer::completer::CommandExitStatus;
|
||||
use warp_core::{command::ExitCode, safe_info, safe_warn};
|
||||
use warpui::{r#async::FutureExt, ModelContext, ModelSpawner, SingletonEntity};
|
||||
|
||||
use super::{terminal::TerminalDriver, AgentDriverError};
|
||||
use warp_cli::agent::Harness;
|
||||
|
||||
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,
|
||||
ctx: &mut ModelContext<TerminalDriver>,
|
||||
) -> impl Future<Output = Result<(), PrepareEnvironmentError>> {
|
||||
let spawner = ctx.spawner();
|
||||
async move {
|
||||
let AmbientAgentEnvironment {
|
||||
github_repos,
|
||||
setup_commands,
|
||||
..
|
||||
} = environment;
|
||||
|
||||
// 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 && !github_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,
|
||||
&github_repos,
|
||||
setup_commands,
|
||||
should_index_codebase,
|
||||
Arc::clone(&repo_channels),
|
||||
)
|
||||
.await;
|
||||
|
||||
if should_subscribe_to_index_updates {
|
||||
let _ = spawner
|
||||
.spawn(|_, ctx| {
|
||||
ctx.unsubscribe_from_model(&CodebaseIndexManager::handle(ctx));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
async fn prepare_environment_impl(
|
||||
spawner: &ModelSpawner<TerminalDriver>,
|
||||
working_dir: &Path,
|
||||
is_sandbox: bool,
|
||||
github_repos: &[GithubRepo],
|
||||
setup_commands: Vec<String>,
|
||||
should_index_codebase: bool,
|
||||
repo_channels: Arc<Mutex<HashMap<PathBuf, oneshot::Sender<()>>>>,
|
||||
) -> 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();
|
||||
|
||||
for repo in github_repos {
|
||||
let repo_name = format!("{}/{}", repo.owner, repo.repo);
|
||||
let repo_url = format!("https://github.com/{repo_name}.git");
|
||||
// We do a partial clone here to speed up environment setup time.
|
||||
let command = format!("git clone --filter=tree:0 {repo_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}")
|
||||
);
|
||||
}
|
||||
|
||||
// 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_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_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())
|
||||
);
|
||||
}
|
||||
|
||||
if 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let has_setup_commands = !setup_commands.is_empty();
|
||||
if has_setup_commands {
|
||||
// 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}")
|
||||
);
|
||||
}
|
||||
|
||||
if has_setup_commands {
|
||||
// Unset CI after setup commands complete so the agent session
|
||||
// does not run with CI=true.
|
||||
execute_command("unset CI".to_string(), spawner).await?;
|
||||
}
|
||||
|
||||
if !github_repos.is_empty() {
|
||||
// Wait for codebase indexing for all repositories after running setup commands.
|
||||
// We skip this if running in Docker sandboxes since they don't have a cache volume.
|
||||
// We also skip this in Namespace to reduce startup time.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
let should_wait_for_indexing = !matches!(
|
||||
warp_isolation_platform::detect(),
|
||||
Some(
|
||||
warp_isolation_platform::IsolationPlatformType::DockerSandbox
|
||||
| warp_isolation_platform::IsolationPlatformType::Namespace
|
||||
)
|
||||
);
|
||||
#[cfg(target_family = "wasm")]
|
||||
let should_wait_for_indexing = true;
|
||||
|
||||
if should_wait_for_indexing {
|
||||
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",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
drop(codebase_context_receivers);
|
||||
log::info!("Not waiting for codebase index sync");
|
||||
}
|
||||
}
|
||||
|
||||
// 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(github_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(())
|
||||
}
|
||||
|
||||
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() {
|
||||
repos_to_notify.push(repo.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
if !status.has_pending() && status.last_sync_successful() == Some(false) {
|
||||
safe_warn!(
|
||||
safe: ("Codebase index sync failed for a repo; unblocking environment setup"),
|
||||
full: ("Codebase index sync failed for {repo:?}; unblocking environment setup")
|
||||
);
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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: &[GithubRepo]) -> 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;
|
||||
@@ -0,0 +1,24 @@
|
||||
use super::single_repo_name;
|
||||
use crate::ai::cloud_environments::GithubRepo;
|
||||
|
||||
#[test]
|
||||
fn single_repo_name_returns_repo_when_exactly_one_repo() {
|
||||
let repos = vec![GithubRepo::new(
|
||||
"warpdotdev".to_string(),
|
||||
"warp-internal".to_string(),
|
||||
)];
|
||||
let selected_repo = single_repo_name(&repos);
|
||||
assert_eq!(selected_repo, Some("warp-internal".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_repo_name_returns_none_for_zero_or_many_repos() {
|
||||
let no_repos = Vec::<GithubRepo>::new();
|
||||
assert_eq!(single_repo_name(&no_repos), None);
|
||||
|
||||
let two_repos = vec![
|
||||
GithubRepo::new("warpdotdev".to_string(), "warp-internal".to_string()),
|
||||
GithubRepo::new("warpdotdev".to_string(), "warp-server".to_string()),
|
||||
];
|
||||
assert_eq!(single_repo_name(&two_repos), None);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
use crate::ai::blocklist::task_status_sync_model::classify_renderable_error;
|
||||
use crate::server::server_api::ai::TaskStatusUpdate;
|
||||
use warp_graphql::ai::{AgentTaskState, PlatformErrorCode};
|
||||
|
||||
use super::terminal::ShareSessionError;
|
||||
use super::AgentDriverError;
|
||||
|
||||
/// Classify an `AgentDriverError` into a task state and a `TaskStatusUpdate`
|
||||
/// suitable for reporting via `update_agent_task`.
|
||||
pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskStatusUpdate) {
|
||||
match error {
|
||||
// --- Warp-side errors (task → ERROR) ---
|
||||
AgentDriverError::TerminalUnavailable | AgentDriverError::InvalidRuntimeState => (
|
||||
AgentTaskState::Error,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
"An internal error occurred. Please try running your task again. If the issue persists, contact support.",
|
||||
PlatformErrorCode::InternalError,
|
||||
),
|
||||
),
|
||||
AgentDriverError::BootstrapFailed => (
|
||||
AgentTaskState::Error,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
"Terminal session failed to start. Please try running your task again.",
|
||||
PlatformErrorCode::InternalError,
|
||||
),
|
||||
),
|
||||
AgentDriverError::ShareSessionFailed { error: share_err } => {
|
||||
let message = match share_err {
|
||||
ShareSessionError::Internal(_) => {
|
||||
"Failed to share agent session due to an internal error. Please try running your task again.".to_string()
|
||||
}
|
||||
ShareSessionError::Failed(reason) => {
|
||||
// The reason string comes from the session-sharing layer and is aimed at
|
||||
// interactive users (e.g. "try sharing again"). Provide a cloud-agent-
|
||||
// appropriate message instead of wrapping it, which would produce
|
||||
// repetitive "try again" text.
|
||||
format!("Failed to share agent session: {reason}")
|
||||
}
|
||||
ShareSessionError::Disabled => {
|
||||
"Session sharing is not enabled for your account. This is likely because \
|
||||
an administrator has disabled session sharing for your team. Please \
|
||||
verify that session sharing is enabled in your team settings, or try \
|
||||
running without the --share flag."
|
||||
.to_string()
|
||||
}
|
||||
ShareSessionError::Timeout => {
|
||||
"Failed to share agent session: timed out waiting for the session sharing \
|
||||
server to respond. Please check your network connection and try again."
|
||||
.to_string()
|
||||
}
|
||||
ShareSessionError::Interrupted => {
|
||||
"Session sharing was interrupted before it could complete. Please try running your task again.".to_string()
|
||||
}
|
||||
};
|
||||
(
|
||||
AgentTaskState::Error,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
message,
|
||||
match share_err {
|
||||
ShareSessionError::Disabled => PlatformErrorCode::FeatureNotAvailable,
|
||||
_ => PlatformErrorCode::InternalError,
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
AgentDriverError::WarpDriveSyncFailed => (
|
||||
AgentTaskState::Error,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
"Warp Drive failed to sync. Please check your network connection and try again.",
|
||||
PlatformErrorCode::InternalError,
|
||||
),
|
||||
),
|
||||
AgentDriverError::NotLoggedIn => {
|
||||
let bin = warp_cli::binary_name().unwrap_or_else(|| "warp".to_string());
|
||||
(
|
||||
AgentTaskState::Error,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
format!(
|
||||
"Authentication required. Log in via '{bin} login', provide an API key via '--api-key', or set the WARP_API_KEY environment variable."
|
||||
),
|
||||
PlatformErrorCode::AuthenticationRequired,
|
||||
),
|
||||
)
|
||||
}
|
||||
AgentDriverError::CloudProviderSetupFailed(err) => (
|
||||
AgentTaskState::Error,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
format!("Error configuring cloud access: {err:#}"),
|
||||
PlatformErrorCode::InternalError,
|
||||
),
|
||||
),
|
||||
|
||||
// --- User-side errors (task → FAILED) ---
|
||||
AgentDriverError::MCPServerNotFound(uuid) => (
|
||||
AgentTaskState::Failed,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
format!(
|
||||
"MCP server {uuid} was not found. Verify the server exists in your Warp Drive and the UUID is correct."
|
||||
),
|
||||
PlatformErrorCode::EnvironmentSetupFailed,
|
||||
),
|
||||
),
|
||||
AgentDriverError::MCPStartupFailed => (
|
||||
AgentTaskState::Failed,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
"One or more MCP servers failed to start. Check that your MCP server configuration is valid and the server process is runnable.",
|
||||
PlatformErrorCode::EnvironmentSetupFailed,
|
||||
),
|
||||
),
|
||||
AgentDriverError::MCPJsonParseError(msg) => (
|
||||
AgentTaskState::Failed,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
format!("Failed to parse MCP server JSON configuration: {msg}"),
|
||||
PlatformErrorCode::EnvironmentSetupFailed,
|
||||
),
|
||||
),
|
||||
AgentDriverError::MCPMissingVariables => (
|
||||
AgentTaskState::Failed,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
"MCP server configuration is missing required variables. Provide all required environment variables or template values.",
|
||||
PlatformErrorCode::EnvironmentSetupFailed,
|
||||
),
|
||||
),
|
||||
AgentDriverError::ProfileError(name) => (
|
||||
AgentTaskState::Failed,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
format!(
|
||||
"Agent profile \"{name}\" not found. Check the profile ID and ensure it exists in your team's Warp Drive."
|
||||
),
|
||||
PlatformErrorCode::ResourceNotFound,
|
||||
),
|
||||
),
|
||||
AgentDriverError::AIWorkflowNotFound(id) => (
|
||||
AgentTaskState::Failed,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
format!(
|
||||
"Saved prompt not found for ID {id}. Verify the prompt exists in your Warp Drive."
|
||||
),
|
||||
PlatformErrorCode::ResourceNotFound,
|
||||
),
|
||||
),
|
||||
AgentDriverError::EnvironmentNotFound(id) => (
|
||||
AgentTaskState::Failed,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
format!(
|
||||
"Environment '{id}' not found. Verify the environment ID and ensure it exists in your team settings."
|
||||
),
|
||||
PlatformErrorCode::ResourceNotFound,
|
||||
),
|
||||
),
|
||||
AgentDriverError::EnvironmentSetupFailed(msg) => (
|
||||
AgentTaskState::Failed,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
format!(
|
||||
"Environment setup failed: {msg}. Check your repository URLs and setup commands."
|
||||
),
|
||||
PlatformErrorCode::EnvironmentSetupFailed,
|
||||
),
|
||||
),
|
||||
AgentDriverError::InvalidWorkingDirectory { path, .. } => (
|
||||
AgentTaskState::Failed,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
format!(
|
||||
"Working directory '{}' does not exist or is not a directory. Verify the path in your environment configuration.",
|
||||
path.display()
|
||||
),
|
||||
PlatformErrorCode::EnvironmentSetupFailed,
|
||||
),
|
||||
),
|
||||
|
||||
// --- Conversation errors ---
|
||||
// Delegate to classify_renderable_error for proper ERROR vs FAILED
|
||||
// distinction and PlatformErrorCode. This is a belt-and-suspenders
|
||||
// fallback — TaskStatusSyncModel handles most conversation errors,
|
||||
// but the driver catches them too if the conversation ends with an error.
|
||||
AgentDriverError::ConversationError { error } => {
|
||||
let (state, update) = classify_renderable_error(error);
|
||||
(
|
||||
state,
|
||||
update.unwrap_or_else(|| {
|
||||
TaskStatusUpdate::with_error_code(
|
||||
error.to_string(),
|
||||
PlatformErrorCode::InternalError,
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Cancellation / Blocked (no error code) ---
|
||||
AgentDriverError::ConversationCancelled { .. } => (
|
||||
AgentTaskState::Cancelled,
|
||||
TaskStatusUpdate::message("Task cancelled."),
|
||||
),
|
||||
AgentDriverError::ConversationBlocked { blocked_action } => (
|
||||
AgentTaskState::Blocked,
|
||||
TaskStatusUpdate::message(format!(
|
||||
"The agent got stuck waiting for user confirmation on the action: {blocked_action}"
|
||||
)),
|
||||
),
|
||||
|
||||
// --- Setup errors ---
|
||||
AgentDriverError::TeamMetadataRefreshTimeout => (
|
||||
AgentTaskState::Error,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
"Timed out refreshing team metadata. Please check your network connection and try again.",
|
||||
PlatformErrorCode::InternalError,
|
||||
),
|
||||
),
|
||||
AgentDriverError::SkillResolutionFailed(msg) => (
|
||||
AgentTaskState::Failed,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
format!("Skill resolution failed: {msg}"),
|
||||
PlatformErrorCode::ResourceNotFound,
|
||||
),
|
||||
),
|
||||
AgentDriverError::ConfigBuildFailed(err) => (
|
||||
AgentTaskState::Failed,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
format!("Failed to build agent configuration: {err}"),
|
||||
PlatformErrorCode::EnvironmentSetupFailed,
|
||||
),
|
||||
),
|
||||
AgentDriverError::PromptResolutionFailed(err) => (
|
||||
AgentTaskState::Error,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
format!("Failed to resolve prompt for the run: {err}"),
|
||||
PlatformErrorCode::InternalError,
|
||||
),
|
||||
),
|
||||
AgentDriverError::SecretsFetchFailed(err) => (
|
||||
AgentTaskState::Error,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
format!("Failed to fetch task secrets: {err}"),
|
||||
PlatformErrorCode::InternalError,
|
||||
),
|
||||
),
|
||||
AgentDriverError::AwsBedrockCredentialsFailed(msg) => (
|
||||
AgentTaskState::Failed,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
format!("Failed to initialize AWS Bedrock credentials: {msg}"),
|
||||
PlatformErrorCode::EnvironmentSetupFailed,
|
||||
),
|
||||
),
|
||||
AgentDriverError::ConversationLoadFailed(msg) => (
|
||||
AgentTaskState::Error,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
format!("Failed to load conversation: {msg}"),
|
||||
PlatformErrorCode::InternalError,
|
||||
),
|
||||
),
|
||||
AgentDriverError::ConversationHarnessMismatch { conversation_id, expected, got } => (
|
||||
AgentTaskState::Failed,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
format!(
|
||||
"Conversation {conversation_id} was produced by the {expected} harness, but --harness {got} was requested. \
|
||||
Re-run with --harness {expected} (or omit --harness) to continue this conversation."
|
||||
),
|
||||
PlatformErrorCode::EnvironmentSetupFailed,
|
||||
),
|
||||
),
|
||||
AgentDriverError::TaskHarnessMismatch { task_id, expected, got } => (
|
||||
AgentTaskState::Failed,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
format!(
|
||||
"Task {task_id} was created with the {expected} harness, but --harness {got} was requested. \
|
||||
Re-run with --harness {expected} (or omit --harness) to continue this task."
|
||||
),
|
||||
PlatformErrorCode::EnvironmentSetupFailed,
|
||||
),
|
||||
),
|
||||
AgentDriverError::ConversationResumeStateMissing { harness, conversation_id } => (
|
||||
AgentTaskState::Failed,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
format!(
|
||||
"Conversation {conversation_id} has no stored transcript for the {harness} harness. \
|
||||
The prior run may have crashed before saving any state."
|
||||
),
|
||||
PlatformErrorCode::ResourceNotFound,
|
||||
),
|
||||
),
|
||||
AgentDriverError::HarnessCommandFailed { exit_code } => (
|
||||
AgentTaskState::Failed,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
format!("Harness command exited with code {exit_code}"),
|
||||
PlatformErrorCode::InternalError,
|
||||
),
|
||||
),
|
||||
AgentDriverError::HarnessSetupFailed { harness, reason } => (
|
||||
AgentTaskState::Failed,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
format!("Harness '{harness}' validation failed: {reason}"),
|
||||
PlatformErrorCode::EnvironmentSetupFailed,
|
||||
),
|
||||
),
|
||||
AgentDriverError::HarnessConfigSetupFailed { harness, error } => (
|
||||
AgentTaskState::Failed,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
format!("Harness '{harness}' config setup failed: {error}"),
|
||||
PlatformErrorCode::EnvironmentSetupFailed,
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "error_classification_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,155 @@
|
||||
use warp_graphql::ai::{AgentTaskState, PlatformErrorCode};
|
||||
|
||||
use super::classify_driver_error;
|
||||
use crate::ai::agent_sdk::driver::terminal::ShareSessionError;
|
||||
use crate::ai::agent_sdk::driver::AgentDriverError;
|
||||
|
||||
fn assert_state_and_code(
|
||||
error: AgentDriverError,
|
||||
expected_state: AgentTaskState,
|
||||
expected_code: Option<PlatformErrorCode>,
|
||||
) {
|
||||
let (state, update) = classify_driver_error(&error);
|
||||
assert_eq!(state, expected_state, "unexpected state for {error}");
|
||||
assert_eq!(
|
||||
update.error_code, expected_code,
|
||||
"unexpected error_code for {error}"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Infrastructure errors → ERROR ---
|
||||
|
||||
#[test]
|
||||
fn bootstrap_failed_is_error_with_internal() {
|
||||
assert_state_and_code(
|
||||
AgentDriverError::BootstrapFailed,
|
||||
AgentTaskState::Error,
|
||||
Some(PlatformErrorCode::InternalError),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_unavailable_is_error_with_internal() {
|
||||
assert_state_and_code(
|
||||
AgentDriverError::TerminalUnavailable,
|
||||
AgentTaskState::Error,
|
||||
Some(PlatformErrorCode::InternalError),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_logged_in_is_error_with_auth_required() {
|
||||
let (state, update) = classify_driver_error(&AgentDriverError::NotLoggedIn);
|
||||
assert_eq!(state, AgentTaskState::Error);
|
||||
assert_eq!(
|
||||
update.error_code,
|
||||
Some(PlatformErrorCode::AuthenticationRequired)
|
||||
);
|
||||
assert!(
|
||||
update.message.contains("WARP_API_KEY"),
|
||||
"message should mention WARP_API_KEY: {:?}",
|
||||
update.message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_drive_sync_failed_is_error() {
|
||||
assert_state_and_code(
|
||||
AgentDriverError::WarpDriveSyncFailed,
|
||||
AgentTaskState::Error,
|
||||
Some(PlatformErrorCode::InternalError),
|
||||
);
|
||||
}
|
||||
|
||||
// --- Config/user errors → FAILED ---
|
||||
|
||||
#[test]
|
||||
fn mcp_server_not_found_is_failed_with_env_setup() {
|
||||
assert_state_and_code(
|
||||
AgentDriverError::MCPServerNotFound(uuid::Uuid::nil()),
|
||||
AgentTaskState::Failed,
|
||||
Some(PlatformErrorCode::EnvironmentSetupFailed),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_setup_failed_is_failed() {
|
||||
assert_state_and_code(
|
||||
AgentDriverError::EnvironmentSetupFailed("bad repo".into()),
|
||||
AgentTaskState::Failed,
|
||||
Some(PlatformErrorCode::EnvironmentSetupFailed),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_error_is_failed_with_resource_not_found() {
|
||||
assert_state_and_code(
|
||||
AgentDriverError::ProfileError("my-profile".into()),
|
||||
AgentTaskState::Failed,
|
||||
Some(PlatformErrorCode::ResourceNotFound),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_not_found_is_failed_with_resource_not_found() {
|
||||
assert_state_and_code(
|
||||
AgentDriverError::EnvironmentNotFound("env-123".into()),
|
||||
AgentTaskState::Failed,
|
||||
Some(PlatformErrorCode::ResourceNotFound),
|
||||
);
|
||||
}
|
||||
|
||||
// --- ShareSessionFailed variants ---
|
||||
|
||||
#[test]
|
||||
fn share_session_disabled_gets_feature_not_available() {
|
||||
let (state, update) = classify_driver_error(&AgentDriverError::ShareSessionFailed {
|
||||
error: ShareSessionError::Disabled,
|
||||
});
|
||||
assert_eq!(state, AgentTaskState::Error);
|
||||
assert_eq!(
|
||||
update.error_code,
|
||||
Some(PlatformErrorCode::FeatureNotAvailable)
|
||||
);
|
||||
assert!(update.message.contains("not enabled"));
|
||||
assert!(update.message.contains("--share flag"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_session_timeout_gets_internal_error() {
|
||||
let (state, update) = classify_driver_error(&AgentDriverError::ShareSessionFailed {
|
||||
error: ShareSessionError::Timeout,
|
||||
});
|
||||
assert_eq!(state, AgentTaskState::Error);
|
||||
assert_eq!(update.error_code, Some(PlatformErrorCode::InternalError));
|
||||
assert!(update.message.contains("timed out"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_session_failed_includes_reason() {
|
||||
let (state, update) = classify_driver_error(&AgentDriverError::ShareSessionFailed {
|
||||
error: ShareSessionError::Failed("server rejected".into()),
|
||||
});
|
||||
assert_eq!(state, AgentTaskState::Error);
|
||||
assert!(update.message.contains("server rejected"));
|
||||
}
|
||||
|
||||
// --- Conversation-level outcomes ---
|
||||
|
||||
#[test]
|
||||
fn conversation_cancelled_is_cancelled() {
|
||||
let (state, update) = classify_driver_error(&AgentDriverError::ConversationCancelled {
|
||||
reason: crate::ai::agent::CancellationReason::ManuallyCancelled,
|
||||
});
|
||||
assert_eq!(state, AgentTaskState::Cancelled);
|
||||
assert!(update.error_code.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversation_blocked_is_blocked() {
|
||||
let (state, update) = classify_driver_error(&AgentDriverError::ConversationBlocked {
|
||||
blocked_action: "rm -rf /".into(),
|
||||
});
|
||||
assert_eq!(state, AgentTaskState::Blocked);
|
||||
assert!(update.message.contains("rm -rf /"));
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use tempfile::NamedTempFile;
|
||||
use uuid::Uuid;
|
||||
use warp_cli::agent::Harness;
|
||||
use warpui::{ModelHandle, ModelSpawner};
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::server::server_api::harness_support::{upload_to_target, HarnessSupportClient};
|
||||
use crate::server::server_api::ServerApi;
|
||||
use crate::terminal::model::block::BlockId;
|
||||
use crate::terminal::model::session::ExecuteCommandOptions;
|
||||
use crate::terminal::CLIAgent;
|
||||
|
||||
use super::super::terminal::{CommandHandle, TerminalDriver};
|
||||
use super::super::{AgentDriver, AgentDriverError};
|
||||
use super::claude_transcript::{
|
||||
claude_config_dir, read_envelope, write_envelope, write_session_index_entry, ClaudeResumeInfo,
|
||||
ClaudeTranscriptEnvelope,
|
||||
};
|
||||
use super::json_utils::{read_json_file_or_default, write_json_file};
|
||||
use super::{
|
||||
write_temp_file, HarnessRunner, ManagedSecretValue, ResumePayload, SavePoint, ThirdPartyHarness,
|
||||
};
|
||||
mod parent_bridge;
|
||||
|
||||
#[cfg(test)]
|
||||
use super::super::OZ_MESSAGE_LISTENER_STATE_ROOT_ENV;
|
||||
use parent_bridge::MessageBridge;
|
||||
#[cfg(test)]
|
||||
use parent_bridge::{
|
||||
acknowledge_parent_bridge_hook_output, ensure_parent_bridge_state_dir,
|
||||
parent_bridge_char_count, parent_bridge_hook_output_ack_file, parent_bridge_hook_output_file,
|
||||
parent_bridge_root, parent_bridge_staged_message_path, parent_bridge_surfaced_message_path,
|
||||
prepare_parent_bridge_hook_output, render_parent_bridge_message_block,
|
||||
stage_parent_bridge_message, MessageBridgeHookOutput, MessageBridgeMessageRecord,
|
||||
MESSAGE_BRIDGE_CONTEXT_PREAMBLE,
|
||||
};
|
||||
|
||||
pub(crate) struct ClaudeHarness;
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
impl ThirdPartyHarness for ClaudeHarness {
|
||||
fn harness(&self) -> Harness {
|
||||
Harness::Claude
|
||||
}
|
||||
|
||||
fn cli_agent(&self) -> CLIAgent {
|
||||
CLIAgent::Claude
|
||||
}
|
||||
|
||||
fn install_docs_url(&self) -> Option<&'static str> {
|
||||
Some("https://code.claude.com/docs/en/quickstart")
|
||||
}
|
||||
|
||||
fn prepare_environment_config(
|
||||
&self,
|
||||
working_dir: &Path,
|
||||
_system_prompt: Option<&str>,
|
||||
secrets: &HashMap<String, ManagedSecretValue>,
|
||||
) -> Result<(), AgentDriverError> {
|
||||
prepare_claude_environment_config(working_dir, secrets).map_err(|error| {
|
||||
AgentDriverError::HarnessConfigSetupFailed {
|
||||
harness: self.cli_agent().command_prefix().to_owned(),
|
||||
error,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch the Claude Code transcript for the current task's conversation and wrap it
|
||||
/// into a [`ResumePayload::Claude`]. Maps a server 404 to
|
||||
/// [`AgentDriverError::ConversationResumeStateMissing`] tagged as the `claude` harness
|
||||
/// so the user sees a resume-specific error rather than a generic load failure.
|
||||
async fn fetch_resume_payload(
|
||||
&self,
|
||||
conversation_id: &AIConversationId,
|
||||
harness_support_client: Arc<dyn HarnessSupportClient>,
|
||||
) -> Result<Option<ResumePayload>, AgentDriverError> {
|
||||
let conversation_id_str = conversation_id.to_string();
|
||||
let bytes = harness_support_client
|
||||
.fetch_transcript()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
// A 404 from the server maps to "no stored transcript" so the CLI can tell
|
||||
// the user the prior run never saved state.
|
||||
let message = format!("{err:#}").to_lowercase();
|
||||
if message.contains("status 404") {
|
||||
AgentDriverError::ConversationResumeStateMissing {
|
||||
harness: "claude".to_string(),
|
||||
conversation_id: conversation_id_str.clone(),
|
||||
}
|
||||
} else {
|
||||
AgentDriverError::ConversationLoadFailed(format!("{err:#}"))
|
||||
}
|
||||
})?;
|
||||
let envelope: ClaudeTranscriptEnvelope = serde_json::from_slice(&bytes).map_err(|err| {
|
||||
AgentDriverError::ConversationLoadFailed(format!(
|
||||
"Failed to deserialize Claude transcript for {conversation_id_str}: {err:#}"
|
||||
))
|
||||
})?;
|
||||
let session_id = envelope.uuid;
|
||||
Ok(Some(ResumePayload::Claude(ClaudeResumeInfo {
|
||||
conversation_id: *conversation_id,
|
||||
session_id,
|
||||
envelope,
|
||||
})))
|
||||
}
|
||||
|
||||
fn build_runner(
|
||||
&self,
|
||||
prompt: &str,
|
||||
system_prompt: Option<&str>,
|
||||
resumption_prompt: Option<&str>,
|
||||
working_dir: &Path,
|
||||
task_id: Option<AmbientAgentTaskId>,
|
||||
server_api: Arc<ServerApi>,
|
||||
terminal_driver: ModelHandle<TerminalDriver>,
|
||||
resume: Option<ResumePayload>,
|
||||
) -> Result<Box<dyn HarnessRunner>, AgentDriverError> {
|
||||
// Extract the Claude variant; any other variant is ignored since it belongs to a
|
||||
// different harness. Today there are no other variants, but this keeps the shape
|
||||
// ready for future CLI-specific payloads.
|
||||
let claude_resume = resume.map(|payload| match payload {
|
||||
ResumePayload::Claude(info) => info,
|
||||
});
|
||||
// Claude treats the user-turn message as immediate intent, so the resumption preamble
|
||||
// is most reliable when prepended directly to the prompt that gets piped into the CLI.
|
||||
let owned_prompt = match resumption_prompt {
|
||||
Some(preamble) if !preamble.is_empty() => format!("{preamble}\n\n{prompt}"),
|
||||
_ => prompt.to_string(),
|
||||
};
|
||||
Ok(Box::new(ClaudeHarnessRunner::new(
|
||||
self.cli_agent().command_prefix(),
|
||||
&owned_prompt,
|
||||
system_prompt,
|
||||
working_dir,
|
||||
task_id,
|
||||
server_api,
|
||||
terminal_driver,
|
||||
claude_resume,
|
||||
)?))
|
||||
}
|
||||
}
|
||||
|
||||
/// Format slug sent to the server when creating a Claude Code conversation.
|
||||
const CLAUDE_CODE_FORMAT: &str = "claude_code_cli";
|
||||
/// Command used to exit claude.
|
||||
const CLAUDE_EXIT_COMMAND: &str = "/exit";
|
||||
|
||||
/// Build the shell command that launches the Claude CLI for a given session and
|
||||
/// prompt file.
|
||||
///
|
||||
/// When `resuming` is true we pass `--resume <uuid>` so Claude picks up the
|
||||
/// existing on-disk session; otherwise we pass `--session-id <uuid>` to pin a
|
||||
/// fresh session to that id. If `system_prompt_path` is provided, the CLI is
|
||||
/// told to append its contents to the base system prompt.
|
||||
fn claude_command(
|
||||
cli_name: &str,
|
||||
session_id: &Uuid,
|
||||
prompt_path: &str,
|
||||
system_prompt_path: Option<&str>,
|
||||
resuming: bool,
|
||||
) -> String {
|
||||
let flag = if resuming { "--resume" } else { "--session-id" };
|
||||
let mut cmd = format!("{cli_name} {flag} {session_id} --dangerously-skip-permissions");
|
||||
if let Some(sp_path) = system_prompt_path {
|
||||
let _ = write!(cmd, " --append-system-prompt-file '{sp_path}'");
|
||||
}
|
||||
format!("{cmd} < '{prompt_path}'")
|
||||
}
|
||||
|
||||
/// Runtime state of a [`ClaudeHarnessRunner`].
|
||||
enum ClaudeRunnerState {
|
||||
/// Runner is built but [`HarnessRunner::start`] has not been called yet.
|
||||
Preexec,
|
||||
/// The harness command is running (or has finished).
|
||||
Running {
|
||||
conversation_id: AIConversationId,
|
||||
block_id: BlockId,
|
||||
},
|
||||
}
|
||||
|
||||
struct ClaudeHarnessRunner {
|
||||
command: String,
|
||||
/// The CLI name used to invoke Claude Code.
|
||||
cli_name: String,
|
||||
/// Held so the temp file is cleaned up when the runner is dropped.
|
||||
_temp_prompt_file: NamedTempFile,
|
||||
/// Held so the system prompt temp file is cleaned up when the runner is dropped.
|
||||
_temp_system_prompt_file: Option<NamedTempFile>,
|
||||
client: Arc<dyn HarnessSupportClient>,
|
||||
server_api: Arc<ServerApi>,
|
||||
terminal_driver: ModelHandle<TerminalDriver>,
|
||||
state: Mutex<ClaudeRunnerState>,
|
||||
session_id: Uuid,
|
||||
working_dir: PathBuf,
|
||||
parent_bridge: Option<MessageBridge>,
|
||||
/// Lazily cached output of `claude --version`.
|
||||
claude_version: Mutex<Option<String>>,
|
||||
/// When resuming an existing conversation, we pin the runner's server conversation id
|
||||
/// up front instead of calling `create_external_conversation` in [`HarnessRunner::start`].
|
||||
/// Subsequent saves overwrite the same GCS objects keyed by this id.
|
||||
preexisting_conversation_id: Option<AIConversationId>,
|
||||
}
|
||||
|
||||
impl ClaudeHarnessRunner {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn new(
|
||||
cli_command: &str,
|
||||
prompt: &str,
|
||||
system_prompt: Option<&str>,
|
||||
working_dir: &Path,
|
||||
task_id: Option<AmbientAgentTaskId>,
|
||||
server_api: Arc<ServerApi>,
|
||||
terminal_driver: ModelHandle<TerminalDriver>,
|
||||
resume: Option<ClaudeResumeInfo>,
|
||||
) -> Result<Self, AgentDriverError> {
|
||||
// Write the prompt to a temp file so we can feed it via stdin redirect,
|
||||
// avoiding shell-quoting issues with complex content (e.g. skill instructions).
|
||||
let temp_file = write_temp_file("oz_prompt_", prompt)?;
|
||||
let prompt_path = temp_file.path().display().to_string();
|
||||
|
||||
let (session_id, preexisting_conversation_id, resuming) = match resume {
|
||||
Some(ClaudeResumeInfo {
|
||||
conversation_id,
|
||||
session_id,
|
||||
mut envelope,
|
||||
}) => {
|
||||
// Rehydrate the stored envelope under the current working directory so
|
||||
// `claude --resume <uuid>` finds the jsonl under ~/.claude/projects/<encoded_cwd>/.
|
||||
// The original envelope's cwd usually points at the cloud sandbox path, which
|
||||
// doesn't exist locally.
|
||||
envelope.cwd = working_dir.to_path_buf();
|
||||
let config_root = claude_config_dir().map_err(|e| {
|
||||
AgentDriverError::ConfigBuildFailed(
|
||||
e.context("Failed to resolve Claude config dir"),
|
||||
)
|
||||
})?;
|
||||
write_envelope(&envelope, &config_root).map_err(|e| {
|
||||
AgentDriverError::ConfigBuildFailed(
|
||||
e.context("Failed to rehydrate Claude transcript"),
|
||||
)
|
||||
})?;
|
||||
// Index write is best-effort: upstream Claude versions vary in how they use
|
||||
// `sessions-index.json`, so losing the index entry shouldn't abort the run.
|
||||
if let Err(e) = write_session_index_entry(session_id, working_dir, &config_root) {
|
||||
log::warn!("Failed to update Claude sessions-index.json: {e:#}");
|
||||
}
|
||||
(session_id, Some(conversation_id), true)
|
||||
}
|
||||
None => (Uuid::new_v4(), None, false),
|
||||
};
|
||||
|
||||
let temp_system_prompt_file = system_prompt
|
||||
.map(|sp| write_temp_file("oz_system_prompt_", sp))
|
||||
.transpose()?;
|
||||
let system_prompt_path = temp_system_prompt_file
|
||||
.as_ref()
|
||||
.map(|f| f.path().display().to_string());
|
||||
let parent_bridge = task_id
|
||||
.map(|task_id| MessageBridge::new(task_id.to_string(), session_id))
|
||||
.transpose()
|
||||
.map_err(AgentDriverError::ConfigBuildFailed)?;
|
||||
let client: Arc<dyn HarnessSupportClient> = server_api.clone();
|
||||
|
||||
Ok(Self {
|
||||
command: claude_command(
|
||||
cli_command,
|
||||
&session_id,
|
||||
&prompt_path,
|
||||
system_prompt_path.as_deref(),
|
||||
resuming,
|
||||
),
|
||||
cli_name: cli_command.to_string(),
|
||||
_temp_prompt_file: temp_file,
|
||||
_temp_system_prompt_file: temp_system_prompt_file,
|
||||
client,
|
||||
server_api,
|
||||
terminal_driver,
|
||||
state: Mutex::new(ClaudeRunnerState::Preexec),
|
||||
session_id,
|
||||
working_dir: working_dir.to_path_buf(),
|
||||
parent_bridge,
|
||||
claude_version: Mutex::new(None),
|
||||
preexisting_conversation_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ClaudeHarnessRunner {
|
||||
async fn handle_parent_bridge_session_update(&self) -> Result<()> {
|
||||
let Some(parent_bridge) = self.parent_bridge.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
parent_bridge
|
||||
.handle_session_update(self.server_api.clone())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn flush_parent_bridge_acks(&self) -> Result<()> {
|
||||
let Some(parent_bridge) = self.parent_bridge.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
parent_bridge.flush_acks(self.server_api.clone()).await
|
||||
}
|
||||
/// Return the cached Claude Code version, or resolve it by running
|
||||
/// `<cli_name> --version`.
|
||||
async fn resolve_claude_version(
|
||||
&self,
|
||||
foreground: &ModelSpawner<AgentDriver>,
|
||||
) -> Option<String> {
|
||||
if let Some(cached) = self.claude_version.lock().clone() {
|
||||
return Some(cached);
|
||||
}
|
||||
|
||||
let terminal_driver = self.terminal_driver.clone();
|
||||
let session = foreground
|
||||
.spawn(move |_, ctx| {
|
||||
let tv = terminal_driver.as_ref(ctx).terminal_view().as_ref(ctx);
|
||||
tv.active_session().as_ref(ctx).session(ctx)
|
||||
})
|
||||
.await
|
||||
.ok()?;
|
||||
let session = session?;
|
||||
|
||||
let cli_name = &self.cli_name;
|
||||
let output = session
|
||||
.execute_command(
|
||||
&format!("{cli_name} --version"),
|
||||
None,
|
||||
None,
|
||||
ExecuteCommandOptions::default(),
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
let version = output.to_string().ok()?.trim().to_string();
|
||||
if version.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
*self.claude_version.lock() = Some(version.clone());
|
||||
Some(version)
|
||||
}
|
||||
|
||||
async fn start_parent_bridge(&self, foreground: &ModelSpawner<AgentDriver>) -> Result<()> {
|
||||
let Some(parent_bridge) = self.parent_bridge.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
parent_bridge
|
||||
.start(foreground, self.server_api.clone())
|
||||
.await
|
||||
}
|
||||
|
||||
fn cleanup_parent_bridge(&self) -> Result<()> {
|
||||
if let Some(parent_bridge) = self.parent_bridge.as_ref() {
|
||||
parent_bridge.cleanup()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
impl HarnessRunner for ClaudeHarnessRunner {
|
||||
async fn start(
|
||||
&self,
|
||||
foreground: &ModelSpawner<AgentDriver>,
|
||||
) -> Result<CommandHandle, AgentDriverError> {
|
||||
// When resuming, we already have a server conversation id from the prior run.
|
||||
// Otherwise create a fresh external conversation record for this run.
|
||||
// TODO(REMOTE-1149): `create_external_conversation` currently won't work for local CLI
|
||||
// runs. We should either support it or have a fallback.
|
||||
let conversation_id = match self.preexisting_conversation_id {
|
||||
Some(id) => {
|
||||
log::info!("Resuming external conversation {id}");
|
||||
id
|
||||
}
|
||||
None => {
|
||||
let id = self
|
||||
.client
|
||||
.create_external_conversation(CLAUDE_CODE_FORMAT)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to create external conversation: {e}");
|
||||
AgentDriverError::ConfigBuildFailed(e)
|
||||
})?;
|
||||
log::info!("Created external conversation {id}");
|
||||
id
|
||||
}
|
||||
};
|
||||
self.start_parent_bridge(foreground)
|
||||
.await
|
||||
.map_err(AgentDriverError::ConfigBuildFailed)?;
|
||||
|
||||
let command = self.command.clone();
|
||||
let terminal_driver = self.terminal_driver.clone();
|
||||
let command_handle = match foreground
|
||||
.spawn(move |_, ctx| {
|
||||
terminal_driver.update(ctx, |driver, ctx| driver.execute_command(&command, ctx))
|
||||
})
|
||||
.await??
|
||||
.await
|
||||
{
|
||||
Ok(command_handle) => command_handle,
|
||||
Err(err) => {
|
||||
self.cleanup_parent_bridge()
|
||||
.map_err(AgentDriverError::ConfigBuildFailed)?;
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
// Only store conversation info once the CLI command has started.
|
||||
*self.state.lock() = ClaudeRunnerState::Running {
|
||||
conversation_id,
|
||||
block_id: command_handle.block_id().clone(),
|
||||
};
|
||||
|
||||
Ok(command_handle)
|
||||
}
|
||||
|
||||
async fn exit(&self, foreground: &ModelSpawner<AgentDriver>) -> Result<()> {
|
||||
log::info!("Sending /exit to Claude Code CLI");
|
||||
let terminal_driver = self.terminal_driver.clone();
|
||||
foreground
|
||||
.spawn(move |_, ctx| {
|
||||
terminal_driver.update(ctx, |driver, ctx| {
|
||||
driver.send_text_to_cli(CLAUDE_EXIT_COMMAND.to_string(), ctx);
|
||||
});
|
||||
})
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Agent driver dropped while sending /exit"))
|
||||
}
|
||||
|
||||
async fn handle_session_update(&self, _foreground: &ModelSpawner<AgentDriver>) -> Result<()> {
|
||||
self.handle_parent_bridge_session_update().await
|
||||
}
|
||||
|
||||
async fn save_conversation(
|
||||
&self,
|
||||
save_point: SavePoint,
|
||||
foreground: &ModelSpawner<AgentDriver>,
|
||||
) -> Result<()> {
|
||||
if matches!(save_point, SavePoint::Periodic)
|
||||
&& !super::has_running_cli_agent(&self.terminal_driver, foreground).await
|
||||
{
|
||||
log::debug!("Will not save conversation, Claude Code not in progress");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (conversation_id, block_id) = match &*self.state.lock() {
|
||||
ClaudeRunnerState::Preexec => {
|
||||
log::warn!("save_conversation called before start");
|
||||
return Ok(());
|
||||
}
|
||||
ClaudeRunnerState::Running {
|
||||
conversation_id,
|
||||
block_id,
|
||||
} => (*conversation_id, block_id.clone()),
|
||||
};
|
||||
|
||||
let claude_version = self.resolve_claude_version(foreground).await;
|
||||
|
||||
let client = self.client.as_ref();
|
||||
let session_id = self.session_id;
|
||||
let working_dir = &self.working_dir;
|
||||
|
||||
futures::try_join!(
|
||||
super::upload_current_block_snapshot(
|
||||
foreground,
|
||||
&self.terminal_driver,
|
||||
client,
|
||||
conversation_id,
|
||||
block_id,
|
||||
),
|
||||
upload_transcript(
|
||||
client,
|
||||
conversation_id,
|
||||
session_id,
|
||||
working_dir,
|
||||
claude_version
|
||||
),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
async fn cleanup(&self, _foreground: &ModelSpawner<AgentDriver>) -> Result<()> {
|
||||
self.flush_parent_bridge_acks().await?;
|
||||
self.cleanup_parent_bridge()
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload the Claude Code session transcript to the server.
|
||||
async fn upload_transcript(
|
||||
client: &dyn HarnessSupportClient,
|
||||
conversation_id: AIConversationId,
|
||||
session_id: Uuid,
|
||||
working_dir: &Path,
|
||||
claude_version: Option<String>,
|
||||
) -> Result<()> {
|
||||
log::info!("Uploading Claude Code transcript to conversation {conversation_id}");
|
||||
|
||||
let config_dir = claude_config_dir().context("Failed to resolve Claude config dir")?;
|
||||
let working_dir = working_dir.to_path_buf();
|
||||
let body = tokio::task::spawn_blocking(move || {
|
||||
let mut envelope = read_envelope(session_id, &working_dir, &config_dir)
|
||||
.with_context(|| format!("Failed to read transcript for session {session_id}"))?;
|
||||
envelope.claude_version = claude_version;
|
||||
serde_json::to_vec(&envelope).context("Failed to serialize transcript envelope")
|
||||
})
|
||||
.await
|
||||
.context("read_envelope task panicked")??;
|
||||
let target = client
|
||||
.get_transcript_upload_target(&conversation_id)
|
||||
.await
|
||||
.with_context(|| format!("Failed to get transcript upload target for {conversation_id}"))?;
|
||||
upload_to_target(client.http_client(), &target, body).await
|
||||
}
|
||||
|
||||
fn prepare_claude_environment_config(
|
||||
working_dir: &Path,
|
||||
secrets: &HashMap<String, ManagedSecretValue>,
|
||||
) -> Result<()> {
|
||||
let home_dir =
|
||||
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?;
|
||||
let claude_json_path = home_dir.join(CLAUDE_JSON_FILE_NAME);
|
||||
let claude_settings_path = claude_config_dir()?.join(CLAUDE_SETTINGS_FILE_NAME);
|
||||
let api_key_suffix = resolve_anthropic_api_key_suffix(secrets);
|
||||
prepare_claude_config(&claude_json_path, working_dir, api_key_suffix.as_deref())?;
|
||||
prepare_claude_settings(&claude_settings_path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare_claude_config(
|
||||
claude_json_path: &Path,
|
||||
working_dir: &Path,
|
||||
api_key_suffix: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let mut claude_config: ClaudeConfig = read_json_file_or_default(claude_json_path)?;
|
||||
claude_config.has_completed_onboarding = true;
|
||||
claude_config.lsp_recommendation_disabled = true;
|
||||
claude_config
|
||||
.projects
|
||||
.entry(working_dir.to_string_lossy().into_owned())
|
||||
.or_default()
|
||||
.has_trust_dialog_accepted = true;
|
||||
if let Some(suffix) = api_key_suffix {
|
||||
let responses = claude_config
|
||||
.custom_api_key_responses
|
||||
.get_or_insert_with(CustomApiKeyResponses::default);
|
||||
if !responses.approved.iter().any(|s| s == suffix) {
|
||||
responses.approved.push(suffix.to_owned());
|
||||
}
|
||||
}
|
||||
write_json_file(
|
||||
claude_json_path,
|
||||
&claude_config,
|
||||
"Failed to serialize Claude config",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare_claude_settings(claude_settings_path: &Path) -> Result<()> {
|
||||
let mut settings: ClaudeSettings = read_json_file_or_default(claude_settings_path)?;
|
||||
settings.skip_dangerous_mode_permission_prompt = true;
|
||||
write_json_file(
|
||||
claude_settings_path,
|
||||
&settings,
|
||||
"Failed to serialize Claude settings",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY";
|
||||
const CLAUDE_JSON_FILE_NAME: &str = ".claude.json";
|
||||
const CLAUDE_SETTINGS_FILE_NAME: &str = "settings.json";
|
||||
const ANTHROPIC_API_KEY_SUFFIX_LEN: usize = 20;
|
||||
|
||||
#[derive(Default, Deserialize, Serialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ClaudeConfig {
|
||||
#[serde(default)]
|
||||
has_completed_onboarding: bool,
|
||||
#[serde(default)]
|
||||
lsp_recommendation_disabled: bool,
|
||||
#[serde(default)]
|
||||
projects: HashMap<String, ClaudeProjectConfig>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
custom_api_key_responses: Option<CustomApiKeyResponses>,
|
||||
#[serde(flatten)]
|
||||
extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Serialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CustomApiKeyResponses {
|
||||
#[serde(default)]
|
||||
approved: Vec<String>,
|
||||
#[serde(flatten)]
|
||||
extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Serialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ClaudeProjectConfig {
|
||||
#[serde(default)]
|
||||
has_trust_dialog_accepted: bool,
|
||||
#[serde(flatten)]
|
||||
extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Serialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ClaudeSettings {
|
||||
#[serde(default)]
|
||||
skip_dangerous_mode_permission_prompt: bool,
|
||||
#[serde(flatten)]
|
||||
extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// Try to get the last 20 chars of the ANTHROPIC_API_KEY from the secrets map,
|
||||
/// where 20 chars is the suffix length that Claude Code truncates keys to.
|
||||
/// Falls back to the environment variable.
|
||||
fn resolve_anthropic_api_key_suffix(
|
||||
secrets: &HashMap<String, ManagedSecretValue>,
|
||||
) -> Option<String> {
|
||||
// First, check for an AnthropicApiKey variant anywhere in the secrets map,
|
||||
// since the secret name doesn't necessarily match the env var.
|
||||
for secret in secrets.values() {
|
||||
if let ManagedSecretValue::AnthropicApiKey { api_key } = secret {
|
||||
return suffix_of(api_key).map(str::to_owned);
|
||||
}
|
||||
}
|
||||
// Then check for a RawValue stored under the env var name.
|
||||
if let Some(ManagedSecretValue::RawValue { value }) = secrets.get(ANTHROPIC_API_KEY_ENV) {
|
||||
return suffix_of(value).map(str::to_owned);
|
||||
}
|
||||
// Fall back to the environment variable, which a user may have set separately in the env.
|
||||
std::env::var(ANTHROPIC_API_KEY_ENV)
|
||||
.ok()
|
||||
.and_then(|k| suffix_of(&k).map(str::to_owned))
|
||||
}
|
||||
|
||||
fn suffix_of(key: &str) -> Option<&str> {
|
||||
if key.len() >= ANTHROPIC_API_KEY_SUFFIX_LEN {
|
||||
key.get(key.len() - ANTHROPIC_API_KEY_SUFFIX_LEN..)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "claude_code_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,621 @@
|
||||
//! Bridges durable lead-agent messages into Claude Code's hook-driven
|
||||
//! next-turn context.
|
||||
//!
|
||||
//! The bridge uses an on-disk three-stage state machine inside the per-session
|
||||
//! state directory:
|
||||
//! - `staged/` holds newly observed message IDs from the event stream.
|
||||
//! - `surfaced/` holds the fully hydrated records currently exposed to Claude.
|
||||
//! - `pending-hook-output.json` plus `pending-hook-output.ack` coordinates the
|
||||
//! handoff between Warp's driver and the Claude hook process.
|
||||
use std::fmt::Write as _;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tempfile::NamedTempFile;
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
use uuid::Uuid;
|
||||
use warpui::r#async::SpawnedFutureHandle;
|
||||
use warpui::ModelSpawner;
|
||||
|
||||
use crate::ai::agent_events::{
|
||||
run_agent_event_driver, AgentEventConsumer, AgentEventConsumerControlFlow,
|
||||
AgentEventDriverConfig, MessageHydrator, ServerApiAgentEventSource,
|
||||
};
|
||||
use crate::ai::agent_sdk::driver::{AgentDriver, OZ_MESSAGE_LISTENER_STATE_ROOT_ENV};
|
||||
use crate::server::server_api::ai::AgentRunEvent;
|
||||
use crate::server::server_api::ServerApi;
|
||||
|
||||
const LEGACY_MESSAGE_LISTENER_STATE_ROOT_ENV: &str = "OZ_PARENT_STATE_ROOT";
|
||||
const PARENT_BRIDGE_DEFAULT_STATE_ROOT: &str = ".claude-code/oz-parent-bridge";
|
||||
const PARENT_BRIDGE_SURFACED_DIR_NAME: &str = "surfaced";
|
||||
const PARENT_BRIDGE_HOOK_OUTPUT_FILE_NAME: &str = "pending-hook-output.json";
|
||||
const PARENT_BRIDGE_HOOK_OUTPUT_ACK_FILE_NAME: &str = "pending-hook-output.ack";
|
||||
const PARENT_BRIDGE_MAX_CONTEXT_CHARS_ENV: &str = "OZ_PARENT_MAX_CONTEXT_CHARS";
|
||||
const PARENT_BRIDGE_DEFAULT_MAX_CONTEXT_CHARS: usize = 6000;
|
||||
pub(super) const MESSAGE_BRIDGE_CONTEXT_PREAMBLE: &str = "Lead-agent updates arrived from Oz. Treat the latest lead-agent instructions below as authoritative.\n";
|
||||
const PARENT_BRIDGE_REMAINING_MESSAGES_NOTE: &str =
|
||||
"\n\nMore lead-agent messages are still staged and will be surfaced on a later turn.";
|
||||
|
||||
pub(super) struct MessageBridge {
|
||||
run_id: String,
|
||||
state_dir: PathBuf,
|
||||
runtime: Mutex<Option<MessageBridgeRuntime>>,
|
||||
state_lock: AsyncMutex<()>,
|
||||
}
|
||||
struct MessageBridgeRuntime {
|
||||
task: SpawnedFutureHandle,
|
||||
}
|
||||
|
||||
struct MessageBridgeEventConsumer {
|
||||
run_id: String,
|
||||
state_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
impl AgentEventConsumer for MessageBridgeEventConsumer {
|
||||
async fn on_event(
|
||||
&mut self,
|
||||
event: AgentRunEvent,
|
||||
) -> anyhow::Result<AgentEventConsumerControlFlow> {
|
||||
if event.event_type != "new_message" || event.run_id != self.run_id {
|
||||
return Ok(AgentEventConsumerControlFlow::Continue);
|
||||
}
|
||||
|
||||
let Some(message_id) = event.ref_id else {
|
||||
return Ok(AgentEventConsumerControlFlow::Continue);
|
||||
};
|
||||
|
||||
if let Err(err) = stage_parent_bridge_message(
|
||||
&self.state_dir,
|
||||
&MessageBridgeMessageRecord {
|
||||
sequence: event.sequence,
|
||||
message_id: message_id.clone(),
|
||||
sender_run_id: String::new(),
|
||||
subject: String::new(),
|
||||
body: String::new(),
|
||||
occurred_at: event.occurred_at,
|
||||
},
|
||||
) {
|
||||
log::warn!(
|
||||
"Failed to stage Claude lead-agent message {message_id} at sequence {}: {err:#}",
|
||||
event.sequence
|
||||
);
|
||||
}
|
||||
|
||||
Ok(AgentEventConsumerControlFlow::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub(super) struct MessageBridgeMessageRecord {
|
||||
pub sequence: i64,
|
||||
pub message_id: String,
|
||||
#[serde(default)]
|
||||
pub sender_run_id: String,
|
||||
#[serde(default)]
|
||||
pub subject: String,
|
||||
#[serde(default)]
|
||||
pub body: String,
|
||||
pub occurred_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub(super) struct MessageBridgeHookOutput {
|
||||
pub additional_context: String,
|
||||
pub remaining_staged_count: usize,
|
||||
pub surfaced_count: usize,
|
||||
}
|
||||
|
||||
struct RenderedMessageBridgeMessage {
|
||||
block: String,
|
||||
block_chars: usize,
|
||||
}
|
||||
struct SelectedMessageBridgeMessage {
|
||||
path: PathBuf,
|
||||
record: MessageBridgeMessageRecord,
|
||||
rendered: RenderedMessageBridgeMessage,
|
||||
}
|
||||
|
||||
struct SelectedMessageBridgeMessages {
|
||||
messages: Vec<SelectedMessageBridgeMessage>,
|
||||
context_chars: usize,
|
||||
total_available_count: usize,
|
||||
}
|
||||
|
||||
impl MessageBridge {
|
||||
pub(super) fn new(run_id: String, session_id: Uuid) -> Result<Self> {
|
||||
Ok(Self {
|
||||
run_id,
|
||||
state_dir: parent_bridge_root()?.join(session_id.to_string()),
|
||||
runtime: Mutex::new(None),
|
||||
state_lock: AsyncMutex::new(()),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn start(
|
||||
&self,
|
||||
foreground: &ModelSpawner<AgentDriver>,
|
||||
server_api: Arc<ServerApi>,
|
||||
) -> Result<()> {
|
||||
if self.runtime.lock().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
ensure_parent_bridge_state_dir(&self.state_dir)?;
|
||||
let run_id = self.run_id.clone();
|
||||
let state_dir = self.state_dir.clone();
|
||||
let task = foreground
|
||||
.spawn(move |_, ctx| {
|
||||
ctx.spawn(
|
||||
async move {
|
||||
if let Err(err) =
|
||||
run_parent_bridge_forever(server_api, run_id, state_dir.clone()).await
|
||||
{
|
||||
log::warn!(
|
||||
"Claude message bridge stopped for {}: {err:#}",
|
||||
state_dir.display()
|
||||
);
|
||||
}
|
||||
},
|
||||
|_, _, _| {},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| anyhow!("Agent driver dropped while starting Claude message bridge"))?;
|
||||
*self.runtime.lock() = Some(MessageBridgeRuntime { task });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn handle_session_update(&self, server_api: Arc<ServerApi>) -> Result<()> {
|
||||
if !self.state_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let hydrator = MessageHydrator::new(server_api);
|
||||
let _guard = self.state_lock.lock().await;
|
||||
acknowledge_parent_bridge_hook_output(&hydrator, &self.state_dir).await?;
|
||||
prepare_parent_bridge_hook_output(
|
||||
&hydrator,
|
||||
&self.state_dir,
|
||||
parent_bridge_max_context_chars(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn flush_acks(&self, server_api: Arc<ServerApi>) -> Result<()> {
|
||||
if !self.state_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let hydrator = MessageHydrator::new(server_api);
|
||||
let _guard = self.state_lock.lock().await;
|
||||
acknowledge_parent_bridge_hook_output(&hydrator, &self.state_dir).await
|
||||
}
|
||||
|
||||
pub(super) fn cleanup(&self) -> Result<()> {
|
||||
if let Some(runtime) = self.runtime.lock().take() {
|
||||
runtime.task.abort();
|
||||
}
|
||||
match fs::remove_dir_all(&self.state_dir) {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(err) => {
|
||||
return Err(anyhow::Error::from(err).context(format!(
|
||||
"Failed to remove Claude message bridge state dir {}",
|
||||
self.state_dir.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn parent_bridge_root() -> Result<PathBuf> {
|
||||
for env_name in [
|
||||
OZ_MESSAGE_LISTENER_STATE_ROOT_ENV,
|
||||
LEGACY_MESSAGE_LISTENER_STATE_ROOT_ENV,
|
||||
] {
|
||||
if let Ok(dir) = std::env::var(env_name) {
|
||||
if !dir.is_empty() {
|
||||
return Ok(PathBuf::from(dir));
|
||||
}
|
||||
}
|
||||
}
|
||||
dirs::home_dir()
|
||||
.map(|home| home.join(PARENT_BRIDGE_DEFAULT_STATE_ROOT))
|
||||
.ok_or_else(|| anyhow!("could not determine home directory"))
|
||||
}
|
||||
|
||||
fn parent_bridge_staged_dir(state_dir: &Path) -> PathBuf {
|
||||
state_dir.join("staged")
|
||||
}
|
||||
|
||||
fn parent_bridge_surfaced_dir(state_dir: &Path) -> PathBuf {
|
||||
state_dir.join(PARENT_BRIDGE_SURFACED_DIR_NAME)
|
||||
}
|
||||
|
||||
pub(super) fn parent_bridge_hook_output_file(state_dir: &Path) -> PathBuf {
|
||||
state_dir.join(PARENT_BRIDGE_HOOK_OUTPUT_FILE_NAME)
|
||||
}
|
||||
|
||||
pub(super) fn parent_bridge_hook_output_ack_file(state_dir: &Path) -> PathBuf {
|
||||
state_dir.join(PARENT_BRIDGE_HOOK_OUTPUT_ACK_FILE_NAME)
|
||||
}
|
||||
|
||||
fn parent_bridge_message_path(dir: &Path, sequence: i64, message_id: &str) -> PathBuf {
|
||||
dir.join(format!("{sequence:020}-{message_id}.json"))
|
||||
}
|
||||
|
||||
pub(super) fn parent_bridge_staged_message_path(
|
||||
state_dir: &Path,
|
||||
sequence: i64,
|
||||
message_id: &str,
|
||||
) -> PathBuf {
|
||||
parent_bridge_message_path(&parent_bridge_staged_dir(state_dir), sequence, message_id)
|
||||
}
|
||||
|
||||
pub(super) fn parent_bridge_surfaced_message_path(
|
||||
state_dir: &Path,
|
||||
sequence: i64,
|
||||
message_id: &str,
|
||||
) -> PathBuf {
|
||||
parent_bridge_message_path(&parent_bridge_surfaced_dir(state_dir), sequence, message_id)
|
||||
}
|
||||
|
||||
pub(super) fn ensure_parent_bridge_state_dir(state_dir: &Path) -> Result<()> {
|
||||
fs::create_dir_all(parent_bridge_staged_dir(state_dir))
|
||||
.with_context(|| format!("Failed to create {}", state_dir.display()))?;
|
||||
fs::create_dir_all(parent_bridge_surfaced_dir(state_dir))
|
||||
.with_context(|| format!("Failed to create {}", state_dir.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn stage_parent_bridge_message(
|
||||
state_dir: &Path,
|
||||
record: &MessageBridgeMessageRecord,
|
||||
) -> Result<()> {
|
||||
let target = parent_bridge_staged_message_path(state_dir, record.sequence, &record.message_id);
|
||||
if !target.exists() {
|
||||
write_parent_bridge_json_atomically(&target, record)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parent_bridge_max_context_chars() -> usize {
|
||||
std::env::var(PARENT_BRIDGE_MAX_CONTEXT_CHARS_ENV)
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse::<usize>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(PARENT_BRIDGE_DEFAULT_MAX_CONTEXT_CHARS)
|
||||
}
|
||||
|
||||
fn parent_bridge_sorted_message_paths(dir: &Path) -> Result<Vec<PathBuf>> {
|
||||
if !dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut paths = fs::read_dir(dir)
|
||||
.with_context(|| format!("Failed to read {}", dir.display()))?
|
||||
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
|
||||
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("json"))
|
||||
.collect::<Vec<_>>();
|
||||
paths.sort();
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
fn parent_bridge_message_records(dir: &Path) -> Result<Vec<(PathBuf, MessageBridgeMessageRecord)>> {
|
||||
parent_bridge_sorted_message_paths(dir)?
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
let record = serde_json::from_slice::<MessageBridgeMessageRecord>(
|
||||
&fs::read(&path).with_context(|| format!("Failed to read {}", path.display()))?,
|
||||
)
|
||||
.with_context(|| format!("Failed to parse {}", path.display()))?;
|
||||
Ok((path, record))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn parent_bridge_char_count(text: &str) -> usize {
|
||||
text.chars().count()
|
||||
}
|
||||
|
||||
fn parent_bridge_truncate_chars(text: &str, limit: usize) -> String {
|
||||
text.chars().take(limit).collect()
|
||||
}
|
||||
|
||||
pub(super) fn render_parent_bridge_message_block(record: &MessageBridgeMessageRecord) -> String {
|
||||
let subject = if record.subject.is_empty() {
|
||||
"(no subject)"
|
||||
} else {
|
||||
record.subject.as_str()
|
||||
};
|
||||
|
||||
let mut block = String::from("---\nLead-agent message");
|
||||
if record.sequence != 0 {
|
||||
let _ = write!(block, " #{}", record.sequence);
|
||||
}
|
||||
if !record.sender_run_id.is_empty() {
|
||||
let _ = write!(block, " from {}", record.sender_run_id);
|
||||
}
|
||||
let _ = write!(block, "\nSubject: {subject}\n\n{}", record.body);
|
||||
block
|
||||
}
|
||||
|
||||
fn render_parent_bridge_message(
|
||||
record: &MessageBridgeMessageRecord,
|
||||
) -> RenderedMessageBridgeMessage {
|
||||
let block = render_parent_bridge_message_block(record);
|
||||
let block_chars = parent_bridge_char_count(&block);
|
||||
RenderedMessageBridgeMessage { block, block_chars }
|
||||
}
|
||||
|
||||
fn truncate_parent_bridge_message(rendered: &mut RenderedMessageBridgeMessage, max_chars: usize) {
|
||||
if rendered.block_chars <= max_chars || max_chars <= 3 {
|
||||
return;
|
||||
}
|
||||
|
||||
rendered.block = parent_bridge_truncate_chars(&rendered.block, max_chars - 3);
|
||||
rendered.block.push_str("...");
|
||||
rendered.block_chars = parent_bridge_char_count(&rendered.block);
|
||||
}
|
||||
|
||||
fn build_parent_bridge_hook_output(
|
||||
selected: &SelectedMessageBridgeMessages,
|
||||
max_context_chars: usize,
|
||||
) -> Option<MessageBridgeHookOutput> {
|
||||
if selected.messages.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut additional_context = String::from(MESSAGE_BRIDGE_CONTEXT_PREAMBLE);
|
||||
for (index, message) in selected.messages.iter().enumerate() {
|
||||
if index > 0 {
|
||||
additional_context.push_str("\n\n");
|
||||
}
|
||||
additional_context.push_str(&message.rendered.block);
|
||||
}
|
||||
|
||||
let remaining_staged_count = selected
|
||||
.total_available_count
|
||||
.saturating_sub(selected.messages.len());
|
||||
let remaining_note_chars = parent_bridge_char_count(PARENT_BRIDGE_REMAINING_MESSAGES_NOTE);
|
||||
if remaining_staged_count > 0
|
||||
&& selected.context_chars + remaining_note_chars <= max_context_chars
|
||||
{
|
||||
additional_context.push_str(PARENT_BRIDGE_REMAINING_MESSAGES_NOTE);
|
||||
}
|
||||
|
||||
Some(MessageBridgeHookOutput {
|
||||
additional_context,
|
||||
remaining_staged_count,
|
||||
surfaced_count: selected.messages.len(),
|
||||
})
|
||||
}
|
||||
|
||||
fn write_parent_bridge_hook_output(
|
||||
state_dir: &Path,
|
||||
output: &MessageBridgeHookOutput,
|
||||
) -> Result<()> {
|
||||
let path = parent_bridge_hook_output_file(state_dir);
|
||||
write_parent_bridge_json_atomically(&path, output)
|
||||
}
|
||||
|
||||
fn remove_file_if_exists(path: &Path) -> Result<()> {
|
||||
match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(err) => {
|
||||
Err(anyhow::Error::from(err).context(format!("Failed to remove {}", path.display())))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn hydrate_parent_bridge_message_record(
|
||||
hydrator: &MessageHydrator,
|
||||
record: &MessageBridgeMessageRecord,
|
||||
) -> Result<MessageBridgeMessageRecord> {
|
||||
if !record.sender_run_id.is_empty() {
|
||||
return Ok(record.clone());
|
||||
}
|
||||
|
||||
let message = hydrator
|
||||
.read_message_with_timeout(&record.message_id)
|
||||
.await
|
||||
.with_context(|| format!("Failed to read lead-agent message {}", record.message_id))?;
|
||||
Ok(MessageBridgeMessageRecord {
|
||||
sequence: record.sequence,
|
||||
message_id: message.message_id,
|
||||
sender_run_id: message.sender_run_id,
|
||||
subject: message.subject,
|
||||
body: message.body,
|
||||
occurred_at: record.occurred_at.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn select_parent_bridge_messages_for_hook_output(
|
||||
hydrator: &MessageHydrator,
|
||||
records: Vec<(PathBuf, MessageBridgeMessageRecord)>,
|
||||
max_context_chars: usize,
|
||||
) -> Result<SelectedMessageBridgeMessages> {
|
||||
let total_available_count = records.len();
|
||||
let mut messages = Vec::new();
|
||||
let mut context_chars = parent_bridge_char_count(MESSAGE_BRIDGE_CONTEXT_PREAMBLE);
|
||||
|
||||
for (path, record) in records {
|
||||
let separator_chars = if messages.is_empty() { 0 } else { 2 };
|
||||
let remaining = max_context_chars.saturating_sub(context_chars + separator_chars);
|
||||
if remaining <= 3 && !messages.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let record = hydrate_parent_bridge_message_record(hydrator, &record).await?;
|
||||
let mut rendered = render_parent_bridge_message(&record);
|
||||
if context_chars + separator_chars + rendered.block_chars > max_context_chars {
|
||||
if remaining > 3 && rendered.block_chars > remaining {
|
||||
truncate_parent_bridge_message(&mut rendered, remaining);
|
||||
} else if !messages.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
context_chars += separator_chars + rendered.block_chars;
|
||||
messages.push(SelectedMessageBridgeMessage {
|
||||
path,
|
||||
record,
|
||||
rendered,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(SelectedMessageBridgeMessages {
|
||||
messages,
|
||||
context_chars,
|
||||
total_available_count,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn prepare_parent_bridge_hook_output(
|
||||
hydrator: &MessageHydrator,
|
||||
state_dir: &Path,
|
||||
max_context_chars: usize,
|
||||
) -> Result<()> {
|
||||
let hook_output_path = parent_bridge_hook_output_file(state_dir);
|
||||
if hook_output_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let surfaced_records = parent_bridge_message_records(&parent_bridge_surfaced_dir(state_dir))?;
|
||||
if !surfaced_records.is_empty() {
|
||||
let selected = select_parent_bridge_messages_for_hook_output(
|
||||
hydrator,
|
||||
surfaced_records,
|
||||
max_context_chars,
|
||||
)
|
||||
.await?;
|
||||
for message in &selected.messages {
|
||||
write_parent_bridge_json_atomically(&message.path, &message.record)?;
|
||||
}
|
||||
if let Some(output) = build_parent_bridge_hook_output(&selected, max_context_chars) {
|
||||
write_parent_bridge_hook_output(state_dir, &output)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let staged_records = parent_bridge_message_records(&parent_bridge_staged_dir(state_dir))?;
|
||||
if staged_records.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let selected =
|
||||
select_parent_bridge_messages_for_hook_output(hydrator, staged_records, max_context_chars)
|
||||
.await?;
|
||||
let Some(output) = build_parent_bridge_hook_output(&selected, max_context_chars) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
for message in &selected.messages {
|
||||
let target = parent_bridge_surfaced_message_path(
|
||||
state_dir,
|
||||
message.record.sequence,
|
||||
&message.record.message_id,
|
||||
);
|
||||
fs::rename(&message.path, &target).with_context(|| {
|
||||
format!(
|
||||
"Failed to move message bridge record {} to {}",
|
||||
message.path.display(),
|
||||
target.display()
|
||||
)
|
||||
})?;
|
||||
write_parent_bridge_json_atomically(&target, &message.record)?;
|
||||
}
|
||||
|
||||
remove_file_if_exists(&parent_bridge_hook_output_ack_file(state_dir))?;
|
||||
write_parent_bridge_hook_output(state_dir, &output)
|
||||
}
|
||||
|
||||
pub(super) async fn acknowledge_parent_bridge_hook_output(
|
||||
hydrator: &MessageHydrator,
|
||||
state_dir: &Path,
|
||||
) -> Result<()> {
|
||||
let ack_path = parent_bridge_hook_output_ack_file(state_dir);
|
||||
if !ack_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Remove the hook output first so an acknowledged block cannot be re-emitted
|
||||
// if the harness restarts while delivery cleanup is still in progress.
|
||||
remove_file_if_exists(&parent_bridge_hook_output_file(state_dir))?;
|
||||
|
||||
let surfaced_records = parent_bridge_message_records(&parent_bridge_surfaced_dir(state_dir))?;
|
||||
let message_ids = surfaced_records
|
||||
.iter()
|
||||
.map(|(_, record)| record.message_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let delivery_failures = hydrator
|
||||
.mark_messages_delivered_best_effort(message_ids.iter().map(String::as_str))
|
||||
.await;
|
||||
for (message_id, err) in delivery_failures {
|
||||
log::warn!(
|
||||
"Failed to mark Claude message bridge message {message_id} as delivered: {err:#}"
|
||||
);
|
||||
}
|
||||
|
||||
for (path, _) in surfaced_records {
|
||||
remove_file_if_exists(&path)?;
|
||||
}
|
||||
remove_file_if_exists(&ack_path)
|
||||
}
|
||||
|
||||
async fn run_parent_bridge_forever(
|
||||
server_api: Arc<ServerApi>,
|
||||
run_id: String,
|
||||
state_dir: PathBuf,
|
||||
) -> Result<()> {
|
||||
ensure_parent_bridge_state_dir(&state_dir)?;
|
||||
// The shared driver keeps `since_sequence` in memory across its own retry
|
||||
// loop, which is all this per-session bridge needs because the state dir is
|
||||
// not reused across sessions.
|
||||
let config = AgentEventDriverConfig::retry_forever(vec![run_id.clone()], 0);
|
||||
let source = ServerApiAgentEventSource::new(server_api);
|
||||
let mut consumer = MessageBridgeEventConsumer { run_id, state_dir };
|
||||
run_agent_event_driver(source, config, &mut consumer).await
|
||||
}
|
||||
|
||||
fn write_parent_bridge_json_atomically<T: Serialize>(path: &Path, value: &T) -> Result<()> {
|
||||
write_parent_bridge_bytes_atomically(path, &serde_json::to_vec(value)?)
|
||||
}
|
||||
|
||||
fn write_parent_bridge_bytes_atomically(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
let Some(parent) = path.parent() else {
|
||||
return Err(anyhow!("{} has no parent directory", path.display()));
|
||||
};
|
||||
fs::create_dir_all(parent).with_context(|| format!("Failed to create {}", parent.display()))?;
|
||||
|
||||
let prefix = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("parent-bridge");
|
||||
let mut temp_file = NamedTempFile::new_in(parent)
|
||||
.with_context(|| format!("Failed to create temp file for {}", path.display()))?;
|
||||
temp_file
|
||||
.write_all(bytes)
|
||||
.with_context(|| format!("Failed to write temp file for {}", path.display()))?;
|
||||
temp_file
|
||||
.flush()
|
||||
.with_context(|| format!("Failed to flush temp file for {}", path.display()))?;
|
||||
temp_file
|
||||
.persist(path)
|
||||
.map(|_| ())
|
||||
.map_err(|err| {
|
||||
anyhow::Error::from(err.error).context(format!("Failed to write {}", path.display()))
|
||||
})
|
||||
.with_context(|| format!("Failed to persist temporary {prefix} file"))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
use mockall::predicate::eq;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::*;
|
||||
use crate::ai::agent_events::MessageHydrator;
|
||||
use crate::server::server_api::ai::{MockAIClient, ReadAgentMessageResponse};
|
||||
|
||||
fn sample_parent_bridge_message(
|
||||
sequence: i64,
|
||||
message_id: &str,
|
||||
subject: &str,
|
||||
body: &str,
|
||||
) -> MessageBridgeMessageRecord {
|
||||
MessageBridgeMessageRecord {
|
||||
sequence,
|
||||
message_id: message_id.to_string(),
|
||||
sender_run_id: "parent-run-456".to_string(),
|
||||
subject: subject.to_string(),
|
||||
body: body.to_string(),
|
||||
occurred_at: "2026-04-17T15:46:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_staged_parent_bridge_message(
|
||||
sequence: i64,
|
||||
message_id: &str,
|
||||
) -> MessageBridgeMessageRecord {
|
||||
MessageBridgeMessageRecord {
|
||||
sequence,
|
||||
message_id: message_id.to_string(),
|
||||
sender_run_id: String::new(),
|
||||
subject: String::new(),
|
||||
body: String::new(),
|
||||
occurred_at: "2026-04-17T15:46:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_surfaced_parent_bridge_message(state_dir: &Path, record: &MessageBridgeMessageRecord) {
|
||||
fs::write(
|
||||
parent_bridge_surfaced_message_path(state_dir, record.sequence, &record.message_id),
|
||||
serde_json::to_vec(record).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_command_uses_session_id_when_not_resuming() {
|
||||
let uuid = Uuid::new_v4();
|
||||
let cmd = claude_command("claude", &uuid, "/tmp/prompt.txt", None, false);
|
||||
assert!(
|
||||
cmd.contains(&format!("--session-id {uuid}")),
|
||||
"expected --session-id flag in non-resume command, got: {cmd}"
|
||||
);
|
||||
assert!(
|
||||
!cmd.contains("--resume"),
|
||||
"non-resume command should not contain --resume, got: {cmd}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_command_uses_resume_flag_when_resuming() {
|
||||
let uuid = Uuid::new_v4();
|
||||
let cmd = claude_command("claude", &uuid, "/tmp/prompt.txt", None, true);
|
||||
assert!(
|
||||
cmd.contains(&format!("--resume {uuid}")),
|
||||
"expected --resume flag in resume command, got: {cmd}"
|
||||
);
|
||||
assert!(
|
||||
!cmd.contains("--session-id"),
|
||||
"resume command should not contain --session-id, got: {cmd}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_command_pipes_prompt_path() {
|
||||
let uuid = Uuid::new_v4();
|
||||
let cmd = claude_command("claude", &uuid, "/tmp/prompt with spaces.txt", None, true);
|
||||
assert!(
|
||||
cmd.contains("< '/tmp/prompt with spaces.txt'"),
|
||||
"expected single-quoted stdin redirect of the prompt path, got: {cmd}"
|
||||
);
|
||||
assert!(
|
||||
cmd.contains("--dangerously-skip-permissions"),
|
||||
"expected --dangerously-skip-permissions, got: {cmd}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn parent_bridge_root_prefers_environment_override() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::env::set_var(OZ_MESSAGE_LISTENER_STATE_ROOT_ENV, tmp.path());
|
||||
let root = parent_bridge_root().unwrap();
|
||||
std::env::remove_var(OZ_MESSAGE_LISTENER_STATE_ROOT_ENV);
|
||||
|
||||
assert_eq!(root, tmp.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_parent_bridge_message_writes_message_record() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let state_dir = tmp.path().join("session-123");
|
||||
ensure_parent_bridge_state_dir(&state_dir).unwrap();
|
||||
let record = sample_staged_parent_bridge_message(42, "msg-123");
|
||||
|
||||
stage_parent_bridge_message(&state_dir, &record).unwrap();
|
||||
|
||||
let staged_path = parent_bridge_staged_message_path(&state_dir, 42, "msg-123");
|
||||
let staged_record: MessageBridgeMessageRecord =
|
||||
serde_json::from_slice(&fs::read(&staged_path).unwrap()).unwrap();
|
||||
assert_eq!(staged_record.sequence, 42);
|
||||
assert_eq!(staged_record.message_id, "msg-123");
|
||||
assert!(staged_record.sender_run_id.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepare_parent_bridge_hook_output_moves_selected_messages_to_surfaced_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let state_dir = tmp.path().join("session-123");
|
||||
ensure_parent_bridge_state_dir(&state_dir).unwrap();
|
||||
|
||||
let first = sample_parent_bridge_message(
|
||||
42,
|
||||
"msg-123",
|
||||
"Please pivot",
|
||||
"Inspect the failing tests first.",
|
||||
);
|
||||
stage_parent_bridge_message(
|
||||
&state_dir,
|
||||
&sample_staged_parent_bridge_message(42, "msg-123"),
|
||||
)
|
||||
.unwrap();
|
||||
stage_parent_bridge_message(
|
||||
&state_dir,
|
||||
&sample_staged_parent_bridge_message(43, "msg-456"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut ai_client = MockAIClient::new();
|
||||
let expected_first = first.clone();
|
||||
ai_client
|
||||
.expect_read_agent_message()
|
||||
.with(eq("msg-123"))
|
||||
.times(1)
|
||||
.returning(move |_| {
|
||||
Ok(ReadAgentMessageResponse {
|
||||
message_id: expected_first.message_id.clone(),
|
||||
sender_run_id: expected_first.sender_run_id.clone(),
|
||||
subject: expected_first.subject.clone(),
|
||||
body: expected_first.body.clone(),
|
||||
sent_at: "2026-04-17T15:46:00Z".to_string(),
|
||||
delivered_at: None,
|
||||
read_at: Some("2026-04-17T15:46:02Z".to_string()),
|
||||
})
|
||||
});
|
||||
let hydrator = MessageHydrator::new(
|
||||
Arc::new(ai_client) as Arc<dyn crate::server::server_api::ai::AIClient>
|
||||
);
|
||||
|
||||
let max_context_chars = parent_bridge_char_count(MESSAGE_BRIDGE_CONTEXT_PREAMBLE)
|
||||
+ parent_bridge_char_count(&render_parent_bridge_message_block(&first));
|
||||
prepare_parent_bridge_hook_output(&hydrator, &state_dir, max_context_chars)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let hook_output: MessageBridgeHookOutput =
|
||||
serde_json::from_slice(&fs::read(parent_bridge_hook_output_file(&state_dir)).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(hook_output.surfaced_count, 1);
|
||||
assert_eq!(hook_output.remaining_staged_count, 1);
|
||||
assert!(hook_output.additional_context.contains("Please pivot"));
|
||||
assert!(!hook_output.additional_context.contains("Second update"));
|
||||
let surfaced_path = parent_bridge_surfaced_message_path(&state_dir, 42, "msg-123");
|
||||
assert!(surfaced_path.exists());
|
||||
assert!(parent_bridge_staged_message_path(&state_dir, 43, "msg-456").exists());
|
||||
assert!(!parent_bridge_staged_message_path(&state_dir, 42, "msg-123").exists());
|
||||
let surfaced_record: MessageBridgeMessageRecord =
|
||||
serde_json::from_slice(&fs::read(&surfaced_path).unwrap()).unwrap();
|
||||
assert_eq!(surfaced_record.subject, first.subject);
|
||||
assert_eq!(surfaced_record.body, first.body);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acknowledge_parent_bridge_hook_output_marks_messages_delivered_and_clears_state() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let state_dir = tmp.path().join("session-123");
|
||||
ensure_parent_bridge_state_dir(&state_dir).unwrap();
|
||||
|
||||
let record = sample_parent_bridge_message(
|
||||
42,
|
||||
"msg-123",
|
||||
"Please pivot",
|
||||
"Inspect the failing tests first.",
|
||||
);
|
||||
write_surfaced_parent_bridge_message(&state_dir, &record);
|
||||
fs::write(
|
||||
parent_bridge_hook_output_file(&state_dir),
|
||||
serde_json::to_vec(&MessageBridgeHookOutput {
|
||||
additional_context: "context".to_string(),
|
||||
remaining_staged_count: 0,
|
||||
surfaced_count: 1,
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(parent_bridge_hook_output_ack_file(&state_dir), "").unwrap();
|
||||
|
||||
let mut ai_client = MockAIClient::new();
|
||||
ai_client
|
||||
.expect_mark_message_delivered()
|
||||
.with(eq("msg-123"))
|
||||
.times(1)
|
||||
.returning(|_| Ok(()));
|
||||
let hydrator = MessageHydrator::new(
|
||||
Arc::new(ai_client) as Arc<dyn crate::server::server_api::ai::AIClient>
|
||||
);
|
||||
|
||||
acknowledge_parent_bridge_hook_output(&hydrator, &state_dir)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!parent_bridge_surfaced_message_path(&state_dir, 42, "msg-123").exists());
|
||||
assert!(!parent_bridge_hook_output_file(&state_dir).exists());
|
||||
assert!(!parent_bridge_hook_output_ack_file(&state_dir).exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepare_parent_bridge_hook_output_reuses_surfaced_records_without_rehydrating() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let state_dir = tmp.path().join("session-123");
|
||||
ensure_parent_bridge_state_dir(&state_dir).unwrap();
|
||||
|
||||
let record = sample_parent_bridge_message(
|
||||
42,
|
||||
"msg-123",
|
||||
"Please pivot",
|
||||
"Inspect the failing tests first.",
|
||||
);
|
||||
write_surfaced_parent_bridge_message(&state_dir, &record);
|
||||
|
||||
let mut ai_client = MockAIClient::new();
|
||||
ai_client.expect_read_agent_message().times(0);
|
||||
let hydrator = MessageHydrator::new(
|
||||
Arc::new(ai_client) as Arc<dyn crate::server::server_api::ai::AIClient>
|
||||
);
|
||||
|
||||
let max_context_chars = parent_bridge_char_count(MESSAGE_BRIDGE_CONTEXT_PREAMBLE)
|
||||
+ parent_bridge_char_count(&render_parent_bridge_message_block(&record));
|
||||
prepare_parent_bridge_hook_output(&hydrator, &state_dir, max_context_chars)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let hook_output: MessageBridgeHookOutput =
|
||||
serde_json::from_slice(&fs::read(parent_bridge_hook_output_file(&state_dir)).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(hook_output.surfaced_count, 1);
|
||||
assert_eq!(hook_output.remaining_staged_count, 0);
|
||||
assert!(hook_output.additional_context.contains(&record.subject));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepare_parent_bridge_hook_output_truncates_single_large_message() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let state_dir = tmp.path().join("session-123");
|
||||
ensure_parent_bridge_state_dir(&state_dir).unwrap();
|
||||
stage_parent_bridge_message(
|
||||
&state_dir,
|
||||
&sample_staged_parent_bridge_message(42, "msg-123"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let long_body = "x".repeat(200);
|
||||
let mut ai_client = MockAIClient::new();
|
||||
ai_client
|
||||
.expect_read_agent_message()
|
||||
.with(eq("msg-123"))
|
||||
.times(1)
|
||||
.returning(move |_| {
|
||||
Ok(ReadAgentMessageResponse {
|
||||
message_id: "msg-123".to_string(),
|
||||
sender_run_id: "parent-run-456".to_string(),
|
||||
subject: "Please pivot".to_string(),
|
||||
body: long_body.clone(),
|
||||
sent_at: "2026-04-17T15:46:00Z".to_string(),
|
||||
delivered_at: None,
|
||||
read_at: Some("2026-04-17T15:46:02Z".to_string()),
|
||||
})
|
||||
});
|
||||
let hydrator = MessageHydrator::new(
|
||||
Arc::new(ai_client) as Arc<dyn crate::server::server_api::ai::AIClient>
|
||||
);
|
||||
|
||||
let max_context_chars = parent_bridge_char_count(MESSAGE_BRIDGE_CONTEXT_PREAMBLE) + 48;
|
||||
prepare_parent_bridge_hook_output(&hydrator, &state_dir, max_context_chars)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let hook_output: MessageBridgeHookOutput =
|
||||
serde_json::from_slice(&fs::read(parent_bridge_hook_output_file(&state_dir)).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(hook_output.surfaced_count, 1);
|
||||
assert!(
|
||||
hook_output.additional_context.ends_with("..."),
|
||||
"expected truncated context, got: {}",
|
||||
hook_output.additional_context
|
||||
);
|
||||
assert!(parent_bridge_char_count(&hook_output.additional_context) <= max_context_chars);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acknowledge_parent_bridge_hook_output_ignores_missing_ack_marker() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let state_dir = tmp.path().join("session-123");
|
||||
ensure_parent_bridge_state_dir(&state_dir).unwrap();
|
||||
|
||||
let record = sample_parent_bridge_message(
|
||||
42,
|
||||
"msg-123",
|
||||
"Please pivot",
|
||||
"Inspect the failing tests first.",
|
||||
);
|
||||
write_surfaced_parent_bridge_message(&state_dir, &record);
|
||||
|
||||
let mut ai_client = MockAIClient::new();
|
||||
ai_client.expect_mark_message_delivered().times(0);
|
||||
let hydrator = MessageHydrator::new(
|
||||
Arc::new(ai_client) as Arc<dyn crate::server::server_api::ai::AIClient>
|
||||
);
|
||||
|
||||
acknowledge_parent_bridge_hook_output(&hydrator, &state_dir)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(parent_bridge_surfaced_message_path(&state_dir, 42, "msg-123").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_claude_config_creates_config_file_without_api_suffix() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let claude_json_path = tmp.path().join(".claude.json");
|
||||
let working_dir = tmp.path().join("workspace/project");
|
||||
|
||||
prepare_claude_config(&claude_json_path, &working_dir, None).unwrap();
|
||||
|
||||
let claude_config: Value =
|
||||
serde_json::from_slice(&fs::read(claude_json_path).unwrap()).unwrap();
|
||||
assert_eq!(claude_config["hasCompletedOnboarding"], Value::Bool(true));
|
||||
assert_eq!(
|
||||
claude_config["lspRecommendationDisabled"],
|
||||
Value::Bool(true)
|
||||
);
|
||||
let working_dir_key = working_dir.to_string_lossy().to_string();
|
||||
assert_eq!(
|
||||
claude_config["projects"][working_dir_key]["hasTrustDialogAccepted"],
|
||||
Value::Bool(true)
|
||||
);
|
||||
assert_eq!(claude_config.get("customApiKeyResponses"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_claude_config_creates_config_file_with_api_suffix() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let claude_json_path = tmp.path().join(".claude.json");
|
||||
let working_dir = tmp.path().join("workspace/project");
|
||||
|
||||
prepare_claude_config(
|
||||
&claude_json_path,
|
||||
&working_dir,
|
||||
Some("QLWn-dUnuwQ-hIhDiAAA"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let claude_config: Value =
|
||||
serde_json::from_slice(&fs::read(claude_json_path).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
claude_config["customApiKeyResponses"]["approved"],
|
||||
serde_json::json!(["QLWn-dUnuwQ-hIhDiAAA"]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_claude_config_merges_existing_config() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let claude_json_path = tmp.path().join(".claude.json");
|
||||
fs::write(
|
||||
&claude_json_path,
|
||||
r#"{"theme":"dark","projects":{"/existing/project":{"allowedTools":["Bash"],"nested":{"value":2}}},"customApiKeyResponses":{"approved":["existing-suffix-12345"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let working_dir = tmp.path().join("workspace/project");
|
||||
prepare_claude_config(
|
||||
&claude_json_path,
|
||||
&working_dir,
|
||||
Some("new-suffix-1234567890"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let claude_config: Value =
|
||||
serde_json::from_slice(&fs::read(claude_json_path).unwrap()).unwrap();
|
||||
assert_eq!(claude_config["theme"], "dark");
|
||||
assert_eq!(
|
||||
claude_config["lspRecommendationDisabled"],
|
||||
Value::Bool(true)
|
||||
);
|
||||
assert_eq!(
|
||||
claude_config["projects"]["/existing/project"]["allowedTools"],
|
||||
serde_json::json!(["Bash"])
|
||||
);
|
||||
assert_eq!(
|
||||
claude_config["projects"]["/existing/project"]["nested"]["value"],
|
||||
2
|
||||
);
|
||||
// Both existing and new suffixes should be present.
|
||||
assert_eq!(
|
||||
claude_config["customApiKeyResponses"]["approved"],
|
||||
serde_json::json!(["existing-suffix-12345", "new-suffix-1234567890"]),
|
||||
);
|
||||
let working_dir_key = working_dir.to_string_lossy().to_string();
|
||||
assert_eq!(
|
||||
claude_config["projects"][working_dir_key]["hasTrustDialogAccepted"],
|
||||
Value::Bool(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_claude_config_no_duplicate_suffix() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let claude_json_path = tmp.path().join(".claude.json");
|
||||
fs::write(
|
||||
&claude_json_path,
|
||||
r#"{"customApiKeyResponses":{"approved":["QLWn-dUnuwQ-hIhDiAAA"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let working_dir = tmp.path().join("workspace/project");
|
||||
prepare_claude_config(
|
||||
&claude_json_path,
|
||||
&working_dir,
|
||||
Some("QLWn-dUnuwQ-hIhDiAAA"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let claude_config: Value =
|
||||
serde_json::from_slice(&fs::read(claude_json_path).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
claude_config["customApiKeyResponses"]["approved"],
|
||||
serde_json::json!(["QLWn-dUnuwQ-hIhDiAAA"]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_claude_config_none_suffix_preserves_existing_responses() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let claude_json_path = tmp.path().join(".claude.json");
|
||||
fs::write(
|
||||
&claude_json_path,
|
||||
r#"{"customApiKeyResponses":{"approved":["existing-suffix-12345"],"rejected":["bad-key"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let working_dir = tmp.path().join("workspace/project");
|
||||
prepare_claude_config(&claude_json_path, &working_dir, None).unwrap();
|
||||
|
||||
let claude_config: Value =
|
||||
serde_json::from_slice(&fs::read(claude_json_path).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
claude_config["customApiKeyResponses"]["approved"],
|
||||
serde_json::json!(["existing-suffix-12345"]),
|
||||
);
|
||||
assert_eq!(
|
||||
claude_config["customApiKeyResponses"]["rejected"],
|
||||
serde_json::json!(["bad-key"]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_suffix_from_raw_value_secret() {
|
||||
let key = "sk-ant-api03-abcdefghij1234567890ABCDEFGHIJ1234567890abcdefghij1234567890QLWn-dUnuwQ-hIhDiAAA";
|
||||
let secrets = HashMap::from([(
|
||||
"ANTHROPIC_API_KEY".to_string(),
|
||||
ManagedSecretValue::raw_value(key),
|
||||
)]);
|
||||
let suffix = resolve_anthropic_api_key_suffix(&secrets);
|
||||
assert_eq!(suffix.as_deref(), Some("QLWn-dUnuwQ-hIhDiAAA"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_suffix_from_anthropic_api_key_secret() {
|
||||
let key = "sk-ant-api03-abcdefghij1234567890ABCDEFGHIJ1234567890abcdefghij1234567890QLWn-dUnuwQ-hIhDiAAA";
|
||||
let secrets = HashMap::from([(
|
||||
"ANTHROPIC_API_KEY".to_string(),
|
||||
ManagedSecretValue::anthropic_api_key(key),
|
||||
)]);
|
||||
let suffix = resolve_anthropic_api_key_suffix(&secrets);
|
||||
assert_eq!(suffix.as_deref(), Some("QLWn-dUnuwQ-hIhDiAAA"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_suffix_from_anthropic_api_key_with_different_secret_name() {
|
||||
let key = "sk-ant-api03-abcdefghij1234567890ABCDEFGHIJ1234567890abcdefghij1234567890QLWn-dUnuwQ-hIhDiAAA";
|
||||
// Secret name doesn't match the env var, but the AnthropicApiKey variant
|
||||
// should still be found by iterating all secrets.
|
||||
let secrets = HashMap::from([(
|
||||
"my-anthropic-key".to_string(),
|
||||
ManagedSecretValue::anthropic_api_key(key),
|
||||
)]);
|
||||
let suffix = resolve_anthropic_api_key_suffix(&secrets);
|
||||
assert_eq!(suffix.as_deref(), Some("QLWn-dUnuwQ-hIhDiAAA"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_suffix_prefers_anthropic_api_key_variant_over_raw_value() {
|
||||
let anthropic_key = "sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA-anthropic-suffix";
|
||||
let raw_key = "sk-ant-api03-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB-raw-suffix";
|
||||
let secrets = HashMap::from([
|
||||
(
|
||||
"my-anthropic-key".to_string(),
|
||||
ManagedSecretValue::anthropic_api_key(anthropic_key),
|
||||
),
|
||||
(
|
||||
"ANTHROPIC_API_KEY".to_string(),
|
||||
ManagedSecretValue::raw_value(raw_key),
|
||||
),
|
||||
]);
|
||||
let suffix = resolve_anthropic_api_key_suffix(&secrets);
|
||||
// AnthropicApiKey variant should be preferred.
|
||||
assert_eq!(suffix.as_deref(), Some("AAA-anthropic-suffix"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_suffix_returns_none_for_short_key() {
|
||||
let secrets = HashMap::from([(
|
||||
"ANTHROPIC_API_KEY".to_string(),
|
||||
ManagedSecretValue::raw_value("short"),
|
||||
)]);
|
||||
assert_eq!(resolve_anthropic_api_key_suffix(&secrets), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_suffix_returns_none_for_short_anthropic_api_key() {
|
||||
let secrets = HashMap::from([(
|
||||
"ANTHROPIC_API_KEY".to_string(),
|
||||
ManagedSecretValue::anthropic_api_key("short"),
|
||||
)]);
|
||||
assert_eq!(resolve_anthropic_api_key_suffix(&secrets), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_claude_settings_creates_settings_file() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let claude_settings_path = tmp.path().join(".claude/settings.json");
|
||||
|
||||
prepare_claude_settings(&claude_settings_path).unwrap();
|
||||
|
||||
let claude_settings: Value =
|
||||
serde_json::from_slice(&fs::read(claude_settings_path).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
claude_settings["skipDangerousModePermissionPrompt"],
|
||||
Value::Bool(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_claude_settings_merges_existing_settings() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let claude_settings_path = tmp.path().join("settings.json");
|
||||
fs::write(
|
||||
&claude_settings_path,
|
||||
r#"{"editor":"vim","nested":{"value":1}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
prepare_claude_settings(&claude_settings_path).unwrap();
|
||||
|
||||
let claude_settings: Value =
|
||||
serde_json::from_slice(&fs::read(claude_settings_path).unwrap()).unwrap();
|
||||
assert_eq!(claude_settings["editor"], "vim");
|
||||
assert_eq!(claude_settings["nested"]["value"], 1);
|
||||
assert_eq!(
|
||||
claude_settings["skipDangerousModePermissionPrompt"],
|
||||
Value::Bool(true)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
//! Claude Code transcript layout + rehydration helpers.
|
||||
//!
|
||||
//! This module owns:
|
||||
//! - [`ClaudeTranscriptEnvelope`] — the on-wire/on-GCS shape of a saved Claude session
|
||||
//! (main jsonl entries + subagent jsonl files + per-agent todo JSONs), plus reader/writer
|
||||
//! functions that interoperate with Claude's own `~/.claude` layout.
|
||||
//! - [`ClaudeResumeInfo`] — everything the harness runner needs to resume an existing
|
||||
//! Claude conversation: the Warp server conversation id to reuse, the Claude session uuid
|
||||
//! to pass to `claude --resume`, and the decoded envelope to rehydrate onto disk.
|
||||
//! - [`write_session_index_entry`] — best-effort update of `~/.claude/sessions-index.json`
|
||||
//! so Claude's `--resume <uuid>` lookup can find the freshly-rehydrated jsonl. Upstream
|
||||
//! versions vary in how they use this index (claude-code#33912, #39667, #5768); we write
|
||||
//! a conservative entry and log on failure.
|
||||
//!
|
||||
//! Split out from `claude_code.rs` so the `AIClient` transcript-fetch impl can deserialize
|
||||
//! envelopes without pulling in the rest of the harness runner.
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
use warp_core::safe_warn;
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
|
||||
/// JSON envelope sent to the server representing a complete Claude Code session.
|
||||
///
|
||||
/// Bundles the main session transcript, any subagent transcripts, and
|
||||
/// per-agent TODO lists assembled from the Claude state directory.
|
||||
#[derive(Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub(crate) struct ClaudeTranscriptEnvelope {
|
||||
/// The directory that the Claude Code session started in.
|
||||
pub(crate) cwd: PathBuf,
|
||||
/// Unique session identifier.
|
||||
pub(crate) uuid: Uuid,
|
||||
/// Claude Code version, if available.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) claude_version: Option<String>,
|
||||
/// List of messages in the main agent conversation.
|
||||
pub(crate) entries: Vec<Value>,
|
||||
/// Messages in each subagent conversation, keyed by the agent filename (e.g. `"agent-aac0b7f3db6bccfaf"`).
|
||||
pub(crate) subagents: HashMap<String, Vec<Value>>,
|
||||
/// TODO lists for each agent, keyed on the session and agent (e.g. `"<session_uuid>-agent-<agent_id>"`).
|
||||
pub(crate) todos: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
/// Everything needed to resume an existing Claude conversation.
|
||||
///
|
||||
/// Populated from a `--conversation` id after the client fetches the stored envelope from
|
||||
/// the server. Passed into `ClaudeHarnessRunner::new` so the runner reuses the existing
|
||||
/// session and server conversation ids instead of minting fresh ones.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ClaudeResumeInfo {
|
||||
/// The Warp server-side conversation id. The runner stores this instead of calling
|
||||
/// `create_external_conversation` so subsequent transcript/block-snapshot uploads overwrite
|
||||
/// the same GCS objects.
|
||||
pub(crate) conversation_id: AIConversationId,
|
||||
/// The Claude session uuid to pass to `claude --resume`. Matches `envelope.uuid`.
|
||||
pub(crate) session_id: Uuid,
|
||||
/// Envelope from the server. Its `cwd` field is rewritten to the current run's working
|
||||
/// directory before being written to disk, so `claude --resume <uuid>` finds the jsonl under
|
||||
/// `~/.claude/projects/<encoded(new_cwd)>/`.
|
||||
pub(crate) envelope: ClaudeTranscriptEnvelope,
|
||||
}
|
||||
|
||||
/// Encode a filesystem path as a Claude config directory name, matching the
|
||||
/// Claude CLI convention of replacing every `/` with `-`.
|
||||
///
|
||||
/// Example: `/Users/ben/src/foo` → `-Users-ben-src-foo`
|
||||
pub(crate) fn encode_cwd(cwd: &Path) -> String {
|
||||
cwd.to_string_lossy().replace(['/', '.'], "-")
|
||||
}
|
||||
|
||||
/// Resolve the Claude config directory.
|
||||
///
|
||||
/// Reads `$CLAUDE_CONFIG_DIR` if set, otherwise falls back to `~/.claude`.
|
||||
//
|
||||
/// TODO(REMOTE-1209): Use the transcript path reported by our hook.
|
||||
pub(crate) fn claude_config_dir() -> Result<PathBuf> {
|
||||
if let Ok(dir) = std::env::var("CLAUDE_CONFIG_DIR") {
|
||||
return Ok(PathBuf::from(dir));
|
||||
}
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".claude"))
|
||||
.ok_or_else(|| anyhow::anyhow!("could not determine home directory"))
|
||||
}
|
||||
|
||||
/// Assemble a [`ClaudeTranscriptEnvelope`] from the Claude config directory.
|
||||
///
|
||||
/// Reads:
|
||||
/// - `<config_root>/projects/<encoded_cwd>/<session_uuid>.jsonl` - main transcript
|
||||
/// - `<config_root>/projects/<encoded_cwd>/<session_uuid>/subagents/*.jsonl` - subagents
|
||||
/// - `<config_root>/todos/<session_uuid>-agent-*.json` - per-agent todo lists
|
||||
///
|
||||
/// If the main JSONL does not exist yet (e.g. during an early periodic save)
|
||||
/// the envelope is returned with an empty `entries` list rather than an error.
|
||||
pub(crate) fn read_envelope(
|
||||
session_uuid: Uuid,
|
||||
cwd: &Path,
|
||||
config_root: &Path,
|
||||
) -> Result<ClaudeTranscriptEnvelope> {
|
||||
let encoded = encode_cwd(cwd);
|
||||
let projects_dir = config_root.join("projects").join(&encoded);
|
||||
|
||||
// Main session transcript.
|
||||
let session_file = projects_dir.join(format!("{session_uuid}.jsonl"));
|
||||
let entries = read_jsonl(&session_file)?;
|
||||
|
||||
// Subagents are stored in a directory named after the session UUID.
|
||||
let mut subagents: HashMap<String, Vec<Value>> = HashMap::new();
|
||||
let subagents_dir = projects_dir
|
||||
.join(session_uuid.to_string())
|
||||
.join("subagents");
|
||||
if subagents_dir.is_dir() {
|
||||
for entry in std::fs::read_dir(&subagents_dir)
|
||||
.with_context(|| format!("Failed to read subagents dir {}", subagents_dir.display()))?
|
||||
{
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
|
||||
continue;
|
||||
}
|
||||
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
subagents.insert(stem.to_owned(), read_jsonl(&path)?);
|
||||
}
|
||||
}
|
||||
|
||||
// Per-agent todo lists.
|
||||
let mut todos: HashMap<String, Value> = HashMap::new();
|
||||
let todos_dir = config_root.join("todos");
|
||||
let todos_prefix = format!("{session_uuid}-agent-");
|
||||
if todos_dir.is_dir() {
|
||||
for entry in std::fs::read_dir(&todos_dir)
|
||||
.with_context(|| format!("Failed to read todos dir {}", todos_dir.display()))?
|
||||
{
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if !stem.starts_with(&todos_prefix) {
|
||||
continue;
|
||||
}
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(content) => match serde_json::from_str(&content) {
|
||||
Ok(value) => {
|
||||
todos.insert(stem.to_owned(), value);
|
||||
}
|
||||
Err(e) => log::warn!("Failed to parse todos file {}: {e}", path.display()),
|
||||
},
|
||||
Err(e) => log::warn!("Failed to read todos file {}: {e}", path.display()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ClaudeTranscriptEnvelope {
|
||||
cwd: cwd.to_path_buf(),
|
||||
uuid: session_uuid,
|
||||
claude_version: None,
|
||||
entries,
|
||||
subagents,
|
||||
todos,
|
||||
})
|
||||
}
|
||||
|
||||
/// Write a [`ClaudeTranscriptEnvelope`] back to disk using the same layout
|
||||
/// that Claude Code uses.
|
||||
///
|
||||
/// Creates:
|
||||
/// - `<config_root>/projects/<encoded_cwd>/<uuid>.jsonl` - main transcript
|
||||
/// - `<config_root>/projects/<encoded_cwd>/<uuid>/subagents/<stem>.jsonl` - subagents
|
||||
/// - `<config_root>/todos/<stem>.json` - per-agent todo lists
|
||||
pub(crate) fn write_envelope(
|
||||
envelope: &ClaudeTranscriptEnvelope,
|
||||
config_root: &Path,
|
||||
) -> Result<()> {
|
||||
let encoded = encode_cwd(&envelope.cwd);
|
||||
let projects_dir = config_root.join("projects").join(&encoded);
|
||||
std::fs::create_dir_all(&projects_dir)
|
||||
.with_context(|| format!("Failed to create {}", projects_dir.display()))?;
|
||||
|
||||
// Main session JSONL.
|
||||
let session_file = projects_dir.join(format!("{}.jsonl", envelope.uuid));
|
||||
std::fs::write(&session_file, entries_to_jsonl(&envelope.entries)?)
|
||||
.with_context(|| format!("Failed to write {}", session_file.display()))?;
|
||||
|
||||
// Subagent JSONLs.
|
||||
if !envelope.subagents.is_empty() {
|
||||
let subagents_dir = projects_dir
|
||||
.join(envelope.uuid.to_string())
|
||||
.join("subagents");
|
||||
std::fs::create_dir_all(&subagents_dir)
|
||||
.with_context(|| format!("Failed to create {}", subagents_dir.display()))?;
|
||||
for (stem, entries) in &envelope.subagents {
|
||||
let path = subagents_dir.join(format!("{stem}.jsonl"));
|
||||
std::fs::write(&path, entries_to_jsonl(entries)?)
|
||||
.with_context(|| format!("Failed to write {}", path.display()))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Per-agent todo lists.
|
||||
if !envelope.todos.is_empty() {
|
||||
let todos_dir = config_root.join("todos");
|
||||
std::fs::create_dir_all(&todos_dir)
|
||||
.with_context(|| format!("Failed to create {}", todos_dir.display()))?;
|
||||
for (stem, value) in &envelope.todos {
|
||||
let path = todos_dir.join(format!("{stem}.json"));
|
||||
std::fs::write(&path, serde_json::to_vec(value)?)
|
||||
.with_context(|| format!("Failed to write {}", path.display()))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Filename of Claude's global session index.
|
||||
const SESSIONS_INDEX_FILENAME: &str = "sessions-index.json";
|
||||
|
||||
/// Upsert an entry for `session_uuid` into `<config_root>/sessions-index.json` so Claude's
|
||||
/// `claude --resume <uuid>` lookup can find the rehydrated jsonl.
|
||||
///
|
||||
/// Upstream Claude versions vary in how the index is keyed and what fields they read; this
|
||||
/// writer uses a conservative session-uuid-keyed schema (session id, cwd, jsonl path) that
|
||||
/// mirrors the fragments documented in claude-code#33912 / #39667 / #5768. Unknown fields are
|
||||
/// preserved on existing entries, and we never remove other entries.
|
||||
///
|
||||
/// Best-effort: callers should log a warning on failure rather than aborting the run — if the
|
||||
/// index is missing or wrong, `--resume` simply falls back to "No conversation found" and the
|
||||
/// resumed run surfaces the expected resume-failure error.
|
||||
pub(crate) fn write_session_index_entry(
|
||||
session_uuid: Uuid,
|
||||
cwd: &Path,
|
||||
config_root: &Path,
|
||||
) -> Result<()> {
|
||||
let index_path = config_root.join(SESSIONS_INDEX_FILENAME);
|
||||
|
||||
// Read the existing index if present. Missing or malformed files are treated as empty —
|
||||
// we'd rather clobber an unparseable file than fail the whole resume.
|
||||
let mut index: serde_json::Map<String, Value> = match std::fs::read_to_string(&index_path) {
|
||||
Ok(content) => match serde_json::from_str::<Value>(&content) {
|
||||
Ok(Value::Object(map)) => map,
|
||||
Ok(_) => {
|
||||
safe_warn!(
|
||||
safe: ("sessions-index.json is not a JSON object; overwriting"),
|
||||
full: ("sessions-index.json at {} is not a JSON object; overwriting", index_path.display())
|
||||
);
|
||||
serde_json::Map::new()
|
||||
}
|
||||
Err(e) => {
|
||||
safe_warn!(
|
||||
safe: ("Failed to parse sessions-index.json; overwriting"),
|
||||
full: ("Failed to parse sessions-index.json at {}: {e}; overwriting", index_path.display())
|
||||
);
|
||||
serde_json::Map::new()
|
||||
}
|
||||
},
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => serde_json::Map::new(),
|
||||
Err(e) => {
|
||||
return Err(
|
||||
anyhow::Error::from(e).context(format!("Failed to read {}", index_path.display()))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let encoded = encode_cwd(cwd);
|
||||
let transcript_path = format!("projects/{encoded}/{session_uuid}.jsonl");
|
||||
let entry = serde_json::json!({
|
||||
"sessionId": session_uuid.to_string(),
|
||||
"cwd": cwd.to_string_lossy(),
|
||||
"projectPath": encoded,
|
||||
"transcriptPath": transcript_path,
|
||||
});
|
||||
index.insert(session_uuid.to_string(), entry);
|
||||
|
||||
if let Some(parent) = index_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
||||
}
|
||||
std::fs::write(
|
||||
&index_path,
|
||||
serde_json::to_vec_pretty(&Value::Object(index))
|
||||
.context("Failed to serialize sessions-index.json")?,
|
||||
)
|
||||
.with_context(|| format!("Failed to write {}", index_path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize a slice of JSON values as a JSONL byte string (one value per line).
|
||||
fn entries_to_jsonl(entries: &[Value]) -> Result<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
for entry in entries {
|
||||
serde_json::to_writer(&mut buf, entry)?;
|
||||
buf.push(b'\n');
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Read a JSONL file, returning one parsed [`Value`] per non-blank line.
|
||||
///
|
||||
/// Lines that fail to parse as JSON are skipped with a warning rather than
|
||||
/// causing the entire read to fail. A missing file returns an empty [`Vec`].
|
||||
pub(crate) fn read_jsonl(path: &Path) -> Result<Vec<Value>> {
|
||||
let file = match std::fs::File::open(path) {
|
||||
Ok(f) => f,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(e) => {
|
||||
return Err(
|
||||
anyhow::Error::from(e).context(format!("Failed to open {}", path.display()))
|
||||
);
|
||||
}
|
||||
};
|
||||
let reader = BufReader::new(file);
|
||||
let mut entries = Vec::new();
|
||||
for line in reader.lines() {
|
||||
let line = line.with_context(|| format!("Failed to read line from {}", path.display()))?;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match serde_json::from_str(trimmed) {
|
||||
Ok(value) => entries.push(value),
|
||||
Err(e) => {
|
||||
safe_warn!(
|
||||
safe: ("Skipping malformed JSONL entry"),
|
||||
full: ("Skipping malformed JSONL entry in {}: {e}", path.display())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "claude_transcript_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,246 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn write_file(dir: &Path, name: &str, content: &str) {
|
||||
fs::write(dir.join(name), content).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_cwd_replaces_slashes_and_dots() {
|
||||
assert_eq!(
|
||||
encode_cwd(Path::new("/Users/ben/src/foo")),
|
||||
"-Users-ben-src-foo"
|
||||
);
|
||||
assert_eq!(encode_cwd(Path::new("/")), "-");
|
||||
assert_eq!(encode_cwd(Path::new("/a/b")), "-a-b");
|
||||
assert_eq!(
|
||||
encode_cwd(Path::new("/Users/ben/.config/foo")),
|
||||
"-Users-ben--config-foo"
|
||||
);
|
||||
assert_eq!(encode_cwd(Path::new("/a.b/c.d")), "-a-b-c-d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_envelope_main_only() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cwd = Path::new("/my/project");
|
||||
let uuid = Uuid::new_v4();
|
||||
|
||||
let encoded = encode_cwd(cwd);
|
||||
let projects_dir = tmp.path().join("projects").join(&encoded);
|
||||
fs::create_dir_all(&projects_dir).unwrap();
|
||||
write_file(
|
||||
&projects_dir,
|
||||
&format!("{uuid}.jsonl"),
|
||||
"{\"type\":\"user\"}\n{\"type\":\"assistant\"}\n",
|
||||
);
|
||||
|
||||
let envelope = read_envelope(uuid, cwd, tmp.path()).unwrap();
|
||||
assert_eq!(
|
||||
envelope.entries,
|
||||
vec![
|
||||
serde_json::json!({"type": "user"}),
|
||||
serde_json::json!({"type": "assistant"}),
|
||||
]
|
||||
);
|
||||
assert_eq!(envelope.uuid, uuid);
|
||||
assert_eq!(envelope.cwd, cwd);
|
||||
assert!(envelope.subagents.is_empty());
|
||||
assert!(envelope.todos.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_envelope_with_subagents() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cwd = Path::new("/my/project");
|
||||
let uuid = Uuid::new_v4();
|
||||
|
||||
let encoded = encode_cwd(cwd);
|
||||
let projects_dir = tmp.path().join("projects").join(&encoded);
|
||||
fs::create_dir_all(&projects_dir).unwrap();
|
||||
write_file(&projects_dir, &format!("{uuid}.jsonl"), "");
|
||||
|
||||
let subagents_dir = projects_dir.join(uuid.to_string()).join("subagents");
|
||||
fs::create_dir_all(&subagents_dir).unwrap();
|
||||
write_file(
|
||||
&subagents_dir,
|
||||
"agent-abc123def456.jsonl",
|
||||
"{\"type\":\"user\"}\n",
|
||||
);
|
||||
|
||||
let envelope = read_envelope(uuid, cwd, tmp.path()).unwrap();
|
||||
assert_eq!(
|
||||
envelope.subagents["agent-abc123def456"],
|
||||
vec![serde_json::json!({"type": "user"})]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_envelope_missing_session_file() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cwd = Path::new("/my/project");
|
||||
let uuid = Uuid::new_v4();
|
||||
|
||||
// No files created - should return Ok with empty entries rather than an error.
|
||||
let envelope = read_envelope(uuid, cwd, tmp.path()).unwrap();
|
||||
assert!(envelope.entries.is_empty());
|
||||
assert!(envelope.subagents.is_empty());
|
||||
assert!(envelope.todos.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_envelope_creates_files() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cwd = Path::new("/my/project");
|
||||
let uuid = Uuid::new_v4();
|
||||
let todo_stem = format!("{uuid}-agent-{uuid}");
|
||||
|
||||
let envelope = ClaudeTranscriptEnvelope {
|
||||
cwd: cwd.to_path_buf(),
|
||||
uuid,
|
||||
claude_version: None,
|
||||
entries: vec![serde_json::json!({"type": "user"})],
|
||||
subagents: HashMap::from([(
|
||||
"agent-abc".to_string(),
|
||||
vec![serde_json::json!({"type": "assistant"})],
|
||||
)]),
|
||||
todos: HashMap::from([(
|
||||
todo_stem.clone(),
|
||||
serde_json::json!([{"id": "1", "title": "Do it"}]),
|
||||
)]),
|
||||
};
|
||||
|
||||
write_envelope(&envelope, tmp.path()).unwrap();
|
||||
|
||||
let encoded = encode_cwd(cwd);
|
||||
let projects_dir = tmp.path().join("projects").join(&encoded);
|
||||
|
||||
// Main session JSONL.
|
||||
let session_file = projects_dir.join(format!("{uuid}.jsonl"));
|
||||
assert!(session_file.exists(), "session JSONL missing");
|
||||
assert_eq!(read_jsonl(&session_file).unwrap(), envelope.entries);
|
||||
|
||||
// Subagent JSONL.
|
||||
let subagent_file = projects_dir
|
||||
.join(uuid.to_string())
|
||||
.join("subagents")
|
||||
.join("agent-abc.jsonl");
|
||||
assert!(subagent_file.exists(), "subagent JSONL missing");
|
||||
assert_eq!(
|
||||
read_jsonl(&subagent_file).unwrap(),
|
||||
envelope.subagents["agent-abc"]
|
||||
);
|
||||
|
||||
// Todo JSON.
|
||||
let todo_file = tmp.path().join("todos").join(format!("{todo_stem}.json"));
|
||||
assert!(todo_file.exists(), "todo JSON missing");
|
||||
let todo_on_disk: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(&todo_file).unwrap()).unwrap();
|
||||
assert_eq!(todo_on_disk, envelope.todos[&todo_stem]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_envelope_round_trip() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cwd = Path::new("/test/dir");
|
||||
let uuid = Uuid::new_v4();
|
||||
|
||||
let original = ClaudeTranscriptEnvelope {
|
||||
cwd: cwd.to_path_buf(),
|
||||
uuid,
|
||||
claude_version: None,
|
||||
entries: vec![serde_json::json!({"type": "user"})],
|
||||
subagents: HashMap::new(),
|
||||
todos: HashMap::new(),
|
||||
};
|
||||
|
||||
write_envelope(&original, tmp.path()).unwrap();
|
||||
|
||||
let decoded = read_envelope(uuid, cwd, tmp.path()).unwrap();
|
||||
assert_eq!(decoded, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_session_index_entry_creates_missing_file() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let uuid = Uuid::new_v4();
|
||||
let cwd = Path::new("/my/project");
|
||||
|
||||
write_session_index_entry(uuid, cwd, tmp.path()).unwrap();
|
||||
|
||||
let index: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(tmp.path().join("sessions-index.json")).unwrap()).unwrap();
|
||||
let entry = &index[uuid.to_string()];
|
||||
assert_eq!(entry["sessionId"], uuid.to_string());
|
||||
assert_eq!(entry["cwd"], "/my/project");
|
||||
assert_eq!(entry["projectPath"], "-my-project");
|
||||
assert_eq!(
|
||||
entry["transcriptPath"],
|
||||
format!("projects/-my-project/{uuid}.jsonl")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_session_index_entry_preserves_other_entries() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let other_uuid = Uuid::new_v4();
|
||||
fs::write(
|
||||
tmp.path().join("sessions-index.json"),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
other_uuid.to_string(): {"sessionId": other_uuid.to_string(), "custom_field": 42},
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let new_uuid = Uuid::new_v4();
|
||||
write_session_index_entry(new_uuid, Path::new("/my/project"), tmp.path()).unwrap();
|
||||
|
||||
let index: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(tmp.path().join("sessions-index.json")).unwrap()).unwrap();
|
||||
// New entry landed.
|
||||
assert_eq!(
|
||||
index[new_uuid.to_string()]["sessionId"],
|
||||
new_uuid.to_string()
|
||||
);
|
||||
// Old entry preserved verbatim, including unknown fields.
|
||||
assert_eq!(
|
||||
index[other_uuid.to_string()]["sessionId"],
|
||||
other_uuid.to_string()
|
||||
);
|
||||
assert_eq!(index[other_uuid.to_string()]["custom_field"], 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_session_index_entry_overwrites_same_session() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let uuid = Uuid::new_v4();
|
||||
|
||||
write_session_index_entry(uuid, Path::new("/old/cwd"), tmp.path()).unwrap();
|
||||
write_session_index_entry(uuid, Path::new("/new/cwd"), tmp.path()).unwrap();
|
||||
|
||||
let index: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(tmp.path().join("sessions-index.json")).unwrap()).unwrap();
|
||||
// Only one entry for this session id, with the newer cwd.
|
||||
assert_eq!(index.as_object().unwrap().len(), 1);
|
||||
assert_eq!(index[uuid.to_string()]["cwd"], "/new/cwd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_session_index_entry_overwrites_malformed_file() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
fs::write(tmp.path().join("sessions-index.json"), b"not json").unwrap();
|
||||
|
||||
let uuid = Uuid::new_v4();
|
||||
write_session_index_entry(uuid, Path::new("/my/project"), tmp.path()).unwrap();
|
||||
|
||||
let index: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(tmp.path().join("sessions-index.json")).unwrap()).unwrap();
|
||||
assert_eq!(index[uuid.to_string()]["sessionId"], uuid.to_string());
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use tempfile::NamedTempFile;
|
||||
use warp_cli::agent::Harness;
|
||||
use warp_managed_secrets::ManagedSecretValue;
|
||||
use warpui::{ModelHandle, ModelSpawner};
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::server::server_api::harness_support::HarnessSupportClient;
|
||||
use crate::server::server_api::ServerApi;
|
||||
use crate::terminal::model::block::BlockId;
|
||||
use crate::terminal::CLIAgent;
|
||||
|
||||
use super::super::terminal::{CommandHandle, TerminalDriver};
|
||||
use super::super::{AgentDriver, AgentDriverError};
|
||||
use super::json_utils::{read_json_file_or_default, write_json_file};
|
||||
use super::{write_temp_file, HarnessRunner, ResumePayload, SavePoint, ThirdPartyHarness};
|
||||
|
||||
pub(crate) struct GeminiHarness;
|
||||
|
||||
/// Format slug sent to the server when creating a Gemini conversation.
|
||||
const GEMINI_CLI_FORMAT: &str = "gemini_cli";
|
||||
/// Slash command Gemini's TUI recognises as a graceful shutdown.
|
||||
const GEMINI_EXIT_COMMAND: &str = "/quit";
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
impl ThirdPartyHarness for GeminiHarness {
|
||||
fn harness(&self) -> Harness {
|
||||
Harness::Gemini
|
||||
}
|
||||
|
||||
fn cli_agent(&self) -> CLIAgent {
|
||||
CLIAgent::Gemini
|
||||
}
|
||||
|
||||
fn install_docs_url(&self) -> Option<&'static str> {
|
||||
Some("https://geminicli.com/")
|
||||
}
|
||||
|
||||
fn prepare_environment_config(
|
||||
&self,
|
||||
working_dir: &Path,
|
||||
system_prompt: Option<&str>,
|
||||
_secrets: &HashMap<String, ManagedSecretValue>,
|
||||
) -> Result<(), AgentDriverError> {
|
||||
prepare_gemini_environment_config(working_dir, system_prompt).map_err(|error| {
|
||||
AgentDriverError::HarnessConfigSetupFailed {
|
||||
harness: self.cli_agent().command_prefix().to_owned(),
|
||||
error,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn build_runner(
|
||||
&self,
|
||||
prompt: &str,
|
||||
system_prompt: Option<&str>,
|
||||
_resumption_prompt: Option<&str>,
|
||||
working_dir: &Path,
|
||||
_task_id: Option<AmbientAgentTaskId>,
|
||||
server_api: Arc<ServerApi>,
|
||||
terminal_driver: ModelHandle<TerminalDriver>,
|
||||
_resume: Option<ResumePayload>,
|
||||
) -> Result<Box<dyn HarnessRunner>, AgentDriverError> {
|
||||
// Gemini does not support conversation resume yet. When it does, it will add its
|
||||
// own `ResumePayload::Gemini(..)` variant and override `fetch_resume_payload`,
|
||||
// and decide how to surface the user-turn resumption preamble.
|
||||
let client: Arc<dyn HarnessSupportClient> = server_api;
|
||||
Ok(Box::new(GeminiHarnessRunner::new(
|
||||
self.cli_agent().command_prefix(),
|
||||
prompt,
|
||||
system_prompt,
|
||||
working_dir,
|
||||
client,
|
||||
terminal_driver,
|
||||
)?))
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the shell command that launches the Gemini TUI.
|
||||
///
|
||||
/// `--yolo` auto-approves tool call. `-i` seeds the initial prompt and
|
||||
/// continues in interactive TUI mode.
|
||||
fn gemini_command(cli_name: &str, prompt_path: &str) -> String {
|
||||
format!("{cli_name} --yolo -i \"$(cat '{prompt_path}')\"")
|
||||
}
|
||||
|
||||
enum GeminiRunnerState {
|
||||
Preexec,
|
||||
Running {
|
||||
conversation_id: AIConversationId,
|
||||
block_id: BlockId,
|
||||
},
|
||||
}
|
||||
|
||||
struct GeminiHarnessRunner {
|
||||
command: String,
|
||||
/// Held so the temp file is cleaned up when the runner is dropped.
|
||||
_temp_prompt_file: NamedTempFile,
|
||||
client: Arc<dyn HarnessSupportClient>,
|
||||
terminal_driver: ModelHandle<TerminalDriver>,
|
||||
state: Mutex<GeminiRunnerState>,
|
||||
}
|
||||
|
||||
impl GeminiHarnessRunner {
|
||||
fn new(
|
||||
cli_command: &str,
|
||||
prompt: &str,
|
||||
_system_prompt: Option<&str>,
|
||||
_working_dir: &Path,
|
||||
client: Arc<dyn HarnessSupportClient>,
|
||||
terminal_driver: ModelHandle<TerminalDriver>,
|
||||
) -> Result<Self, AgentDriverError> {
|
||||
let temp_file = write_temp_file("oz_prompt_", prompt)?;
|
||||
let prompt_path = temp_file.path().display().to_string();
|
||||
|
||||
Ok(Self {
|
||||
command: gemini_command(cli_command, &prompt_path),
|
||||
_temp_prompt_file: temp_file,
|
||||
client,
|
||||
terminal_driver,
|
||||
state: Mutex::new(GeminiRunnerState::Preexec),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
impl HarnessRunner for GeminiHarnessRunner {
|
||||
async fn start(
|
||||
&self,
|
||||
foreground: &ModelSpawner<AgentDriver>,
|
||||
) -> Result<CommandHandle, AgentDriverError> {
|
||||
// Create the external conversation record on the server.
|
||||
let conversation_id = self
|
||||
.client
|
||||
.create_external_conversation(GEMINI_CLI_FORMAT)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to create external conversation: {e}");
|
||||
AgentDriverError::ConfigBuildFailed(e)
|
||||
})?;
|
||||
log::info!("Created external conversation {conversation_id}");
|
||||
|
||||
let command = self.command.clone();
|
||||
let terminal_driver = self.terminal_driver.clone();
|
||||
let command_handle = foreground
|
||||
.spawn(move |_, ctx| {
|
||||
terminal_driver.update(ctx, |driver, ctx| driver.execute_command(&command, ctx))
|
||||
})
|
||||
.await??
|
||||
.await?;
|
||||
|
||||
// Only store conversation info once the CLI command has started.
|
||||
*self.state.lock() = GeminiRunnerState::Running {
|
||||
conversation_id,
|
||||
block_id: command_handle.block_id().clone(),
|
||||
};
|
||||
|
||||
Ok(command_handle)
|
||||
}
|
||||
|
||||
async fn exit(&self, foreground: &ModelSpawner<AgentDriver>) -> Result<()> {
|
||||
log::info!("Sending /quit to Gemini CLI");
|
||||
let terminal_driver = self.terminal_driver.clone();
|
||||
foreground
|
||||
.spawn(move |_, ctx| {
|
||||
terminal_driver.update(ctx, |driver, ctx| {
|
||||
driver.send_text_to_cli(GEMINI_EXIT_COMMAND.to_string(), ctx);
|
||||
});
|
||||
})
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Agent driver dropped while sending /quit"))
|
||||
}
|
||||
|
||||
async fn save_conversation(
|
||||
&self,
|
||||
save_point: SavePoint,
|
||||
foreground: &ModelSpawner<AgentDriver>,
|
||||
) -> Result<()> {
|
||||
if matches!(save_point, SavePoint::Periodic)
|
||||
&& !super::has_running_cli_agent(&self.terminal_driver, foreground).await
|
||||
{
|
||||
log::debug!("Will not save conversation, Gemini not in progress");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (conversation_id, block_id) = match &*self.state.lock() {
|
||||
GeminiRunnerState::Preexec => {
|
||||
log::warn!("save_conversation called before start");
|
||||
return Ok(());
|
||||
}
|
||||
GeminiRunnerState::Running {
|
||||
conversation_id,
|
||||
block_id,
|
||||
} => (*conversation_id, block_id.clone()),
|
||||
};
|
||||
|
||||
// TODO(REMOTE-1408) Also save the conversation transcript.
|
||||
super::upload_current_block_snapshot(
|
||||
foreground,
|
||||
&self.terminal_driver,
|
||||
self.client.as_ref(),
|
||||
conversation_id,
|
||||
block_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_gemini_environment_config(
|
||||
working_dir: &Path,
|
||||
system_prompt: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let home_dir =
|
||||
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?;
|
||||
let gemini_dir = home_dir.join(GEMINI_CONFIG_DIR);
|
||||
prepare_gemini_settings(
|
||||
&gemini_dir.join(GEMINI_SETTINGS_FILE_NAME),
|
||||
system_prompt.is_some(),
|
||||
)?;
|
||||
prepare_gemini_trusted_folders(
|
||||
&gemini_dir.join(GEMINI_TRUSTED_FOLDERS_FILE_NAME),
|
||||
working_dir,
|
||||
)?;
|
||||
if let Some(prompt) = system_prompt {
|
||||
let prompt_path = gemini_dir.join(GEMINI_SYSTEM_PROMPT_FILE_NAME);
|
||||
std::fs::write(&prompt_path, prompt).with_context(|| {
|
||||
format!(
|
||||
"Failed to write Gemini system prompt to {}",
|
||||
prompt_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare_gemini_settings(settings_path: &Path, has_system_prompt: bool) -> Result<()> {
|
||||
let mut settings: GeminiSettings = read_json_file_or_default(settings_path)?;
|
||||
settings
|
||||
.security
|
||||
.get_or_insert_with(GeminiSecurity::default)
|
||||
.auth
|
||||
.get_or_insert_with(GeminiAuth::default)
|
||||
.selected_type = Some(GEMINI_API_KEY_AUTH_TYPE.to_owned());
|
||||
|
||||
if has_system_prompt {
|
||||
let context = settings.context.get_or_insert_with(GeminiContext::default);
|
||||
let file_name = GEMINI_SYSTEM_PROMPT_FILE_NAME.to_owned();
|
||||
if !context.file_name.contains(&file_name) {
|
||||
context.file_name.push(file_name);
|
||||
}
|
||||
}
|
||||
|
||||
write_json_file(
|
||||
settings_path,
|
||||
&settings,
|
||||
"Failed to serialize Gemini settings",
|
||||
)
|
||||
}
|
||||
|
||||
fn prepare_gemini_trusted_folders(trusted_path: &Path, working_dir: &Path) -> Result<()> {
|
||||
let mut trusted: HashMap<String, String> = read_json_file_or_default(trusted_path)?;
|
||||
trusted.insert(
|
||||
working_dir.to_string_lossy().into_owned(),
|
||||
GEMINI_TRUST_LEVEL_FOLDER.to_owned(),
|
||||
);
|
||||
write_json_file(
|
||||
trusted_path,
|
||||
&trusted,
|
||||
"Failed to serialize Gemini trusted folders",
|
||||
)
|
||||
}
|
||||
|
||||
const GEMINI_CONFIG_DIR: &str = ".gemini";
|
||||
const GEMINI_SETTINGS_FILE_NAME: &str = "settings.json";
|
||||
const GEMINI_TRUSTED_FOLDERS_FILE_NAME: &str = "trustedFolders.json";
|
||||
const GEMINI_SYSTEM_PROMPT_FILE_NAME: &str = "OZ_SYSTEM_PROMPT.md";
|
||||
/// Auth-type discriminant for API-key auth — matches `AuthType.USE_GEMINI` in
|
||||
/// Gemini's `packages/core/src/core/contentGenerator.ts`.
|
||||
const GEMINI_API_KEY_AUTH_TYPE: &str = "gemini-api-key";
|
||||
/// Trust level discriminant that grants full trust to a single folder — matches
|
||||
/// Gemini's `TrustLevel.TRUST_FOLDER` in `packages/cli/src/config/trustedFolders.ts`.
|
||||
const GEMINI_TRUST_LEVEL_FOLDER: &str = "TRUST_FOLDER";
|
||||
|
||||
#[derive(Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GeminiSettings {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
security: Option<GeminiSecurity>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
context: Option<GeminiContext>,
|
||||
#[serde(flatten)]
|
||||
extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GeminiSecurity {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
auth: Option<GeminiAuth>,
|
||||
#[serde(flatten)]
|
||||
extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GeminiAuth {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
selected_type: Option<String>,
|
||||
#[serde(flatten)]
|
||||
extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GeminiContext {
|
||||
#[serde(default)]
|
||||
file_name: Vec<String>,
|
||||
#[serde(flatten)]
|
||||
extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "gemini_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,166 @@
|
||||
use std::fs;
|
||||
|
||||
use serde_json::Value;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prepare_gemini_settings_creates_file_with_api_key_auth() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let settings_path = tmp.path().join("settings.json");
|
||||
|
||||
prepare_gemini_settings(&settings_path, false).unwrap();
|
||||
|
||||
let settings: Value = serde_json::from_slice(&fs::read(settings_path).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
settings["security"]["auth"]["selectedType"],
|
||||
Value::String("gemini-api-key".to_owned()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_gemini_settings_preserves_unrelated_keys() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let settings_path = tmp.path().join("settings.json");
|
||||
fs::write(
|
||||
&settings_path,
|
||||
r#"{
|
||||
"ui": {"theme": "dark"},
|
||||
"security": {
|
||||
"folderTrust": {"enabled": true},
|
||||
"auth": {"enforcedType": "vertex-ai"}
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
prepare_gemini_settings(&settings_path, false).unwrap();
|
||||
|
||||
let settings: Value = serde_json::from_slice(&fs::read(settings_path).unwrap()).unwrap();
|
||||
assert_eq!(settings["ui"]["theme"], "dark");
|
||||
assert_eq!(settings["security"]["folderTrust"]["enabled"], true);
|
||||
// Sibling auth fields survive, and selectedType is set alongside them.
|
||||
assert_eq!(settings["security"]["auth"]["enforcedType"], "vertex-ai");
|
||||
assert_eq!(
|
||||
settings["security"]["auth"]["selectedType"],
|
||||
"gemini-api-key",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_gemini_settings_surfaces_malformed_json_as_error() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let settings_path = tmp.path().join("settings.json");
|
||||
// `security` typed as a string instead of an object is a parse error;
|
||||
// we prefer surfacing that to silently rewriting user-owned state.
|
||||
fs::write(
|
||||
&settings_path,
|
||||
r#"{"ui":{"theme":"dark"},"security":"broken"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(prepare_gemini_settings(&settings_path, false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_gemini_trusted_folders_creates_file_with_working_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let trusted_path = tmp.path().join("trustedFolders.json");
|
||||
let working_dir = tmp.path().join("workspace/project");
|
||||
|
||||
prepare_gemini_trusted_folders(&trusted_path, &working_dir).unwrap();
|
||||
|
||||
let trusted: Value = serde_json::from_slice(&fs::read(trusted_path).unwrap()).unwrap();
|
||||
let working_dir_key = working_dir.to_string_lossy().to_string();
|
||||
assert_eq!(trusted[working_dir_key], "TRUST_FOLDER");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_gemini_trusted_folders_preserves_existing_entries() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let trusted_path = tmp.path().join("trustedFolders.json");
|
||||
fs::write(
|
||||
&trusted_path,
|
||||
r#"{"/other/project":"TRUST_PARENT","/do/not/trust":"DO_NOT_TRUST"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let working_dir = tmp.path().join("workspace/project");
|
||||
|
||||
prepare_gemini_trusted_folders(&trusted_path, &working_dir).unwrap();
|
||||
|
||||
let trusted: Value = serde_json::from_slice(&fs::read(trusted_path).unwrap()).unwrap();
|
||||
assert_eq!(trusted["/other/project"], "TRUST_PARENT");
|
||||
assert_eq!(trusted["/do/not/trust"], "DO_NOT_TRUST");
|
||||
let working_dir_key = working_dir.to_string_lossy().to_string();
|
||||
assert_eq!(trusted[working_dir_key], "TRUST_FOLDER");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_gemini_settings_adds_context_file_name_when_system_prompt_present() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let settings_path = tmp.path().join("settings.json");
|
||||
|
||||
prepare_gemini_settings(&settings_path, true).unwrap();
|
||||
|
||||
let settings: Value = serde_json::from_slice(&fs::read(settings_path).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
settings["context"]["fileName"],
|
||||
Value::Array(vec![Value::String("OZ_SYSTEM_PROMPT.md".to_owned())]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_gemini_settings_appends_to_existing_context_file_name() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let settings_path = tmp.path().join("settings.json");
|
||||
fs::write(
|
||||
&settings_path,
|
||||
r#"{"context":{"fileName":["existing.md"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
prepare_gemini_settings(&settings_path, true).unwrap();
|
||||
|
||||
let settings: Value = serde_json::from_slice(&fs::read(settings_path).unwrap()).unwrap();
|
||||
let file_names: Vec<String> = settings["context"]["fileName"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| v.as_str().unwrap().to_owned())
|
||||
.collect();
|
||||
assert_eq!(file_names, vec!["existing.md", "OZ_SYSTEM_PROMPT.md"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_gemini_settings_does_not_duplicate_system_prompt_file_name() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let settings_path = tmp.path().join("settings.json");
|
||||
fs::write(
|
||||
&settings_path,
|
||||
r#"{"context":{"fileName":["OZ_SYSTEM_PROMPT.md"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
prepare_gemini_settings(&settings_path, true).unwrap();
|
||||
|
||||
let settings: Value = serde_json::from_slice(&fs::read(settings_path).unwrap()).unwrap();
|
||||
let file_names: Vec<String> = settings["context"]["fileName"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| v.as_str().unwrap().to_owned())
|
||||
.collect();
|
||||
assert_eq!(file_names, vec!["OZ_SYSTEM_PROMPT.md"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_gemini_settings_omits_context_when_no_system_prompt() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let settings_path = tmp.path().join("settings.json");
|
||||
|
||||
prepare_gemini_settings(&settings_path, false).unwrap();
|
||||
|
||||
let settings: Value = serde_json::from_slice(&fs::read(settings_path).unwrap()).unwrap();
|
||||
assert!(settings.get("context").is_none());
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Shared JSON read/merge/write helpers for third-party harness config prep.
|
||||
//!
|
||||
//! Third-party CLIs like Claude Code and Gemini CLI persist onboarding, trust,
|
||||
//! and auth state in JSON files. The harness preparation step
|
||||
//! needs to set a few keys on those files without clobbering
|
||||
//! user-owned state. These helpers allow us to read and merge with existing
|
||||
//! JSON file state easily.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Read a JSON file as `T`, or return `T::default()` if the file does not exist.
|
||||
///
|
||||
/// Returns an error if the file exists but cannot be read or parsed.
|
||||
pub(super) fn read_json_file_or_default<T>(path: &Path) -> Result<T>
|
||||
where
|
||||
T: Default + for<'de> Deserialize<'de>,
|
||||
{
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(content) => content,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(T::default());
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(
|
||||
anyhow::Error::from(e).context(format!("Failed to read {}", path.display()))
|
||||
);
|
||||
}
|
||||
};
|
||||
serde_json::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))
|
||||
}
|
||||
|
||||
/// Serialize `value` as pretty JSON and write it to `path`, creating parent
|
||||
/// directories as needed. `serialize_error` is used as the context for the
|
||||
/// serialization step so the caller-facing error is specific to the config
|
||||
/// file being written.
|
||||
pub(super) fn write_json_file<T>(
|
||||
path: &Path,
|
||||
value: &T,
|
||||
serialize_error: &'static str,
|
||||
) -> Result<()>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
||||
}
|
||||
std::fs::write(
|
||||
path,
|
||||
serde_json::to_vec_pretty(value).context(serialize_error)?,
|
||||
)
|
||||
.with_context(|| format!("Failed to write {}", path.display()))
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsString;
|
||||
use std::fmt;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use tempfile::NamedTempFile;
|
||||
use warp_cli::agent::Harness;
|
||||
use warp_managed_secrets::ManagedSecretValue;
|
||||
use warpui::{ModelHandle, ModelSpawner, SingletonEntity};
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::server::server_api::harness_support::{upload_to_target, HarnessSupportClient};
|
||||
use crate::server::server_api::ServerApi;
|
||||
use crate::terminal::cli_agent_sessions::{CLIAgentSessionStatus, CLIAgentSessionsModel};
|
||||
use crate::terminal::model::block::{BlockId, SerializedBlock};
|
||||
use crate::terminal::CLIAgent;
|
||||
use crate::util::path::resolve_executable;
|
||||
use warp_cli::{
|
||||
OZ_CLI_ENV, OZ_HARNESS_ENV, OZ_PARENT_RUN_ID_ENV, OZ_RUN_ID_ENV, SERVER_ROOT_URL_OVERRIDE_ENV,
|
||||
SESSION_SHARING_SERVER_URL_OVERRIDE_ENV, WS_SERVER_URL_OVERRIDE_ENV,
|
||||
};
|
||||
use warp_core::channel::ChannelState;
|
||||
|
||||
use super::terminal::{CommandHandle, TerminalDriver};
|
||||
use super::{
|
||||
AgentDriver, AgentDriverError, LEGACY_OZ_PARENT_LISTENER_MANAGED_EXTERNALLY_ENV,
|
||||
LEGACY_OZ_PARENT_STATE_ROOT_ENV, OZ_MESSAGE_LISTENER_MANAGED_EXTERNALLY_ENV,
|
||||
OZ_MESSAGE_LISTENER_STATE_ROOT_ENV,
|
||||
};
|
||||
|
||||
mod claude_code;
|
||||
pub(crate) mod claude_transcript;
|
||||
mod gemini;
|
||||
mod json_utils;
|
||||
|
||||
pub(crate) use claude_code::ClaudeHarness;
|
||||
use claude_transcript::ClaudeResumeInfo;
|
||||
use gemini::GeminiHarness;
|
||||
|
||||
/// Harness-agnostic payload describing how to resume an existing conversation.
|
||||
///
|
||||
/// Each variant carries the data a specific harness needs to rehydrate state before its CLI
|
||||
/// launches. Harnesses match on the variant they produce and ignore others; new CLIs that
|
||||
/// want resume support add a new variant and override [`ThirdPartyHarness::fetch_resume_payload`].
|
||||
pub(crate) enum ResumePayload {
|
||||
/// Claude Code session state fetched from the server's transcript endpoint.
|
||||
Claude(ClaudeResumeInfo),
|
||||
}
|
||||
|
||||
/// Trait for third-party agent harnesses that execute prompts via their own CLIs.
|
||||
///
|
||||
/// Each new external harness (e.g. Claude, Codex) implements this to be used with cloud agents.
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
pub(crate) trait ThirdPartyHarness: Send + Sync {
|
||||
/// Returns the [`Harness`] variant this implementation corresponds to.
|
||||
fn harness(&self) -> Harness;
|
||||
|
||||
/// Returns the CLIAgent type associated with this harness.
|
||||
fn cli_agent(&self) -> CLIAgent;
|
||||
|
||||
/// URL to install instructions for this harness's CLI, surfaced in the
|
||||
/// default [`validate`] impl when the CLI is not on `PATH`.
|
||||
fn install_docs_url(&self) -> Option<&'static str> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Validate that the harness is ready to run. Default impl checks that the
|
||||
/// CLI is installed on `PATH`; override for additional checks.
|
||||
fn validate(&self) -> Result<(), AgentDriverError> {
|
||||
validate_cli_installed(self.cli_agent().command_prefix(), self.install_docs_url())
|
||||
}
|
||||
|
||||
/// Prepare CLI-specific config files before launching the harness command.
|
||||
fn prepare_environment_config(
|
||||
&self,
|
||||
_working_dir: &Path,
|
||||
_system_prompt: Option<&str>,
|
||||
_secrets: &HashMap<String, ManagedSecretValue>,
|
||||
) -> Result<(), AgentDriverError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch the harness-specific resume payload for an existing conversation.
|
||||
///
|
||||
/// The driver calls this when the user passes `--conversation <id>` and the harness
|
||||
/// matches the stored conversation's harness. Harnesses that don't support resume
|
||||
/// use the default impl, which returns `Ok(None)` and causes the run to start fresh.
|
||||
///
|
||||
/// Implementations download the raw transcript via [`HarnessSupportClient::fetch_transcript`]
|
||||
/// (which derives the conversation from the current task's `agent_conversation_id`) and
|
||||
/// own all harness-specific deserialization and error mapping (e.g. a 404 maps to
|
||||
/// [`AgentDriverError::ConversationResumeStateMissing`] tagged with the harness label).
|
||||
async fn fetch_resume_payload(
|
||||
&self,
|
||||
_conversation_id: &AIConversationId,
|
||||
_harness_support_client: Arc<dyn HarnessSupportClient>,
|
||||
) -> Result<Option<ResumePayload>, AgentDriverError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Build a runner for executing this harness with the given prompt.
|
||||
///
|
||||
/// If `resume` is `Some`, the harness matches on its own [`ResumePayload`] variant and
|
||||
/// reuses the stored session/conversation ids instead of minting fresh ones. Variants
|
||||
/// belonging to other harnesses are ignored.
|
||||
///
|
||||
/// `resumption_prompt`, when non-empty, is a short user-turn preamble the server emits
|
||||
/// during a resumed session. Each harness decides exactly how to surface it (e.g. Claude
|
||||
/// prepends it to the user-turn prompt that gets piped into the CLI). Harnesses that
|
||||
/// don't yet support resumption can ignore it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_runner(
|
||||
&self,
|
||||
prompt: &str,
|
||||
system_prompt: Option<&str>,
|
||||
resumption_prompt: Option<&str>,
|
||||
working_dir: &Path,
|
||||
task_id: Option<AmbientAgentTaskId>,
|
||||
server_api: Arc<ServerApi>,
|
||||
terminal_driver: ModelHandle<TerminalDriver>,
|
||||
resume: Option<ResumePayload>,
|
||||
) -> Result<Box<dyn HarnessRunner>, AgentDriverError>;
|
||||
}
|
||||
|
||||
/// Harness type for driver dispatch.
|
||||
pub(crate) enum HarnessKind {
|
||||
Oz,
|
||||
/// Third-party CLI-backed harness (e.g. Claude, Gemini).
|
||||
ThirdParty(Box<dyn ThirdPartyHarness>),
|
||||
/// Harnesses that exist in the shared CLI enum but are not supported by the
|
||||
/// standalone agent driver.
|
||||
Unsupported(Harness),
|
||||
}
|
||||
|
||||
impl HarnessKind {
|
||||
/// Corresponding [`Harness`] enum value.
|
||||
pub(crate) fn harness(&self) -> Harness {
|
||||
match self {
|
||||
HarnessKind::Oz => Harness::Oz,
|
||||
HarnessKind::ThirdParty(h) => h.harness(),
|
||||
HarnessKind::Unsupported(harness) => *harness,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for HarnessKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
// Use the `Display` method on the [`Harness`] enum.
|
||||
write!(f, "{}", self.harness())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a [`HarnessKind`] for the given [`Harness`].
|
||||
///
|
||||
/// We shouldn't ever get a `--harness unknown` here because clap should handle
|
||||
/// it.
|
||||
pub(crate) fn harness_kind(harness: Harness) -> Result<HarnessKind, AgentDriverError> {
|
||||
match harness {
|
||||
Harness::Oz => Ok(HarnessKind::Oz),
|
||||
Harness::Claude => Ok(HarnessKind::ThirdParty(Box::new(ClaudeHarness))),
|
||||
Harness::OpenCode => Ok(HarnessKind::Unsupported(Harness::OpenCode)),
|
||||
Harness::Gemini => Ok(HarnessKind::ThirdParty(Box::new(GeminiHarness))),
|
||||
Harness::Unknown => Err(AgentDriverError::InvalidRuntimeState),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check that `cli` is installed and on PATH, returning a `HarnessSetupFailed`
|
||||
/// error with an optional install-docs link when it isn't.
|
||||
pub(crate) fn validate_cli_installed(
|
||||
cli: &str,
|
||||
install_docs_url: Option<&str>,
|
||||
) -> Result<(), AgentDriverError> {
|
||||
if resolve_executable(cli).is_none() {
|
||||
let mut reason = format!("'{cli}' CLI not found on your machine.");
|
||||
if let Some(url) = install_docs_url {
|
||||
reason.push_str(&format!(" Install it first: {url}"));
|
||||
}
|
||||
return Err(AgentDriverError::HarnessSetupFailed {
|
||||
harness: cli.into(),
|
||||
reason,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_non_empty_task_env_var(
|
||||
env_vars: &mut HashMap<OsString, OsString>,
|
||||
key: &'static str,
|
||||
value: String,
|
||||
) {
|
||||
if value.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
env_vars.insert(OsString::from(key), OsString::from(value));
|
||||
}
|
||||
|
||||
fn insert_task_env_var_aliases(
|
||||
env_vars: &mut HashMap<OsString, OsString>,
|
||||
keys: &[&'static str],
|
||||
value: &str,
|
||||
) {
|
||||
for key in keys {
|
||||
env_vars.insert(OsString::from(key), OsString::from(value));
|
||||
}
|
||||
}
|
||||
|
||||
fn message_listener_state_root() -> Option<String> {
|
||||
[
|
||||
OZ_MESSAGE_LISTENER_STATE_ROOT_ENV,
|
||||
LEGACY_OZ_PARENT_STATE_ROOT_ENV,
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|key| std::env::var(key).ok().filter(|value| !value.is_empty()))
|
||||
}
|
||||
|
||||
fn task_env_vars_for_harness_name(
|
||||
task_id: Option<&AmbientAgentTaskId>,
|
||||
parent_run_id: Option<&str>,
|
||||
selected_harness: Harness,
|
||||
) -> HashMap<OsString, OsString> {
|
||||
let mut env_vars = HashMap::with_capacity(7);
|
||||
|
||||
if let Some(id) = task_id {
|
||||
env_vars.insert(
|
||||
OsString::from(OZ_RUN_ID_ENV),
|
||||
OsString::from(id.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(parent_run_id) = parent_run_id.filter(|id| !id.is_empty()) {
|
||||
env_vars.insert(
|
||||
OsString::from(OZ_PARENT_RUN_ID_ENV),
|
||||
OsString::from(parent_run_id),
|
||||
);
|
||||
}
|
||||
|
||||
env_vars.insert(
|
||||
OsString::from(OZ_CLI_ENV),
|
||||
OsString::from(
|
||||
std::env::current_exe()
|
||||
.unwrap_or_else(|_| ChannelState::channel().cli_command_name().into()),
|
||||
),
|
||||
);
|
||||
// `OZ_HARNESS` is only consumed by child orchestration telemetry when the child
|
||||
// CLI emits `run message *` events.
|
||||
env_vars.insert(
|
||||
OsString::from(OZ_HARNESS_ENV),
|
||||
OsString::from(selected_harness.to_string()),
|
||||
);
|
||||
if selected_harness == Harness::Claude && task_id.is_some() {
|
||||
insert_task_env_var_aliases(
|
||||
&mut env_vars,
|
||||
&[
|
||||
OZ_MESSAGE_LISTENER_MANAGED_EXTERNALLY_ENV,
|
||||
LEGACY_OZ_PARENT_LISTENER_MANAGED_EXTERNALLY_ENV,
|
||||
],
|
||||
"1",
|
||||
);
|
||||
if let Some(state_root) = message_listener_state_root() {
|
||||
insert_task_env_var_aliases(
|
||||
&mut env_vars,
|
||||
&[
|
||||
OZ_MESSAGE_LISTENER_STATE_ROOT_ENV,
|
||||
LEGACY_OZ_PARENT_STATE_ROOT_ENV,
|
||||
],
|
||||
&state_root,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Server URL overrides are disabled on release channels, so there's no
|
||||
// override to propagate to child processes there.
|
||||
if ChannelState::channel().allows_server_url_overrides() {
|
||||
insert_non_empty_task_env_var(
|
||||
&mut env_vars,
|
||||
SERVER_ROOT_URL_OVERRIDE_ENV,
|
||||
ChannelState::server_root_url().into_owned(),
|
||||
);
|
||||
insert_non_empty_task_env_var(
|
||||
&mut env_vars,
|
||||
WS_SERVER_URL_OVERRIDE_ENV,
|
||||
ChannelState::ws_server_url().into_owned(),
|
||||
);
|
||||
if let Some(url) = ChannelState::session_sharing_server_url()
|
||||
.map(Cow::into_owned)
|
||||
.filter(|url| !url.is_empty())
|
||||
{
|
||||
env_vars.insert(
|
||||
OsString::from(SESSION_SHARING_SERVER_URL_OVERRIDE_ENV),
|
||||
OsString::from(url),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
env_vars
|
||||
}
|
||||
|
||||
pub(crate) fn task_env_vars(
|
||||
task_id: Option<&AmbientAgentTaskId>,
|
||||
parent_run_id: Option<&str>,
|
||||
selected_harness: Harness,
|
||||
) -> HashMap<OsString, OsString> {
|
||||
task_env_vars_for_harness_name(task_id, parent_run_id, selected_harness)
|
||||
}
|
||||
|
||||
/// Indicates when the harness conversation is being saved.
|
||||
/// Implementations may use this to customize the saved data, such as
|
||||
/// recording additional metadata on completion.
|
||||
pub(crate) enum SavePoint {
|
||||
/// A periodic auto-save to minimize data loss.
|
||||
Periodic,
|
||||
/// The final save of conversation state, after the harness has completed.
|
||||
Final,
|
||||
/// A save after the harness reports it finished an agent turn.
|
||||
PostTurn,
|
||||
}
|
||||
|
||||
/// Stateful per-run representation of an external harness produced
|
||||
/// by [`ThirdPartyHarness::build_runner`].
|
||||
///
|
||||
/// All `HarnessRunner` methods take `&self` as a parameter, but may mutate internal
|
||||
/// state. There are no `&mut self` methods, as this would require that the `AgentDriver`
|
||||
/// store the runner in a mutex and lock it across `await` points.
|
||||
///
|
||||
/// The driver uses this to manage the lifecycle of a particular third-party harness.
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
pub(crate) trait HarnessRunner: Send + Sync {
|
||||
/// Create the external conversation on the server and start the harness
|
||||
/// command in the terminal.
|
||||
///
|
||||
/// Returns a [`CommandHandle`] that resolves to the exit code. The runner
|
||||
/// stores the conversation ID and block ID internally for use in
|
||||
/// [`save_conversation`].
|
||||
async fn start(
|
||||
&self,
|
||||
foreground: &ModelSpawner<AgentDriver>,
|
||||
) -> Result<CommandHandle, AgentDriverError>;
|
||||
|
||||
/// Save the current conversation state (transcript upload, etc.).
|
||||
async fn save_conversation(
|
||||
&self,
|
||||
save_point: SavePoint,
|
||||
foreground: &ModelSpawner<AgentDriver>,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Gracefully ask the harness to exit.
|
||||
async fn exit(&self, foreground: &ModelSpawner<AgentDriver>) -> Result<()>;
|
||||
/// Handle a CLI session update such as a prompt submit or completed tool use.
|
||||
async fn handle_session_update(&self, _foreground: &ModelSpawner<AgentDriver>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clean up any harness-owned background state after the harness exits.
|
||||
async fn cleanup(&self, _foreground: &ModelSpawner<AgentDriver>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the terminal tracked by `terminal_driver` has a CLI agent session
|
||||
/// that is currently in progress.
|
||||
pub(crate) async fn has_running_cli_agent(
|
||||
terminal_driver: &ModelHandle<TerminalDriver>,
|
||||
foreground: &ModelSpawner<AgentDriver>,
|
||||
) -> bool {
|
||||
let driver = terminal_driver.clone();
|
||||
let Ok(running) = foreground
|
||||
.spawn(move |_, ctx| {
|
||||
let terminal_view_id = driver.as_ref(ctx).terminal_view().id();
|
||||
CLIAgentSessionsModel::handle(ctx)
|
||||
.as_ref(ctx)
|
||||
.session(terminal_view_id)
|
||||
.is_some_and(|s| s.status == CLIAgentSessionStatus::InProgress)
|
||||
})
|
||||
.await
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
running
|
||||
}
|
||||
|
||||
/// Create a [`NamedTempFile`] with the given prefix and write `content` into it.
|
||||
///
|
||||
/// Used by third-party harnesses to stage prompts / system prompts on disk
|
||||
/// before launching the CLI, avoiding shell-quoting issues with complex input.
|
||||
pub(super) fn write_temp_file(
|
||||
prefix: &str,
|
||||
content: &str,
|
||||
) -> Result<NamedTempFile, AgentDriverError> {
|
||||
let mut file = tempfile::Builder::new()
|
||||
.prefix(prefix)
|
||||
.suffix(".txt")
|
||||
.tempfile()
|
||||
.map_err(|e| {
|
||||
AgentDriverError::ConfigBuildFailed(anyhow::anyhow!(
|
||||
"Failed to create temp file '{prefix}': {e}"
|
||||
))
|
||||
})?;
|
||||
file.write_all(content.as_bytes()).map_err(|e| {
|
||||
AgentDriverError::ConfigBuildFailed(anyhow::anyhow!(
|
||||
"Failed to write temp file '{prefix}': {e}"
|
||||
))
|
||||
})?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
/// Upload a [`SerializedBlock`] as the JSON block snapshot for a third-party harness conversation.
|
||||
pub(crate) async fn upload_block_snapshot(
|
||||
client: &dyn HarnessSupportClient,
|
||||
conversation_id: AIConversationId,
|
||||
block: SerializedBlock,
|
||||
) -> Result<()> {
|
||||
log::info!("Uploading block snapshot for CLI agent to conversation {conversation_id}");
|
||||
let target = client
|
||||
.get_block_snapshot_upload_target(&conversation_id)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("Unable to get block upload slot for conversation {conversation_id}")
|
||||
})?;
|
||||
|
||||
let body = block
|
||||
.to_json()
|
||||
.with_context(|| format!("Unable to serialize block for conversation {conversation_id}"))?;
|
||||
|
||||
upload_to_target(client.http_client(), &target, body).await
|
||||
}
|
||||
|
||||
/// Fetch the current block snapshot for `block_id` and upload it to the server.
|
||||
///
|
||||
/// If the snapshot cannot be fetched, logs a warning and returns `Ok(())`.
|
||||
pub(super) async fn upload_current_block_snapshot(
|
||||
foreground: &ModelSpawner<AgentDriver>,
|
||||
terminal_driver: &ModelHandle<TerminalDriver>,
|
||||
client: &dyn HarnessSupportClient,
|
||||
conversation_id: AIConversationId,
|
||||
block_id: BlockId,
|
||||
) -> Result<()> {
|
||||
let td = terminal_driver.clone();
|
||||
let snapshot = foreground
|
||||
.spawn(move |_, ctx| td.as_ref(ctx).block_snapshot(&block_id, ctx))
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Agent driver dropped"))?;
|
||||
match snapshot {
|
||||
Some(block) => upload_block_snapshot(client, conversation_id, block).await,
|
||||
None => {
|
||||
log::warn!("No block snapshot found for harness command");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,33 @@
|
||||
use super::validate_cli_installed;
|
||||
use crate::ai::agent_sdk::driver::AgentDriverError;
|
||||
|
||||
fn assert_harness_setup_failed(err: &AgentDriverError) -> (&str, &str) {
|
||||
match err {
|
||||
AgentDriverError::HarnessSetupFailed { harness, reason } => (harness, reason),
|
||||
other => panic!("expected HarnessSetupFailed, got: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn validate_cli_installed_succeeds_for_known_binary() {
|
||||
assert!(validate_cli_installed("ls", None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_cli_installed_fails_for_missing_binary() {
|
||||
let err = validate_cli_installed("__nonexistent_cli_abc123__", None).unwrap_err();
|
||||
let (harness, reason) = assert_harness_setup_failed(&err);
|
||||
assert_eq!(harness, "__nonexistent_cli_abc123__");
|
||||
assert!(reason.contains("not found"));
|
||||
assert!(!reason.contains("Install it first"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_cli_installed_includes_docs_url_in_error() {
|
||||
let url = "https://example.com/install";
|
||||
let err = validate_cli_installed("__nonexistent_cli_abc123__", Some(url)).unwrap_err();
|
||||
let (_, reason) = assert_harness_setup_failed(&err);
|
||||
assert!(reason.contains(url));
|
||||
assert!(reason.contains("Install it first"));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,648 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
ffi::OsString,
|
||||
future::Future,
|
||||
path::PathBuf,
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
task::{Context, Poll},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use futures::channel::oneshot;
|
||||
use session_sharing_protocol::common::{Role, SessionId};
|
||||
use session_sharing_protocol::sharer::SessionSourceType;
|
||||
use warp_cli::share::{ShareAccessLevel, ShareRequest, ShareSubject};
|
||||
use warp_completer::completer::CommandOutput;
|
||||
use warp_core::command::ExitCode;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warp_util::path::ShellFamily;
|
||||
use warpui::{
|
||||
r#async::FutureExt, AppContext, Entity, ModelContext, ModelHandle, SingletonEntity as _,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use crate::terminal::model::session::ExecuteCommandOptions;
|
||||
|
||||
use crate::{
|
||||
ai::ambient_agents::AmbientAgentTaskId,
|
||||
pane_group::NewTerminalOptions,
|
||||
root_view::{open_new_with_workspace_source, NewWorkspaceSource},
|
||||
terminal::{
|
||||
model::block::{BlockId, SerializedBlock},
|
||||
shared_session::{self, IsSharedSessionCreator},
|
||||
shell::ShellType,
|
||||
view::ConversationRestorationInNewPaneType,
|
||||
TerminalView,
|
||||
},
|
||||
util::sync::Condition,
|
||||
workspaces::user_workspaces::UserWorkspaces,
|
||||
};
|
||||
|
||||
use crate::ai::attachment_utils::attachments_download_dir;
|
||||
|
||||
use super::AgentDriverError;
|
||||
|
||||
/// Describes why an agent's session-sharing request failed.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum ShareSessionError {
|
||||
/// Connection to the session-sharing server failed.
|
||||
#[error("Internal error")]
|
||||
Internal(#[source] Arc<anyhow::Error>),
|
||||
/// The server rejected the session-sharing request.
|
||||
#[error("{0}")]
|
||||
Failed(String),
|
||||
/// Session sharing is disabled for this user or team.
|
||||
#[error(
|
||||
"Session sharing is not enabled. This is likely because an administrator has disabled session sharing for your team."
|
||||
)]
|
||||
Disabled,
|
||||
/// The session-sharing request timed out.
|
||||
#[error("Timed out waiting for session sharing to start")]
|
||||
Timeout,
|
||||
/// The session-sharing channel was dropped before completing.
|
||||
#[error("Session sharing was interrupted")]
|
||||
Interrupted,
|
||||
}
|
||||
|
||||
const TERMINAL_SESSION_BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const TERMINAL_SESSION_SHARE_DELAY: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Options for creating the terminal view before constructing a [`TerminalDriver`].
|
||||
pub(crate) struct TerminalDriverOptions {
|
||||
pub working_dir: PathBuf,
|
||||
pub env_vars: HashMap<OsString, OsString>,
|
||||
pub should_share: bool,
|
||||
pub task_id: Option<AmbientAgentTaskId>,
|
||||
pub conversation_restoration: Option<ConversationRestorationInNewPaneType>,
|
||||
}
|
||||
|
||||
/// Events emitted by [`TerminalDriver`] for [`super::AgentDriver`] to react to.
|
||||
pub(crate) enum TerminalDriverEvent {
|
||||
/// Terminal bootstrap is taking unusually long.
|
||||
SlowBootstrap,
|
||||
/// The terminal session has established a shared session.
|
||||
EstablishedSharedSession {
|
||||
session_id: session_sharing_protocol::common::SessionId,
|
||||
join_url: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Manages the terminal session lifecycle for the agent driver.
|
||||
///
|
||||
/// Responsibilities:
|
||||
/// - Monitoring for terminal bootstrapping to be done
|
||||
/// - Configuring session sharing and applying guest requests
|
||||
/// - Executing commands in the session
|
||||
/// - Detecting block completion
|
||||
pub(crate) struct TerminalDriver {
|
||||
terminal_view: ViewHandle<TerminalView>,
|
||||
session_bootstrapped: Condition,
|
||||
/// The session ID once sharing has been established.
|
||||
shared_session_id: Option<SessionId>,
|
||||
/// Receiver for the session sharing result. Present when sharing is expected
|
||||
/// and `wait_for_session_shared` has not yet been called.
|
||||
session_share_rx: Option<oneshot::Receiver<Result<(), ShareSessionError>>>,
|
||||
pending_share_requests: Vec<ShareRequest>,
|
||||
waiting_command: Option<oneshot::Sender<ExitCode>>,
|
||||
|
||||
/// State for the pending command we're expecting to start executing.
|
||||
/// The `String` is the expected command text, and the sender is used
|
||||
/// to send the block ID to the waiting caller.
|
||||
pending_command_start: Option<(String, oneshot::Sender<BlockId>)>,
|
||||
}
|
||||
|
||||
impl Entity for TerminalDriver {
|
||||
type Event = TerminalDriverEvent;
|
||||
}
|
||||
|
||||
/// Create the terminal window and extract the [`ViewHandle<TerminalView>`].
|
||||
///
|
||||
/// This is separate from [`TerminalDriver::new`] because [`AppContext::add_model`]
|
||||
/// requires an infallible constructor; the fallible window/view creation must happen first.
|
||||
fn create_terminal_view(
|
||||
options: TerminalDriverOptions,
|
||||
ctx: &mut AppContext,
|
||||
) -> Result<ViewHandle<TerminalView>, AgentDriverError> {
|
||||
let is_shared_session_creator = if options.should_share {
|
||||
IsSharedSessionCreator::Yes {
|
||||
source_type: SessionSourceType::AmbientAgent {
|
||||
task_id: options.task_id.map(|t| t.to_string()),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
IsSharedSessionCreator::No
|
||||
};
|
||||
|
||||
let (_, root_view) = open_new_with_workspace_source(
|
||||
NewWorkspaceSource::Session {
|
||||
options: Box::new(NewTerminalOptions {
|
||||
is_shared_session_creator,
|
||||
initial_directory: Some(options.working_dir),
|
||||
env_vars: options.env_vars,
|
||||
conversation_restoration: options.conversation_restoration,
|
||||
..Default::default()
|
||||
}),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
root_view
|
||||
.as_ref(ctx)
|
||||
.workspace_view()
|
||||
.ok_or(AgentDriverError::TerminalUnavailable)?
|
||||
.as_ref(ctx)
|
||||
.active_tab_pane_group()
|
||||
.as_ref(ctx)
|
||||
.active_session_view(ctx)
|
||||
.ok_or(AgentDriverError::TerminalUnavailable)
|
||||
}
|
||||
|
||||
impl TerminalDriver {
|
||||
/// Create a terminal view from the given options and wrap it in a new `TerminalDriver` model.
|
||||
pub(crate) fn create(
|
||||
options: TerminalDriverOptions,
|
||||
ctx: &mut AppContext,
|
||||
) -> Result<ModelHandle<Self>, AgentDriverError> {
|
||||
let should_share = options.should_share;
|
||||
let task_id = options.task_id;
|
||||
let working_dir = options.working_dir.clone();
|
||||
let terminal_view = create_terminal_view(options, ctx)?;
|
||||
Ok(ctx.add_model(|ctx| Self::new(terminal_view, should_share, task_id, working_dir, ctx)))
|
||||
}
|
||||
|
||||
/// Wrap an already-created terminal view in a new `TerminalDriver` model.
|
||||
///
|
||||
/// Unlike [`Self::create`], this does not open a new window — it reuses an
|
||||
/// existing view (e.g. a docker sandbox pane). Session sharing is disabled
|
||||
/// and no task ID is associated.
|
||||
pub(crate) fn create_from_existing_view(
|
||||
terminal_view: ViewHandle<TerminalView>,
|
||||
ctx: &mut AppContext,
|
||||
) -> ModelHandle<Self> {
|
||||
ctx.add_model(|ctx| Self::new(terminal_view, false, None, PathBuf::default(), ctx))
|
||||
}
|
||||
|
||||
/// Set up event subscriptions and session-sharing conditions for an
|
||||
/// already-created terminal view.
|
||||
fn new(
|
||||
terminal_view: ViewHandle<TerminalView>,
|
||||
should_share: bool,
|
||||
task_id: Option<AmbientAgentTaskId>,
|
||||
working_dir: PathBuf,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
let session_bootstrapped = Condition::new();
|
||||
|
||||
// Create a oneshot channel for session sharing when sharing is expected.
|
||||
// When sharing is disabled (or running against ngrok), leave both halves
|
||||
// as None so that `wait_for_session_shared` returns immediately.
|
||||
let sharing_expected =
|
||||
should_share && !warp_core::channel::ChannelState::server_root_url().contains("ngrok");
|
||||
let (mut session_share_tx, session_share_rx) = if sharing_expected {
|
||||
if !FeatureFlag::CreatingSharedSessions.is_enabled() {
|
||||
// Session sharing was requested but the feature is not enabled for this
|
||||
// user/team (typically an enterprise/admin setting). Fail immediately
|
||||
// with a clear error rather than waiting for a timeout.
|
||||
log::warn!(
|
||||
"Session sharing requested but the CreatingSharedSessions feature flag \
|
||||
is not enabled. This is likely due to a team administrator disabling \
|
||||
session sharing."
|
||||
);
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let _ = tx.send(Err(ShareSessionError::Disabled));
|
||||
(None, Some(rx))
|
||||
} else {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
(Some(tx), Some(rx))
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
// Set the task_id and attachments download dir on the AI controller right away
|
||||
// so they're available for session sharing and file downloads.
|
||||
// Only set the download dir when a task_id is present (cloud mode),
|
||||
// since attachments require a task to fetch presigned URLs.
|
||||
if let Some(tid) = task_id {
|
||||
let attachments_dir = attachments_download_dir(&working_dir);
|
||||
terminal_view.update(ctx, |terminal, ctx| {
|
||||
terminal.ai_controller().update(ctx, |controller, ctx| {
|
||||
controller.set_ambient_agent_task_id(Some(tid), ctx);
|
||||
controller.set_attachments_download_dir(attachments_dir);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ctx.subscribe_to_view(&terminal_view, move |me, event, ctx| {
|
||||
me.handle_terminal_view_event(event, &mut session_share_tx, ctx);
|
||||
});
|
||||
|
||||
// If the session already bootstrapped before we subscribed, set the
|
||||
// condition immediately so callers of `wait_for_session_bootstrapped`
|
||||
// don't block forever.
|
||||
let already_bootstrapped = terminal_view.read(ctx, |terminal, _| {
|
||||
terminal
|
||||
.model
|
||||
.lock()
|
||||
.block_list()
|
||||
.is_bootstrapping_precmd_done()
|
||||
});
|
||||
if already_bootstrapped {
|
||||
session_bootstrapped.set();
|
||||
}
|
||||
|
||||
Self {
|
||||
terminal_view,
|
||||
session_bootstrapped,
|
||||
shared_session_id: None,
|
||||
session_share_rx,
|
||||
pending_share_requests: Vec::new(),
|
||||
waiting_command: None,
|
||||
pending_command_start: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a handle to the backing terminal view.
|
||||
pub fn terminal_view(&self) -> &ViewHandle<TerminalView> {
|
||||
&self.terminal_view
|
||||
}
|
||||
|
||||
/// Provide mutable access to the terminal view through a closure.
|
||||
pub fn with_terminal_view(
|
||||
&self,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
f: impl FnOnce(&mut TerminalView, &mut warpui::ViewContext<TerminalView>),
|
||||
) {
|
||||
self.terminal_view.update(ctx, f);
|
||||
}
|
||||
|
||||
/// Request that the terminal session be shared with the given participants.
|
||||
///
|
||||
/// This has no effect if the session is not being shared.
|
||||
pub fn add_share_requests(
|
||||
&mut self,
|
||||
share_requests: impl IntoIterator<Item = ShareRequest>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.pending_share_requests.extend(share_requests);
|
||||
if self.shared_session_id.is_some() {
|
||||
self.apply_share_requests(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply pending session-sharing guest requests.
|
||||
fn apply_share_requests(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if self.pending_share_requests.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let share_requests = std::mem::take(&mut self.pending_share_requests);
|
||||
self.terminal_view.update(ctx, |terminal_view, ctx| {
|
||||
let mut viewer_emails = Vec::new();
|
||||
let mut editor_emails = Vec::new();
|
||||
|
||||
for request in share_requests {
|
||||
let role = match request.access_level {
|
||||
ShareAccessLevel::View => Role::Reader,
|
||||
ShareAccessLevel::Edit => Role::Executor,
|
||||
};
|
||||
|
||||
match request.subject {
|
||||
ShareSubject::Team => {
|
||||
if let Some(team_uid) = UserWorkspaces::as_ref(ctx).current_team_uid() {
|
||||
terminal_view.update_session_team_permissions(
|
||||
Some(role),
|
||||
team_uid.to_string(),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
ShareSubject::Public => {
|
||||
// Apply an anyone-with-link ACL at the requested role.
|
||||
// This uses the same path as the share modal's
|
||||
// "anyone with link" toggle. The workspace-level
|
||||
// anyone-with-link setting on the server still gates
|
||||
// whether the ACL write succeeds.
|
||||
terminal_view.update_session_link_permissions(Some(role), ctx);
|
||||
}
|
||||
ShareSubject::User { email } => match request.access_level {
|
||||
ShareAccessLevel::View => viewer_emails.push(email),
|
||||
ShareAccessLevel::Edit => editor_emails.push(email),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if !viewer_emails.is_empty() {
|
||||
terminal_view.add_guests(viewer_emails, Role::Reader, ctx);
|
||||
}
|
||||
if !editor_emails.is_empty() {
|
||||
terminal_view.add_guests(editor_emails, Role::Executor, ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Submit `text` to the active CLI agent on the terminal PTY using the
|
||||
/// agent-specific submission strategy.
|
||||
///
|
||||
/// Used to send exit commands to third-party harnesses.
|
||||
pub(super) fn send_text_to_cli(&self, text: String, ctx: &mut ModelContext<Self>) {
|
||||
self.terminal_view.update(ctx, |terminal, ctx| {
|
||||
terminal.submit_text_to_cli_agent_pty(text, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Return a snapshot of the block with the given ID.
|
||||
pub fn block_snapshot(&self, block_id: &BlockId, ctx: &AppContext) -> Option<SerializedBlock> {
|
||||
let terminal = self.terminal_view.as_ref(ctx);
|
||||
let model = terminal.model.lock();
|
||||
model
|
||||
.block_list()
|
||||
.block_with_id(block_id)
|
||||
.map(SerializedBlock::from)
|
||||
}
|
||||
|
||||
/// Execute a command in the terminal and return a future that resolves to a
|
||||
/// [`CommandHandle`] once the command starts executing.
|
||||
pub fn execute_command(
|
||||
&mut self,
|
||||
command: &str,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Result<impl Future<Output = Result<CommandHandle, AgentDriverError>>, AgentDriverError>
|
||||
{
|
||||
let (exit_tx, exit_rx) = oneshot::channel::<ExitCode>();
|
||||
let (start_tx, start_rx) = oneshot::channel::<BlockId>();
|
||||
|
||||
// We should not be able to execute a command while we are still waiting on another one.
|
||||
// This is enforced by the caller by waiting on rx before continuing.
|
||||
if self.waiting_command.is_some() || self.pending_command_start.is_some() {
|
||||
return Err(AgentDriverError::InvalidRuntimeState);
|
||||
}
|
||||
|
||||
let command_string = command.to_string();
|
||||
self.terminal_view.update(ctx, |terminal, ctx| {
|
||||
self.waiting_command = Some(exit_tx);
|
||||
self.pending_command_start = Some((command_string, start_tx));
|
||||
terminal.execute_command_or_set_pending(command, ctx);
|
||||
});
|
||||
|
||||
Ok(async move {
|
||||
let block_id = start_rx
|
||||
.await
|
||||
.map_err(|_| AgentDriverError::InvalidRuntimeState)?;
|
||||
Ok(CommandHandle {
|
||||
exit_status_rx: exit_rx,
|
||||
block_id,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute a command through the active session's in-band command
|
||||
/// executor, without adding a block to the user-visible blocklist.
|
||||
///
|
||||
/// Intended for silent probes (e.g. `test -d`) that the agent needs to
|
||||
/// drive through the terminal session (so they run against the correct
|
||||
/// filesystem, including inside a Docker sandbox) but should not clutter
|
||||
/// the user's command history.
|
||||
pub fn execute_silent_command(
|
||||
&self,
|
||||
command: String,
|
||||
ctx: &ModelContext<Self>,
|
||||
) -> impl Future<Output = Result<CommandOutput, AgentDriverError>> {
|
||||
let session = self.terminal_view.read(ctx, |terminal, app| {
|
||||
terminal
|
||||
.active_block_session_id()
|
||||
.and_then(|id| terminal.sessions_model().as_ref(app).get(id))
|
||||
});
|
||||
async move {
|
||||
let session = session.ok_or(AgentDriverError::InvalidRuntimeState)?;
|
||||
session
|
||||
.execute_command(&command, None, None, ExecuteCommandOptions::default())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::warn!("silent command failed: {e:#}");
|
||||
AgentDriverError::InvalidRuntimeState
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the shell type of the active terminal session, if known.
|
||||
pub fn active_session_shell_type(&self, ctx: &AppContext) -> Option<ShellType> {
|
||||
self.terminal_view
|
||||
.read(ctx, |terminal, app| terminal.active_session_shell_type(app))
|
||||
}
|
||||
|
||||
/// Build the shell-aware `cd <escaped>` command for the active session.
|
||||
///
|
||||
/// Shared between [`Self::cd`] and [`Self::cd_silent`] so both paths use
|
||||
/// the same [`ShellFamily::shell_escape`] logic (posix single-quoting,
|
||||
/// fish backslash, pwsh double-quote doubling) and don't drift.
|
||||
fn build_cd_command(&self, target: &str, ctx: &AppContext) -> String {
|
||||
let shell_family = self.terminal_view.read(ctx, |terminal, app| {
|
||||
terminal
|
||||
.active_session_shell_type(app)
|
||||
.map(ShellFamily::from)
|
||||
.unwrap_or(ShellFamily::Posix)
|
||||
});
|
||||
let escaped_target = shell_family.shell_escape(target);
|
||||
format!("cd {escaped_target}")
|
||||
}
|
||||
|
||||
/// Change directory within the active terminal session.
|
||||
pub fn cd(
|
||||
&mut self,
|
||||
target: &str,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Result<impl Future<Output = Result<CommandHandle, AgentDriverError>>, AgentDriverError>
|
||||
{
|
||||
let cd_command = self.build_cd_command(target, ctx);
|
||||
self.execute_command(&cd_command, ctx)
|
||||
}
|
||||
|
||||
/// Change directory within the active terminal session, silently — no
|
||||
/// visible block is added to the user-facing blocklist.
|
||||
///
|
||||
/// Uses the same shell-aware escaping as [`Self::cd`] but dispatches
|
||||
/// through [`Self::execute_silent_command`]. Intended for callers that
|
||||
/// need to position the session's CWD as an implementation detail of a
|
||||
/// larger setup step (e.g. positioning the session before running
|
||||
/// silent probes).
|
||||
pub fn cd_silent(
|
||||
&self,
|
||||
target: &str,
|
||||
ctx: &ModelContext<Self>,
|
||||
) -> impl Future<Output = Result<CommandOutput, AgentDriverError>> {
|
||||
let cd_command = self.build_cd_command(target, ctx);
|
||||
self.execute_silent_command(cd_command, ctx)
|
||||
}
|
||||
|
||||
/// The current working directory of the active terminal session, if known.
|
||||
#[allow(dead_code)]
|
||||
pub fn current_directory(&self, ctx: &AppContext) -> Option<PathBuf> {
|
||||
// TODO(ben): This should handle non-local paths.
|
||||
self.terminal_view
|
||||
.as_ref(ctx)
|
||||
.active_session_path_if_local(ctx)
|
||||
}
|
||||
|
||||
/// Returns a future that resolves when the session has bootstrapped.
|
||||
///
|
||||
/// This only waits for the `SessionBootstrapped` terminal view event.
|
||||
pub fn wait_for_session_bootstrapped(
|
||||
&self,
|
||||
) -> impl Future<Output = Result<(), AgentDriverError>> {
|
||||
let session_bootstrapped = self.session_bootstrapped.clone();
|
||||
|
||||
async move {
|
||||
session_bootstrapped
|
||||
.wait()
|
||||
.with_timeout(TERMINAL_SESSION_BOOTSTRAP_TIMEOUT)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
log::error!("Timed out waiting for session bootstrap");
|
||||
AgentDriverError::BootstrapFailed
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a future that resolves when (optional) session sharing has started.
|
||||
///
|
||||
/// This is separate from `wait_for_session_bootstrapped` so that callers can:
|
||||
/// - wait for terminal bootstrap early (e.g. before starting MCP servers)
|
||||
/// - wait for session sharing later (e.g. right before running visible commands)
|
||||
pub fn wait_for_session_shared(
|
||||
&mut self,
|
||||
) -> impl Future<Output = Result<(), AgentDriverError>> {
|
||||
let rx = self.session_share_rx.take();
|
||||
|
||||
async move {
|
||||
let Some(rx) = rx else {
|
||||
// Sharing is disabled or already resolved.
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
match rx.with_timeout(TERMINAL_SESSION_SHARE_DELAY).await {
|
||||
Ok(Ok(Ok(()))) => Ok(()),
|
||||
Ok(Ok(Err(error))) => {
|
||||
log::error!("Session sharing failed: {error}");
|
||||
Err(AgentDriverError::ShareSessionFailed { error })
|
||||
}
|
||||
Ok(Err(_canceled)) => {
|
||||
log::error!("Session sharing channel dropped");
|
||||
Err(AgentDriverError::ShareSessionFailed {
|
||||
error: ShareSessionError::Interrupted,
|
||||
})
|
||||
}
|
||||
Err(_timeout) => {
|
||||
log::error!(
|
||||
"Timed out waiting for session sharing to start after {}s",
|
||||
TERMINAL_SESSION_SHARE_DELAY.as_secs()
|
||||
);
|
||||
Err(AgentDriverError::ShareSessionFailed {
|
||||
error: ShareSessionError::Timeout,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A handle to a running terminal command.
|
||||
///
|
||||
/// Resolves to the command's [`ExitCode`] when the block completes.
|
||||
/// Also carries the [`BlockId`] so callers can retrieve the block snapshot
|
||||
/// after completion.
|
||||
pub(crate) struct CommandHandle {
|
||||
exit_status_rx: oneshot::Receiver<ExitCode>,
|
||||
block_id: BlockId,
|
||||
}
|
||||
|
||||
impl CommandHandle {
|
||||
/// The block ID of the command that was executed.
|
||||
pub fn block_id(&self) -> &BlockId {
|
||||
&self.block_id
|
||||
}
|
||||
}
|
||||
|
||||
impl Future for CommandHandle {
|
||||
type Output = Result<ExitCode, AgentDriverError>;
|
||||
|
||||
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
|
||||
Pin::new(&mut self.exit_status_rx)
|
||||
.poll(cx)
|
||||
.map(|result| result.map_err(|_| AgentDriverError::InvalidRuntimeState))
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalDriver {
|
||||
/// Handle terminal view events.
|
||||
fn handle_terminal_view_event(
|
||||
&mut self,
|
||||
event: &crate::terminal::view::Event,
|
||||
session_share_tx: &mut Option<oneshot::Sender<Result<(), ShareSessionError>>>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
crate::terminal::view::Event::SessionBootstrapped => {
|
||||
self.session_bootstrapped.set();
|
||||
}
|
||||
crate::terminal::view::Event::SlowBootstrap => {
|
||||
ctx.emit(TerminalDriverEvent::SlowBootstrap);
|
||||
}
|
||||
crate::terminal::view::Event::EstablishedSharedSession { session_id } => {
|
||||
self.shared_session_id = Some(*session_id);
|
||||
if let Some(tx) = session_share_tx.take() {
|
||||
let _ = tx.send(Ok(()));
|
||||
}
|
||||
|
||||
// Apply any pending share requests now that the session is established.
|
||||
self.apply_share_requests(ctx);
|
||||
|
||||
ctx.emit(TerminalDriverEvent::EstablishedSharedSession {
|
||||
session_id: *session_id,
|
||||
join_url: shared_session::join_link(session_id),
|
||||
});
|
||||
}
|
||||
crate::terminal::view::Event::FailedToShareSession { reason, cause } => {
|
||||
if let Some(tx) = session_share_tx.take() {
|
||||
let error = match cause {
|
||||
Some(cause) => ShareSessionError::Internal(cause.clone()),
|
||||
None => ShareSessionError::Failed(reason.clone()),
|
||||
};
|
||||
let _ = tx.send(Err(error));
|
||||
}
|
||||
}
|
||||
crate::terminal::view::Event::ExecuteCommand(event) => {
|
||||
if let Some((_expected_command, sender)) = self
|
||||
.pending_command_start
|
||||
.take_if(|(cmd, _)| *cmd == event.command)
|
||||
{
|
||||
let block_id = self.terminal_view.read(ctx, |terminal, _| {
|
||||
terminal.model.lock().block_list().active_block_id().clone()
|
||||
});
|
||||
let _ = sender.send(block_id);
|
||||
}
|
||||
}
|
||||
crate::terminal::view::Event::BlockCompleted { block, .. } => {
|
||||
if let Some(sender) = self.waiting_command.take_if(|_| {
|
||||
let bootstrapping_done = self.terminal_view.read(ctx, |terminal, _| {
|
||||
terminal
|
||||
.model
|
||||
.lock()
|
||||
.block_list()
|
||||
.is_bootstrapping_precmd_done()
|
||||
});
|
||||
// This was originally checking `bootstrapping_done && block.did_execute`.
|
||||
// Oddly, we've seen cases where we missed the preexec hook, so
|
||||
// `block.did_execute` is false even though the command actually did run.
|
||||
// To hedge against this while we're still figuring out the root cause,
|
||||
// we instead simply make sure it was not a background block.
|
||||
bootstrapping_done && !block.is_background
|
||||
}) {
|
||||
let _ = sender.send(block.exit_code);
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
use std::{ffi::OsString, sync::Arc, time::Duration};
|
||||
|
||||
use futures::channel::oneshot;
|
||||
use warp_cli::agent::Harness;
|
||||
use warp_cli::{
|
||||
OZ_CLI_ENV, OZ_HARNESS_ENV, OZ_PARENT_RUN_ID_ENV, OZ_RUN_ID_ENV, SERVER_ROOT_URL_OVERRIDE_ENV,
|
||||
SESSION_SHARING_SERVER_URL_OVERRIDE_ENV, WS_SERVER_URL_OVERRIDE_ENV,
|
||||
};
|
||||
use warp_core::channel::ChannelState;
|
||||
|
||||
use super::{
|
||||
IdleTimeoutSender, LEGACY_OZ_PARENT_LISTENER_MANAGED_EXTERNALLY_ENV,
|
||||
LEGACY_OZ_PARENT_STATE_ROOT_ENV, OZ_MESSAGE_LISTENER_MANAGED_EXTERNALLY_ENV,
|
||||
OZ_MESSAGE_LISTENER_STATE_ROOT_ENV,
|
||||
};
|
||||
use crate::ai::agent::{
|
||||
task::TaskId, AIAgentActionResult, AIAgentActionResultType, AIAgentInput, AIAgentOutput,
|
||||
AIAgentOutputMessage, ArtifactCreatedData, MessageId, UploadArtifactResult,
|
||||
};
|
||||
use crate::ai::mcp::parsing::normalize_mcp_json;
|
||||
use crate::ai::{agent_sdk::task_env_vars, ambient_agents::AmbientAgentTaskId};
|
||||
|
||||
#[test]
|
||||
fn test_normalize_single_cli_server() {
|
||||
let input = r#"{"command": "npx", "args": ["-y", "mcp-server"]}"#;
|
||||
let result = normalize_mcp_json(input).unwrap();
|
||||
|
||||
// Should wrap with a generated name
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
let parsed = parsed.as_object().unwrap();
|
||||
assert_eq!(parsed.len(), 1);
|
||||
let (_name, server) = parsed.iter().next().unwrap();
|
||||
assert_eq!(server["command"].as_str().unwrap(), "npx");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_single_sse_server() {
|
||||
let input = r#"{"url": "http://localhost:3000/mcp", "headers": {"API_KEY": "value"}}"#;
|
||||
let result = normalize_mcp_json(input).unwrap();
|
||||
|
||||
// Should wrap with a generated name
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
let parsed = parsed.as_object().unwrap();
|
||||
assert_eq!(parsed.len(), 1);
|
||||
let (_name, server) = parsed.iter().next().unwrap();
|
||||
assert_eq!(server["url"].as_str().unwrap(), "http://localhost:3000/mcp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_already_wrapped_server() {
|
||||
let input = r#"{"my-server": {"command": "npx", "args": []}}"#;
|
||||
let result = normalize_mcp_json(input).unwrap();
|
||||
|
||||
// Should return as-is (no command/url at top level)
|
||||
assert_eq!(result, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_mcp_servers_wrapper() {
|
||||
let input = r#"{"mcpServers": {"server-name": {"command": "npx", "args": []}}}"#;
|
||||
let result = normalize_mcp_json(input).unwrap();
|
||||
|
||||
// Should return as-is (no command/url at top level)
|
||||
assert_eq!(result, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_servers_wrapper() {
|
||||
let input = r#"{"servers": {"server-name": {"url": "http://example.com"}}}"#;
|
||||
let result = normalize_mcp_json(input).unwrap();
|
||||
|
||||
// Should return as-is (no command/url at top level)
|
||||
assert_eq!(result, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_invalid_json() {
|
||||
let input = "not valid json";
|
||||
let result = normalize_mcp_json(input);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_cli_server_with_env() {
|
||||
let input = r#"{"command": "npx", "args": ["-y", "mcp-server"], "env": {"API_KEY": "secret"}}"#;
|
||||
let result = normalize_mcp_json(input).unwrap();
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
let parsed = parsed.as_object().unwrap();
|
||||
assert_eq!(parsed.len(), 1);
|
||||
let (_name, server) = parsed.iter().next().unwrap();
|
||||
assert_eq!(server["env"]["API_KEY"].as_str().unwrap(), "secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_sse_server_with_headers() {
|
||||
let input =
|
||||
r#"{"url": "http://localhost:5000/mcp", "headers": {"Authorization": "Bearer token"}}"#;
|
||||
let result = normalize_mcp_json(input).unwrap();
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
let parsed = parsed.as_object().unwrap();
|
||||
assert_eq!(parsed.len(), 1);
|
||||
let (_name, server) = parsed.iter().next().unwrap();
|
||||
assert_eq!(
|
||||
server["headers"]["Authorization"].as_str().unwrap(),
|
||||
"Bearer token"
|
||||
);
|
||||
}
|
||||
|
||||
// ── IdleTimeoutSender tests ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn idle_timeout_sender_send_now_delivers_value() {
|
||||
let (tx, mut rx) = oneshot::channel::<i32>();
|
||||
let idle_timeout = IdleTimeoutSender::new(tx);
|
||||
idle_timeout.end_run_now(42);
|
||||
assert_eq!(rx.try_recv().unwrap(), Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_timeout_sender_send_now_only_delivers_once() {
|
||||
let (tx, mut rx) = oneshot::channel::<i32>();
|
||||
let idle_timeout = IdleTimeoutSender::new(tx);
|
||||
idle_timeout.end_run_now(1);
|
||||
idle_timeout.end_run_now(2);
|
||||
assert_eq!(rx.try_recv().unwrap(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_timeout_sender_send_after_delivers_after_timeout() {
|
||||
let (tx, mut rx) = oneshot::channel::<i32>();
|
||||
let idle_timeout = IdleTimeoutSender::new(tx);
|
||||
idle_timeout.end_run_after(Duration::from_millis(50), 99);
|
||||
|
||||
// Not yet delivered.
|
||||
assert_eq!(rx.try_recv().unwrap(), None);
|
||||
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
assert_eq!(rx.try_recv().unwrap(), Some(99));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_timeout_sender_cancel_prevents_delivery() {
|
||||
let (tx, mut rx) = oneshot::channel::<i32>();
|
||||
let idle_timeout = IdleTimeoutSender::new(tx);
|
||||
idle_timeout.end_run_after(Duration::from_millis(50), 99);
|
||||
idle_timeout.cancel_idle_timeout();
|
||||
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
// Sender was not consumed, so the channel is still open but empty.
|
||||
assert_eq!(rx.try_recv().unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_timeout_sender_cancel_then_send_now_delivers() {
|
||||
let (tx, mut rx) = oneshot::channel::<i32>();
|
||||
let idle_timeout = IdleTimeoutSender::new(tx);
|
||||
idle_timeout.end_run_after(Duration::from_millis(50), 1);
|
||||
idle_timeout.cancel_idle_timeout();
|
||||
idle_timeout.end_run_now(2);
|
||||
|
||||
assert_eq!(rx.try_recv().unwrap(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_timeout_sender_later_send_after_supersedes_earlier() {
|
||||
let (tx, mut rx) = oneshot::channel::<i32>();
|
||||
let idle_timeout = IdleTimeoutSender::new(tx);
|
||||
// First timer: long timeout.
|
||||
idle_timeout.end_run_after(Duration::from_secs(10), 1);
|
||||
// Second timer: short timeout. The first is implicitly cancelled.
|
||||
idle_timeout.end_run_after(Duration::from_millis(50), 2);
|
||||
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
assert_eq!(rx.try_recv().unwrap(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_env_vars_include_parent_run_id_when_present() {
|
||||
let task_id: AmbientAgentTaskId = "550e8400-e29b-41d4-a716-446655440000".parse().unwrap();
|
||||
let env_vars = task_env_vars(Some(&task_id), Some("parent-run-123"), Harness::Claude);
|
||||
let overrides_allowed = ChannelState::channel().allows_server_url_overrides();
|
||||
|
||||
assert_eq!(
|
||||
env_vars.get(&OsString::from(OZ_RUN_ID_ENV)),
|
||||
Some(&OsString::from(task_id.to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
env_vars.get(&OsString::from(OZ_PARENT_RUN_ID_ENV)),
|
||||
Some(&OsString::from("parent-run-123"))
|
||||
);
|
||||
assert_eq!(
|
||||
env_vars.get(&OsString::from(OZ_HARNESS_ENV)),
|
||||
Some(&OsString::from("claude"))
|
||||
);
|
||||
assert_eq!(
|
||||
env_vars.get(&OsString::from(OZ_MESSAGE_LISTENER_MANAGED_EXTERNALLY_ENV)),
|
||||
Some(&OsString::from("1"))
|
||||
);
|
||||
assert_eq!(
|
||||
env_vars.get(&OsString::from(
|
||||
LEGACY_OZ_PARENT_LISTENER_MANAGED_EXTERNALLY_ENV
|
||||
)),
|
||||
Some(&OsString::from("1"))
|
||||
);
|
||||
assert!(env_vars
|
||||
.get(&OsString::from(OZ_CLI_ENV))
|
||||
.is_some_and(|value| !value.is_empty()));
|
||||
|
||||
let server_root_url = ChannelState::server_root_url().into_owned();
|
||||
if overrides_allowed && !server_root_url.is_empty() {
|
||||
assert_eq!(
|
||||
env_vars.get(&OsString::from(SERVER_ROOT_URL_OVERRIDE_ENV)),
|
||||
Some(&OsString::from(server_root_url))
|
||||
);
|
||||
} else {
|
||||
assert!(!env_vars.contains_key(&OsString::from(SERVER_ROOT_URL_OVERRIDE_ENV)));
|
||||
}
|
||||
|
||||
let ws_server_url = ChannelState::ws_server_url().into_owned();
|
||||
if overrides_allowed && !ws_server_url.is_empty() {
|
||||
assert_eq!(
|
||||
env_vars.get(&OsString::from(WS_SERVER_URL_OVERRIDE_ENV)),
|
||||
Some(&OsString::from(ws_server_url))
|
||||
);
|
||||
} else {
|
||||
assert!(!env_vars.contains_key(&OsString::from(WS_SERVER_URL_OVERRIDE_ENV)));
|
||||
}
|
||||
|
||||
if overrides_allowed {
|
||||
match ChannelState::session_sharing_server_url() {
|
||||
Some(url) if !url.is_empty() => assert_eq!(
|
||||
env_vars.get(&OsString::from(SESSION_SHARING_SERVER_URL_OVERRIDE_ENV)),
|
||||
Some(&OsString::from(url.into_owned()))
|
||||
),
|
||||
_ => {
|
||||
assert!(!env_vars
|
||||
.contains_key(&OsString::from(SESSION_SHARING_SERVER_URL_OVERRIDE_ENV)))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
assert!(!env_vars.contains_key(&OsString::from(SESSION_SHARING_SERVER_URL_OVERRIDE_ENV)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_env_vars_omit_parent_run_id_when_absent() {
|
||||
let task_id: AmbientAgentTaskId = "550e8400-e29b-41d4-a716-446655440001".parse().unwrap();
|
||||
let env_vars = task_env_vars(Some(&task_id), None, Harness::Oz);
|
||||
let overrides_allowed = ChannelState::channel().allows_server_url_overrides();
|
||||
|
||||
assert_eq!(
|
||||
env_vars.get(&OsString::from(OZ_RUN_ID_ENV)),
|
||||
Some(&OsString::from(task_id.to_string()))
|
||||
);
|
||||
assert!(!env_vars.contains_key(&OsString::from(OZ_PARENT_RUN_ID_ENV)));
|
||||
assert_eq!(
|
||||
env_vars.get(&OsString::from(OZ_HARNESS_ENV)),
|
||||
Some(&OsString::from("oz"))
|
||||
);
|
||||
assert!(!env_vars.contains_key(&OsString::from(OZ_MESSAGE_LISTENER_MANAGED_EXTERNALLY_ENV)));
|
||||
assert!(!env_vars.contains_key(&OsString::from(
|
||||
LEGACY_OZ_PARENT_LISTENER_MANAGED_EXTERNALLY_ENV
|
||||
)));
|
||||
assert_eq!(
|
||||
env_vars.contains_key(&OsString::from(SERVER_ROOT_URL_OVERRIDE_ENV)),
|
||||
overrides_allowed && !ChannelState::server_root_url().is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
env_vars.contains_key(&OsString::from(WS_SERVER_URL_OVERRIDE_ENV)),
|
||||
overrides_allowed && !ChannelState::ws_server_url().is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_env_vars_enable_external_parent_listener_for_claude_runs_without_parent_run_id() {
|
||||
let task_id: AmbientAgentTaskId = "550e8400-e29b-41d4-a716-446655440002".parse().unwrap();
|
||||
let env_vars = task_env_vars(Some(&task_id), None, Harness::Claude);
|
||||
assert_eq!(
|
||||
env_vars.get(&OsString::from(OZ_MESSAGE_LISTENER_MANAGED_EXTERNALLY_ENV)),
|
||||
Some(&OsString::from("1"))
|
||||
);
|
||||
assert_eq!(
|
||||
env_vars.get(&OsString::from(
|
||||
LEGACY_OZ_PARENT_LISTENER_MANAGED_EXTERNALLY_ENV
|
||||
)),
|
||||
Some(&OsString::from("1"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn task_env_vars_propagate_message_listener_state_root_with_legacy_alias() {
|
||||
let task_id: AmbientAgentTaskId = "550e8400-e29b-41d4-a716-446655440003".parse().unwrap();
|
||||
std::env::set_var(
|
||||
OZ_MESSAGE_LISTENER_STATE_ROOT_ENV,
|
||||
"/tmp/message-listener-root",
|
||||
);
|
||||
let env_vars = task_env_vars(Some(&task_id), None, Harness::Claude);
|
||||
std::env::remove_var(OZ_MESSAGE_LISTENER_STATE_ROOT_ENV);
|
||||
|
||||
assert_eq!(
|
||||
env_vars.get(&OsString::from(OZ_MESSAGE_LISTENER_STATE_ROOT_ENV)),
|
||||
Some(&OsString::from("/tmp/message-listener-root"))
|
||||
);
|
||||
assert_eq!(
|
||||
env_vars.get(&OsString::from(LEGACY_OZ_PARENT_STATE_ROOT_ENV)),
|
||||
Some(&OsString::from("/tmp/message-listener-root"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_env_vars_can_use_opencode_harness() {
|
||||
let task_id: AmbientAgentTaskId = "550e8400-e29b-41d4-a716-446655440004".parse().unwrap();
|
||||
let env_vars = task_env_vars(Some(&task_id), Some("parent-run-456"), Harness::OpenCode);
|
||||
|
||||
assert_eq!(
|
||||
env_vars.get(&OsString::from(OZ_HARNESS_ENV)),
|
||||
Some(&OsString::from("opencode"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_format_output_includes_filename_for_file_artifact_created_event() {
|
||||
let output = AIAgentOutput {
|
||||
messages: vec![AIAgentOutputMessage::artifact_created(
|
||||
MessageId::new("message-1".to_string()),
|
||||
ArtifactCreatedData::File {
|
||||
artifact_uid: "artifact-uid".to_string(),
|
||||
filepath: "outputs/report.txt".to_string(),
|
||||
filename: "report.txt".to_string(),
|
||||
mime_type: "text/plain".to_string(),
|
||||
description: Some("Build output for the latest run".to_string()),
|
||||
size_bytes: 42,
|
||||
},
|
||||
)],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut bytes = Vec::new();
|
||||
super::output::json::format_output(&output, &mut bytes).expect("json formatting should work");
|
||||
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_slice(&bytes).expect("output should be valid json");
|
||||
|
||||
assert_eq!(value["type"], "artifact_created");
|
||||
assert_eq!(value["artifact_type"], "file");
|
||||
assert_eq!(value["artifact_uid"], "artifact-uid");
|
||||
assert_eq!(value["filepath"], "outputs/report.txt");
|
||||
assert_eq!(value["filename"], "report.txt");
|
||||
assert_eq!(value["mime_type"], "text/plain");
|
||||
assert_eq!(value["description"], "Build output for the latest run");
|
||||
assert_eq!(value["size_bytes"], 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_format_input_omits_filepath_and_description_for_proto_upload_result() {
|
||||
let input = AIAgentInput::ActionResult {
|
||||
result: AIAgentActionResult {
|
||||
id: "tool-call-1".to_string().into(),
|
||||
task_id: TaskId::new("task-1".to_string()),
|
||||
result: AIAgentActionResultType::UploadArtifact(UploadArtifactResult::Success {
|
||||
artifact_uid: "artifact-123".to_string(),
|
||||
filepath: None,
|
||||
mime_type: "text/plain".to_string(),
|
||||
description: None,
|
||||
size_bytes: 42,
|
||||
}),
|
||||
},
|
||||
context: Arc::from([]),
|
||||
};
|
||||
|
||||
let mut bytes = Vec::new();
|
||||
super::output::json::format_input(&input, &mut bytes).expect("json formatting should work");
|
||||
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_slice(&bytes).expect("output should be valid json");
|
||||
|
||||
assert_eq!(value["type"], "tool_result");
|
||||
assert_eq!(value["tool"], "upload_artifact");
|
||||
assert_eq!(value["artifact_uid"], "artifact-123");
|
||||
assert_eq!(value["mime_type"], "text/plain");
|
||||
assert_eq!(value["size_bytes"], 42);
|
||||
assert!(value.get("filepath").is_none());
|
||||
assert!(value.get("description").is_none());
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
use std::process;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde_json::json;
|
||||
use warp_cli::federate::{FederateCommand, IssueGcpTokenArgs, IssueTokenArgs};
|
||||
use warp_cli::{agent::OutputFormat, GlobalOptions};
|
||||
use warp_core::{features::FeatureFlag, report_error};
|
||||
use warp_managed_secrets::ManagedSecretManager;
|
||||
use warpui::{platform::TerminationMode, AppContext, SingletonEntity as _};
|
||||
|
||||
use super::common::set_ambient_task_context_from_run_id;
|
||||
|
||||
/// Run identity federation commands.
|
||||
pub fn run(
|
||||
ctx: &mut AppContext,
|
||||
global_options: GlobalOptions,
|
||||
command: FederateCommand,
|
||||
) -> Result<()> {
|
||||
if !FeatureFlag::OzIdentityFederation.is_enabled() {
|
||||
return Err(anyhow::anyhow!("This feature is not enabled"));
|
||||
}
|
||||
match command {
|
||||
FederateCommand::IssueToken(args) => issue_token(ctx, args, global_options.output_format),
|
||||
FederateCommand::IssueGcpToken(args) => issue_gcp_token(ctx, args),
|
||||
}
|
||||
}
|
||||
|
||||
fn issue_token(
|
||||
ctx: &mut AppContext,
|
||||
args: IssueTokenArgs,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
// Set the task ID so the ambient workload token header is sent.
|
||||
set_ambient_task_context_from_run_id(ctx, &args.run_id)?;
|
||||
|
||||
let duration: std::time::Duration = args.duration.into();
|
||||
let audience = args.audience;
|
||||
let subject_template = match args.subject_template {
|
||||
Some(template) => vec1::Vec1::try_from_vec(template)
|
||||
.map_err(|_| anyhow::anyhow!("--subject-template requires at least one value"))?,
|
||||
None => vec1::vec1!["principal".to_owned()],
|
||||
};
|
||||
|
||||
ManagedSecretManager::handle(ctx).update(ctx, move |manager, ctx| {
|
||||
let future =
|
||||
manager.issue_task_identity_token(warp_managed_secrets::client::IdentityTokenOptions {
|
||||
audience,
|
||||
requested_duration: duration,
|
||||
subject_template,
|
||||
});
|
||||
ctx.spawn(future, move |_, result, ctx| match result {
|
||||
Ok(token) => {
|
||||
let token_value = token.token;
|
||||
let expires_at = token.expires_at.to_rfc3339();
|
||||
let issuer = token.issuer;
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Ndjson => {
|
||||
let output = json!({
|
||||
"token": token_value,
|
||||
"expires_at": expires_at,
|
||||
"issuer": issuer,
|
||||
});
|
||||
let output =
|
||||
serde_json::to_string(&output).expect("token output should serialize");
|
||||
println!("{output}");
|
||||
}
|
||||
OutputFormat::Text => {
|
||||
println!("{token_value}");
|
||||
}
|
||||
OutputFormat::Pretty => {
|
||||
println!("Token: {token_value}");
|
||||
println!("Expires at: {expires_at}");
|
||||
println!("Issuer: {issuer}");
|
||||
}
|
||||
}
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
}
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
fn issue_gcp_token(ctx: &mut AppContext, args: IssueGcpTokenArgs) -> Result<()> {
|
||||
// Set the task ID so the ambient workload token header is sent.
|
||||
set_ambient_task_context_from_run_id(ctx, &args.run_id)?;
|
||||
|
||||
let duration: std::time::Duration = args.duration.into();
|
||||
let audience = args.audience;
|
||||
let token_type = args.token_type;
|
||||
let output_file = args.output_file;
|
||||
|
||||
ManagedSecretManager::handle(ctx).update(ctx, move |manager, ctx| {
|
||||
let future =
|
||||
manager.issue_gcp_workload_identity_federation_token(audience, token_type, duration);
|
||||
ctx.spawn(future, move |_, result, ctx| match result {
|
||||
Ok(token) => {
|
||||
let output =
|
||||
serde_json::to_string(&token).expect("gcp token output should serialize");
|
||||
|
||||
// If we can't cache the token, report an error but don't fail the command.
|
||||
if let Some(output_path) = output_file {
|
||||
if let Err(err) = std::fs::write(&output_path, &output) {
|
||||
report_error!(anyhow!(err)
|
||||
.context(format!("Error writing GCP token to {output_path}")));
|
||||
}
|
||||
}
|
||||
|
||||
println!("{output}");
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
}
|
||||
Err(err) => {
|
||||
// The GCP SDK requires the executable to print a JSON error to stderr
|
||||
// and exit with a non-zero status code. Because of this, we exit
|
||||
// directly instead of via `ctx.terminate_app` (which would print a
|
||||
// non-JSON error).
|
||||
let output =
|
||||
serde_json::to_string(&err).expect("gcp error output should serialize");
|
||||
eprintln!("{output}");
|
||||
process::exit(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//! `warp harness-support` CLI dispatch and the singleton model all subcommands run async work on.
|
||||
//!
|
||||
//! Subcommands:
|
||||
//! - [`ping`] — fetches the current run by task ID and prints its info.
|
||||
//! - [`report_artifact`] — reports an artifact (e.g. a PR) back to the Oz platform.
|
||||
use anyhow::Result;
|
||||
use warp_cli::agent::OutputFormat;
|
||||
use warp_cli::harness_support::{
|
||||
FinishTaskArgs, HarnessSupportArgs, HarnessSupportCommand, NotifyUserArgs, ReportArtifactArgs,
|
||||
ReportArtifactCommand, TaskStatus,
|
||||
};
|
||||
use warp_cli::GlobalOptions;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::{platform::TerminationMode, AppContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use super::common::set_ambient_task_context_from_run_id;
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::ai::artifacts::Artifact;
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
|
||||
/// Run harness-support commands.
|
||||
pub fn run(
|
||||
ctx: &mut AppContext,
|
||||
global_options: GlobalOptions,
|
||||
args: HarnessSupportArgs,
|
||||
) -> Result<()> {
|
||||
if !FeatureFlag::AgentHarness.is_enabled() {
|
||||
return Err(anyhow::anyhow!("This feature is not enabled"));
|
||||
}
|
||||
|
||||
// Store the run ID so that it's included on all server requests, along with a workload token.
|
||||
let task_id = set_ambient_task_context_from_run_id(ctx, &args.run_id)?;
|
||||
let runner = ctx.add_singleton_model(|_| HarnessSupportRunner);
|
||||
|
||||
match args.command {
|
||||
HarnessSupportCommand::Ping => ping(ctx, runner, task_id, global_options.output_format),
|
||||
HarnessSupportCommand::ReportArtifact(report_args) => {
|
||||
report_artifact(ctx, runner, report_args, global_options.output_format)
|
||||
}
|
||||
HarnessSupportCommand::NotifyUser(notify_args) => {
|
||||
notify_user(ctx, runner, notify_args, global_options.output_format)
|
||||
}
|
||||
HarnessSupportCommand::FinishTask(finish_args) => {
|
||||
finish_task(ctx, runner, finish_args, global_options.output_format)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the current run by ID and print its info.
|
||||
fn ping(
|
||||
ctx: &mut AppContext,
|
||||
runner: ModelHandle<HarnessSupportRunner>,
|
||||
task_id: AmbientAgentTaskId,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
runner.update(ctx, |_, ctx| {
|
||||
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
|
||||
|
||||
ctx.spawn(
|
||||
async move {
|
||||
let task = ai_client.get_ambient_agent_task(&task_id).await?;
|
||||
Ok(task)
|
||||
},
|
||||
move |_, result, ctx| match result {
|
||||
Ok(task) => {
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Ndjson => {
|
||||
let json = serde_json::to_string(&task).unwrap_or_else(|e| {
|
||||
serde_json::json!({"error": e.to_string()}).to_string()
|
||||
});
|
||||
println!("{json}");
|
||||
}
|
||||
OutputFormat::Pretty | OutputFormat::Text => {
|
||||
super::ambient::print_tasks(&[task]);
|
||||
}
|
||||
}
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
}
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Report an artifact back to the Oz platform.
|
||||
fn report_artifact(
|
||||
ctx: &mut AppContext,
|
||||
runner: ModelHandle<HarnessSupportRunner>,
|
||||
args: ReportArtifactArgs,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
runner.update(ctx, |_, ctx| {
|
||||
let client = ServerApiProvider::as_ref(ctx).get_harness_support_client();
|
||||
|
||||
let artifact = match args.command {
|
||||
ReportArtifactCommand::PullRequest(pr_args) => Artifact::PullRequest {
|
||||
url: pr_args.url,
|
||||
branch: pr_args.branch,
|
||||
repo: None,
|
||||
number: None,
|
||||
},
|
||||
};
|
||||
|
||||
ctx.spawn(
|
||||
async move { client.report_artifact(&artifact).await },
|
||||
move |_, result, ctx| match result {
|
||||
Ok(response) => {
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Ndjson => {
|
||||
let json = serde_json::to_string(&response).unwrap_or_else(|e| {
|
||||
serde_json::json!({"error": e.to_string()}).to_string()
|
||||
});
|
||||
println!("{json}");
|
||||
}
|
||||
OutputFormat::Pretty | OutputFormat::Text => {
|
||||
println!("Artifact reported: {}", response.artifact_uid);
|
||||
}
|
||||
}
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
}
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a progress notification to the task's originating platform.
|
||||
fn notify_user(
|
||||
ctx: &mut AppContext,
|
||||
runner: ModelHandle<HarnessSupportRunner>,
|
||||
args: NotifyUserArgs,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
runner.update(ctx, |_, ctx| {
|
||||
let client = ServerApiProvider::as_ref(ctx).get_harness_support_client();
|
||||
|
||||
ctx.spawn(
|
||||
async move { client.notify_user(&args.message).await },
|
||||
move |_, result, ctx| match result {
|
||||
Ok(()) => {
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Ndjson => {
|
||||
println!("{{}}");
|
||||
}
|
||||
OutputFormat::Pretty | OutputFormat::Text => {
|
||||
println!("Notification sent.");
|
||||
}
|
||||
}
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
}
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Report task completion or failure.
|
||||
fn finish_task(
|
||||
ctx: &mut AppContext,
|
||||
runner: ModelHandle<HarnessSupportRunner>,
|
||||
args: FinishTaskArgs,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
runner.update(ctx, |_, ctx| {
|
||||
let client = ServerApiProvider::as_ref(ctx).get_harness_support_client();
|
||||
|
||||
ctx.spawn(
|
||||
async move {
|
||||
let success = args.status == TaskStatus::Success;
|
||||
client.finish_task(success, &args.summary).await
|
||||
},
|
||||
move |_, result, ctx| match result {
|
||||
Ok(()) => {
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Ndjson => {
|
||||
println!("{{}}");
|
||||
}
|
||||
OutputFormat::Pretty | OutputFormat::Text => {
|
||||
println!("Task finished.");
|
||||
}
|
||||
}
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
}
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Singleton model for running async harness-support operations.
|
||||
struct HarnessSupportRunner;
|
||||
|
||||
impl warpui::Entity for HarnessSupportRunner {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for HarnessSupportRunner {}
|
||||
@@ -0,0 +1,524 @@
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use futures::future;
|
||||
use warp_cli::{
|
||||
integration::{CreateIntegrationArgs, IntegrationCommand, UpdateIntegrationArgs},
|
||||
provider::ProviderType,
|
||||
GlobalOptions,
|
||||
};
|
||||
use warp_graphql::mutations::create_simple_integration::CreateSimpleIntegrationOutput;
|
||||
use warp_graphql::queries::get_oauth_connect_tx_status::OauthConnectTxStatus;
|
||||
use warp_graphql::queries::get_simple_integrations::SimpleIntegrationsOutput;
|
||||
use warpui::{platform::TerminationMode, AppContext, ModelContext, SingletonEntity};
|
||||
|
||||
use super::common::{EnvironmentChoice, ResolveConfigurationError};
|
||||
use super::integration_output;
|
||||
use super::oauth_flow::poll_oauth_until_terminal;
|
||||
|
||||
pub fn run(
|
||||
ctx: &mut AppContext,
|
||||
global_options: GlobalOptions,
|
||||
command: IntegrationCommand,
|
||||
) -> anyhow::Result<()> {
|
||||
let runner = ctx.add_singleton_model(|_ctx| IntegrationCommandRunner);
|
||||
match command {
|
||||
IntegrationCommand::Create(args) => {
|
||||
runner.update(ctx, |runner, ctx| runner.create(args, ctx));
|
||||
}
|
||||
IntegrationCommand::Update(args) => {
|
||||
runner.update(ctx, |runner, ctx| runner.update(args, ctx));
|
||||
}
|
||||
IntegrationCommand::List => {
|
||||
runner.update(ctx, |runner, ctx| runner.list(global_options, ctx));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct IntegrationCommandRunner;
|
||||
|
||||
impl IntegrationCommandRunner {
|
||||
fn list(&self, global_options: GlobalOptions, ctx: &mut ModelContext<Self>) {
|
||||
// Hardcoded set of providers that this client knows how to render.
|
||||
let providers = vec![ProviderType::Linear, ProviderType::Slack];
|
||||
let provider_slugs: Vec<String> = providers.into_iter().map(|p| p.slug()).collect();
|
||||
|
||||
let integrations_client = ServerApiProvider::as_ref(ctx).get_integrations_client();
|
||||
|
||||
let list_future = async move {
|
||||
integrations_client
|
||||
.list_simple_integrations(provider_slugs)
|
||||
.await
|
||||
};
|
||||
|
||||
ctx.spawn(
|
||||
list_future,
|
||||
move |_, result: anyhow::Result<SimpleIntegrationsOutput>, ctx| match result {
|
||||
Ok(output) => {
|
||||
integration_output::print_integrations(&output, global_options.output_format);
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
}
|
||||
Err(err) => {
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, Some(Err(err)));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn create(&self, args: CreateIntegrationArgs, ctx: &mut ModelContext<Self>) {
|
||||
let refresh_future = super::common::refresh_workspace_metadata(ctx);
|
||||
let warp_drive_sync_future = super::common::refresh_warp_drive(ctx);
|
||||
let setup_future = future::try_join(refresh_future, warp_drive_sync_future);
|
||||
|
||||
ctx.spawn(setup_future, move |runner, setup_result, ctx| {
|
||||
if let Err(err) = setup_result {
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, Some(Err(err)));
|
||||
return;
|
||||
}
|
||||
|
||||
let loaded_file = match args.config_file.file.as_deref() {
|
||||
Some(path) => match super::config_file::load_config_file(path) {
|
||||
Ok(file) => Some(file),
|
||||
Err(err) => {
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, Some(Err(err)));
|
||||
return;
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
let integration_type = args.provider.slug();
|
||||
let enabled = true;
|
||||
let is_update = false;
|
||||
|
||||
let cli_mcp_servers =
|
||||
match super::mcp_config::build_mcp_servers_from_specs(&args.mcp_specs) {
|
||||
Ok(mcp_servers) => mcp_servers,
|
||||
Err(err) => {
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, Some(Err(err)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut merged_config = super::config_file::merge_with_precedence(
|
||||
loaded_file.as_ref(),
|
||||
crate::ai::ambient_agents::AgentConfigSnapshot {
|
||||
name: None,
|
||||
environment_id: args.environment.environment.clone(),
|
||||
model_id: args.model.model.clone(),
|
||||
base_prompt: args.prompt.clone(),
|
||||
mcp_servers: cli_mcp_servers,
|
||||
profile_id: None,
|
||||
worker_host: args.worker_host.clone(),
|
||||
skill_spec: None,
|
||||
// TODO(QUALITY-295): Support computer use flag in integrations.
|
||||
computer_use_enabled: None,
|
||||
// TODO(REMOTE-1134): Support harness selection for integrations.
|
||||
harness: None,
|
||||
harness_auth_secrets: None,
|
||||
},
|
||||
);
|
||||
|
||||
// We must wait until after workspace metadata is refreshed to check available LLMs.
|
||||
let model_id = match merged_config
|
||||
.model_id
|
||||
.as_deref()
|
||||
.map(|model_id| super::common::validate_agent_mode_base_model_id(model_id, ctx))
|
||||
.transpose()
|
||||
{
|
||||
Ok(model_id) => model_id.map(|model_id| model_id.to_string()),
|
||||
Err(err) => {
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, Some(Err(err)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let base_prompt = merged_config.base_prompt.take();
|
||||
let worker_host = merged_config.worker_host.take();
|
||||
|
||||
let mcp_servers_json = match merged_config.mcp_servers.take() {
|
||||
Some(map) => match serde_json::to_string(&map) {
|
||||
Ok(json) => Some(json),
|
||||
Err(err) => {
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, Some(Err(err.into())));
|
||||
return;
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
//If the user didn't explicitly request no environment, load environment from the config
|
||||
let mut environment_args = args.environment;
|
||||
if environment_args.environment.is_none() && !environment_args.no_environment {
|
||||
environment_args.environment = merged_config.environment_id.take();
|
||||
}
|
||||
|
||||
let environment_uid = match EnvironmentChoice::resolve_for_create(environment_args, ctx)
|
||||
{
|
||||
Ok(EnvironmentChoice::None) => {
|
||||
eprintln!("Creating integration without an environment.");
|
||||
None
|
||||
}
|
||||
Ok(EnvironmentChoice::Environment { id, .. }) => {
|
||||
eprintln!("Creating integration with environment {id}.");
|
||||
Some(id)
|
||||
}
|
||||
Err(ResolveConfigurationError::Canceled) => {
|
||||
eprintln!("Integration creation canceled.");
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
super::report_fatal_error(anyhow::anyhow!(err), ctx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
runner.start_create_or_update_flow(
|
||||
ctx,
|
||||
integration_type,
|
||||
environment_uid,
|
||||
base_prompt,
|
||||
model_id,
|
||||
mcp_servers_json,
|
||||
None,
|
||||
worker_host,
|
||||
enabled,
|
||||
is_update,
|
||||
1,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn start_create_or_update_flow(
|
||||
&self,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
integration_type: String,
|
||||
environment_uid: Option<String>,
|
||||
base_prompt: Option<String>,
|
||||
model_id: Option<String>,
|
||||
mcp_servers_json: Option<String>,
|
||||
remove_mcp_server_names: Option<Vec<String>>,
|
||||
worker_host: Option<String>,
|
||||
enabled: bool,
|
||||
is_update: bool,
|
||||
attempt: u32,
|
||||
) {
|
||||
const MAX_CREATE_ATTEMPTS: u32 = 8;
|
||||
let action = if is_update { "update" } else { "creation" };
|
||||
|
||||
if attempt > MAX_CREATE_ATTEMPTS {
|
||||
ctx.terminate_app(
|
||||
TerminationMode::ForceTerminate,
|
||||
Some(Err(anyhow::anyhow!(
|
||||
"Exceeded maximum number of integration creation attempts ({}). Retry.",
|
||||
MAX_CREATE_ATTEMPTS
|
||||
))),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let integrations_client = ServerApiProvider::as_ref(ctx).get_integrations_client();
|
||||
|
||||
let future_integration_type = integration_type.clone();
|
||||
let future_environment_uid = environment_uid.clone();
|
||||
let future_base_prompt = base_prompt.clone();
|
||||
let future_model_id = model_id.clone();
|
||||
let future_mcp_servers_json = mcp_servers_json.clone();
|
||||
let future_remove_mcp_server_names = remove_mcp_server_names.clone();
|
||||
let future_worker_host = worker_host.clone();
|
||||
let future_is_update = is_update;
|
||||
|
||||
let create_future = async move {
|
||||
integrations_client
|
||||
.create_or_update_simple_integration(
|
||||
future_integration_type,
|
||||
future_is_update,
|
||||
future_environment_uid,
|
||||
future_base_prompt,
|
||||
future_model_id,
|
||||
future_mcp_servers_json,
|
||||
future_remove_mcp_server_names,
|
||||
future_worker_host,
|
||||
enabled,
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
ctx.spawn(
|
||||
create_future,
|
||||
move |_runner, result: anyhow::Result<CreateSimpleIntegrationOutput>, ctx| {
|
||||
match result {
|
||||
Ok(output) => {
|
||||
println!("{}", output.message);
|
||||
|
||||
let auth_url = output.auth_url;
|
||||
let tx_id = output.tx_id;
|
||||
|
||||
match (auth_url, tx_id) {
|
||||
(Some(auth_url), Some(tx_id)) => {
|
||||
// We have another auth step: open URL and poll txId.
|
||||
println!("Authorize the provider here: {auth_url}\n");
|
||||
ctx.open_url(&auth_url);
|
||||
|
||||
let integrations_client = ServerApiProvider::as_ref(ctx)
|
||||
.get_integrations_client();
|
||||
let tx_id = tx_id.into_inner();
|
||||
|
||||
let poll_future =
|
||||
poll_oauth_until_terminal(integrations_client, tx_id);
|
||||
|
||||
let next_integration_type = integration_type.clone();
|
||||
let next_environment_uid = environment_uid.clone();
|
||||
let next_base_prompt = base_prompt.clone();
|
||||
let next_model_id = model_id.clone();
|
||||
let next_mcp_servers_json = mcp_servers_json.clone();
|
||||
let next_remove_mcp_server_names = remove_mcp_server_names.clone();
|
||||
let next_worker_host = worker_host.clone();
|
||||
let next_enabled = enabled;
|
||||
let next_is_update = is_update;
|
||||
let next_attempt = attempt + 1;
|
||||
|
||||
ctx.spawn(
|
||||
poll_future,
|
||||
move |runner, poll_result, ctx| {
|
||||
match poll_result {
|
||||
Ok(OauthConnectTxStatus::Completed) => {
|
||||
// Inner loop done; try create or update again (outer loop).
|
||||
// This may happen multiple times if the user needs to authorize multiple services.
|
||||
runner.start_create_or_update_flow(
|
||||
ctx,
|
||||
next_integration_type,
|
||||
next_environment_uid,
|
||||
next_base_prompt,
|
||||
next_model_id,
|
||||
next_mcp_servers_json,
|
||||
next_remove_mcp_server_names,
|
||||
next_worker_host,
|
||||
next_enabled,
|
||||
next_is_update,
|
||||
next_attempt,
|
||||
);
|
||||
}
|
||||
Ok(OauthConnectTxStatus::Failed) => {
|
||||
ctx.terminate_app(
|
||||
TerminationMode::ForceTerminate,
|
||||
Some(Err(anyhow::anyhow!("OAuth authorization failed."))),
|
||||
);
|
||||
}
|
||||
Ok(OauthConnectTxStatus::Expired) => {
|
||||
ctx.terminate_app(
|
||||
TerminationMode::ForceTerminate,
|
||||
Some(Err(anyhow::anyhow!("OAuth authorization expired."))),
|
||||
);
|
||||
}
|
||||
Ok(OauthConnectTxStatus::Pending)
|
||||
| Ok(OauthConnectTxStatus::InProgress) => {
|
||||
// Should not be returned by poll_oauth_until_terminal.
|
||||
ctx.terminate_app(
|
||||
TerminationMode::ForceTerminate,
|
||||
Some(Err(anyhow::anyhow!("Unexpected non-terminal OAuth status returned"))),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
ctx.terminate_app(
|
||||
TerminationMode::ForceTerminate,
|
||||
Some(Err(anyhow::anyhow!("Error polling OAuth status: {err}"))),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
(Some(auth_url), None) => {
|
||||
println!("Authorize the provider here: {auth_url}\n");
|
||||
ctx.open_url(&auth_url);
|
||||
println!(
|
||||
"After authorizing, re-run the command to continue the integration {action} process.",
|
||||
);
|
||||
ctx.terminate_app(
|
||||
TerminationMode::ForceTerminate,
|
||||
None,
|
||||
);
|
||||
}
|
||||
(None, Some(_)) => {
|
||||
ctx.terminate_app(
|
||||
TerminationMode::ForceTerminate,
|
||||
Some(Err(anyhow::anyhow!("Server did not return an authURL for the integration creation process."))),
|
||||
);
|
||||
}
|
||||
(None, None) => {
|
||||
// No more auth steps; finalize.
|
||||
if output.success {
|
||||
ctx.terminate_app(
|
||||
TerminationMode::ForceTerminate,
|
||||
None,
|
||||
);
|
||||
} else {
|
||||
ctx.terminate_app(
|
||||
TerminationMode::ForceTerminate,
|
||||
Some(Err(anyhow::anyhow!("Integration creation reported failure: {}", output.message))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
ctx.terminate_app(
|
||||
TerminationMode::ForceTerminate,
|
||||
Some(Err(err)),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn update(&self, args: UpdateIntegrationArgs, ctx: &mut ModelContext<Self>) {
|
||||
let refresh_future = super::common::refresh_workspace_metadata(ctx);
|
||||
let warp_drive_sync_future = super::common::refresh_warp_drive(ctx);
|
||||
let setup_future = future::try_join(refresh_future, warp_drive_sync_future);
|
||||
|
||||
ctx.spawn(setup_future, move |runner, setup_result, ctx| {
|
||||
if let Err(err) = setup_result {
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, Some(Err(err)));
|
||||
return;
|
||||
}
|
||||
|
||||
let loaded_file = match args.config_file.file.as_deref() {
|
||||
Some(path) => match super::config_file::load_config_file(path) {
|
||||
Ok(file) => Some(file),
|
||||
Err(err) => {
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, Some(Err(err)));
|
||||
return;
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
let remove_mcp = args.remove_mcp.clone();
|
||||
|
||||
let integration_type = args.provider.slug();
|
||||
let enabled = true;
|
||||
let is_update = true;
|
||||
|
||||
let cli_mcp_servers =
|
||||
match super::mcp_config::build_mcp_servers_from_specs(&args.mcp_specs) {
|
||||
Ok(mcp_servers) => mcp_servers,
|
||||
Err(err) => {
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, Some(Err(err)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut merged_config = super::config_file::merge_with_precedence(
|
||||
loaded_file.as_ref(),
|
||||
crate::ai::ambient_agents::AgentConfigSnapshot {
|
||||
name: None,
|
||||
environment_id: args.environment.environment.clone(),
|
||||
model_id: args.model.model.clone(),
|
||||
base_prompt: args.prompt.clone(),
|
||||
mcp_servers: cli_mcp_servers,
|
||||
profile_id: None,
|
||||
worker_host: args.worker_host.clone(),
|
||||
skill_spec: None,
|
||||
// TODO(QUALITY-295): Support computer use flag in integrations.
|
||||
computer_use_enabled: None,
|
||||
// TODO(REMOTE-1134): Support harness selection for integrations.
|
||||
harness: None,
|
||||
harness_auth_secrets: None,
|
||||
},
|
||||
);
|
||||
|
||||
// We must wait until after workspace metadata is refreshed to check available LLMs.
|
||||
let model_id = match merged_config
|
||||
.model_id
|
||||
.as_deref()
|
||||
.map(|model_id| super::common::validate_agent_mode_base_model_id(model_id, ctx))
|
||||
.transpose()
|
||||
{
|
||||
Ok(model_id) => model_id.map(|model_id| model_id.to_string()),
|
||||
Err(err) => {
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, Some(Err(err)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let base_prompt = merged_config.base_prompt.take();
|
||||
let worker_host = merged_config.worker_host.take();
|
||||
|
||||
// MCP update semantics are patch-only:
|
||||
// - `mcp_servers_json` adds/overwrites MCP servers.
|
||||
// - `remove_mcp_server_names` removes MCP servers.
|
||||
// If both are present, removals win by filtering removed names out of the JSON payload.
|
||||
let mcp_servers_json = match merged_config.mcp_servers.take() {
|
||||
Some(mut map) => {
|
||||
for name in &remove_mcp {
|
||||
map.remove(name);
|
||||
}
|
||||
|
||||
if map.is_empty() {
|
||||
None
|
||||
} else {
|
||||
match serde_json::to_string(&map) {
|
||||
Ok(json) => Some(json),
|
||||
Err(err) => {
|
||||
ctx.terminate_app(
|
||||
TerminationMode::ForceTerminate,
|
||||
Some(Err(err.into())),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let remove_mcp_server_names = if args.remove_mcp.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(args.remove_mcp)
|
||||
};
|
||||
|
||||
if args.environment.remove_environment {
|
||||
// Explicitly requested to update without an environment.
|
||||
runner.start_create_or_update_flow(
|
||||
ctx,
|
||||
integration_type,
|
||||
Some(String::new()),
|
||||
base_prompt,
|
||||
model_id,
|
||||
mcp_servers_json,
|
||||
remove_mcp_server_names,
|
||||
worker_host,
|
||||
enabled,
|
||||
is_update,
|
||||
1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let environment_uid = merged_config.environment_id.take();
|
||||
|
||||
runner.start_create_or_update_flow(
|
||||
ctx,
|
||||
integration_type,
|
||||
environment_uid,
|
||||
base_prompt,
|
||||
model_id,
|
||||
mcp_servers_json,
|
||||
remove_mcp_server_names,
|
||||
worker_host,
|
||||
enabled,
|
||||
is_update,
|
||||
1,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl warpui::Entity for IntegrationCommandRunner {
|
||||
type Event = ();
|
||||
}
|
||||
impl SingletonEntity for IntegrationCommandRunner {}
|
||||
@@ -0,0 +1,355 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use comfy_table::{presets::UTF8_FULL, Cell, Table};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Map, Value};
|
||||
use warp_cli::agent::OutputFormat;
|
||||
|
||||
use crate::ai::agent_sdk::output::{self, TableFormat};
|
||||
use crate::util::time_format::format_approx_duration_from_now_utc;
|
||||
use warp_graphql::queries::get_simple_integrations::{
|
||||
ListedSimpleIntegrationConfig, SimpleIntegration, SimpleIntegrationConnectionStatus,
|
||||
SimpleIntegrationsOutput,
|
||||
};
|
||||
|
||||
const MAX_LINE_WIDTH: usize = 90;
|
||||
|
||||
/// Print simple integrations.
|
||||
pub fn print_integrations(graphql_output: &SimpleIntegrationsOutput, output_format: OutputFormat) {
|
||||
if let Some(message) = &graphql_output.message {
|
||||
eprintln!("{message}");
|
||||
return;
|
||||
}
|
||||
|
||||
let integrations = &graphql_output.integrations;
|
||||
|
||||
if integrations.is_empty() {
|
||||
println!("No integrations found.");
|
||||
return;
|
||||
}
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Ndjson => {
|
||||
// Convert to serializable format and use common output utilities
|
||||
let integration_infos: Vec<IntegrationInfo> = integrations
|
||||
.iter()
|
||||
.map(IntegrationInfo::from_graphql)
|
||||
.collect();
|
||||
output::print_list(integration_infos, output_format);
|
||||
}
|
||||
OutputFormat::Pretty | OutputFormat::Text => {
|
||||
// Use the existing card-style layout for pretty/text output
|
||||
if integrations.len() == 1 {
|
||||
println!("\nIntegration:");
|
||||
} else {
|
||||
println!("\nIntegrations:");
|
||||
}
|
||||
|
||||
for integration in integrations {
|
||||
print_integration_card(integration);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_labeled_wrapped_lines(label: &str, lines: &[String], width: usize) -> String {
|
||||
let indent = " ".repeat(label.len() + 2); // align under "{label}: "
|
||||
let mut out = String::new();
|
||||
|
||||
for (idx, line) in lines.iter().enumerate() {
|
||||
let wrapped = crate::ai::agent_sdk::text_layout::word_wrap(line, width);
|
||||
for (widx, wline) in wrapped.iter().enumerate() {
|
||||
if !out.is_empty() {
|
||||
out.push('\n');
|
||||
}
|
||||
if idx == 0 && widx == 0 {
|
||||
out.push_str(&format!("{label}: {wline}"));
|
||||
} else {
|
||||
out.push_str(&indent);
|
||||
out.push_str(wline);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn format_mcp_server_display(name: &str, config: &Value) -> String {
|
||||
let Some(obj) = config.as_object() else {
|
||||
return name.to_string();
|
||||
};
|
||||
|
||||
if let Some(url) = obj
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
return format!("{name}: {url}");
|
||||
}
|
||||
|
||||
if let Some(command) = obj
|
||||
.get("command")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
let args = obj
|
||||
.get("args")
|
||||
.and_then(Value::as_array)
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<String>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if args.is_empty() {
|
||||
return format!("{name}: {command}");
|
||||
}
|
||||
|
||||
return format!("{name}: {command} {}", args.join(" "));
|
||||
}
|
||||
|
||||
if let Some(warp_id) = obj
|
||||
.get("warp_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
return format!("{name}: warp_id={warp_id}");
|
||||
}
|
||||
|
||||
name.to_string()
|
||||
}
|
||||
|
||||
fn mcp_server_display_lines(config: &ListedSimpleIntegrationConfig) -> Vec<String> {
|
||||
let json = config.mcp_servers_json.trim();
|
||||
if json.is_empty() || json == "{}" {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let Ok(map) = serde_json::from_str::<Map<String, Value>>(json) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut entries: Vec<(String, Value)> = map.into_iter().collect();
|
||||
entries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|(name, cfg)| format_mcp_server_display(&name, &cfg))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn print_integration_card(integration: &SimpleIntegration) {
|
||||
let mut table = Table::new();
|
||||
table.load_preset(UTF8_FULL);
|
||||
|
||||
// Row 1: provider name (title-cased slug) and description, no label
|
||||
let provider_name =
|
||||
crate::ai::agent_sdk::text_layout::title_case_identifier(&integration.provider_slug);
|
||||
let title_row = crate::ai::agent_sdk::text_layout::render_labeled_wrapped_field(
|
||||
&provider_name,
|
||||
&integration.description,
|
||||
MAX_LINE_WIDTH,
|
||||
);
|
||||
table.add_row(vec![title_row]);
|
||||
|
||||
// Row 2: Status: <emoji> Status description
|
||||
let emoji = status_emoji(integration.connection_status);
|
||||
let explanation = status_explanation(integration.connection_status);
|
||||
let status_text = format!("{emoji} {explanation}");
|
||||
let status_row = crate::ai::agent_sdk::text_layout::render_labeled_wrapped_field(
|
||||
"Status",
|
||||
&status_text,
|
||||
MAX_LINE_WIDTH,
|
||||
);
|
||||
table.add_row(vec![status_row]);
|
||||
|
||||
// Environment row.
|
||||
let env_value = match &integration.integration_config {
|
||||
Some(ListedSimpleIntegrationConfig {
|
||||
environment_uid, ..
|
||||
}) if !environment_uid.is_empty() => environment_uid.clone(),
|
||||
_ => "(none)".to_string(),
|
||||
};
|
||||
let env_row = crate::ai::agent_sdk::text_layout::render_labeled_wrapped_field(
|
||||
"Environment",
|
||||
&env_value,
|
||||
MAX_LINE_WIDTH,
|
||||
);
|
||||
table.add_row(vec![env_row]);
|
||||
|
||||
// Model row (only if present).
|
||||
if let Some(ListedSimpleIntegrationConfig { model_id, .. }) = &integration.integration_config {
|
||||
if !model_id.is_empty() {
|
||||
let model_row = crate::ai::agent_sdk::text_layout::render_labeled_wrapped_field(
|
||||
"Model",
|
||||
model_id,
|
||||
MAX_LINE_WIDTH,
|
||||
);
|
||||
table.add_row(vec![model_row]);
|
||||
}
|
||||
}
|
||||
|
||||
// Base prompt row (only if present).
|
||||
if let Some(ListedSimpleIntegrationConfig { base_prompt, .. }) = &integration.integration_config
|
||||
{
|
||||
if !base_prompt.is_empty() {
|
||||
let base_prompt_row = crate::ai::agent_sdk::text_layout::render_labeled_wrapped_field(
|
||||
"Base prompt",
|
||||
base_prompt,
|
||||
MAX_LINE_WIDTH,
|
||||
);
|
||||
table.add_row(vec![base_prompt_row]);
|
||||
}
|
||||
}
|
||||
|
||||
// MCP servers row (only if present).
|
||||
if let Some(config) = &integration.integration_config {
|
||||
let lines = mcp_server_display_lines(config);
|
||||
if !lines.is_empty() {
|
||||
let row = render_labeled_wrapped_lines("MCP servers", &lines, MAX_LINE_WIDTH);
|
||||
table.add_row(vec![row]);
|
||||
}
|
||||
}
|
||||
|
||||
// Timestamps: keep created/updated in a single row, no label.
|
||||
let mut created_updated = String::new();
|
||||
if let Some(created) = integration.created_at {
|
||||
let dt = created.utc();
|
||||
let formatted = format_approx_duration_from_now_utc(dt);
|
||||
created_updated.push_str(&format!("Created: {formatted}"));
|
||||
}
|
||||
if let Some(updated) = integration.updated_at {
|
||||
let dt = updated.utc();
|
||||
let formatted = format_approx_duration_from_now_utc(dt);
|
||||
if !created_updated.is_empty() {
|
||||
created_updated.push_str(" | ");
|
||||
}
|
||||
created_updated.push_str(&format!("Updated: {formatted}"));
|
||||
}
|
||||
if !created_updated.is_empty() {
|
||||
let wrapped =
|
||||
crate::ai::agent_sdk::text_layout::word_wrap(&created_updated, MAX_LINE_WIDTH);
|
||||
let ts_cell = wrapped.join("\n");
|
||||
table.add_row(vec![ts_cell]);
|
||||
}
|
||||
|
||||
println!("{table}");
|
||||
}
|
||||
|
||||
fn status_emoji(status: SimpleIntegrationConnectionStatus) -> &'static str {
|
||||
match status {
|
||||
SimpleIntegrationConnectionStatus::NotConnected => "❌",
|
||||
// TODO(bens): these warning emojis render weirdly, maybe switch?
|
||||
SimpleIntegrationConnectionStatus::ConnectionError => "⚠️",
|
||||
SimpleIntegrationConnectionStatus::IntegrationNotConfigured => "⚠️",
|
||||
SimpleIntegrationConnectionStatus::NotEnabled => "⚠️",
|
||||
SimpleIntegrationConnectionStatus::Active => "✅",
|
||||
}
|
||||
}
|
||||
|
||||
fn status_explanation(status: SimpleIntegrationConnectionStatus) -> &'static str {
|
||||
match status {
|
||||
SimpleIntegrationConnectionStatus::NotConnected => "This integration is not connected.",
|
||||
SimpleIntegrationConnectionStatus::ConnectionError => {
|
||||
"This provider is connected but there is an error."
|
||||
}
|
||||
SimpleIntegrationConnectionStatus::IntegrationNotConfigured => {
|
||||
"Connection is active, but the agent integration has not been configured yet."
|
||||
}
|
||||
SimpleIntegrationConnectionStatus::NotEnabled => {
|
||||
"Integration is configured but currently disabled."
|
||||
}
|
||||
SimpleIntegrationConnectionStatus::Active => "Integration is connected and enabled.",
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializable integration info for output.
|
||||
#[derive(Serialize)]
|
||||
struct IntegrationInfo {
|
||||
provider: String,
|
||||
description: String,
|
||||
status: String,
|
||||
environment_uid: Option<String>,
|
||||
base_prompt: Option<String>,
|
||||
created_at: Option<DateTime<Utc>>,
|
||||
updated_at: Option<DateTime<Utc>>,
|
||||
#[serde(skip_serializing)]
|
||||
created_at_formatted: String,
|
||||
#[serde(skip_serializing)]
|
||||
updated_at_formatted: String,
|
||||
}
|
||||
|
||||
impl IntegrationInfo {
|
||||
fn from_graphql(integration: &SimpleIntegration) -> Self {
|
||||
let provider =
|
||||
crate::ai::agent_sdk::text_layout::title_case_identifier(&integration.provider_slug);
|
||||
let status = status_explanation(integration.connection_status).to_string();
|
||||
|
||||
let environment_uid = integration.integration_config.as_ref().and_then(|config| {
|
||||
if config.environment_uid.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(config.environment_uid.clone())
|
||||
}
|
||||
});
|
||||
|
||||
let base_prompt = integration.integration_config.as_ref().and_then(|config| {
|
||||
if config.base_prompt.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(config.base_prompt.clone())
|
||||
}
|
||||
});
|
||||
|
||||
let created_at = integration.created_at.map(|t| t.utc());
|
||||
let updated_at = integration.updated_at.map(|t| t.utc());
|
||||
|
||||
let created_at_formatted = created_at
|
||||
.map(format_approx_duration_from_now_utc)
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
|
||||
let updated_at_formatted = updated_at
|
||||
.map(format_approx_duration_from_now_utc)
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
|
||||
Self {
|
||||
provider,
|
||||
description: integration.description.clone(),
|
||||
status,
|
||||
environment_uid,
|
||||
base_prompt,
|
||||
created_at,
|
||||
updated_at,
|
||||
created_at_formatted,
|
||||
updated_at_formatted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TableFormat for IntegrationInfo {
|
||||
fn header() -> Vec<Cell> {
|
||||
vec![
|
||||
Cell::new("Provider"),
|
||||
Cell::new("Description"),
|
||||
Cell::new("Status"),
|
||||
Cell::new("Environment"),
|
||||
Cell::new("Created"),
|
||||
Cell::new("Updated"),
|
||||
]
|
||||
}
|
||||
|
||||
fn row(&self) -> Vec<Cell> {
|
||||
vec![
|
||||
Cell::new(&self.provider),
|
||||
Cell::new(&self.description),
|
||||
Cell::new(&self.status),
|
||||
Cell::new(self.environment_uid.as_deref().unwrap_or("(none)")),
|
||||
Cell::new(&self.created_at_formatted),
|
||||
Cell::new(&self.updated_at_formatted),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
use comfy_table::Cell;
|
||||
use serde::Serialize;
|
||||
use warp_cli::{mcp::MCPCommand, GlobalOptions};
|
||||
use warpui::{AppContext, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::ai::agent_sdk::output::{self, TableFormat};
|
||||
use crate::ai::mcp::TemplatableMCPServerManager;
|
||||
use crate::server::cloud_objects::update_manager::UpdateManager;
|
||||
|
||||
/// Handle MCP-related CLI commands.
|
||||
pub fn run(
|
||||
ctx: &mut AppContext,
|
||||
global_options: GlobalOptions,
|
||||
command: MCPCommand,
|
||||
) -> anyhow::Result<()> {
|
||||
let runner = ctx.add_singleton_model(|_ctx| MCPCommandRunner);
|
||||
match command {
|
||||
MCPCommand::List => {
|
||||
runner.update(ctx, |runner, ctx| runner.list(global_options, ctx));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Singleton model for running async work as part of MCP CLI commands.
|
||||
struct MCPCommandRunner;
|
||||
|
||||
impl MCPCommandRunner {
|
||||
fn list(&self, global_options: GlobalOptions, ctx: &mut ModelContext<Self>) {
|
||||
let initial_sync = UpdateManager::as_ref(ctx).initial_load_complete();
|
||||
|
||||
ctx.spawn(initial_sync, move |_, _, ctx| {
|
||||
let mut servers = TemplatableMCPServerManager::get_all_runnable_mcp_servers(ctx);
|
||||
servers.sort_by_key(|(uuid, _)| *uuid);
|
||||
|
||||
output::print_list(
|
||||
servers
|
||||
.into_iter()
|
||||
.map(|(uuid, name)| MCPServerInfo { uuid, name }),
|
||||
global_options.output_format,
|
||||
);
|
||||
|
||||
ctx.terminate_app(warpui::platform::TerminationMode::ForceTerminate, None);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl warpui::Entity for MCPCommandRunner {
|
||||
type Event = ();
|
||||
}
|
||||
impl SingletonEntity for MCPCommandRunner {}
|
||||
|
||||
/// MCP server information that's shown in the `list` command.
|
||||
#[derive(Serialize)]
|
||||
struct MCPServerInfo {
|
||||
uuid: uuid::Uuid,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl TableFormat for MCPServerInfo {
|
||||
fn header() -> Vec<Cell> {
|
||||
vec![Cell::new("UUID"), Cell::new("Name")]
|
||||
}
|
||||
|
||||
fn row(&self) -> Vec<Cell> {
|
||||
vec![Cell::new(self.uuid), Cell::new(&self.name)]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
use anyhow::Context as _;
|
||||
use serde_json::{Map, Value};
|
||||
use warp_cli::mcp::MCPSpec;
|
||||
|
||||
use crate::ai::mcp::TemplatableMCPServer;
|
||||
|
||||
/// Build the `mcp_servers` map to send to the public ambient-agent API.
|
||||
///
|
||||
/// Returns the unwrapped server map (`{ <server_name>: <server_config>, ... }`).
|
||||
/// If user input includes wrapper shapes like `{ "mcpServers": { ... } }`, we unpack them.
|
||||
///
|
||||
/// Notes:
|
||||
/// - UUID specs are coerced into `{"<uuid>": {"warp_id": "<uuid>"}}`.
|
||||
/// - We do light validation to catch obvious config errors before sending the request.
|
||||
pub(super) fn build_mcp_servers_from_specs(
|
||||
specs: &[MCPSpec],
|
||||
) -> anyhow::Result<Option<Map<String, Value>>> {
|
||||
if specs.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut merged = Map::new();
|
||||
|
||||
for spec in specs {
|
||||
match spec {
|
||||
MCPSpec::Uuid(uuid) => {
|
||||
// TODO: Look up and use the real MCP server name from MCP managers instead of using the UUID.
|
||||
let name = uuid.to_string();
|
||||
insert_unique(
|
||||
&mut merged,
|
||||
name.clone(),
|
||||
Value::Object({
|
||||
let mut obj = Map::new();
|
||||
obj.insert("warp_id".to_string(), Value::String(name));
|
||||
obj
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
MCPSpec::Json(json_str) => {
|
||||
let json_str = normalize_mcp_json_for_single_server(json_str)?;
|
||||
let value = parse_json_with_optional_braces(&json_str)?;
|
||||
|
||||
let server_map = TemplatableMCPServer::find_template_map(value)
|
||||
.context("Failed to parse MCP server map")?;
|
||||
|
||||
for (name, config) in server_map {
|
||||
insert_unique(&mut merged, name, config)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validate_mcp_servers(&merged)?;
|
||||
|
||||
if merged.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(merged))
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_unique(map: &mut Map<String, Value>, name: String, config: Value) -> anyhow::Result<()> {
|
||||
if map.contains_key(&name) {
|
||||
anyhow::bail!("Duplicate MCP server name '{name}' specified multiple times");
|
||||
}
|
||||
|
||||
map.insert(name, config);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_json_with_optional_braces(input: &str) -> anyhow::Result<Value> {
|
||||
// Some docs don't show curly braces around the json object, so add them if necessary.
|
||||
let json = input.trim();
|
||||
let json = if json.starts_with('{') {
|
||||
json.to_owned()
|
||||
} else {
|
||||
format!("{{{json}}}")
|
||||
};
|
||||
|
||||
serde_json::from_str(&json).with_context(|| "Invalid MCP JSON".to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn normalize_mcp_json_for_single_server(input: &str) -> anyhow::Result<String> {
|
||||
crate::ai::mcp::parsing::normalize_mcp_json(input)
|
||||
.map_err(|e| anyhow::anyhow!(e))
|
||||
.context("Failed to normalize MCP JSON")
|
||||
}
|
||||
|
||||
// The CLI + ambient-agent API isn’t used in WASM builds, but this module still needs to compile.
|
||||
// Implement the same normalization behavior (single-server shorthand wrap) locally.
|
||||
#[cfg(target_family = "wasm")]
|
||||
fn normalize_mcp_json_for_single_server(input: &str) -> anyhow::Result<String> {
|
||||
let json = input.trim();
|
||||
let json_for_parsing = if json.starts_with('{') {
|
||||
json.to_owned()
|
||||
} else {
|
||||
format!("{{{json}}}")
|
||||
};
|
||||
|
||||
let value: Value =
|
||||
serde_json::from_str(&json_for_parsing).with_context(|| "Invalid MCP JSON".to_string())?;
|
||||
|
||||
let is_single_server = value.get("command").is_some() || value.get("url").is_some();
|
||||
if is_single_server {
|
||||
let name = uuid::Uuid::new_v4().to_string();
|
||||
let mut map = Map::new();
|
||||
map.insert(name, value);
|
||||
Ok(Value::Object(map).to_string())
|
||||
} else {
|
||||
Ok(input.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn validate_mcp_servers(mcp_servers: &Map<String, Value>) -> anyhow::Result<()> {
|
||||
for (name, config) in mcp_servers {
|
||||
validate_server_config(name, config)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_server_config(server_name: &str, config: &Value) -> anyhow::Result<()> {
|
||||
let obj = config.as_object().ok_or_else(|| {
|
||||
anyhow::anyhow!("MCP server '{server_name}' config must be a JSON object")
|
||||
})?;
|
||||
|
||||
let has_warp_id = obj.contains_key("warp_id");
|
||||
let has_command = obj.contains_key("command");
|
||||
let has_url = obj.contains_key("url");
|
||||
|
||||
let kind_count = usize::from(has_warp_id) + usize::from(has_command) + usize::from(has_url);
|
||||
if kind_count != 1 {
|
||||
anyhow::bail!(
|
||||
"MCP server '{server_name}' must have exactly one of: 'warp_id', 'command', or 'url'"
|
||||
);
|
||||
}
|
||||
|
||||
if has_warp_id {
|
||||
let warp_id = obj.get("warp_id").and_then(Value::as_str).ok_or_else(|| {
|
||||
anyhow::anyhow!("MCP server '{server_name}' field 'warp_id' must be a string")
|
||||
})?;
|
||||
|
||||
uuid::Uuid::parse_str(warp_id).with_context(|| {
|
||||
format!("MCP server '{server_name}' field 'warp_id' must be a UUID")
|
||||
})?;
|
||||
}
|
||||
|
||||
if has_command {
|
||||
let command = obj.get("command").and_then(Value::as_str).ok_or_else(|| {
|
||||
anyhow::anyhow!("MCP server '{server_name}' field 'command' must be a string")
|
||||
})?;
|
||||
|
||||
if command.is_empty() {
|
||||
anyhow::bail!("MCP server '{server_name}' field 'command' must be non-empty");
|
||||
}
|
||||
|
||||
if let Some(args) = obj.get("args") {
|
||||
let args = args.as_array().ok_or_else(|| {
|
||||
anyhow::anyhow!("MCP server '{server_name}' field 'args' must be an array")
|
||||
})?;
|
||||
|
||||
for (idx, arg) in args.iter().enumerate() {
|
||||
if !arg.is_string() {
|
||||
anyhow::bail!(
|
||||
"MCP server '{server_name}' field 'args[{idx}]' must be a string"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if has_url {
|
||||
let url = obj.get("url").and_then(Value::as_str).ok_or_else(|| {
|
||||
anyhow::anyhow!("MCP server '{server_name}' field 'url' must be a string")
|
||||
})?;
|
||||
|
||||
if url.is_empty() {
|
||||
anyhow::bail!("MCP server '{server_name}' field 'url' must be non-empty");
|
||||
}
|
||||
}
|
||||
|
||||
validate_string_map_field(obj, server_name, "env")?;
|
||||
validate_string_map_field(obj, server_name, "headers")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_string_map_field(
|
||||
obj: &Map<String, Value>,
|
||||
server_name: &str,
|
||||
field: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let Some(value) = obj.get(field) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let map = value.as_object().ok_or_else(|| {
|
||||
anyhow::anyhow!("MCP server '{server_name}' field '{field}' must be an object")
|
||||
})?;
|
||||
|
||||
for (key, value) in map {
|
||||
if !value.is_string() {
|
||||
anyhow::bail!("MCP server '{server_name}' field '{field}.{key}' must be a string");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mcp_config_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,300 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
use warp_cli::mcp::MCPSpec;
|
||||
|
||||
use super::build_mcp_servers_from_specs;
|
||||
|
||||
fn build(specs: Vec<MCPSpec>) -> Map<String, Value> {
|
||||
build_mcp_servers_from_specs(&specs)
|
||||
.expect("builder should not error")
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_specs_returns_none() {
|
||||
assert!(build_mcp_servers_from_specs(&[]).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uuid_spec_is_coerced_to_warp_id() {
|
||||
let uuid = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
|
||||
let servers = build(vec![MCPSpec::Uuid(uuid)]);
|
||||
|
||||
let entry = servers.get(&uuid.to_string()).unwrap();
|
||||
assert_eq!(
|
||||
entry["warp_id"].as_str(),
|
||||
Some("550e8400-e29b-41d4-a716-446655440000")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapper_mcp_servers_is_unpacked() {
|
||||
let spec = json!({
|
||||
"mcpServers": {
|
||||
"github": { "command": "npx", "args": ["-y", "server"] }
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let servers = build(vec![MCPSpec::Json(spec)]);
|
||||
|
||||
assert!(servers.contains_key("github"));
|
||||
assert_eq!(servers["github"]["command"].as_str(), Some("npx"));
|
||||
assert!(servers.get("mcpServers").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapper_mcp_servers_snake_case_is_unpacked() {
|
||||
let spec = json!({
|
||||
"mcp_servers": {
|
||||
"s": { "url": "https://example.com/mcp" }
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let servers = build(vec![MCPSpec::Json(spec)]);
|
||||
|
||||
assert!(servers.contains_key("s"));
|
||||
assert_eq!(
|
||||
servers["s"]["url"].as_str(),
|
||||
Some("https://example.com/mcp")
|
||||
);
|
||||
assert!(servers.get("mcp_servers").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapper_servers_is_unpacked() {
|
||||
let spec = json!({
|
||||
"servers": {
|
||||
"s": { "command": "python", "args": ["mcp.py"] }
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let servers = build(vec![MCPSpec::Json(spec)]);
|
||||
|
||||
assert!(servers.contains_key("s"));
|
||||
assert_eq!(servers["s"]["command"].as_str(), Some("python"));
|
||||
assert!(servers.get("servers").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapper_mcp_dot_servers_is_unpacked() {
|
||||
let spec = json!({
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"s": { "url": "https://example.com/mcp" }
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let servers = build(vec![MCPSpec::Json(spec)]);
|
||||
|
||||
assert!(servers.contains_key("s"));
|
||||
assert_eq!(
|
||||
servers["s"]["url"].as_str(),
|
||||
Some("https://example.com/mcp")
|
||||
);
|
||||
assert!(servers.get("mcp").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_map_is_accepted() {
|
||||
let spec = json!({
|
||||
"github": { "command": "npx", "args": [] },
|
||||
"remote": { "url": "https://example.com/mcp" }
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let servers = build(vec![MCPSpec::Json(spec)]);
|
||||
|
||||
assert!(servers.contains_key("github"));
|
||||
assert!(servers.contains_key("remote"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_outer_braces_is_accepted() {
|
||||
// Emulate copying docs that omit the top-level `{}`.
|
||||
let full = json!({
|
||||
"mcpServers": {
|
||||
"s": { "command": "npx", "args": [] }
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let inner = &full[1..full.len() - 1];
|
||||
let spec = format!(" {inner} ");
|
||||
|
||||
let servers = build(vec![MCPSpec::Json(spec)]);
|
||||
|
||||
assert!(servers.contains_key("s"));
|
||||
assert_eq!(servers["s"]["command"].as_str(), Some("npx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_server_shorthand_command_is_wrapped() {
|
||||
let spec = json!({ "command": "npx", "args": [] }).to_string();
|
||||
|
||||
let servers = build(vec![MCPSpec::Json(spec)]);
|
||||
|
||||
assert_eq!(servers.len(), 1);
|
||||
let (_name, config) = servers.iter().next().unwrap();
|
||||
assert_eq!(config["command"].as_str(), Some("npx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_without_args_is_accepted() {
|
||||
// args should be optional for command-based MCP servers
|
||||
let spec = json!({
|
||||
"mcpServers": {
|
||||
"s": { "command": "uvx" }
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let servers = build(vec![MCPSpec::Json(spec)]);
|
||||
|
||||
assert!(servers.contains_key("s"));
|
||||
assert_eq!(servers["s"]["command"].as_str(), Some("uvx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_server_shorthand_url_is_wrapped() {
|
||||
let spec = json!({ "url": "https://example.com/mcp" }).to_string();
|
||||
|
||||
let servers = build(vec![MCPSpec::Json(spec)]);
|
||||
|
||||
assert_eq!(servers.len(), 1);
|
||||
let (_name, config) = servers.iter().next().unwrap();
|
||||
assert_eq!(config["url"].as_str(), Some("https://example.com/mcp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_multiple_specs_and_duplicate_name_errors() {
|
||||
let s1 = json!({ "mcpServers": { "a": { "command": "npx", "args": [] } } }).to_string();
|
||||
let s2 = json!({ "mcpServers": { "b": { "url": "https://example.com/mcp" } } }).to_string();
|
||||
|
||||
let servers = build(vec![MCPSpec::Json(s1.clone()), MCPSpec::Json(s2)]);
|
||||
assert!(servers.contains_key("a"));
|
||||
assert!(servers.contains_key("b"));
|
||||
|
||||
let err =
|
||||
build_mcp_servers_from_specs(&[MCPSpec::Json(s1.clone()), MCPSpec::Json(s1)]).unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("Duplicate MCP server name 'a'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_escaped_strings_in_env_values() {
|
||||
let spec = json!({
|
||||
"mcpServers": {
|
||||
"s": {
|
||||
"command": "npx",
|
||||
"args": [],
|
||||
"env": {
|
||||
"TOKEN": "a\"b\\c\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let servers = build(vec![MCPSpec::Json(spec)]);
|
||||
let token = servers["s"]["env"]["TOKEN"].as_str().unwrap();
|
||||
|
||||
// `serde_json` will decode escapes.
|
||||
assert_eq!(token, "a\"b\\c\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_rejects_invalid_entries() {
|
||||
// Both command and url.
|
||||
let spec = json!({
|
||||
"mcpServers": {
|
||||
"bad": { "command": "npx", "url": "https://example.com" }
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let err = build_mcp_servers_from_specs(&[MCPSpec::Json(spec)]).unwrap_err();
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("must have exactly one of: 'warp_id', 'command', or 'url'"));
|
||||
|
||||
// warp_id must be a UUID string.
|
||||
let spec = json!({ "mcpServers": { "bad": { "warp_id": "not-a-uuid" } } }).to_string();
|
||||
let err = build_mcp_servers_from_specs(&[MCPSpec::Json(spec)]).unwrap_err();
|
||||
assert!(err.to_string().contains("field 'warp_id' must be a UUID"));
|
||||
|
||||
// args must be array.
|
||||
let spec = json!({ "mcpServers": { "bad": { "command": "npx", "args": "nope" } } }).to_string();
|
||||
let err = build_mcp_servers_from_specs(&[MCPSpec::Json(spec)]).unwrap_err();
|
||||
assert!(err.to_string().contains("field 'args' must be an array"));
|
||||
|
||||
// args entries must be strings.
|
||||
let spec = json!({ "mcpServers": { "bad": { "command": "npx", "args": [1] } } }).to_string();
|
||||
let err = build_mcp_servers_from_specs(&[MCPSpec::Json(spec)]).unwrap_err();
|
||||
assert!(err.to_string().contains("args[0]"));
|
||||
|
||||
// env values must be strings.
|
||||
let spec = json!({
|
||||
"mcpServers": { "bad": { "command": "npx", "args": [], "env": { "X": 1 } } }
|
||||
})
|
||||
.to_string();
|
||||
let err = build_mcp_servers_from_specs(&[MCPSpec::Json(spec)]).unwrap_err();
|
||||
assert!(err.to_string().contains("env.X"));
|
||||
|
||||
// headers values must be strings.
|
||||
let spec = json!({
|
||||
"mcpServers": { "bad": { "url": "https://example.com", "headers": { "X": 1 } } }
|
||||
})
|
||||
.to_string();
|
||||
let err = build_mcp_servers_from_specs(&[MCPSpec::Json(spec)]).unwrap_err();
|
||||
assert!(err.to_string().contains("headers.X"));
|
||||
|
||||
// server config must be an object.
|
||||
let spec = json!({ "mcpServers": { "bad": 1 } }).to_string();
|
||||
let err = build_mcp_servers_from_specs(&[MCPSpec::Json(spec)]).unwrap_err();
|
||||
assert!(err.to_string().contains("config must be a JSON object"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_mcp_servers_as_object_not_string() {
|
||||
use crate::ai::ambient_agents::AgentConfigSnapshot;
|
||||
use crate::server::server_api::ai::SpawnAgentRequest;
|
||||
|
||||
let uuid = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
|
||||
let mcp_servers = build_mcp_servers_from_specs(&[MCPSpec::Uuid(uuid)])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let request = SpawnAgentRequest {
|
||||
prompt: "hello".to_string(),
|
||||
config: Some(AgentConfigSnapshot {
|
||||
mcp_servers: Some(mcp_servers),
|
||||
..Default::default()
|
||||
}),
|
||||
title: None,
|
||||
team: None,
|
||||
skill: None,
|
||||
attachments: vec![],
|
||||
interactive: None,
|
||||
parent_run_id: None,
|
||||
runtime_skills: vec![],
|
||||
referenced_attachments: vec![],
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&request).unwrap();
|
||||
|
||||
let config = value.get("config").unwrap();
|
||||
let mcp_servers = config.get("mcp_servers").unwrap();
|
||||
|
||||
assert!(mcp_servers.is_object());
|
||||
assert!(mcp_servers.get("mcpServers").is_none());
|
||||
|
||||
let server = mcp_servers.get(uuid.to_string()).unwrap();
|
||||
assert_eq!(
|
||||
server.get("warp_id").unwrap(),
|
||||
&Value::String(uuid.to_string())
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,143 @@
|
||||
use serde_json::json;
|
||||
use warp_cli::{
|
||||
artifact::{ArtifactCommand, DownloadArtifactArgs, GetArtifactArgs, UploadArtifactArgs},
|
||||
task::{MessageCommand, MessageSendArgs, MessageWatchArgs, TaskCommand},
|
||||
CliCommand,
|
||||
};
|
||||
use warp_core::telemetry::TelemetryEvent;
|
||||
|
||||
use super::{command_requires_auth, command_to_telemetry_event};
|
||||
|
||||
#[test]
|
||||
fn logout_does_not_require_auth() {
|
||||
assert!(!command_requires_auth(&CliCommand::Logout));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_does_not_require_auth() {
|
||||
assert!(!command_requires_auth(&CliCommand::Login));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artifact_download_requires_auth() {
|
||||
assert!(command_requires_auth(&CliCommand::Artifact(
|
||||
ArtifactCommand::Download(DownloadArtifactArgs {
|
||||
artifact_uid: "artifact-123".to_string(),
|
||||
out: None,
|
||||
},)
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_message_send_requires_auth() {
|
||||
assert!(command_requires_auth(&CliCommand::Run(
|
||||
TaskCommand::Message(MessageCommand::Send(MessageSendArgs {
|
||||
to: vec!["run-456".to_string()],
|
||||
subject: "subject".to_string(),
|
||||
body: "body".to_string(),
|
||||
sender_run_id: "run-123".to_string(),
|
||||
}),)
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artifact_get_requires_auth() {
|
||||
assert!(command_requires_auth(&CliCommand::Artifact(
|
||||
ArtifactCommand::Get(GetArtifactArgs {
|
||||
artifact_uid: "artifact-123".to_string(),
|
||||
},)
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artifact_upload_requires_auth() {
|
||||
assert!(command_requires_auth(&CliCommand::Artifact(
|
||||
ArtifactCommand::Upload(UploadArtifactArgs {
|
||||
path: "artifact.txt".into(),
|
||||
run_id: Some("run-123".to_string()),
|
||||
conversation_id: None,
|
||||
description: None,
|
||||
},)
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn run_message_send_telemetry_uses_canonical_harness_from_env() {
|
||||
std::env::set_var("OZ_HARNESS", " CLAUDE ");
|
||||
let event = command_to_telemetry_event(&CliCommand::Run(TaskCommand::Message(
|
||||
MessageCommand::Send(MessageSendArgs {
|
||||
to: vec!["run-456".to_string()],
|
||||
subject: "subject".to_string(),
|
||||
body: "body".to_string(),
|
||||
sender_run_id: "run-123".to_string(),
|
||||
}),
|
||||
)));
|
||||
std::env::remove_var("OZ_HARNESS");
|
||||
|
||||
assert_eq!(event.payload(), Some(json!({ "harness": "claude" })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn run_message_send_telemetry_supports_claude_code_alias() {
|
||||
std::env::set_var("OZ_HARNESS", "CLAUDE_CODE");
|
||||
let event = command_to_telemetry_event(&CliCommand::Run(TaskCommand::Message(
|
||||
MessageCommand::Send(MessageSendArgs {
|
||||
to: vec!["run-456".to_string()],
|
||||
subject: "subject".to_string(),
|
||||
body: "body".to_string(),
|
||||
sender_run_id: "run-123".to_string(),
|
||||
}),
|
||||
)));
|
||||
std::env::remove_var("OZ_HARNESS");
|
||||
|
||||
assert_eq!(event.payload(), Some(json!({ "harness": "claude" })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn run_message_send_telemetry_supports_opencode_harness() {
|
||||
std::env::set_var("OZ_HARNESS", "opencode");
|
||||
let event = command_to_telemetry_event(&CliCommand::Run(TaskCommand::Message(
|
||||
MessageCommand::Send(MessageSendArgs {
|
||||
to: vec!["run-456".to_string()],
|
||||
subject: "subject".to_string(),
|
||||
body: "body".to_string(),
|
||||
sender_run_id: "run-123".to_string(),
|
||||
}),
|
||||
)));
|
||||
std::env::remove_var("OZ_HARNESS");
|
||||
|
||||
assert_eq!(event.payload(), Some(json!({ "harness": "opencode" })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn run_message_send_telemetry_defaults_to_unknown_harness() {
|
||||
std::env::remove_var("OZ_HARNESS");
|
||||
let event = command_to_telemetry_event(&CliCommand::Run(TaskCommand::Message(
|
||||
MessageCommand::Send(MessageSendArgs {
|
||||
to: vec!["run-456".to_string()],
|
||||
subject: "subject".to_string(),
|
||||
body: "body".to_string(),
|
||||
sender_run_id: "run-123".to_string(),
|
||||
}),
|
||||
)));
|
||||
|
||||
assert_eq!(event.payload(), Some(json!({ "harness": "unknown" })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn run_message_watch_telemetry_defaults_to_unknown_harness() {
|
||||
std::env::remove_var("OZ_HARNESS");
|
||||
let event = command_to_telemetry_event(&CliCommand::Run(TaskCommand::Message(
|
||||
MessageCommand::Watch(MessageWatchArgs {
|
||||
run_id: "run-123".to_string(),
|
||||
since_sequence: 0,
|
||||
}),
|
||||
)));
|
||||
|
||||
assert_eq!(event.payload(), Some(json!({ "harness": "unknown" })));
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::ai::agent_sdk::output::{self, TableFormat};
|
||||
use crate::ai::llms::LLMPreferences;
|
||||
use comfy_table::Cell;
|
||||
use serde::Serialize;
|
||||
use warp_cli::{model::ModelCommand, GlobalOptions};
|
||||
use warpui::{platform::TerminationMode, AppContext, ModelContext, SingletonEntity};
|
||||
|
||||
/// Handle model-related CLI commands.
|
||||
pub fn run(
|
||||
ctx: &mut AppContext,
|
||||
global_options: GlobalOptions,
|
||||
command: ModelCommand,
|
||||
) -> anyhow::Result<()> {
|
||||
let runner = ctx.add_singleton_model(|_ctx| ModelCommandRunner);
|
||||
match command {
|
||||
ModelCommand::List => {
|
||||
runner.update(ctx, |runner, ctx| runner.list(global_options, ctx));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Singleton model for running async work as part of model CLI commands.
|
||||
struct ModelCommandRunner;
|
||||
|
||||
impl ModelCommandRunner {
|
||||
fn list(&self, global_options: GlobalOptions, ctx: &mut ModelContext<Self>) {
|
||||
let output_format = global_options.output_format;
|
||||
|
||||
// Ensure workspace metadata is refreshed so LLM preferences are up-to-date.
|
||||
let refresh_future = super::common::refresh_workspace_metadata(ctx);
|
||||
|
||||
ctx.spawn(refresh_future, move |_, refresh_result, ctx| {
|
||||
if refresh_result.is_err() {
|
||||
super::report_fatal_error(
|
||||
anyhow::anyhow!("Timed out refreshing workspace metadata"),
|
||||
ctx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let llm_prefs = LLMPreferences::as_ref(ctx);
|
||||
let mut ids = BTreeSet::new();
|
||||
for info in llm_prefs.get_base_llm_choices_for_agent_mode() {
|
||||
ids.insert(info.id.to_string());
|
||||
}
|
||||
|
||||
let items = ids
|
||||
.into_iter()
|
||||
.map(|id| ModelListItem { id })
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
output::print_list(items, output_format);
|
||||
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl warpui::Entity for ModelCommandRunner {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for ModelCommandRunner {}
|
||||
|
||||
/// Model information that's shown in the `list` command.
|
||||
#[derive(Serialize)]
|
||||
struct ModelListItem {
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl TableFormat for ModelListItem {
|
||||
fn header() -> Vec<Cell> {
|
||||
vec![Cell::new("MODEL ID")]
|
||||
}
|
||||
|
||||
fn row(&self) -> Vec<Cell> {
|
||||
vec![Cell::new(&self.id)]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use warpui::r#async::Timer;
|
||||
|
||||
use crate::server::server_api::integrations::IntegrationsClient;
|
||||
use warp_graphql::queries::get_oauth_connect_tx_status::OauthConnectTxStatus;
|
||||
|
||||
/// Shared helpers for OAuth-based connect flows (txId + polling).
|
||||
///
|
||||
/// This is used by the integration CLI create flow, and can be reused by
|
||||
/// other command flows that rely on the same OAuth connect transaction API.
|
||||
pub async fn poll_oauth_until_terminal(
|
||||
integrations_client: Arc<dyn IntegrationsClient>,
|
||||
tx_id: String,
|
||||
) -> Result<OauthConnectTxStatus> {
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(5);
|
||||
const MAX_ATTEMPTS: u32 = 120; // 10 minutes total
|
||||
// TODO(bens): render some kind of spinner here
|
||||
println!(
|
||||
"Waiting for authorization to complete... If this doesn't update after authorizing, please restart the command and try again.\n"
|
||||
);
|
||||
|
||||
for attempt in 1..=MAX_ATTEMPTS {
|
||||
Timer::after(POLL_INTERVAL).await;
|
||||
|
||||
let status = integrations_client
|
||||
.poll_oauth_connect_status(tx_id.clone())
|
||||
.await?;
|
||||
|
||||
match status {
|
||||
OauthConnectTxStatus::Completed
|
||||
| OauthConnectTxStatus::Failed
|
||||
| OauthConnectTxStatus::Expired => {
|
||||
return Ok(status);
|
||||
}
|
||||
OauthConnectTxStatus::Pending | OauthConnectTxStatus::InProgress => {
|
||||
if attempt % 5 == 0 {
|
||||
log::debug!("Still waiting for authorization... ({attempt}/{MAX_ATTEMPTS})",);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("Timed out waiting for OAuth authorization"))
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
use std::io::Write as _;
|
||||
|
||||
use anyhow::Context;
|
||||
use comfy_table::modifiers::UTF8_ROUND_CORNERS;
|
||||
use comfy_table::presets::UTF8_FULL;
|
||||
use comfy_table::{Cell, ContentArrangement, Table};
|
||||
use jaq_all::data::Runner;
|
||||
use jaq_all::fmts::write::Writer;
|
||||
use jaq_all::fmts::Format;
|
||||
// Use jaq_json directly to ensure serde support is included.
|
||||
use jaq_json::{write as jaq_write, Val};
|
||||
use serde::Serialize;
|
||||
use tabwriter::TabWriter;
|
||||
use warp_cli::agent::OutputFormat;
|
||||
use warp_cli::json_filter::{JqFilter, JsonOutput};
|
||||
|
||||
pub fn standard_table() -> Table {
|
||||
let mut table = Table::new();
|
||||
table
|
||||
.load_preset(UTF8_FULL)
|
||||
.apply_modifier(UTF8_ROUND_CORNERS)
|
||||
.set_content_arrangement(ContentArrangement::Dynamic);
|
||||
table
|
||||
}
|
||||
|
||||
/// Trait for types that can be printed as a table.
|
||||
pub trait TableFormat {
|
||||
fn header() -> Vec<Cell>;
|
||||
|
||||
fn row(&self) -> Vec<Cell>;
|
||||
}
|
||||
|
||||
/// Print a list of items to stdout, respecting the `output_format`.
|
||||
pub fn print_list<I, T>(items: I, output_format: OutputFormat)
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: TableFormat + Serialize,
|
||||
{
|
||||
if let Err(err) = write_list(items, output_format, &mut std::io::stdout()) {
|
||||
// If we can't write to stdout, try reporting to the log file.
|
||||
log::warn!("Unable to write to stdout: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a serializable value to `output` as pretty JSON.
|
||||
pub fn write_json<T, W>(value: &T, mut output: W) -> anyhow::Result<()>
|
||||
where
|
||||
T: Serialize,
|
||||
W: std::io::Write,
|
||||
{
|
||||
serde_json::to_writer_pretty(&mut output, value).context("unable to write JSON output")?;
|
||||
writeln!(&mut output)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a serializable value to `output` as a single-line JSON record.
|
||||
pub fn write_json_line<T, W>(value: &T, mut output: W) -> anyhow::Result<()>
|
||||
where
|
||||
T: Serialize,
|
||||
W: std::io::Write,
|
||||
{
|
||||
serde_json::to_writer(&mut output, value).context("unable to write JSON output")?;
|
||||
writeln!(&mut output)?;
|
||||
Ok(())
|
||||
}
|
||||
/// RAII guard that locks stdout and, on Unix, temporarily clears `O_NONBLOCK`
|
||||
/// so writes block instead of returning `EAGAIN`. Restores the original flags
|
||||
/// and releases the stdout lock on drop.
|
||||
///
|
||||
/// It's possible that stdout has `O_NONBLOCK` set, especially if it's a PTY.
|
||||
/// If we're writing a large payload faster than it can be read, we can fill
|
||||
/// up the PTY buffer, and get an `EAGAIN` error instead of blocking until the
|
||||
/// reader consumes the data.
|
||||
///
|
||||
/// Rather than reimplement backoff based on `EAGAIN` ourselves, we can ensure
|
||||
/// that writes will block for the lifetime of the guard.
|
||||
struct StdoutBlockingGuard {
|
||||
/// Locked stdout handle. Declared first so that on drop, the `O_NONBLOCK`
|
||||
/// flag is restored (via `Drop for StdoutBlockingGuard`) before the lock
|
||||
/// is released.
|
||||
lock: std::io::StdoutLock<'static>,
|
||||
/// The original stdout flags to restore on drop, or `None` if stdout was
|
||||
/// already blocking (or we could not read/modify the flags).
|
||||
#[cfg(unix)]
|
||||
original_flags: Option<libc::c_int>,
|
||||
}
|
||||
|
||||
impl StdoutBlockingGuard {
|
||||
fn new() -> Self {
|
||||
let lock = std::io::stdout().lock();
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use libc::{fcntl, F_GETFL, F_SETFL, O_NONBLOCK, STDOUT_FILENO};
|
||||
// SAFETY: `fcntl` with `F_GETFL` / `F_SETFL` only reads and writes
|
||||
// the file status flags on stdout; it does not mutate Rust memory.
|
||||
let original_flags = unsafe {
|
||||
let flags = fcntl(STDOUT_FILENO, F_GETFL, 0);
|
||||
if flags < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
log::warn!("Unable to read stdout flags: {err}");
|
||||
None
|
||||
} else if flags & O_NONBLOCK == 0 {
|
||||
// Already blocking; nothing to change and nothing to restore.
|
||||
None
|
||||
} else if fcntl(STDOUT_FILENO, F_SETFL, flags & !O_NONBLOCK) < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
log::warn!("Unable to clear O_NONBLOCK on stdout: {err}");
|
||||
None
|
||||
} else {
|
||||
Some(flags)
|
||||
}
|
||||
};
|
||||
Self {
|
||||
lock,
|
||||
original_flags,
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
Self { lock }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::io::Write for StdoutBlockingGuard {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.lock.write(buf)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
self.lock.flush()
|
||||
}
|
||||
|
||||
fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> {
|
||||
self.lock.write_all(buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StdoutBlockingGuard {
|
||||
fn drop(&mut self) {
|
||||
#[cfg(unix)]
|
||||
if let Some(flags) = self.original_flags {
|
||||
use libc::{fcntl, F_SETFL, STDOUT_FILENO};
|
||||
// SAFETY: `fcntl` with `F_SETFL` only updates the file status
|
||||
// flags on stdout; it does not mutate Rust memory.
|
||||
unsafe {
|
||||
if fcntl(STDOUT_FILENO, F_SETFL, flags) < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
log::warn!("Unable to restore stdout flags: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a raw `serde_json::Value` to stdout as pretty-printed JSON.
|
||||
///
|
||||
/// Useful for CLI commands that want to pass through server responses verbatim
|
||||
/// when `--output-format json` is set, rather than re-serializing through a
|
||||
/// typed model.
|
||||
///
|
||||
/// When `json_output.filter` is `Some`, the JSON value is instead passed
|
||||
/// through the given filter, each output of which is printed to stdout.
|
||||
pub fn print_raw_json(value: serde_json::Value, json_output: &JsonOutput) -> anyhow::Result<()> {
|
||||
// Ensure that writes to stdout are blocking, to enforce backpressure as we
|
||||
// potentially write a large amount of data. Otherwise, writes may fail with
|
||||
// `EAGAIN`, which we can't easily handle while serializing JSON. The guard
|
||||
// also locks stdout for the duration of the write, and restores the prior
|
||||
// blocking mode when it goes out of scope.
|
||||
//
|
||||
// Stream directly to stdout so we don't buffer a large `String`
|
||||
// unnecessarily, and so we can return I/O errors rather than potentially
|
||||
// panicking (as `println!` would).
|
||||
let mut out = StdoutBlockingGuard::new();
|
||||
match json_output.filter.as_ref() {
|
||||
None => {
|
||||
serde_json::to_writer_pretty(&mut out, &value)
|
||||
.context("unable to write JSON output")?;
|
||||
writeln!(&mut out)?;
|
||||
}
|
||||
Some(filter) => run_jq_filter(value, filter, &mut out)?,
|
||||
}
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run `jq_filter` on `value` and write each output to `out` on its own line.
|
||||
///
|
||||
/// Top-level scalar outputs are written as raw text (see [`write_filter_output`]).
|
||||
/// Runtime errors from the filter are returned as `anyhow::Error`; any outputs
|
||||
/// produced before the error are still written to `out`, matching jq's behavior.
|
||||
fn run_jq_filter<W: std::io::Write>(
|
||||
value: serde_json::Value,
|
||||
jq_filter: &JqFilter,
|
||||
out: &mut W,
|
||||
) -> anyhow::Result<()> {
|
||||
let input_result = serde_json::from_value::<Val>(value);
|
||||
|
||||
let runner = Runner {
|
||||
null_input: false,
|
||||
color_err: false,
|
||||
writer: Writer {
|
||||
format: Format::Json,
|
||||
pp: pretty_pp(),
|
||||
join: false,
|
||||
},
|
||||
};
|
||||
|
||||
jaq_all::data::run(
|
||||
&runner,
|
||||
jq_filter,
|
||||
Default::default(),
|
||||
[input_result].into_iter(),
|
||||
// Callback to format invalid input errors.
|
||||
|err| anyhow::anyhow!("Invalid data: {err}"),
|
||||
// Callback to handle filter outputs.
|
||||
|result| match result {
|
||||
Ok(val) => write_filter_output(&val, out),
|
||||
Err(err) => anyhow::bail!("jq filter error: {err}"),
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pretty-printer configuration used for non-scalar filter output. Matches
|
||||
/// `serde_json`'s pretty printer: two-space indent, space after `:`, no
|
||||
/// trailing space after `,` (since commas sit at end-of-line).
|
||||
fn pretty_pp() -> jaq_write::Pp {
|
||||
jaq_write::Pp {
|
||||
indent: Some(" ".to_string()),
|
||||
sep_space: true,
|
||||
..jaq_write::Pp::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a single filter output, unwrapping top-level scalars to raw text.
|
||||
///
|
||||
/// - `Null` -> `null`
|
||||
/// - `Bool` -> `true` / `false`
|
||||
/// - `Num` -> its decimal form
|
||||
/// - `TStr` / `BStr` -> the unescaped string content (no surrounding quotes)
|
||||
/// - `Arr` / `Obj` -> pretty-printed JSON via `jaq_json::write`, with the same
|
||||
/// formatting conventions as the non-filtered `--output-format json` path.
|
||||
///
|
||||
/// Every output is followed by a newline.
|
||||
fn write_filter_output<W: std::io::Write>(val: &Val, out: &mut W) -> anyhow::Result<()> {
|
||||
match val {
|
||||
Val::Null => writeln!(out, "null")?,
|
||||
Val::Bool(b) => writeln!(out, "{b}")?,
|
||||
Val::Num(n) => writeln!(out, "{n}")?,
|
||||
Val::TStr(bytes) | Val::BStr(bytes) => {
|
||||
out.write_all(bytes)?;
|
||||
writeln!(out)?;
|
||||
}
|
||||
Val::Arr(_) | Val::Obj(_) => {
|
||||
jaq_write::write(&mut *out, &pretty_pp(), 0, val)
|
||||
.context("unable to write jq output as JSON")?;
|
||||
writeln!(out)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a list of items to `output`, respecting the `output_format`.
|
||||
pub fn write_list<I, T, W>(
|
||||
items: I,
|
||||
output_format: OutputFormat,
|
||||
mut output: W,
|
||||
) -> anyhow::Result<()>
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: TableFormat + Serialize,
|
||||
W: std::io::Write,
|
||||
{
|
||||
match output_format {
|
||||
OutputFormat::Json => {
|
||||
let items = items.into_iter().collect::<Vec<_>>();
|
||||
serde_json::to_writer(&mut output, &items).context("unable to write JSON output")
|
||||
}
|
||||
OutputFormat::Ndjson => {
|
||||
for item in items {
|
||||
write_json_line(&item, &mut output)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
OutputFormat::Pretty => {
|
||||
// Use comfy-table to print a table with terminal formatting.
|
||||
let mut table = standard_table();
|
||||
table.set_header(T::header());
|
||||
for item in items {
|
||||
table.add_row(T::row(&item));
|
||||
}
|
||||
writeln!(&mut output, "{table}")?;
|
||||
Ok(())
|
||||
}
|
||||
OutputFormat::Text => {
|
||||
// Print a plain-text table.
|
||||
let mut tw = TabWriter::new(output);
|
||||
|
||||
for (idx, column) in T::header().iter().enumerate() {
|
||||
if idx > 0 {
|
||||
write!(&mut tw, "\t")?;
|
||||
}
|
||||
write!(&mut tw, "{}", column.content())?;
|
||||
}
|
||||
writeln!(&mut tw)?;
|
||||
|
||||
for item in items {
|
||||
for (idx, column) in T::row(&item).iter().enumerate() {
|
||||
if idx > 0 {
|
||||
write!(&mut tw, "\t")?;
|
||||
}
|
||||
write!(&mut tw, "{}", column.content())?;
|
||||
}
|
||||
writeln!(&mut tw)?;
|
||||
}
|
||||
tw.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "output_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,230 @@
|
||||
use super::{
|
||||
run_jq_filter, write_filter_output, write_json, write_json_line, write_list, TableFormat,
|
||||
};
|
||||
use comfy_table::Cell;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use warp_cli::agent::OutputFormat;
|
||||
use warp_cli::json_filter::parse_jq_filter;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TestItem {
|
||||
id: &'static str,
|
||||
subject: &'static str,
|
||||
}
|
||||
|
||||
impl TableFormat for TestItem {
|
||||
fn header() -> Vec<Cell> {
|
||||
vec![Cell::new("ID"), Cell::new("SUBJECT")]
|
||||
}
|
||||
|
||||
fn row(&self) -> Vec<Cell> {
|
||||
vec![Cell::new(self.id), Cell::new(self.subject)]
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_list_emits_json_for_json_output_format() {
|
||||
let mut output = Vec::new();
|
||||
let items = [TestItem {
|
||||
id: "message-1",
|
||||
subject: "Build update",
|
||||
}];
|
||||
|
||||
write_list(items, OutputFormat::Json, &mut output).unwrap();
|
||||
|
||||
let rendered = String::from_utf8(output).unwrap();
|
||||
assert_eq!(rendered, r#"[{"id":"message-1","subject":"Build update"}]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_list_emits_ndjson_for_ndjson_output_format() {
|
||||
let mut output = Vec::new();
|
||||
let items = [
|
||||
TestItem {
|
||||
id: "message-1",
|
||||
subject: "Build update",
|
||||
},
|
||||
TestItem {
|
||||
id: "message-2",
|
||||
subject: "Pivot",
|
||||
},
|
||||
];
|
||||
|
||||
write_list(items, OutputFormat::Ndjson, &mut output).unwrap();
|
||||
|
||||
let rendered = String::from_utf8(output).unwrap();
|
||||
assert_eq!(
|
||||
rendered,
|
||||
"{\"id\":\"message-1\",\"subject\":\"Build update\"}\n{\"id\":\"message-2\",\"subject\":\"Pivot\"}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_json_emits_pretty_json_with_trailing_newline() {
|
||||
let mut output = Vec::new();
|
||||
let item = TestItem {
|
||||
id: "message-1",
|
||||
subject: "Build update",
|
||||
};
|
||||
|
||||
write_json(&item, &mut output).unwrap();
|
||||
|
||||
let rendered = String::from_utf8(output).unwrap();
|
||||
assert_eq!(
|
||||
rendered,
|
||||
"{\n \"id\": \"message-1\",\n \"subject\": \"Build update\"\n}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_json_line_emits_compact_json_with_trailing_newline() {
|
||||
let mut output = Vec::new();
|
||||
let item = TestItem {
|
||||
id: "message-1",
|
||||
subject: "Build update",
|
||||
};
|
||||
|
||||
write_json_line(&item, &mut output).unwrap();
|
||||
|
||||
let rendered = String::from_utf8(output).unwrap();
|
||||
assert_eq!(
|
||||
rendered,
|
||||
"{\"id\":\"message-1\",\"subject\":\"Build update\"}\n"
|
||||
);
|
||||
}
|
||||
/// A small fixture that matches the shape of `GET /api/v1/agent/runs`.
|
||||
fn list_response_fixture() -> serde_json::Value {
|
||||
json!({
|
||||
"runs": [
|
||||
{ "task_id": "01HX0000000000000000000001", "title": "Alpha", "state": "succeeded" },
|
||||
{ "task_id": "01HX0000000000000000000002", "title": "Beta", "state": "failed" },
|
||||
],
|
||||
"page_info": { "has_next_page": false, "next_cursor": null }
|
||||
})
|
||||
}
|
||||
|
||||
/// Run a filter against `value` and return its output as a UTF-8 string.
|
||||
fn run(value: serde_json::Value, filter: &str) -> String {
|
||||
let filter = parse_jq_filter(filter).expect("filter compiles");
|
||||
let mut buf = Vec::new();
|
||||
run_jq_filter(value, &filter, &mut buf).expect("filter runs without error");
|
||||
String::from_utf8(buf).expect("output is valid utf-8")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_filter_matches_non_filtered_json_output() {
|
||||
let fixture = list_response_fixture();
|
||||
let filtered = run(fixture.clone(), ".");
|
||||
|
||||
let mut expected = Vec::new();
|
||||
serde_json::to_writer_pretty(&mut expected, &fixture).unwrap();
|
||||
expected.push(b'\n');
|
||||
|
||||
assert_eq!(filtered.as_bytes(), expected.as_slice());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_string_is_unwrapped() {
|
||||
let fixture = list_response_fixture();
|
||||
let out = run(fixture, ".runs[0].task_id");
|
||||
assert_eq!(out, "01HX0000000000000000000001\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_number_is_unwrapped() {
|
||||
let fixture = list_response_fixture();
|
||||
let out = run(fixture, ".runs | length");
|
||||
assert_eq!(out, "2\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_bool_and_null_are_unwrapped() {
|
||||
let fixture = list_response_fixture();
|
||||
assert_eq!(run(fixture.clone(), ".page_info.has_next_page"), "false\n");
|
||||
assert_eq!(run(fixture.clone(), ".page_info.next_cursor"), "null\n");
|
||||
assert_eq!(run(fixture, "true"), "true\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_scalar_outputs_each_on_own_line() {
|
||||
let fixture = list_response_fixture();
|
||||
let out = run(fixture, ".runs[].task_id");
|
||||
assert_eq!(
|
||||
out,
|
||||
"01HX0000000000000000000001\n01HX0000000000000000000002\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_scalar_output_is_pretty_json() {
|
||||
let fixture = list_response_fixture();
|
||||
let out = run(fixture, ".runs[0]");
|
||||
let expected = serde_json::to_string_pretty(&json!({
|
||||
"task_id": "01HX0000000000000000000001",
|
||||
"title": "Alpha",
|
||||
"state": "succeeded",
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(out, format!("{expected}\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inner_scalars_stay_json_encoded() {
|
||||
let fixture = list_response_fixture();
|
||||
let out = run(fixture, ".runs");
|
||||
assert!(
|
||||
out.contains(r#""title": "Alpha""#),
|
||||
"inner strings should remain JSON-encoded, got:\n{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains(r#""task_id": "01HX0000000000000000000001""#),
|
||||
"inner task_id should remain JSON-encoded, got:\n{out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_filter_produces_no_output() {
|
||||
let fixture = list_response_fixture();
|
||||
let out = run(fixture, "empty");
|
||||
assert_eq!(out, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_error_is_surfaced_after_partial_output() {
|
||||
// `.runs[].title | .[0]` succeeds on the first string but fails on a
|
||||
// later element if we introduce a non-indexable value. Build a fixture
|
||||
// where the second element has a numeric title, which triggers a runtime
|
||||
// error when we try to index it as a string.
|
||||
let fixture = json!({
|
||||
"runs": [
|
||||
{ "title": "hello" },
|
||||
{ "title": 42 },
|
||||
]
|
||||
});
|
||||
let filter = parse_jq_filter(".runs[].title | .[0:1]").expect("filter compiles");
|
||||
let mut buf = Vec::new();
|
||||
let result = run_jq_filter(fixture, &filter, &mut buf);
|
||||
|
||||
// The filter should fail at runtime on the integer title.
|
||||
assert!(result.is_err(), "expected runtime error");
|
||||
// The valid output from the first element should already be on the buffer.
|
||||
let rendered = String::from_utf8(buf).unwrap();
|
||||
assert!(
|
||||
rendered.starts_with("h\n"),
|
||||
"expected partial output before the error, got: {rendered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_filter_output_respects_scalar_unwrapping_for_direct_vals() {
|
||||
use jaq_json::Val;
|
||||
|
||||
// Exercise `write_filter_output` directly with jaq values to lock in the
|
||||
// expected rendering of each scalar variant.
|
||||
let mut buf = Vec::new();
|
||||
write_filter_output(&Val::Null, &mut buf).unwrap();
|
||||
write_filter_output(&Val::Bool(true), &mut buf).unwrap();
|
||||
write_filter_output(&Val::Bool(false), &mut buf).unwrap();
|
||||
assert_eq!(String::from_utf8(buf).unwrap(), "null\ntrue\nfalse\n");
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use comfy_table::Cell;
|
||||
use serde::Serialize;
|
||||
use warp_cli::{agent::AgentProfileCommand, GlobalOptions};
|
||||
use warpui::{AppContext, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::ai::agent_sdk::output::{self, TableFormat};
|
||||
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
|
||||
use crate::cloud_object::model::generic_string_model::StringModel;
|
||||
use crate::server::cloud_objects::update_manager::UpdateManager;
|
||||
use crate::server::ids::SyncId;
|
||||
|
||||
/// Handle Agent Profile-related CLI commands.
|
||||
pub fn run(
|
||||
ctx: &mut AppContext,
|
||||
global_options: GlobalOptions,
|
||||
command: AgentProfileCommand,
|
||||
) -> anyhow::Result<()> {
|
||||
let runner = ctx.add_singleton_model(|_ctx| ProfilesCommandRunner);
|
||||
match command {
|
||||
AgentProfileCommand::List => {
|
||||
runner.update(ctx, |runner, ctx| runner.list(global_options, ctx));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Singleton model that runs async work for profile CLI commands.
|
||||
struct ProfilesCommandRunner;
|
||||
|
||||
impl ProfilesCommandRunner {
|
||||
fn list(&self, global_options: GlobalOptions, ctx: &mut ModelContext<Self>) {
|
||||
// Ensure initial cloud sync completes so profiles from the server are available.
|
||||
let initial_sync = UpdateManager::as_ref(ctx).initial_load_complete();
|
||||
|
||||
ctx.spawn(initial_sync, move |_, _, ctx| {
|
||||
let profiles_model = AIExecutionProfilesModel::as_ref(ctx);
|
||||
|
||||
let profile_ids = profiles_model.get_all_profile_ids();
|
||||
|
||||
let profiles: Vec<_> = profile_ids
|
||||
.iter()
|
||||
.flat_map(|id| profiles_model.get_profile_by_id(*id, ctx))
|
||||
.map(|profile| {
|
||||
let name = profile.data().display_name().to_string();
|
||||
let id = match profile.sync_id() {
|
||||
Some(SyncId::ServerId(server_id)) => server_id.to_string(),
|
||||
_ => "Unsynced".to_string(),
|
||||
};
|
||||
ProfileInfo { id, name }
|
||||
})
|
||||
.collect();
|
||||
|
||||
output::print_list(profiles, global_options.output_format);
|
||||
|
||||
ctx.terminate_app(warpui::platform::TerminationMode::ForceTerminate, None);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl warpui::Entity for ProfilesCommandRunner {
|
||||
type Event = ();
|
||||
}
|
||||
impl SingletonEntity for ProfilesCommandRunner {}
|
||||
|
||||
/// Profile information that's shown in the `list` command.
|
||||
#[derive(Serialize)]
|
||||
struct ProfileInfo {
|
||||
id: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl TableFormat for ProfileInfo {
|
||||
fn header() -> Vec<Cell> {
|
||||
vec![Cell::new("ID"), Cell::new("Name")]
|
||||
}
|
||||
|
||||
fn row(&self) -> Vec<Cell> {
|
||||
vec![Cell::new(&self.id), Cell::new(&self.name)]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Provider command for linking third-party services.
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use comfy_table::Cell;
|
||||
use serde::Serialize;
|
||||
use warp_cli::{
|
||||
provider::{ProviderCommand, ProviderType},
|
||||
GlobalOptions,
|
||||
};
|
||||
use warp_core::channel::ChannelState;
|
||||
use warpui::{platform::TerminationMode, AppContext, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::ai::agent_sdk::output::{self, TableFormat};
|
||||
|
||||
/// Handle provider-related CLI commands.
|
||||
pub fn run(
|
||||
ctx: &mut AppContext,
|
||||
global_options: GlobalOptions,
|
||||
command: ProviderCommand,
|
||||
) -> anyhow::Result<()> {
|
||||
let runner = ctx.add_singleton_model(|_ctx| ProviderCommandRunner);
|
||||
match command {
|
||||
ProviderCommand::Setup(args) => runner.update(ctx, |runner, ctx| {
|
||||
runner.setup(args.provider_type, args.team, args.personal, ctx)
|
||||
}),
|
||||
ProviderCommand::List => runner.update(ctx, |runner, ctx| runner.list(global_options, ctx)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Singleton model for running provider CLI commands.
|
||||
struct ProviderCommandRunner;
|
||||
|
||||
impl ProviderCommandRunner {
|
||||
// This shouldn't need to be done, it's usually done as part of create
|
||||
fn setup(
|
||||
&self,
|
||||
provider_type: ProviderType,
|
||||
team: bool,
|
||||
personal: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> anyhow::Result<()> {
|
||||
// Construct the OAuth connect URL
|
||||
let server_url = ChannelState::server_root_url();
|
||||
|
||||
let mut use_team_auth = team;
|
||||
if !team && !personal {
|
||||
if provider_type.allowed_in_team_context()
|
||||
&& provider_type.allowed_in_personal_context()
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Provider '{}' must be setup for either a team or personal account",
|
||||
provider_type.slug()
|
||||
));
|
||||
}
|
||||
use_team_auth = provider_type.allowed_in_team_context();
|
||||
} else if personal {
|
||||
use_team_auth = false;
|
||||
}
|
||||
|
||||
// TODO(bens): initiate the OAuth flow and use the login-less auth URL
|
||||
let slug = provider_type.slug();
|
||||
let url = if use_team_auth {
|
||||
let team_uid = match UserWorkspaces::as_ref(ctx).current_team_uid() {
|
||||
Some(uid) => uid,
|
||||
None => {
|
||||
return Err(anyhow::anyhow!("User is not on a team"));
|
||||
}
|
||||
};
|
||||
format!("{server_url}/oauth/connect/{slug}?principalType=team&principalId={team_uid}")
|
||||
} else {
|
||||
format!("{server_url}/oauth/connect/{slug}")
|
||||
};
|
||||
|
||||
println!("To authenticate {slug}, open this URL in your browser: {url}");
|
||||
|
||||
// Open the URL in the default browser
|
||||
ctx.open_url(&url);
|
||||
|
||||
// TODO(bens): poll/subscribe until connection is created
|
||||
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn list(
|
||||
&self,
|
||||
global_options: GlobalOptions,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> anyhow::Result<()> {
|
||||
let providers = vec![ProviderType::Linear, ProviderType::Slack];
|
||||
|
||||
let provider_infos: Vec<_> = providers
|
||||
.into_iter()
|
||||
.map(|provider| {
|
||||
let name = provider.name();
|
||||
let slug = provider.slug();
|
||||
let mut allowed_for = Vec::new();
|
||||
|
||||
if provider.allowed_in_personal_context() {
|
||||
allowed_for.push("personal");
|
||||
}
|
||||
if provider.allowed_in_team_context() {
|
||||
allowed_for.push("team");
|
||||
}
|
||||
|
||||
let allowed_str = allowed_for.join(", ");
|
||||
let status = "❌ Not Connected".to_string(); // TODO(bens): get this from gql
|
||||
|
||||
ProviderInfo {
|
||||
name,
|
||||
slug,
|
||||
allowed_for: allowed_str,
|
||||
status,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
output::print_list(provider_infos, global_options.output_format);
|
||||
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl warpui::Entity for ProviderCommandRunner {
|
||||
type Event = ();
|
||||
}
|
||||
impl SingletonEntity for ProviderCommandRunner {}
|
||||
|
||||
/// Provider information that's shown in the `list` command.
|
||||
#[derive(Serialize)]
|
||||
struct ProviderInfo {
|
||||
name: String,
|
||||
slug: String,
|
||||
allowed_for: String,
|
||||
status: String,
|
||||
}
|
||||
|
||||
impl TableFormat for ProviderInfo {
|
||||
fn header() -> Vec<Cell> {
|
||||
vec![
|
||||
Cell::new("NAME"),
|
||||
Cell::new("SLUG"),
|
||||
Cell::new("ALLOWED FOR"),
|
||||
Cell::new("STATUS"),
|
||||
]
|
||||
}
|
||||
|
||||
fn row(&self) -> Vec<Cell> {
|
||||
vec![
|
||||
Cell::new(&self.name),
|
||||
Cell::new(&self.slug),
|
||||
Cell::new(&self.allowed_for),
|
||||
Cell::new(&self.status),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Shared retry primitives for HTTP-backed operations in the agent SDK.
|
||||
//!
|
||||
//! Both the end-of-run snapshot upload pipeline and the handoff snapshot download pipeline
|
||||
//! need to retry transient HTTP failures on a bounded, predictable schedule. This module
|
||||
//! centralizes the backoff policy, transient-vs-permanent classification, and retry loop so
|
||||
//! the two call sites share a single source of truth.
|
||||
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use warpui::duration_with_jitter;
|
||||
use warpui::r#async::Timer;
|
||||
|
||||
pub(crate) use crate::server::retry_strategies::is_transient_http_error;
|
||||
|
||||
/// Maximum total attempts per operation (initial attempt plus retries on transient errors).
|
||||
pub(crate) const MAX_ATTEMPTS: usize = 3;
|
||||
|
||||
/// Base backoff between retry attempts; each subsequent attempt multiplies by [`BACKOFF_FACTOR`].
|
||||
pub(crate) const INITIAL_BACKOFF: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Exponential growth factor for retry backoff.
|
||||
pub(crate) const BACKOFF_FACTOR: f32 = 2.0;
|
||||
|
||||
/// Maximum jitter as a fraction of the backoff interval.
|
||||
pub(crate) const BACKOFF_JITTER: f32 = 0.3;
|
||||
|
||||
/// Run `attempt_fn` with bounded exponential-backoff retries on transient failures.
|
||||
///
|
||||
/// `operation` is included in retry logs so concurrent callers can be distinguished.
|
||||
///
|
||||
/// `attempt_fn` is called repeatedly with a fresh `Future` per attempt, so callers that need
|
||||
/// per-attempt state (e.g. cloning a request body) own that inside their closure.
|
||||
///
|
||||
/// Transient errors are retried up to [`MAX_ATTEMPTS`] total. Permanent errors return
|
||||
/// immediately. A warning is logged between attempts so retries are visible in logs.
|
||||
pub(crate) async fn with_bounded_retry<T, F, Fut>(operation: &str, mut attempt_fn: F) -> Result<T>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: Future<Output = Result<T>>,
|
||||
{
|
||||
let mut delay = INITIAL_BACKOFF;
|
||||
for attempt in 1..=MAX_ATTEMPTS {
|
||||
match attempt_fn().await {
|
||||
Ok(value) => return Ok(value),
|
||||
Err(e) if attempt >= MAX_ATTEMPTS || !is_transient_http_error(&e) => return Err(e),
|
||||
Err(e) => {
|
||||
log::warn!("{operation}: attempt {attempt}/{MAX_ATTEMPTS} failed, retrying: {e:#}");
|
||||
Timer::after(duration_with_jitter(delay, BACKOFF_JITTER)).await;
|
||||
delay = delay.mul_f32(BACKOFF_FACTOR);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Unreachable when MAX_ATTEMPTS >= 1.
|
||||
Err(anyhow!(
|
||||
"retry loop exhausted without attempting operation (MAX_ATTEMPTS={MAX_ATTEMPTS})"
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "retry_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,101 @@
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use futures::executor::block_on;
|
||||
|
||||
use super::*;
|
||||
use crate::server::server_api::presigned_upload::HttpStatusError;
|
||||
|
||||
fn http_err(status: u16) -> anyhow::Error {
|
||||
HttpStatusError {
|
||||
status,
|
||||
body: format!("status {status} body"),
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_5xx_status_codes_are_retryable() {
|
||||
assert!(is_transient_http_error(&http_err(503)));
|
||||
assert!(is_transient_http_error(&http_err(500)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_408_and_429_are_retryable() {
|
||||
assert!(is_transient_http_error(&http_err(408)));
|
||||
assert!(is_transient_http_error(&http_err(429)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permanent_4xx_status_codes_are_not_retryable() {
|
||||
assert!(!is_transient_http_error(&http_err(403)));
|
||||
assert!(!is_transient_http_error(&http_err(404)));
|
||||
assert!(!is_transient_http_error(&http_err(400)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_without_http_status_are_treated_as_transient() {
|
||||
// Network-layer errors (connection reset, timeout, DNS failure) aren't `HttpStatusError`;
|
||||
// treat them as transient so the retry loop gives them a chance.
|
||||
let err = anyhow!("connection reset by peer");
|
||||
assert!(is_transient_http_error(&err));
|
||||
|
||||
let err = anyhow!("Failed to send request: timed out");
|
||||
assert!(is_transient_http_error(&err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_loop_succeeds_on_first_attempt() {
|
||||
let attempts = Rc::new(Cell::new(0));
|
||||
let attempts_clone = attempts.clone();
|
||||
let result: Result<()> = block_on(with_bounded_retry("test retry", || {
|
||||
attempts_clone.set(attempts_clone.get() + 1);
|
||||
async { Ok(()) }
|
||||
}));
|
||||
result.unwrap();
|
||||
assert_eq!(attempts.get(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_loop_retries_transient_and_eventually_succeeds() {
|
||||
let attempts = Rc::new(Cell::new(0));
|
||||
let attempts_clone = attempts.clone();
|
||||
let result: Result<u32> = block_on(with_bounded_retry("test retry", || {
|
||||
let n = attempts_clone.get() + 1;
|
||||
attempts_clone.set(n);
|
||||
async move {
|
||||
if n < 2 {
|
||||
Err(http_err(503))
|
||||
} else {
|
||||
Ok(n)
|
||||
}
|
||||
}
|
||||
}));
|
||||
assert_eq!(result.unwrap(), 2);
|
||||
assert_eq!(attempts.get(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_loop_stops_at_max_attempts_on_persistent_transient() {
|
||||
let attempts = Rc::new(Cell::new(0));
|
||||
let attempts_clone = attempts.clone();
|
||||
let result: Result<()> = block_on(with_bounded_retry("test retry", || {
|
||||
attempts_clone.set(attempts_clone.get() + 1);
|
||||
async { Err(http_err(503)) }
|
||||
}));
|
||||
assert!(result.is_err());
|
||||
assert_eq!(attempts.get(), MAX_ATTEMPTS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_loop_fails_fast_on_permanent_error() {
|
||||
let attempts = Rc::new(Cell::new(0));
|
||||
let attempts_clone = attempts.clone();
|
||||
let result: Result<()> = block_on(with_bounded_retry("test retry", || {
|
||||
attempts_clone.set(attempts_clone.get() + 1);
|
||||
async { Err(http_err(403)) }
|
||||
}));
|
||||
assert!(result.is_err());
|
||||
assert_eq!(attempts.get(), 1, "permanent errors should not retry");
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use comfy_table::Cell;
|
||||
use futures::future;
|
||||
use serde::Serialize;
|
||||
use warp_cli::schedule::{
|
||||
CreateScheduleArgs, DeleteScheduleArgs, GetScheduleArgs, PauseScheduleArgs, ScheduleCommand,
|
||||
ScheduleSubcommand, UnpauseScheduleArgs, UpdateScheduleArgs,
|
||||
};
|
||||
use warp_cli::{agent::OutputFormat, GlobalOptions};
|
||||
use warp_graphql::queries::get_scheduled_agent_history::ScheduledAgentHistory;
|
||||
use warpui::platform::TerminationMode;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
use crate::ai::ambient_agents::scheduled::{
|
||||
CloudScheduledAmbientAgent, ScheduledAgentManager, ScheduledAmbientAgent, UpdateScheduleParams,
|
||||
};
|
||||
use crate::ai::ambient_agents::AgentConfigSnapshot;
|
||||
use crate::cloud_object::CloudObject;
|
||||
use crate::server::ids::{ServerId, SyncId};
|
||||
use crate::util::time_format::format_approx_duration_from_now_utc;
|
||||
|
||||
use super::common::{EnvironmentChoice, ResolveConfigurationError};
|
||||
use super::output::{self, TableFormat};
|
||||
|
||||
/// Run a scheduled agent command.
|
||||
pub fn run(
|
||||
ctx: &mut AppContext,
|
||||
global_options: GlobalOptions,
|
||||
command: ScheduleCommand,
|
||||
) -> anyhow::Result<()> {
|
||||
let output_format = global_options.output_format;
|
||||
match command.into_subcommand() {
|
||||
ScheduleSubcommand::Create(args) => create(ctx, args),
|
||||
ScheduleSubcommand::List => list(ctx, output_format),
|
||||
ScheduleSubcommand::Get(args) => get(ctx, output_format, args),
|
||||
ScheduleSubcommand::Pause(args) => pause(ctx, args),
|
||||
ScheduleSubcommand::Unpause(args) => unpause(ctx, args),
|
||||
ScheduleSubcommand::Update(args) => update(ctx, args),
|
||||
ScheduleSubcommand::Delete(args) => delete(ctx, args),
|
||||
}
|
||||
}
|
||||
|
||||
fn create(ctx: &mut AppContext, args: CreateScheduleArgs) -> anyhow::Result<()> {
|
||||
ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| {
|
||||
let refresh_future = super::common::refresh_workspace_metadata(ctx);
|
||||
let warp_drive_sync_future = super::common::refresh_warp_drive(ctx);
|
||||
let setup_future = future::try_join(refresh_future, warp_drive_sync_future);
|
||||
|
||||
ctx.spawn(setup_future, move |manager, setup_result, ctx| {
|
||||
if let Err(err) = setup_result {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
let loaded_file = match args.config_file.file.as_deref() {
|
||||
Some(path) => match super::config_file::load_config_file(path) {
|
||||
Ok(file) => Some(file),
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
let mut environment_args = args.environment;
|
||||
if environment_args.environment.is_none() && !environment_args.no_environment {
|
||||
if let Some(environment_id) = loaded_file
|
||||
.as_ref()
|
||||
.and_then(|f| f.file.environment_id.clone())
|
||||
{
|
||||
environment_args.environment = Some(environment_id);
|
||||
}
|
||||
}
|
||||
|
||||
let environment_id = match EnvironmentChoice::resolve_for_create(environment_args, ctx)
|
||||
{
|
||||
Ok(EnvironmentChoice::None) => {
|
||||
eprintln!("Scheduling agent to run without an environment.");
|
||||
None
|
||||
}
|
||||
Ok(EnvironmentChoice::Environment { id, .. }) => Some(id),
|
||||
Err(ResolveConfigurationError::Canceled) => {
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
super::report_fatal_error(anyhow::anyhow!(err), ctx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let owner =
|
||||
match super::common::resolve_owner(args.scope.team, args.scope.personal, ctx) {
|
||||
Ok(owner) => owner,
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let cli_mcp_servers =
|
||||
match super::mcp_config::build_mcp_servers_from_specs(&args.mcp_specs) {
|
||||
Ok(mcp_servers) => mcp_servers,
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let merged_config = super::config_file::merge_with_precedence(
|
||||
loaded_file.as_ref(),
|
||||
crate::ai::ambient_agents::AgentConfigSnapshot {
|
||||
name: None,
|
||||
environment_id,
|
||||
model_id: args.model.model.clone(),
|
||||
base_prompt: None,
|
||||
mcp_servers: cli_mcp_servers,
|
||||
profile_id: None,
|
||||
worker_host: args.worker_host,
|
||||
skill_spec: args.skill.map(|s| s.to_string()),
|
||||
// TODO(QUALITY-294): Support computer use flag in scheduled agents.
|
||||
computer_use_enabled: None,
|
||||
// TODO(REMOTE-1134): Support harness flag for scheduled agents.
|
||||
harness: None,
|
||||
harness_auth_secrets: None,
|
||||
},
|
||||
);
|
||||
|
||||
// We must wait until after workspace metadata is refreshed to check available LLMs.
|
||||
let model_id = match merged_config
|
||||
.model_id
|
||||
.as_deref()
|
||||
.map(|model_id| super::common::validate_agent_mode_base_model_id(model_id, ctx))
|
||||
.transpose()
|
||||
{
|
||||
Ok(id) => id.map(|id| id.to_string()),
|
||||
Err(err) => {
|
||||
super::report_fatal_error(anyhow::anyhow!(err), ctx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut agent_config = merged_config;
|
||||
agent_config.model_id = model_id;
|
||||
|
||||
let prompt = args.prompt.unwrap_or_default();
|
||||
let mut config = ScheduledAmbientAgent::new(args.name, args.cron, true, prompt);
|
||||
config.agent_config = agent_config;
|
||||
|
||||
// Print something here because scheduling an agent can take a while.
|
||||
println!("Scheduling agent {}...", config.name);
|
||||
let create_future = manager.create_schedule(config, owner, ctx);
|
||||
ctx.spawn(create_future, |_manager, result, ctx| match result {
|
||||
Ok(sync_id) => {
|
||||
println!("Scheduled agent: {sync_id}");
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
}
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ScheduleInfo {
|
||||
id: String,
|
||||
name: String,
|
||||
cron_schedule: String,
|
||||
paused: bool,
|
||||
last_ran: Option<DateTime<Utc>>,
|
||||
next_run: Option<DateTime<Utc>>,
|
||||
scope: String,
|
||||
prompt: String,
|
||||
last_spawn_error: Option<String>,
|
||||
agent_config: AgentConfigSnapshot,
|
||||
}
|
||||
|
||||
impl ScheduleInfo {
|
||||
fn new(
|
||||
id: String,
|
||||
scope: String,
|
||||
config: ScheduledAmbientAgent,
|
||||
history: Option<&ScheduledAgentHistory>,
|
||||
) -> Self {
|
||||
let last_ran = history.and_then(|h| h.last_ran.as_ref().map(|t| t.utc()));
|
||||
let next_run = history.and_then(|h| h.next_run.as_ref().map(|t| t.utc()));
|
||||
ScheduleInfo {
|
||||
id,
|
||||
name: config.name,
|
||||
cron_schedule: config.cron_schedule,
|
||||
paused: !config.enabled,
|
||||
last_ran,
|
||||
next_run,
|
||||
scope,
|
||||
prompt: config.prompt,
|
||||
last_spawn_error: config.last_spawn_error,
|
||||
agent_config: config.agent_config,
|
||||
}
|
||||
}
|
||||
|
||||
fn last_ran_display(&self) -> String {
|
||||
let timestamp = self
|
||||
.last_ran
|
||||
.map(format_approx_duration_from_now_utc)
|
||||
.unwrap_or("-".to_string());
|
||||
|
||||
if self.last_spawn_error.is_some() {
|
||||
format!("❌ {}", timestamp)
|
||||
} else {
|
||||
timestamp
|
||||
}
|
||||
}
|
||||
|
||||
fn next_run_display(&self) -> String {
|
||||
// We can't use format_approx_duration_from_now_utc because the date is in the future.
|
||||
// We use RTC 2822 rather than RFC 3339 because it's more human-readable.
|
||||
self.next_run
|
||||
.map(|dt| dt.to_rfc2822())
|
||||
.unwrap_or("-".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl TableFormat for ScheduleInfo {
|
||||
fn header() -> Vec<Cell> {
|
||||
vec![
|
||||
Cell::new("ID"),
|
||||
Cell::new("Name"),
|
||||
Cell::new("Schedule"),
|
||||
Cell::new("Paused"),
|
||||
Cell::new("Last ran"),
|
||||
Cell::new("Next run"),
|
||||
Cell::new("Scope"),
|
||||
]
|
||||
}
|
||||
|
||||
fn row(&self) -> Vec<Cell> {
|
||||
let paused_display = if self.paused { "Yes" } else { "No" };
|
||||
vec![
|
||||
Cell::new(&self.id),
|
||||
Cell::new(&self.name),
|
||||
Cell::new(&self.cron_schedule),
|
||||
Cell::new(paused_display),
|
||||
Cell::new(self.last_ran_display()),
|
||||
Cell::new(self.next_run_display()),
|
||||
Cell::new(&self.scope),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
fn print_schedule_info(info: &ScheduleInfo, output_format: OutputFormat) -> anyhow::Result<()> {
|
||||
let paused_display = if info.paused { "Yes" } else { "No" };
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json => {
|
||||
serde_json::to_writer(std::io::stdout(), info)?;
|
||||
Ok(())
|
||||
}
|
||||
OutputFormat::Ndjson => output::write_json_line(info, std::io::stdout()),
|
||||
OutputFormat::Text => {
|
||||
println!("Name: {}", info.name);
|
||||
println!("Cron schedule: {}", info.cron_schedule);
|
||||
println!("Paused: {paused_display}");
|
||||
|
||||
let last_ran = info.last_ran_display();
|
||||
let next_run = info.next_run_display();
|
||||
println!("Last ran: {last_ran}");
|
||||
if let Some(error) = &info.last_spawn_error {
|
||||
println!("Last error: {error}");
|
||||
}
|
||||
println!("Next run: {next_run}");
|
||||
|
||||
println!("Prompt: {}", info.prompt);
|
||||
|
||||
if let Some(environment_id) = &info.agent_config.environment_id {
|
||||
println!("Environment ID: {environment_id}");
|
||||
}
|
||||
if let Some(model_id) = &info.agent_config.model_id {
|
||||
println!("Model ID: {model_id}");
|
||||
}
|
||||
if let Some(agent_name) = &info.agent_config.name {
|
||||
println!("Agent name: {agent_name}");
|
||||
}
|
||||
if let Some(skill_spec) = &info.agent_config.skill_spec {
|
||||
println!("Skill: {skill_spec}");
|
||||
}
|
||||
if let Some(worker_host) = &info.agent_config.worker_host {
|
||||
println!("Host: {worker_host}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
OutputFormat::Pretty => {
|
||||
let mut table = output::standard_table();
|
||||
table.add_row(vec![Cell::new("Name"), Cell::new(&info.name)]);
|
||||
table.add_row(vec![
|
||||
Cell::new("Cron schedule"),
|
||||
Cell::new(&info.cron_schedule),
|
||||
]);
|
||||
table.add_row(vec![Cell::new("Paused"), Cell::new(paused_display)]);
|
||||
|
||||
let last_ran = info.last_ran_display();
|
||||
let next_run = info.next_run_display();
|
||||
table.add_row(vec![Cell::new("Last ran"), Cell::new(last_ran)]);
|
||||
if let Some(error) = &info.last_spawn_error {
|
||||
table.add_row(vec![Cell::new("Last error"), Cell::new(error)]);
|
||||
}
|
||||
table.add_row(vec![Cell::new("Next run"), Cell::new(next_run)]);
|
||||
|
||||
table.add_row(vec![Cell::new("Prompt"), Cell::new(&info.prompt)]);
|
||||
|
||||
if let Some(environment_id) = &info.agent_config.environment_id {
|
||||
table.add_row(vec![Cell::new("Environment ID"), Cell::new(environment_id)]);
|
||||
}
|
||||
if let Some(model_id) = &info.agent_config.model_id {
|
||||
table.add_row(vec![Cell::new("Model ID"), Cell::new(model_id)]);
|
||||
}
|
||||
if let Some(agent_name) = &info.agent_config.name {
|
||||
table.add_row(vec![Cell::new("Agent name"), Cell::new(agent_name)]);
|
||||
}
|
||||
if let Some(skill_spec) = &info.agent_config.skill_spec {
|
||||
table.add_row(vec![Cell::new("Skill"), Cell::new(skill_spec)]);
|
||||
}
|
||||
if let Some(worker_host) = &info.agent_config.worker_host {
|
||||
table.add_row(vec![Cell::new("Host"), Cell::new(worker_host)]);
|
||||
}
|
||||
|
||||
println!("{table}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pause(ctx: &mut AppContext, args: PauseScheduleArgs) -> anyhow::Result<()> {
|
||||
let schedule_id = SyncId::ServerId(ServerId::try_from(args.schedule_id)?);
|
||||
|
||||
ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| {
|
||||
let warp_drive_sync_future = super::common::refresh_warp_drive(ctx);
|
||||
ctx.spawn(warp_drive_sync_future, move |manager, result, ctx| {
|
||||
if let Err(err) = result {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
println!("Pausing agent...");
|
||||
let pause_future = manager.pause_schedule(schedule_id, ctx);
|
||||
ctx.spawn(pause_future, |_manager, result, ctx| match result {
|
||||
Ok(()) => {
|
||||
println!("Schedule paused");
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
}
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unpause(ctx: &mut AppContext, args: UnpauseScheduleArgs) -> anyhow::Result<()> {
|
||||
let schedule_id = SyncId::ServerId(ServerId::try_from(args.schedule_id)?);
|
||||
|
||||
ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| {
|
||||
let warp_drive_sync_future = super::common::refresh_warp_drive(ctx);
|
||||
ctx.spawn(warp_drive_sync_future, move |manager, result, ctx| {
|
||||
if let Err(err) = result {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
println!("Resuming agent...");
|
||||
let unpause_future = manager.unpause_schedule(schedule_id, ctx);
|
||||
ctx.spawn(unpause_future, |_manager, result, ctx| match result {
|
||||
Ok(()) => {
|
||||
println!("Schedule unpaused");
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
}
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update(ctx: &mut AppContext, args: UpdateScheduleArgs) -> anyhow::Result<()> {
|
||||
let schedule_id = SyncId::ServerId(ServerId::try_from(args.schedule_id)?);
|
||||
|
||||
ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| {
|
||||
let refresh_future = super::common::refresh_workspace_metadata(ctx);
|
||||
let warp_drive_sync_future = super::common::refresh_warp_drive(ctx);
|
||||
let setup_future = future::try_join(refresh_future, warp_drive_sync_future);
|
||||
|
||||
ctx.spawn(setup_future, move |manager, setup_result, ctx| {
|
||||
if let Err(err) = setup_result {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
let loaded_file = match args.config_file.file.as_deref() {
|
||||
Some(path) => match super::config_file::load_config_file(path) {
|
||||
Ok(file) => Some(file),
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
let file_config = loaded_file.as_ref().map(|f| &f.file);
|
||||
|
||||
// We must wait until after workspace metadata is refreshed to check available LLMs.
|
||||
let model_id = match args
|
||||
.model
|
||||
.model
|
||||
.as_deref()
|
||||
.or_else(|| file_config.and_then(|f| f.model_id.as_deref()))
|
||||
.map(|model_id| super::common::validate_agent_mode_base_model_id(model_id, ctx))
|
||||
.transpose()
|
||||
{
|
||||
Ok(id) => id.map(|id| id.to_string()),
|
||||
Err(err) => {
|
||||
super::report_fatal_error(anyhow::anyhow!(err), ctx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut environment_args = args.environment;
|
||||
if environment_args.environment.is_none() && !environment_args.remove_environment {
|
||||
if let Some(environment_id) = file_config.and_then(|f| f.environment_id.clone()) {
|
||||
environment_args.environment = Some(environment_id);
|
||||
}
|
||||
}
|
||||
|
||||
let environment_id = match EnvironmentChoice::resolve_for_update(environment_args, ctx)
|
||||
{
|
||||
Ok(choice) => choice.map(|c| match c {
|
||||
EnvironmentChoice::None => None,
|
||||
EnvironmentChoice::Environment { id, .. } => Some(id),
|
||||
}),
|
||||
Err(ResolveConfigurationError::Canceled) => {
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
super::report_fatal_error(anyhow::anyhow!(err), ctx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// MCP update semantics are patch-only:
|
||||
// - file and CLI MCP servers are treated as upserts (CLI wins on key conflicts)
|
||||
// - `--remove-mcp` removes keys
|
||||
// If both are present, removals win by filtering removed names out of the upsert payload.
|
||||
let mut mcp_servers_upsert = match file_config.and_then(|f| f.mcp_servers.clone()) {
|
||||
Some(map) if map.is_empty() => None,
|
||||
Some(map) => Some(map),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let cli_mcp_servers_upsert =
|
||||
match super::mcp_config::build_mcp_servers_from_specs(&args.mcp_specs) {
|
||||
Ok(mcp_servers) => mcp_servers,
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(cli_map) = cli_mcp_servers_upsert {
|
||||
let merged = mcp_servers_upsert.get_or_insert_with(serde_json::Map::new);
|
||||
for (name, config_value) in cli_map {
|
||||
merged.insert(name, config_value);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(map) = mcp_servers_upsert.as_mut() {
|
||||
for name in &args.remove_mcp {
|
||||
map.remove(name);
|
||||
}
|
||||
if map.is_empty() {
|
||||
mcp_servers_upsert = None;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle skill update semantics: --skill sets it, --remove-skill clears it
|
||||
let skill_spec = if args.remove_skill {
|
||||
Some(None)
|
||||
} else {
|
||||
args.skill.map(|s| Some(s.to_string()))
|
||||
};
|
||||
|
||||
println!("Updating agent...");
|
||||
let update_future = manager.update_schedule(
|
||||
schedule_id,
|
||||
UpdateScheduleParams {
|
||||
name: args.name,
|
||||
cron: args.cron,
|
||||
model_id,
|
||||
environment_id,
|
||||
base_prompt: file_config.and_then(|f| f.base_prompt.clone()),
|
||||
prompt: args.prompt,
|
||||
mcp_servers_upsert,
|
||||
remove_mcp_server_names: args.remove_mcp,
|
||||
skill_spec,
|
||||
worker_host: args.worker_host,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
ctx.spawn(update_future, |_manager, result, ctx| match result {
|
||||
Ok(()) => {
|
||||
println!("Schedule updated");
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
}
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all scheduled agents available to the current user.
|
||||
fn list(ctx: &mut AppContext, output_format: OutputFormat) -> anyhow::Result<()> {
|
||||
ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| {
|
||||
let warp_drive_sync_future = super::common::refresh_warp_drive(ctx);
|
||||
ctx.spawn(warp_drive_sync_future, move |manager, result, ctx| {
|
||||
if let Err(err) = result {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut schedules = manager.list_schedules(ctx);
|
||||
schedules.sort_by_key(|schedule| schedule.model().string_model.name.clone());
|
||||
|
||||
let futures = schedules.into_iter().map(|schedule| {
|
||||
let config = schedule.model().string_model.clone();
|
||||
let sync_id = schedule.sync_id();
|
||||
let scope = super::common::format_owner(&schedule.permissions().owner).to_string();
|
||||
|
||||
// TODO(ben): Consider a bulk lookup API for scheduled agent history.
|
||||
let history_future = manager.fetch_schedule_history(sync_id, ctx);
|
||||
|
||||
async move {
|
||||
// Try to fetch the scheduled agent history, but still show output if this fails.
|
||||
let history = match history_future.await {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
log::warn!("Failed to fetch scheduled agent history: {err:#}");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let id = match sync_id {
|
||||
SyncId::ServerId(server_id) => server_id.to_string(),
|
||||
SyncId::ClientId(_) => "Unsynced".to_string(),
|
||||
};
|
||||
|
||||
ScheduleInfo::new(id, scope, config, history.as_ref())
|
||||
}
|
||||
});
|
||||
|
||||
let output_format = output_format;
|
||||
ctx.spawn(
|
||||
futures::future::join_all(futures),
|
||||
move |_manager, infos, ctx| {
|
||||
output::print_list(infos, output_format);
|
||||
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get(
|
||||
ctx: &mut AppContext,
|
||||
output_format: OutputFormat,
|
||||
args: GetScheduleArgs,
|
||||
) -> anyhow::Result<()> {
|
||||
let schedule_id = SyncId::ServerId(ServerId::try_from(args.schedule_id)?);
|
||||
|
||||
ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| {
|
||||
let warp_drive_sync_future = super::common::refresh_warp_drive(ctx);
|
||||
ctx.spawn(warp_drive_sync_future, move |manager, result, ctx| {
|
||||
if let Err(err) = result {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(schedule) = CloudScheduledAmbientAgent::get_by_id(&schedule_id, ctx) else {
|
||||
super::report_fatal_error(anyhow::anyhow!("Schedule not found"), ctx);
|
||||
return;
|
||||
};
|
||||
|
||||
let id = match &schedule_id {
|
||||
SyncId::ServerId(server_id) => server_id.to_string(),
|
||||
SyncId::ClientId(_) => "Unsynced".to_string(),
|
||||
};
|
||||
let scope = super::common::format_owner(&schedule.permissions().owner).to_string();
|
||||
let config = schedule.model().string_model.clone();
|
||||
|
||||
// Don't hold references into the CloudObject store across an async spawn.
|
||||
let history_future = manager.fetch_schedule_history(schedule_id, ctx);
|
||||
|
||||
ctx.spawn(history_future, move |_manager, history, ctx| {
|
||||
let history = match history {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
log::warn!("Failed to fetch scheduled agent history: {err:#}");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let info = ScheduleInfo::new(id, scope, config, history.as_ref());
|
||||
if let Err(err) = print_schedule_info(&info, output_format) {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete(ctx: &mut AppContext, args: DeleteScheduleArgs) -> anyhow::Result<()> {
|
||||
let schedule_id = SyncId::ServerId(ServerId::try_from(args.schedule_id)?);
|
||||
|
||||
ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| {
|
||||
let warp_drive_sync_future = super::common::refresh_warp_drive(ctx);
|
||||
ctx.spawn(warp_drive_sync_future, move |manager, result, ctx| {
|
||||
if let Err(err) = result {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
println!("Deleting agent...");
|
||||
let delete_future = manager.delete_schedule(schedule_id, ctx);
|
||||
ctx.spawn(delete_future, |_manager, result, ctx| match result {
|
||||
Ok(()) => {
|
||||
println!("Schedule deleted");
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
}
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,776 @@
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, IsTerminal as _, Read},
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use comfy_table::Cell;
|
||||
use inquire::{Confirm, InquireError, Password};
|
||||
use serde::Serialize;
|
||||
use warp_cli::{
|
||||
agent::OutputFormat,
|
||||
scope::ObjectScope,
|
||||
secret::{
|
||||
AnthropicMethod, CreateProvider, CreateSecretArgs, DeleteSecretArgs, ListSecretsArgs,
|
||||
SecretCommand, SecretType, UpdateSecretArgs, ValueArgs,
|
||||
},
|
||||
GlobalOptions,
|
||||
};
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warp_graphql::{
|
||||
managed_secrets::{ManagedSecret, ManagedSecretType},
|
||||
object::SpaceType,
|
||||
};
|
||||
use warp_managed_secrets::{client::SecretOwner, ManagedSecretManager, ManagedSecretValue};
|
||||
use warpui::{platform::TerminationMode, AppContext, SingletonEntity as _};
|
||||
|
||||
use crate::{
|
||||
auth::UserUid, cloud_object::Owner, server::ids::ServerId,
|
||||
util::time_format::format_approx_duration_from_now_utc,
|
||||
};
|
||||
|
||||
use super::output::{self, TableFormat};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SecretInfo {
|
||||
name: String,
|
||||
scope: String,
|
||||
#[serde(rename = "type")]
|
||||
secret_type: ManagedSecretType,
|
||||
#[serde(rename = "created")]
|
||||
created_at: DateTime<Utc>,
|
||||
#[serde(rename = "updated")]
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl TableFormat for SecretInfo {
|
||||
fn header() -> Vec<Cell> {
|
||||
vec![
|
||||
Cell::new("Name"),
|
||||
Cell::new("Scope"),
|
||||
Cell::new("Type"),
|
||||
Cell::new("Created"),
|
||||
Cell::new("Updated"),
|
||||
]
|
||||
}
|
||||
|
||||
fn row(&self) -> Vec<Cell> {
|
||||
vec![
|
||||
Cell::new(&self.name),
|
||||
Cell::new(&self.scope),
|
||||
Cell::new(format_secret_type(&self.secret_type)),
|
||||
Cell::new(format_approx_duration_from_now_utc(self.created_at)),
|
||||
Cell::new(format_approx_duration_from_now_utc(self.updated_at)),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Run secret-related commands.
|
||||
pub fn run(
|
||||
ctx: &mut AppContext,
|
||||
global_options: GlobalOptions,
|
||||
command: SecretCommand,
|
||||
) -> Result<()> {
|
||||
if !FeatureFlag::WarpManagedSecrets.is_enabled() {
|
||||
return Err(anyhow::anyhow!("This feature is not enabled"));
|
||||
}
|
||||
|
||||
match command {
|
||||
SecretCommand::Create(args) => create_secret(ctx, args),
|
||||
SecretCommand::Delete(args) => delete_secret(ctx, args),
|
||||
SecretCommand::Update(args) => update_secret(ctx, args),
|
||||
SecretCommand::List(args) => list_secrets(ctx, global_options.output_format, args),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deferred secret value reader. Constructed during argument parsing but read after validation
|
||||
/// (refresh metadata, resolve owner) so users are not prompted for secrets before we know the
|
||||
/// request is valid.
|
||||
enum SecretInput {
|
||||
/// Single-field secret types (raw value, Anthropic API key).
|
||||
Simple {
|
||||
secret_type: SecretType,
|
||||
value_args: ValueArgs,
|
||||
},
|
||||
/// Multi-field Bedrock API key secret with dedicated CLI flags.
|
||||
Bedrock {
|
||||
bedrock_api_key: Option<String>,
|
||||
region: Option<String>,
|
||||
},
|
||||
/// Multi-field Bedrock access key secret with dedicated CLI flags.
|
||||
BedrockAccessKey {
|
||||
access_key_id: Option<String>,
|
||||
secret_access_key: Option<String>,
|
||||
session_token: Option<String>,
|
||||
region: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl SecretInput {
|
||||
/// Read the secret value, prompting the user if necessary.
|
||||
/// Returns `Ok(None)` when the user cancels the prompt.
|
||||
fn read(self) -> Result<Option<ManagedSecretValue>> {
|
||||
match self {
|
||||
SecretInput::Simple {
|
||||
secret_type,
|
||||
value_args,
|
||||
} => {
|
||||
let raw = match read_simple_secret_value(&value_args)? {
|
||||
Some(v) => v,
|
||||
None => return Ok(None),
|
||||
};
|
||||
Ok(Some(make_simple_secret_value(secret_type, &raw)))
|
||||
}
|
||||
SecretInput::Bedrock {
|
||||
bedrock_api_key,
|
||||
region,
|
||||
} => read_bedrock_secret_value(bedrock_api_key, region),
|
||||
SecretInput::BedrockAccessKey {
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
session_token,
|
||||
region,
|
||||
} => read_bedrock_access_key_secret_value(
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
session_token,
|
||||
region,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new secret. Dispatches to the provider subcommand if present.
|
||||
fn create_secret(ctx: &mut AppContext, args: CreateSecretArgs) -> Result<()> {
|
||||
// Resolve provider subcommand into common fields plus a deferred value reader.
|
||||
let (name, input, description, scope) = match args.provider {
|
||||
Some(CreateProvider::Anthropic(anthropic)) => match anthropic.method {
|
||||
AnthropicMethod::ApiKey(a) => (
|
||||
a.common.name,
|
||||
SecretInput::Simple {
|
||||
secret_type: SecretType::AnthropicApiKey,
|
||||
value_args: a.value,
|
||||
},
|
||||
a.common.description,
|
||||
a.common.scope,
|
||||
),
|
||||
AnthropicMethod::BedrockApiKey(a) => (
|
||||
a.common.name,
|
||||
SecretInput::Bedrock {
|
||||
bedrock_api_key: a.bedrock_api_key,
|
||||
region: a.region,
|
||||
},
|
||||
a.common.description,
|
||||
a.common.scope,
|
||||
),
|
||||
AnthropicMethod::BedrockAccessKey(a) => (
|
||||
a.common.name,
|
||||
SecretInput::BedrockAccessKey {
|
||||
access_key_id: a.access_key_id,
|
||||
secret_access_key: a.secret_access_key,
|
||||
session_token: a.session_token,
|
||||
region: a.region,
|
||||
},
|
||||
a.common.description,
|
||||
a.common.scope,
|
||||
),
|
||||
},
|
||||
None => {
|
||||
let name = args.name.ok_or_else(|| {
|
||||
anyhow::anyhow!("Secret name is required. Usage: oz secret create <NAME>")
|
||||
})?;
|
||||
(
|
||||
name,
|
||||
SecretInput::Simple {
|
||||
secret_type: args.secret_type,
|
||||
value_args: args.value,
|
||||
},
|
||||
args.description,
|
||||
args.scope,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
create_secret_with_input(ctx, name, input, description, scope)
|
||||
}
|
||||
|
||||
/// Shared creation logic: refreshes metadata, resolves the owner, reads the secret value, and
|
||||
/// creates the secret.
|
||||
fn create_secret_with_input(
|
||||
ctx: &mut AppContext,
|
||||
name: String,
|
||||
input: SecretInput,
|
||||
description: Option<String>,
|
||||
scope: ObjectScope,
|
||||
) -> Result<()> {
|
||||
ManagedSecretManager::handle(ctx).update(ctx, move |_manager, ctx| {
|
||||
// Perform as much validation as possible up-front, before prompting the user for a secret.
|
||||
// It's a bad UX if we make them type in a secret and then fail on something we could have
|
||||
// checked beforehand.
|
||||
let refresh_future = super::common::refresh_workspace_metadata(ctx);
|
||||
ctx.spawn(refresh_future, move |manager, refresh_result, ctx| {
|
||||
if let Err(err) = refresh_result {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
let owner = match super::common::resolve_owner(scope.team, scope.personal, ctx) {
|
||||
Ok(owner) => owner,
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let managed_value = match input.read() {
|
||||
Ok(Some(value)) => value,
|
||||
Ok(None) => {
|
||||
// Treat this as a cancellation.
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let secret_owner = match owner {
|
||||
Owner::User { .. } => SecretOwner::CurrentUser,
|
||||
Owner::Team { team_uid } => SecretOwner::Team {
|
||||
team_uid: team_uid.uid(),
|
||||
},
|
||||
};
|
||||
|
||||
let create_future = manager.create_secret(
|
||||
secret_owner,
|
||||
name.clone(),
|
||||
managed_value,
|
||||
description.clone(),
|
||||
);
|
||||
ctx.spawn(create_future, move |_, result, ctx| match result {
|
||||
Ok(secret) => {
|
||||
println!("Secret '{}' created", secret.name);
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
}
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a secret.
|
||||
fn delete_secret(ctx: &mut AppContext, args: DeleteSecretArgs) -> Result<()> {
|
||||
let name = args.name;
|
||||
let force = args.force;
|
||||
let team = args.scope.team;
|
||||
let personal = args.scope.personal;
|
||||
|
||||
ManagedSecretManager::handle(ctx).update(ctx, move |_manager, ctx| {
|
||||
let refresh_future = super::common::refresh_workspace_metadata(ctx);
|
||||
let name = name.clone();
|
||||
ctx.spawn(refresh_future, move |manager, refresh_result, ctx| {
|
||||
if let Err(err) = refresh_result {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
let owner = match super::common::resolve_owner(team, personal, ctx) {
|
||||
Ok(owner) => owner,
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let secret_owner = match owner {
|
||||
Owner::User { .. } => SecretOwner::CurrentUser,
|
||||
Owner::Team { team_uid } => SecretOwner::Team {
|
||||
team_uid: team_uid.uid(),
|
||||
},
|
||||
};
|
||||
|
||||
if !force {
|
||||
if !io::stdin().is_terminal() {
|
||||
super::report_fatal_error(
|
||||
anyhow::anyhow!(
|
||||
"Refusing to delete secret without confirmation in non-interactive mode (use --force to bypass)"
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let scope = match owner {
|
||||
Owner::User { .. } => "personal",
|
||||
Owner::Team { .. } => "team",
|
||||
};
|
||||
|
||||
let should_delete = match Confirm::new(&format!("Delete {scope} secret '{name}'?"))
|
||||
.with_default(false)
|
||||
.with_help_message("This action cannot be undone")
|
||||
.prompt()
|
||||
{
|
||||
Ok(should_delete) => should_delete,
|
||||
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
|
||||
ctx
|
||||
.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err.into(), ctx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if !should_delete {
|
||||
println!("Deletion cancelled");
|
||||
ctx
|
||||
.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let delete_future = manager.delete_secret(secret_owner, name.clone());
|
||||
ctx.spawn(delete_future, move |_, result, ctx| match result {
|
||||
Ok(()) => {
|
||||
println!("Secret '{name}' deleted");
|
||||
ctx
|
||||
.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
}
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update a secret.
|
||||
fn update_secret(ctx: &mut AppContext, args: UpdateSecretArgs) -> Result<()> {
|
||||
ManagedSecretManager::handle(ctx).update(ctx, move |_manager, ctx| {
|
||||
// Perform as much validation as possible up-front, before prompting the user for a secret.
|
||||
let refresh_future = super::common::refresh_workspace_metadata(ctx);
|
||||
ctx.spawn(refresh_future, move |manager, refresh_result, ctx| {
|
||||
if let Err(err) = refresh_result {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
let owner =
|
||||
match super::common::resolve_owner(args.scope.team, args.scope.personal, ctx) {
|
||||
Ok(owner) => owner,
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Read the secret value if either --value or --value-file is provided.
|
||||
let secret_value = if args.value || args.value_args.value_file.is_some() {
|
||||
// Create ValueArgs to handle reading from file or prompting
|
||||
match read_simple_secret_value(&args.value_args) {
|
||||
Ok(Some(value)) => Some(value),
|
||||
Ok(None) => {
|
||||
// Treat this as a cancellation.
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let secret_owner = match owner {
|
||||
Owner::User { .. } => SecretOwner::CurrentUser,
|
||||
Owner::Team { team_uid } => SecretOwner::Team {
|
||||
team_uid: team_uid.uid(),
|
||||
},
|
||||
};
|
||||
|
||||
if let Some(secret_value) = secret_value {
|
||||
// Look up the existing secret's type so we use the correct ManagedSecretValue variant.
|
||||
let list_future = manager.list_secrets();
|
||||
ctx.spawn(list_future, move |manager, list_result, ctx| {
|
||||
let secrets = match list_result {
|
||||
Ok(secrets) => secrets,
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let secret_type = match find_secret_type(&secrets, &args.name, &secret_owner) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
super::report_fatal_error(
|
||||
anyhow::anyhow!("Secret '{}' not found", args.name),
|
||||
ctx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let managed_secret_value =
|
||||
match make_secret_value_from_gql_type(secret_type, &secret_value) {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let update_future = manager.update_secret(
|
||||
secret_owner,
|
||||
args.name.clone(),
|
||||
Some(managed_secret_value),
|
||||
args.description.clone(),
|
||||
);
|
||||
ctx.spawn(update_future, move |_, result, ctx| match result {
|
||||
Ok(secret) => {
|
||||
println!("Secret '{}' updated", secret.name);
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
}
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Description-only update; no encryption needed.
|
||||
let update_future = manager.update_secret(
|
||||
secret_owner,
|
||||
args.name.clone(),
|
||||
None,
|
||||
args.description.clone(),
|
||||
);
|
||||
ctx.spawn(update_future, move |_, result, ctx| match result {
|
||||
Ok(secret) => {
|
||||
println!("Secret '{}' updated", secret.name);
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
}
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List secrets.
|
||||
fn list_secrets(
|
||||
ctx: &mut AppContext,
|
||||
output_format: OutputFormat,
|
||||
_args: ListSecretsArgs,
|
||||
) -> Result<()> {
|
||||
ManagedSecretManager::handle(ctx).update(ctx, |manager, ctx| {
|
||||
ctx.spawn(manager.list_secrets(), move |_, result, ctx| match result {
|
||||
Ok(secrets) => {
|
||||
let secret_infos = secrets.into_iter().map(|secret| {
|
||||
let owner = match secret.owner.type_ {
|
||||
SpaceType::User => Owner::User {
|
||||
user_uid: UserUid::new(secret.owner.uid.inner()),
|
||||
},
|
||||
SpaceType::Team => Owner::Team {
|
||||
team_uid: ServerId::from_string_lossy(secret.owner.uid.inner()),
|
||||
},
|
||||
};
|
||||
|
||||
SecretInfo {
|
||||
name: secret.name,
|
||||
scope: super::common::format_owner(&owner).to_string(),
|
||||
secret_type: secret.type_,
|
||||
created_at: secret.created_at.utc(),
|
||||
updated_at: secret.updated_at.utc(),
|
||||
}
|
||||
});
|
||||
|
||||
output::print_list(secret_infos, output_format);
|
||||
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
}
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
}
|
||||
});
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
/// Read a raw secret string from either the provided file or stdin.
|
||||
fn read_simple_secret_value(args: &ValueArgs) -> Result<Option<String>> {
|
||||
if let Some(value_file) = args.value_file.as_ref() {
|
||||
let value = fs::read_to_string(value_file).with_context(|| {
|
||||
format!("Failed to read secret value from: {}", value_file.display())
|
||||
})?;
|
||||
if value.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(value))
|
||||
}
|
||||
} else if io::stdin().is_terminal() {
|
||||
let result = Password::new("Secret value:")
|
||||
.with_display_toggle_enabled()
|
||||
.without_confirmation()
|
||||
.prompt();
|
||||
match result {
|
||||
Ok(value) => {
|
||||
if value.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(value))
|
||||
}
|
||||
}
|
||||
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Ok(None),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
} else {
|
||||
let mut buf = String::new();
|
||||
io::stdin().read_to_string(&mut buf)?;
|
||||
if buf.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(buf))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs the appropriate [`ManagedSecretValue`] for single-field CLI secret types.
|
||||
fn make_simple_secret_value(secret_type: SecretType, raw: &str) -> ManagedSecretValue {
|
||||
match secret_type {
|
||||
SecretType::RawValue => ManagedSecretValue::raw_value(raw),
|
||||
SecretType::AnthropicApiKey => ManagedSecretValue::anthropic_api_key(raw),
|
||||
SecretType::AnthropicBedrockApiKey => {
|
||||
// Bedrock secrets are multi-field and handled via SecretInput::Bedrock.
|
||||
unreachable!("Bedrock secrets should not go through make_simple_secret_value")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs the appropriate [`ManagedSecretValue`] for the given GraphQL secret type.
|
||||
/// Used when updating an existing secret whose type is fetched from the server.
|
||||
fn make_secret_value_from_gql_type(
|
||||
gql_type: ManagedSecretType,
|
||||
raw: &str,
|
||||
) -> Result<ManagedSecretValue> {
|
||||
match gql_type {
|
||||
ManagedSecretType::RawValue | ManagedSecretType::Dotenvx => {
|
||||
Ok(ManagedSecretValue::raw_value(raw))
|
||||
}
|
||||
ManagedSecretType::AnthropicApiKey => Ok(ManagedSecretValue::anthropic_api_key(raw)),
|
||||
ManagedSecretType::AnthropicBedrockAccessKey => {
|
||||
// Bedrock access key secrets cannot be updated through the generic raw-string path.
|
||||
Err(anyhow::anyhow!(
|
||||
"Bedrock access key secrets cannot be updated via `--value`; re-create the secret instead"
|
||||
))
|
||||
}
|
||||
ManagedSecretType::AnthropicBedrockApiKey => {
|
||||
// Bedrock secrets cannot be updated through the generic raw-string path.
|
||||
// The caller should use the dedicated Bedrock creation flow instead.
|
||||
Err(anyhow::anyhow!(
|
||||
"Bedrock API key secrets cannot be updated via `--value`; re-create the secret instead"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a Bedrock API key secret from dedicated CLI flags or interactive prompts.
|
||||
fn read_bedrock_secret_value(
|
||||
bedrock_api_key: Option<String>,
|
||||
region: Option<String>,
|
||||
) -> Result<Option<ManagedSecretValue>> {
|
||||
let api_key = match bedrock_api_key {
|
||||
Some(k) if !k.is_empty() => k,
|
||||
_ => {
|
||||
if !io::stdin().is_terminal() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Bedrock secrets require --bedrock-api-key and --region in non-interactive mode"
|
||||
));
|
||||
}
|
||||
let result = Password::new("Bedrock API key:")
|
||||
.with_display_toggle_enabled()
|
||||
.without_confirmation()
|
||||
.prompt();
|
||||
match result {
|
||||
Ok(value) if !value.is_empty() => value,
|
||||
Ok(_) => return Ok(None),
|
||||
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
|
||||
return Ok(None);
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let region = match region {
|
||||
Some(r) if !r.is_empty() => r,
|
||||
_ => {
|
||||
if !io::stdin().is_terminal() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Bedrock secrets require --bedrock-api-key and --region in non-interactive mode"
|
||||
));
|
||||
}
|
||||
let result = inquire::Text::new("AWS Region:").prompt();
|
||||
match result {
|
||||
Ok(value) if !value.is_empty() => value,
|
||||
Ok(_) => return Ok(None),
|
||||
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
|
||||
return Ok(None);
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(ManagedSecretValue::anthropic_bedrock_api_key(
|
||||
api_key, region,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Read a Bedrock access key secret from dedicated CLI flags or interactive prompts.
|
||||
///
|
||||
/// `session_token` is optional: if the user passes an empty `--session-token`
|
||||
/// value or hits Enter at the interactive prompt, no session token is stored.
|
||||
/// This supports persistent IAM credentials, which do not require a session token.
|
||||
fn read_bedrock_access_key_secret_value(
|
||||
access_key_id: Option<String>,
|
||||
secret_access_key: Option<String>,
|
||||
session_token: Option<String>,
|
||||
region: Option<String>,
|
||||
) -> Result<Option<ManagedSecretValue>> {
|
||||
// Error message used for all three required fields when running non-interactively.
|
||||
// --session-token is intentionally omitted because it is optional.
|
||||
const NON_INTERACTIVE_REQUIRED_MSG: &str = "Bedrock access key secrets require --access-key-id, --secret-access-key, and --region in non-interactive mode";
|
||||
|
||||
let access_key_id = match access_key_id {
|
||||
Some(v) if !v.is_empty() => v,
|
||||
_ => {
|
||||
if !io::stdin().is_terminal() {
|
||||
return Err(anyhow::anyhow!(NON_INTERACTIVE_REQUIRED_MSG));
|
||||
}
|
||||
match inquire::Text::new("AWS Access Key ID:").prompt() {
|
||||
Ok(value) if !value.is_empty() => value,
|
||||
Ok(_) => return Ok(None),
|
||||
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
|
||||
return Ok(None);
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let secret_access_key = match secret_access_key {
|
||||
Some(v) if !v.is_empty() => v,
|
||||
_ => {
|
||||
if !io::stdin().is_terminal() {
|
||||
return Err(anyhow::anyhow!(NON_INTERACTIVE_REQUIRED_MSG));
|
||||
}
|
||||
match Password::new("AWS Secret Access Key:")
|
||||
.with_display_toggle_enabled()
|
||||
.without_confirmation()
|
||||
.prompt()
|
||||
{
|
||||
Ok(value) if !value.is_empty() => value,
|
||||
Ok(_) => return Ok(None),
|
||||
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
|
||||
return Ok(None);
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The session token is optional. An empty --session-token flag or an empty
|
||||
// interactive submission is treated as "no token" rather than as a cancel.
|
||||
let session_token: Option<String> = match session_token {
|
||||
Some(v) if !v.is_empty() => Some(v),
|
||||
Some(_) => None,
|
||||
None => {
|
||||
if !io::stdin().is_terminal() {
|
||||
// In non-interactive mode, omitting --session-token is fine:
|
||||
// persistent IAM credentials do not need a session token.
|
||||
None
|
||||
} else {
|
||||
match Password::new("AWS Session Token (optional, press Enter to skip):")
|
||||
.with_display_toggle_enabled()
|
||||
.without_confirmation()
|
||||
.prompt()
|
||||
{
|
||||
Ok(value) if !value.is_empty() => Some(value),
|
||||
// Empty input signals "no session token", not a cancel.
|
||||
Ok(_) => None,
|
||||
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
|
||||
return Ok(None);
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let region = match region {
|
||||
Some(r) if !r.is_empty() => r,
|
||||
_ => {
|
||||
if !io::stdin().is_terminal() {
|
||||
return Err(anyhow::anyhow!(NON_INTERACTIVE_REQUIRED_MSG));
|
||||
}
|
||||
match inquire::Text::new("AWS Region:").prompt() {
|
||||
Ok(value) if !value.is_empty() => value,
|
||||
Ok(_) => return Ok(None),
|
||||
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
|
||||
return Ok(None);
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(ManagedSecretValue::anthropic_bedrock_access_key(
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
session_token,
|
||||
region,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Finds the type of an existing secret by name and owner scope.
|
||||
fn find_secret_type(
|
||||
secrets: &[ManagedSecret],
|
||||
name: &str,
|
||||
owner: &SecretOwner,
|
||||
) -> Option<ManagedSecretType> {
|
||||
secrets
|
||||
.iter()
|
||||
.find(|s| {
|
||||
s.name == name
|
||||
&& match owner {
|
||||
SecretOwner::CurrentUser => matches!(s.owner.type_, SpaceType::User),
|
||||
SecretOwner::Team { team_uid } => {
|
||||
matches!(s.owner.type_, SpaceType::Team) && s.owner.uid.inner() == team_uid
|
||||
}
|
||||
}
|
||||
})
|
||||
.map(|s| s.type_)
|
||||
}
|
||||
|
||||
fn format_secret_type(type_: &ManagedSecretType) -> String {
|
||||
match type_ {
|
||||
ManagedSecretType::RawValue => "Raw Value".to_string(),
|
||||
ManagedSecretType::Dotenvx => "dotenvx".to_string(),
|
||||
ManagedSecretType::AnthropicApiKey => "Anthropic API Key".to_string(),
|
||||
ManagedSecretType::AnthropicBedrockAccessKey => "Anthropic Bedrock Access Key".to_string(),
|
||||
ManagedSecretType::AnthropicBedrockApiKey => "Anthropic Bedrock API Key".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
use crate::features::FeatureFlag;
|
||||
use serde_json::{json, Value};
|
||||
use strum_macros::{EnumDiscriminants, EnumIter};
|
||||
use warp_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
|
||||
|
||||
#[derive(Debug, EnumDiscriminants)]
|
||||
#[strum_discriminants(derive(EnumIter))]
|
||||
pub(super) enum CliTelemetryEvent {
|
||||
/// Executing `warp agent run`
|
||||
AgentRun {
|
||||
gui: bool,
|
||||
requested_mcp_servers: usize,
|
||||
has_environment: bool,
|
||||
/// Optional task ID when running against an ambient agent task.
|
||||
task_id: Option<String>,
|
||||
/// Which execution harness was selected (e.g. "oz", "claude").
|
||||
harness: String,
|
||||
},
|
||||
/// Executing `warp agent run-ambient`
|
||||
AgentRunAmbient,
|
||||
/// Executing `warp agent profile list`
|
||||
AgentProfileList,
|
||||
/// Executing `warp agent list`
|
||||
AgentList,
|
||||
/// Executing `warp environment list`
|
||||
EnvironmentList,
|
||||
/// Executing `warp environment create`
|
||||
EnvironmentCreate,
|
||||
/// Executing `warp environment delete`
|
||||
EnvironmentDelete,
|
||||
/// Executing `warp environment update`
|
||||
EnvironmentUpdate,
|
||||
/// Executing `warp environment get`
|
||||
EnvironmentGet,
|
||||
/// Executing `warp environment image list`
|
||||
EnvironmentImageList,
|
||||
/// Executing `warp mcp list`
|
||||
MCPList,
|
||||
/// Executing `warp model list`
|
||||
ModelList,
|
||||
/// Executing `warp task list`
|
||||
TaskList,
|
||||
/// Executing `warp task get`
|
||||
TaskGet,
|
||||
/// Executing `warp run conversation get`
|
||||
ConversationGet,
|
||||
/// Executing `warp run get <id> --conversation`
|
||||
RunConversationGet,
|
||||
/// Executing `warp run message watch`
|
||||
RunMessageWatch { harness: &'static str },
|
||||
/// Executing `warp run message send`
|
||||
RunMessageSend { harness: &'static str },
|
||||
/// Executing `warp run message list`
|
||||
RunMessageList { harness: &'static str },
|
||||
/// Executing `warp run message read`
|
||||
RunMessageRead { harness: &'static str },
|
||||
/// Executing `warp run message mark-delivered`
|
||||
RunMessageMarkDelivered { harness: &'static str },
|
||||
/// Executing `warp login`
|
||||
Login,
|
||||
/// Executing `warp logout`
|
||||
Logout,
|
||||
/// Executing `warp whoami`
|
||||
Whoami,
|
||||
/// Executing `warp provider setup`
|
||||
ProviderSetup,
|
||||
/// Executing `warp provider list`
|
||||
ProviderList,
|
||||
/// Executing `warp integration create`
|
||||
IntegrationCreate,
|
||||
/// Executing `warp integration update`
|
||||
IntegrationUpdate,
|
||||
/// Executing `warp integration list`
|
||||
IntegrationList,
|
||||
/// Executing `warp artifact upload`
|
||||
ArtifactUpload,
|
||||
/// Executing `warp artifact get`
|
||||
ArtifactGet,
|
||||
/// Executing `warp artifact download`
|
||||
ArtifactDownload,
|
||||
/// Executing `warp schedule create`
|
||||
ScheduleCreate,
|
||||
/// Executing `warp schedule list`
|
||||
ScheduleList,
|
||||
/// Executing `warp schedule get`
|
||||
ScheduleGet,
|
||||
/// Executing `warp schedule pause`
|
||||
SchedulePause,
|
||||
/// Executing `warp schedule unpause`
|
||||
ScheduleUnpause,
|
||||
/// Executing `warp schedule update`
|
||||
ScheduleUpdate,
|
||||
/// Executing `warp schedule delete`
|
||||
ScheduleDelete,
|
||||
/// Executing `warp secret create`
|
||||
SecretCreate,
|
||||
/// Executing `warp secret delete`
|
||||
SecretDelete,
|
||||
/// Executing `warp secret update`
|
||||
SecretUpdate,
|
||||
/// Executing `warp secret list`
|
||||
SecretList,
|
||||
/// Executing `warp federate issue-token`
|
||||
FederateIssueToken,
|
||||
/// Executing `warp federate issue-gcp-token`
|
||||
FederateIssueGcpToken,
|
||||
/// Executing `warp harness-support ping`
|
||||
HarnessSupportPing,
|
||||
/// Executing `warp harness-support report-artifact`
|
||||
HarnessSupportReportArtifact { artifact_type: &'static str },
|
||||
/// Executing `warp harness-support notify-user`
|
||||
HarnessSupportNotifyUser,
|
||||
/// Executing `warp harness-support finish-task`
|
||||
HarnessSupportFinishTask { success: bool },
|
||||
}
|
||||
|
||||
impl TelemetryEvent for CliTelemetryEvent {
|
||||
fn name(&self) -> &'static str {
|
||||
CliTelemetryEventDiscriminants::from(self).name()
|
||||
}
|
||||
|
||||
fn payload(&self) -> Option<Value> {
|
||||
match self {
|
||||
CliTelemetryEvent::AgentRun {
|
||||
gui,
|
||||
requested_mcp_servers,
|
||||
has_environment,
|
||||
task_id,
|
||||
harness,
|
||||
} => Some(json!({
|
||||
"gui": gui,
|
||||
"requested_mcp_servers": requested_mcp_servers,
|
||||
"has_environment": has_environment,
|
||||
"task_id": task_id,
|
||||
"harness": harness,
|
||||
})),
|
||||
CliTelemetryEvent::AgentRunAmbient => None,
|
||||
CliTelemetryEvent::AgentProfileList => None,
|
||||
CliTelemetryEvent::AgentList => None,
|
||||
CliTelemetryEvent::EnvironmentList => None,
|
||||
CliTelemetryEvent::EnvironmentCreate => None,
|
||||
CliTelemetryEvent::EnvironmentDelete => None,
|
||||
CliTelemetryEvent::EnvironmentUpdate => None,
|
||||
CliTelemetryEvent::EnvironmentGet => None,
|
||||
CliTelemetryEvent::EnvironmentImageList => None,
|
||||
CliTelemetryEvent::MCPList => None,
|
||||
CliTelemetryEvent::ModelList => None,
|
||||
CliTelemetryEvent::TaskList => None,
|
||||
CliTelemetryEvent::TaskGet => None,
|
||||
CliTelemetryEvent::ConversationGet => None,
|
||||
CliTelemetryEvent::RunConversationGet => None,
|
||||
CliTelemetryEvent::RunMessageWatch { harness } => Some(json!({ "harness": harness })),
|
||||
CliTelemetryEvent::RunMessageSend { harness } => Some(json!({ "harness": harness })),
|
||||
CliTelemetryEvent::RunMessageList { harness } => Some(json!({ "harness": harness })),
|
||||
CliTelemetryEvent::RunMessageRead { harness } => Some(json!({ "harness": harness })),
|
||||
CliTelemetryEvent::RunMessageMarkDelivered { harness } => {
|
||||
Some(json!({ "harness": harness }))
|
||||
}
|
||||
CliTelemetryEvent::Login => None,
|
||||
CliTelemetryEvent::Logout => None,
|
||||
CliTelemetryEvent::Whoami => None,
|
||||
CliTelemetryEvent::ProviderSetup => None,
|
||||
CliTelemetryEvent::ProviderList => None,
|
||||
CliTelemetryEvent::IntegrationCreate => None,
|
||||
CliTelemetryEvent::IntegrationUpdate => None,
|
||||
CliTelemetryEvent::IntegrationList => None,
|
||||
CliTelemetryEvent::ArtifactUpload => None,
|
||||
CliTelemetryEvent::ArtifactGet => None,
|
||||
CliTelemetryEvent::ArtifactDownload => None,
|
||||
CliTelemetryEvent::ScheduleCreate => None,
|
||||
CliTelemetryEvent::ScheduleList => None,
|
||||
CliTelemetryEvent::ScheduleGet => None,
|
||||
CliTelemetryEvent::SchedulePause => None,
|
||||
CliTelemetryEvent::ScheduleUnpause => None,
|
||||
CliTelemetryEvent::ScheduleUpdate => None,
|
||||
CliTelemetryEvent::ScheduleDelete => None,
|
||||
CliTelemetryEvent::SecretCreate => None,
|
||||
CliTelemetryEvent::SecretDelete => None,
|
||||
CliTelemetryEvent::SecretUpdate => None,
|
||||
CliTelemetryEvent::SecretList => None,
|
||||
CliTelemetryEvent::FederateIssueToken => None,
|
||||
CliTelemetryEvent::FederateIssueGcpToken => None,
|
||||
CliTelemetryEvent::HarnessSupportPing => None,
|
||||
CliTelemetryEvent::HarnessSupportReportArtifact { artifact_type } => {
|
||||
Some(json!({ "artifact_type": artifact_type }))
|
||||
}
|
||||
CliTelemetryEvent::HarnessSupportNotifyUser => None,
|
||||
CliTelemetryEvent::HarnessSupportFinishTask { success } => {
|
||||
Some(json!({ "success": success }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
CliTelemetryEventDiscriminants::from(self).description()
|
||||
}
|
||||
|
||||
fn enablement_state(&self) -> EnablementState {
|
||||
CliTelemetryEventDiscriminants::from(self).enablement_state()
|
||||
}
|
||||
|
||||
fn contains_ugc(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn event_descs() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>> {
|
||||
warp_core::telemetry::enum_events::<Self>()
|
||||
}
|
||||
}
|
||||
|
||||
impl TelemetryEventDesc for CliTelemetryEventDiscriminants {
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
CliTelemetryEventDiscriminants::AgentRun => "CLI.Execute.Agent.Run",
|
||||
CliTelemetryEventDiscriminants::AgentRunAmbient => "CLI.Execute.Agent.RunAmbient",
|
||||
CliTelemetryEventDiscriminants::AgentProfileList => "CLI.Execute.Agent.Profile.List",
|
||||
CliTelemetryEventDiscriminants::AgentList => "CLI.Execute.Agent.List",
|
||||
CliTelemetryEventDiscriminants::EnvironmentList => "CLI.Execute.Environment.List",
|
||||
CliTelemetryEventDiscriminants::EnvironmentCreate => "CLI.Execute.Environment.Create",
|
||||
CliTelemetryEventDiscriminants::EnvironmentDelete => "CLI.Execute.Environment.Delete",
|
||||
CliTelemetryEventDiscriminants::EnvironmentUpdate => "CLI.Execute.Environment.Update",
|
||||
CliTelemetryEventDiscriminants::EnvironmentGet => "CLI.Execute.Environment.Get",
|
||||
CliTelemetryEventDiscriminants::EnvironmentImageList => {
|
||||
"CLI.Execute.Environment.Image.List"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::MCPList => "CLI.Execute.MCP.List",
|
||||
CliTelemetryEventDiscriminants::ModelList => "CLI.Execute.Model.List",
|
||||
CliTelemetryEventDiscriminants::TaskList => "CLI.Execute.Task.List",
|
||||
CliTelemetryEventDiscriminants::TaskGet => "CLI.Execute.Task.Get",
|
||||
CliTelemetryEventDiscriminants::ConversationGet => "CLI.Execute.Conversation.Get",
|
||||
CliTelemetryEventDiscriminants::RunConversationGet => {
|
||||
"CLI.Execute.Run.Conversation.Get"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::RunMessageWatch => "CLI.Execute.Run.Message.Watch",
|
||||
CliTelemetryEventDiscriminants::RunMessageSend => "CLI.Execute.Run.Message.Send",
|
||||
CliTelemetryEventDiscriminants::RunMessageList => "CLI.Execute.Run.Message.List",
|
||||
CliTelemetryEventDiscriminants::RunMessageRead => "CLI.Execute.Run.Message.Read",
|
||||
CliTelemetryEventDiscriminants::RunMessageMarkDelivered => {
|
||||
"CLI.Execute.Run.Message.MarkDelivered"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::Login => "CLI.Execute.Login",
|
||||
CliTelemetryEventDiscriminants::Logout => "CLI.Execute.Logout",
|
||||
CliTelemetryEventDiscriminants::Whoami => "CLI.Execute.Whoami",
|
||||
CliTelemetryEventDiscriminants::ProviderSetup => "CLI.Execute.Provider.Setup",
|
||||
CliTelemetryEventDiscriminants::ProviderList => "CLI.Execute.Provider.List",
|
||||
CliTelemetryEventDiscriminants::IntegrationCreate => "CLI.Execute.Integration.Create",
|
||||
CliTelemetryEventDiscriminants::IntegrationUpdate => "CLI.Execute.Integration.Update",
|
||||
CliTelemetryEventDiscriminants::IntegrationList => "CLI.Execute.Integration.List",
|
||||
CliTelemetryEventDiscriminants::ArtifactUpload => "CLI.Execute.Artifact.Upload",
|
||||
CliTelemetryEventDiscriminants::ArtifactGet => "CLI.Execute.Artifact.Get",
|
||||
CliTelemetryEventDiscriminants::ArtifactDownload => "CLI.Execute.Artifact.Download",
|
||||
CliTelemetryEventDiscriminants::ScheduleCreate => "CLI.Execute.Schedule.Create",
|
||||
CliTelemetryEventDiscriminants::ScheduleList => "CLI.Execute.Schedule.List",
|
||||
CliTelemetryEventDiscriminants::ScheduleGet => "CLI.Execute.Schedule.Get",
|
||||
CliTelemetryEventDiscriminants::SchedulePause => "CLI.Execute.Schedule.Pause",
|
||||
CliTelemetryEventDiscriminants::ScheduleUnpause => "CLI.Execute.Schedule.Unpause",
|
||||
CliTelemetryEventDiscriminants::ScheduleUpdate => "CLI.Execute.Schedule.Update",
|
||||
CliTelemetryEventDiscriminants::ScheduleDelete => "CLI.Execute.Schedule.Delete",
|
||||
CliTelemetryEventDiscriminants::SecretCreate => "CLI.Execute.Secret.Create",
|
||||
CliTelemetryEventDiscriminants::SecretDelete => "CLI.Execute.Secret.Delete",
|
||||
CliTelemetryEventDiscriminants::SecretUpdate => "CLI.Execute.Secret.Update",
|
||||
CliTelemetryEventDiscriminants::SecretList => "CLI.Execute.Secret.List",
|
||||
CliTelemetryEventDiscriminants::FederateIssueToken => "CLI.Execute.Federate.IssueToken",
|
||||
CliTelemetryEventDiscriminants::FederateIssueGcpToken => {
|
||||
"CLI.Execute.Federate.IssueGcpToken"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::HarnessSupportPing => "CLI.Execute.HarnessSupport.Ping",
|
||||
CliTelemetryEventDiscriminants::HarnessSupportReportArtifact => {
|
||||
"CLI.Execute.HarnessSupport.ReportArtifact"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::HarnessSupportNotifyUser => {
|
||||
"CLI.Execute.HarnessSupport.NotifyUser"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::HarnessSupportFinishTask => {
|
||||
"CLI.Execute.HarnessSupport.FinishTask"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
match self {
|
||||
CliTelemetryEventDiscriminants::AgentRun => "Ran an agent from the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::AgentRunAmbient => {
|
||||
"Ran an ambient agent from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::AgentProfileList => {
|
||||
"Listed agent profiles from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::AgentList => "Listed agents from the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::EnvironmentList => {
|
||||
"Listed cloud environments from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::EnvironmentCreate => {
|
||||
"Created a cloud environment from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::EnvironmentDelete => {
|
||||
"Deleted a cloud environment from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::EnvironmentUpdate => {
|
||||
"Updated a cloud environment from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::EnvironmentGet => {
|
||||
"Got cloud environment details from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::EnvironmentImageList => {
|
||||
"Listed available base images from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::MCPList => "Listed MCP servers from the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::ModelList => "Listed models from the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::TaskList => "Listed tasks from the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::TaskGet => "Got status of task from the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::ConversationGet => {
|
||||
"Got conversation by ID from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::RunConversationGet => {
|
||||
"Got run conversation from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::RunMessageWatch => {
|
||||
"Watched run messages from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::RunMessageSend => {
|
||||
"Sent a run message from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::RunMessageList => {
|
||||
"Listed run messages from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::RunMessageRead => {
|
||||
"Read a run message from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::RunMessageMarkDelivered => {
|
||||
"Marked a run message as delivered from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::Login => "Logged in via the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::Logout => "Logged out via the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::Whoami => "Printed current user info from the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::ProviderSetup => "Set up a provider via the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::ProviderList => "Listed providers from the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::IntegrationCreate => {
|
||||
"Created an integration from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::IntegrationUpdate => {
|
||||
"Updated an integration from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::IntegrationList => {
|
||||
"Listed integrations from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::ArtifactUpload => {
|
||||
"Uploaded an artifact from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::ArtifactGet => {
|
||||
"Got artifact metadata from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::ArtifactDownload => {
|
||||
"Downloaded an artifact from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::ScheduleCreate => {
|
||||
"Created a scheduled agent from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::ScheduleList => {
|
||||
"Listed scheduled agents from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::ScheduleGet => {
|
||||
"Got scheduled agent configuration from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::SchedulePause => {
|
||||
"Paused a scheduled agent from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::ScheduleUnpause => {
|
||||
"Unpaused a scheduled agent from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::ScheduleUpdate => {
|
||||
"Updated a scheduled agent from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::ScheduleDelete => {
|
||||
"Deleted a scheduled agent from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::SecretCreate => "Created a secret from the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::SecretDelete => "Deleted a secret from the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::SecretUpdate => "Updated a secret from the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::SecretList => "Listed secrets from the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::FederateIssueToken => {
|
||||
"Issued a federated identity token from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::FederateIssueGcpToken => {
|
||||
"Issued a GCP federated identity token from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::HarnessSupportPing => {
|
||||
"Pinged harness-support from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::HarnessSupportReportArtifact => {
|
||||
"Reported an artifact via harness-support from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::HarnessSupportNotifyUser => {
|
||||
"Sent a user notification via harness-support from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::HarnessSupportFinishTask => {
|
||||
"Reported task completion via harness-support from the Warp CLI"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn enablement_state(&self) -> EnablementState {
|
||||
match self {
|
||||
Self::FederateIssueToken | Self::FederateIssueGcpToken => {
|
||||
EnablementState::Flag(FeatureFlag::OzIdentityFederation)
|
||||
}
|
||||
Self::HarnessSupportPing
|
||||
| Self::HarnessSupportReportArtifact
|
||||
| Self::HarnessSupportNotifyUser
|
||||
| Self::HarnessSupportFinishTask => EnablementState::Flag(FeatureFlag::AgentHarness),
|
||||
Self::ArtifactUpload | Self::ArtifactGet | Self::ArtifactDownload => {
|
||||
EnablementState::Flag(FeatureFlag::ArtifactCommand)
|
||||
}
|
||||
Self::RunMessageWatch
|
||||
| Self::RunMessageSend
|
||||
| Self::RunMessageList
|
||||
| Self::RunMessageRead
|
||||
| Self::RunMessageMarkDelivered => EnablementState::Flag(FeatureFlag::OrchestrationV2),
|
||||
_ => EnablementState::Always,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
warp_core::register_telemetry_event!(CliTelemetryEvent);
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Test-only helpers shared across `agent_sdk` unit tests.
|
||||
|
||||
/// Build a reqwest-backed `http_client::Client` with TLS/proxy disabled and the connection
|
||||
/// pool disabled (`pool_max_idle_per_host(0)`).
|
||||
///
|
||||
/// The pool setting is the key bit: reqwest's default 90-second idle timeout holds sockets
|
||||
/// open past a test's return, which nextest's leak detector flags on retry-exhaustion tests
|
||||
/// that spin up multiple connections.
|
||||
pub(super) fn build_test_http_client() -> http_client::Client {
|
||||
let builder = reqwest::ClientBuilder::new()
|
||||
.tls_built_in_native_certs(false)
|
||||
.tls_built_in_root_certs(false)
|
||||
.tls_built_in_webpki_certs(false)
|
||||
.no_proxy()
|
||||
.pool_max_idle_per_host(0);
|
||||
http_client::Client::from_client_builder(builder)
|
||||
.expect("should not fail to build test http client")
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/// Helper function to wrap text to fit within a maximum width, breaking on word boundaries.
|
||||
pub fn word_wrap(text: &str, max_width: usize) -> Vec<String> {
|
||||
if text.trim().is_empty() {
|
||||
return vec![String::new()];
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
let mut current = String::new();
|
||||
|
||||
for word in text.split_whitespace() {
|
||||
let needs_space = !current.is_empty();
|
||||
let prospective_len = current.len() + if needs_space { 1 } else { 0 } + word.len();
|
||||
|
||||
if current.is_empty() {
|
||||
if word.len() <= max_width {
|
||||
current.push_str(word);
|
||||
} else {
|
||||
// Single word longer than max width: emit it as its own line
|
||||
lines.push(word.to_string());
|
||||
}
|
||||
} else if prospective_len <= max_width {
|
||||
if needs_space {
|
||||
current.push(' ');
|
||||
}
|
||||
current.push_str(word);
|
||||
} else {
|
||||
// Push current line and start a new one with the word (or emit if too long)
|
||||
if !current.is_empty() {
|
||||
if current.len() < max_width {
|
||||
let pad = max_width - current.len();
|
||||
current.extend(std::iter::repeat_n(' ', pad));
|
||||
}
|
||||
lines.push(std::mem::take(&mut current));
|
||||
}
|
||||
if word.len() <= max_width {
|
||||
current.push_str(word);
|
||||
} else {
|
||||
lines.push(word.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !current.is_empty() {
|
||||
if current.len() < max_width {
|
||||
let pad = max_width - current.len();
|
||||
current.extend(std::iter::repeat_n(' ', pad));
|
||||
}
|
||||
lines.push(current);
|
||||
}
|
||||
|
||||
if lines.is_empty() {
|
||||
vec![String::new()]
|
||||
} else {
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a labeled, wrapped field as a single multi-line cell.
|
||||
pub fn render_labeled_wrapped_field(label: &str, text: &str, width: usize) -> String {
|
||||
let mut out = String::new();
|
||||
let wrapped = word_wrap(text, width);
|
||||
let indent = " ".repeat(label.len() + 2); // align under "{label}: "
|
||||
for (i, line) in wrapped.iter().enumerate() {
|
||||
if i == 0 {
|
||||
out.push_str(&format!("{label}: {line}"));
|
||||
} else {
|
||||
out.push('\n');
|
||||
out.push_str(&indent);
|
||||
out.push_str(line);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Convert an identifier like a slug (e.g. "slack", "github-actions") into a
|
||||
/// human-friendly, title-cased name (e.g. "Slack", "Github Actions").
|
||||
pub fn title_case_identifier(s: &str) -> String {
|
||||
let mut parts = Vec::new();
|
||||
for part in s.split(|c: char| !c.is_alphanumeric()) {
|
||||
if part.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut chars = part.chars();
|
||||
let first = chars
|
||||
.next()
|
||||
.expect("non-empty identifier component must have a first character");
|
||||
let mut word = String::new();
|
||||
word.extend(first.to_uppercase());
|
||||
word.push_str(&chars.as_str().to_lowercase());
|
||||
parts.push(word);
|
||||
}
|
||||
if parts.is_empty() {
|
||||
s.to_string()
|
||||
} else {
|
||||
parts.join(" ")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user