first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -4,6 +4,8 @@ use anyhow::{Context, Result};
|
||||
use galaxy_cli::agent::OutputFormat;
|
||||
use galaxyui::{platform::TerminationMode, AppContext, SingletonEntity};
|
||||
use serde::Serialize;
|
||||
use galaxyui::platform::TerminationMode;
|
||||
use galaxyui::{AppContext, SingletonEntity};
|
||||
|
||||
use crate::auth::auth_manager::{AuthManager, AuthManagerEvent};
|
||||
use crate::auth::user::PrincipalType;
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
//! Commands to interact with available agents via the public API.
|
||||
|
||||
use warp_cli::agent::ListAgentSkillsArgs;
|
||||
use warp_graphql::queries::get_oauth_connect_tx_status::OauthConnectTxStatus;
|
||||
use warp_graphql::queries::user_repo_auth_status::UserRepoAuthStatusEnum;
|
||||
use warpui::platform::TerminationMode;
|
||||
use warpui::{AppContext, ModelContext, SingletonEntity};
|
||||
|
||||
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::ai::AgentSkillItem;
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use galaxy_cli::agent::ListAgentConfigsArgs;
|
||||
use galaxy_graphql::queries::get_oauth_connect_tx_status::OauthConnectTxStatus;
|
||||
use galaxy_graphql::queries::user_repo_auth_status::UserRepoAuthStatusEnum;
|
||||
use galaxyui::{platform::TerminationMode, AppContext, ModelContext, SingletonEntity};
|
||||
|
||||
const MAX_LINE_WIDTH: usize = 90;
|
||||
const MAX_AUTH_ATTEMPTS: u32 = 8;
|
||||
@@ -15,8 +17,8 @@ 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<()> {
|
||||
/// List all available agent skills.
|
||||
pub fn list_skills(ctx: &mut AppContext, args: ListAgentSkillsArgs) -> anyhow::Result<()> {
|
||||
let runner = ctx.add_singleton_model(|_ctx| AgentConfigRunner);
|
||||
runner.update(ctx, |runner, ctx| runner.list(args.repo.clone(), ctx))
|
||||
}
|
||||
@@ -220,7 +222,7 @@ impl AgentConfigRunner {
|
||||
println!("Fetching agent skills from your Warp environments...");
|
||||
}
|
||||
|
||||
let list_future = async move { ai_client.list_agents(repo).await };
|
||||
let list_future = async move { ai_client.list_skills(repo).await };
|
||||
|
||||
ctx.spawn(list_future, |_, result, ctx| match result {
|
||||
Ok(agents) => {
|
||||
@@ -234,9 +236,9 @@ impl AgentConfigRunner {
|
||||
}
|
||||
|
||||
/// Print a list of agents in a card-style format.
|
||||
fn print_agents_table(agents: &[AgentListItem]) {
|
||||
fn print_agents_table(agents: &[AgentSkillItem]) {
|
||||
if agents.is_empty() {
|
||||
println!("No agents found.");
|
||||
println!("No skills found.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
//! Commands to manage named agents via the public API.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::io::Write as _;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use comfy_table::Cell;
|
||||
use serde::Serialize;
|
||||
use warp_cli::agent::{
|
||||
AgentCreateArgs, AgentDeleteArgs, AgentGetArgs, AgentListArgs, AgentSortByArg, AgentUpdateArgs,
|
||||
OutputFormat,
|
||||
};
|
||||
use warp_cli::json_filter::JsonOutput;
|
||||
use warp_cli::SortOrderArg;
|
||||
use warpui::platform::TerminationMode;
|
||||
use warpui::{AppContext, ModelContext, SingletonEntity};
|
||||
|
||||
use super::output::TableFormat;
|
||||
use crate::server::server_api::ai::{
|
||||
AgentResponse, CreateAgentRequest, SecretRef, UpdateAgentRequest,
|
||||
};
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
|
||||
/// Singleton model that runs async work for named-agent CLI commands.
|
||||
struct AgentManagementRunner;
|
||||
|
||||
pub fn list_agents(
|
||||
ctx: &mut AppContext,
|
||||
output_format: OutputFormat,
|
||||
args: AgentListArgs,
|
||||
) -> anyhow::Result<()> {
|
||||
let runner = ctx.add_singleton_model(|_ctx| AgentManagementRunner);
|
||||
runner.update(ctx, |runner, ctx| runner.list(args, output_format, ctx))
|
||||
}
|
||||
|
||||
pub fn get_agent(
|
||||
ctx: &mut AppContext,
|
||||
output_format: OutputFormat,
|
||||
args: AgentGetArgs,
|
||||
) -> anyhow::Result<()> {
|
||||
let runner = ctx.add_singleton_model(|_ctx| AgentManagementRunner);
|
||||
runner.update(ctx, |runner, ctx| runner.get(args, output_format, ctx))
|
||||
}
|
||||
|
||||
pub fn create_agent(
|
||||
ctx: &mut AppContext,
|
||||
output_format: OutputFormat,
|
||||
args: AgentCreateArgs,
|
||||
) -> anyhow::Result<()> {
|
||||
let runner = ctx.add_singleton_model(|_ctx| AgentManagementRunner);
|
||||
runner.update(ctx, |runner, ctx| runner.create(args, output_format, ctx))
|
||||
}
|
||||
|
||||
pub fn update_agent(
|
||||
ctx: &mut AppContext,
|
||||
output_format: OutputFormat,
|
||||
args: AgentUpdateArgs,
|
||||
) -> anyhow::Result<()> {
|
||||
let runner = ctx.add_singleton_model(|_ctx| AgentManagementRunner);
|
||||
runner.update(ctx, |runner, ctx| runner.update(args, output_format, ctx))
|
||||
}
|
||||
|
||||
pub fn delete_agent(
|
||||
ctx: &mut AppContext,
|
||||
output_format: OutputFormat,
|
||||
args: AgentDeleteArgs,
|
||||
) -> anyhow::Result<()> {
|
||||
let runner = ctx.add_singleton_model(|_ctx| AgentManagementRunner);
|
||||
runner.update(ctx, |runner, ctx| runner.delete(args, output_format, ctx))
|
||||
}
|
||||
impl AgentManagementRunner {
|
||||
fn spawn_command(
|
||||
&self,
|
||||
future: impl warpui::r#async::Spawnable<Output = anyhow::Result<()>>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
ctx.spawn(future, |_, result, ctx| match result {
|
||||
Ok(()) => {
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
}
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn list(
|
||||
&self,
|
||||
args: AgentListArgs,
|
||||
output_format: OutputFormat,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> anyhow::Result<()> {
|
||||
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
|
||||
let future = async move {
|
||||
ensure_json_sort_is_not_requested(output_format, &args.json_output, &args)?;
|
||||
|
||||
if matches!(output_format, OutputFormat::Json) || args.json_output.force_json_output() {
|
||||
let response = ai_client.list_agents_raw().await?;
|
||||
super::output::print_raw_json(response, &args.json_output)?;
|
||||
} else {
|
||||
let mut agents = ai_client.list_agents().await?;
|
||||
sort_agents(&mut agents, args.sort_by, args.sort_order);
|
||||
print_agents(&agents, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
self.spawn_command(future, ctx);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get(
|
||||
&self,
|
||||
args: AgentGetArgs,
|
||||
output_format: OutputFormat,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> anyhow::Result<()> {
|
||||
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
|
||||
let future = async move {
|
||||
if matches!(output_format, OutputFormat::Json) || args.json_output.force_json_output() {
|
||||
let response = ai_client.get_agent_raw(&args.uid).await?;
|
||||
super::output::print_raw_json(response, &args.json_output)?;
|
||||
} else {
|
||||
let agent = ai_client.get_agent(&args.uid).await?;
|
||||
print_single_agent(&agent, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
self.spawn_command(future, ctx);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create(
|
||||
&self,
|
||||
args: AgentCreateArgs,
|
||||
output_format: OutputFormat,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> anyhow::Result<()> {
|
||||
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
|
||||
let future = async move {
|
||||
let json_output = args.json_output.clone();
|
||||
let request = CreateAgentRequest {
|
||||
name: args.name,
|
||||
description: args.description,
|
||||
secrets: secret_refs(args.secrets),
|
||||
skills: args.skills,
|
||||
base_model: args.base_model,
|
||||
environment_id: args.environment,
|
||||
};
|
||||
if matches!(output_format, OutputFormat::Json) || json_output.force_json_output() {
|
||||
let response = ai_client.create_agent_raw(request).await?;
|
||||
super::output::print_raw_json(response, &json_output)?;
|
||||
} else {
|
||||
let agent = ai_client.create_agent(request).await?;
|
||||
print_single_agent(&agent, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
self.spawn_command(future, ctx);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update(
|
||||
&self,
|
||||
args: AgentUpdateArgs,
|
||||
output_format: OutputFormat,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> anyhow::Result<()> {
|
||||
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
|
||||
let future = async move {
|
||||
let uid = args.uid.clone();
|
||||
let json_output = args.json_output.clone();
|
||||
let current_agent = if args.add_secrets.is_empty()
|
||||
&& args.remove_secrets.is_empty()
|
||||
&& args.add_skills.is_empty()
|
||||
&& args.remove_skills.is_empty()
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(ai_client.get_agent(&uid).await?)
|
||||
};
|
||||
let request = UpdateAgentRequest {
|
||||
name: args.name,
|
||||
description: if args.remove_description {
|
||||
Some(String::new())
|
||||
} else {
|
||||
args.description
|
||||
},
|
||||
secrets: if args.remove_all_secrets {
|
||||
Some(vec![])
|
||||
} else if args.add_secrets.is_empty() && args.remove_secrets.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let current_agent = current_agent
|
||||
.as_ref()
|
||||
.expect("current agent is fetched when applying secret deltas");
|
||||
Some(apply_secret_deltas(
|
||||
¤t_agent.secrets,
|
||||
args.add_secrets,
|
||||
args.remove_secrets,
|
||||
))
|
||||
},
|
||||
skills: if args.remove_all_skills {
|
||||
Some(vec![])
|
||||
} else if args.add_skills.is_empty() && args.remove_skills.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let current_agent = current_agent
|
||||
.as_ref()
|
||||
.expect("current agent is fetched when applying skill deltas");
|
||||
Some(apply_string_deltas(
|
||||
¤t_agent.skills,
|
||||
args.add_skills,
|
||||
args.remove_skills,
|
||||
))
|
||||
},
|
||||
base_model: if args.remove_base_model {
|
||||
Some(String::new())
|
||||
} else {
|
||||
args.base_model
|
||||
},
|
||||
environment_id: if args.remove_environment {
|
||||
Some(String::new())
|
||||
} else {
|
||||
args.environment
|
||||
},
|
||||
};
|
||||
if request_is_empty(&request) {
|
||||
return Err(anyhow!("No updates requested"));
|
||||
}
|
||||
|
||||
if matches!(output_format, OutputFormat::Json) || json_output.force_json_output() {
|
||||
let response = ai_client.update_agent_raw(&uid, request).await?;
|
||||
super::output::print_raw_json(response, &json_output)?;
|
||||
} else {
|
||||
let agent = ai_client.update_agent(&uid, request).await?;
|
||||
print_single_agent(&agent, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
self.spawn_command(future, ctx);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete(
|
||||
&self,
|
||||
args: AgentDeleteArgs,
|
||||
output_format: OutputFormat,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> anyhow::Result<()> {
|
||||
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
|
||||
let future = async move {
|
||||
ai_client.delete_agent(&args.uid).await?;
|
||||
print_delete_result(&args.uid, output_format)?;
|
||||
Ok(())
|
||||
};
|
||||
self.spawn_command(future, ctx);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn secret_refs(secrets: Vec<String>) -> Vec<SecretRef> {
|
||||
secrets.into_iter().map(|name| SecretRef { name }).collect()
|
||||
}
|
||||
|
||||
/// Add and remove the requested secrets, starting with `current` as a baseline.
|
||||
fn apply_secret_deltas(
|
||||
current: &[SecretRef],
|
||||
add_secrets: Vec<String>,
|
||||
remove_secrets: Vec<String>,
|
||||
) -> Vec<SecretRef> {
|
||||
let names = apply_string_deltas(
|
||||
¤t
|
||||
.iter()
|
||||
.map(|secret| secret.name.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
add_secrets,
|
||||
remove_secrets,
|
||||
);
|
||||
secret_refs(names)
|
||||
}
|
||||
|
||||
/// Add and remove the requested values, starting with `current` as a baseline.
|
||||
fn apply_string_deltas(
|
||||
current: &[String],
|
||||
add_values: Vec<String>,
|
||||
remove_values: Vec<String>,
|
||||
) -> Vec<String> {
|
||||
let mut values = current
|
||||
.iter()
|
||||
.filter(|value| !remove_values.contains(value))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
for value in add_values {
|
||||
if !values.contains(&value) {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
values
|
||||
}
|
||||
|
||||
fn request_is_empty(request: &UpdateAgentRequest) -> bool {
|
||||
request.name.is_none()
|
||||
&& request.description.is_none()
|
||||
&& request.secrets.is_none()
|
||||
&& request.skills.is_none()
|
||||
&& request.base_model.is_none()
|
||||
&& request.environment_id.is_none()
|
||||
}
|
||||
|
||||
fn ensure_json_sort_is_not_requested(
|
||||
output_format: OutputFormat,
|
||||
json_output: &JsonOutput,
|
||||
args: &AgentListArgs,
|
||||
) -> anyhow::Result<()> {
|
||||
if (matches!(output_format, OutputFormat::Json) || json_output.force_json_output())
|
||||
&& (args.sort_by.is_some() || args.sort_order.is_some())
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"--sort-by and --sort-order are not supported with JSON output"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sort_agents(
|
||||
agents: &mut [AgentResponse],
|
||||
sort_by: Option<AgentSortByArg>,
|
||||
sort_order: Option<SortOrderArg>,
|
||||
) {
|
||||
let sort_by = sort_by.unwrap_or(AgentSortByArg::Name);
|
||||
let default_order = match sort_by {
|
||||
AgentSortByArg::Name => SortOrderArg::Asc,
|
||||
AgentSortByArg::CreatedAt => SortOrderArg::Desc,
|
||||
};
|
||||
let sort_order = sort_order.unwrap_or(default_order);
|
||||
|
||||
agents.sort_by(|left, right| {
|
||||
let ordering = match sort_by {
|
||||
AgentSortByArg::Name => left
|
||||
.name
|
||||
.to_lowercase()
|
||||
.cmp(&right.name.to_lowercase())
|
||||
.then_with(|| left.uid.cmp(&right.uid)),
|
||||
AgentSortByArg::CreatedAt => left
|
||||
.created_at
|
||||
.cmp(&right.created_at)
|
||||
.then_with(|| left.uid.cmp(&right.uid)),
|
||||
};
|
||||
|
||||
match sort_order {
|
||||
SortOrderArg::Asc => ordering,
|
||||
SortOrderArg::Desc => match ordering {
|
||||
Ordering::Less => Ordering::Greater,
|
||||
Ordering::Equal => Ordering::Equal,
|
||||
Ordering::Greater => Ordering::Less,
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
impl TableFormat for AgentResponse {
|
||||
fn header() -> Vec<Cell> {
|
||||
vec![
|
||||
Cell::new("UID"),
|
||||
Cell::new("Name"),
|
||||
Cell::new("Created"),
|
||||
Cell::new("Description"),
|
||||
Cell::new("Secrets"),
|
||||
Cell::new("Skills"),
|
||||
Cell::new("Base model"),
|
||||
Cell::new("Environment"),
|
||||
]
|
||||
}
|
||||
|
||||
fn row(&self) -> Vec<Cell> {
|
||||
vec![
|
||||
Cell::new(&self.uid),
|
||||
Cell::new(&self.name),
|
||||
Cell::new(self.created_at.to_rfc3339()),
|
||||
Cell::new(display_optional(self.description.as_deref())),
|
||||
Cell::new(display_list(
|
||||
self.secrets.iter().map(|secret| secret.name.as_str()),
|
||||
)),
|
||||
Cell::new(display_list(self.skills.iter().map(String::as_str))),
|
||||
Cell::new(display_optional(self.base_model.as_deref())),
|
||||
Cell::new(display_optional(self.environment_id.as_deref())),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
fn print_agents(agents: &[AgentResponse], output_format: OutputFormat) -> anyhow::Result<()> {
|
||||
match output_format {
|
||||
OutputFormat::Pretty | OutputFormat::Text => {
|
||||
let (visible_agents, hidden_count) = visible_agents_and_hidden_count(agents);
|
||||
match output_format {
|
||||
OutputFormat::Pretty if visible_agents.is_empty() => {
|
||||
println!("No agents found.");
|
||||
print_skills_hint();
|
||||
}
|
||||
OutputFormat::Pretty => {
|
||||
super::output::write_list(visible_agents, output_format, std::io::stdout())?;
|
||||
print_skills_hint();
|
||||
}
|
||||
OutputFormat::Text => {
|
||||
super::output::write_list(visible_agents, output_format, std::io::stdout())?;
|
||||
}
|
||||
OutputFormat::Json | OutputFormat::Ndjson => {
|
||||
unreachable!("handled by outer match")
|
||||
}
|
||||
}
|
||||
print_disabled_agents_hidden_notice(hidden_count);
|
||||
}
|
||||
OutputFormat::Ndjson => {
|
||||
for agent in agents {
|
||||
super::output::write_json_line(agent, std::io::stdout())?;
|
||||
}
|
||||
}
|
||||
OutputFormat::Json => unreachable!("JSON output is handled by the raw API path"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn visible_agents_and_hidden_count(agents: &[AgentResponse]) -> (Vec<AgentResponse>, usize) {
|
||||
let visible_agents = agents
|
||||
.iter()
|
||||
.filter(|agent| agent.available)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let hidden_count = agents.len() - visible_agents.len();
|
||||
(visible_agents, hidden_count)
|
||||
}
|
||||
|
||||
fn print_disabled_agents_hidden_notice(hidden_count: usize) {
|
||||
if hidden_count > 0 {
|
||||
eprintln!("{hidden_count} disabled agents hidden");
|
||||
}
|
||||
}
|
||||
fn print_single_agent(agent: &AgentResponse, output_format: OutputFormat) -> anyhow::Result<()> {
|
||||
match output_format {
|
||||
OutputFormat::Pretty | OutputFormat::Text => {
|
||||
super::output::write_list([agent.clone()], output_format, std::io::stdout())?;
|
||||
}
|
||||
OutputFormat::Ndjson => {
|
||||
super::output::write_json_line(agent, std::io::stdout())?;
|
||||
}
|
||||
OutputFormat::Json => unreachable!("JSON output is handled by the raw API path"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_skills_hint() {
|
||||
let binary_name = warp_cli::binary_name().unwrap_or_else(|| "warp".to_string());
|
||||
println!("\n\nLooking for your agent skills? Use `{binary_name} agent skills` instead.");
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DeleteAgentResult<'a> {
|
||||
uid: &'a str,
|
||||
deleted: bool,
|
||||
}
|
||||
|
||||
fn print_delete_result(uid: &str, output_format: OutputFormat) -> anyhow::Result<()> {
|
||||
match output_format {
|
||||
OutputFormat::Pretty => {
|
||||
println!("Deleted agent {uid}.");
|
||||
}
|
||||
OutputFormat::Text => {
|
||||
let mut stdout = std::io::stdout();
|
||||
writeln!(stdout, "{uid}")?;
|
||||
}
|
||||
OutputFormat::Ndjson => {
|
||||
super::output::write_json_line(
|
||||
&DeleteAgentResult { uid, deleted: true },
|
||||
std::io::stdout(),
|
||||
)?;
|
||||
}
|
||||
OutputFormat::Json => {
|
||||
super::output::write_json(
|
||||
&DeleteAgentResult { uid, deleted: true },
|
||||
std::io::stdout(),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn display_optional(value: Option<&str>) -> String {
|
||||
value
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("-")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn display_list<'a>(values: impl IntoIterator<Item = &'a str>) -> String {
|
||||
let values = values
|
||||
.into_iter()
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if values.is_empty() {
|
||||
"-".to_string()
|
||||
} else {
|
||||
values.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
impl warpui::Entity for AgentManagementRunner {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for AgentManagementRunner {}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "agent_management_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,170 @@
|
||||
use chrono::{TimeZone as _, Utc};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn agent(uid: &str, name: &str, created_at_seconds: i64) -> AgentResponse {
|
||||
agent_with_available(uid, name, created_at_seconds, true)
|
||||
}
|
||||
|
||||
fn agent_with_available(
|
||||
uid: &str,
|
||||
name: &str,
|
||||
created_at_seconds: i64,
|
||||
available: bool,
|
||||
) -> AgentResponse {
|
||||
AgentResponse {
|
||||
uid: uid.to_string(),
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
available,
|
||||
created_at: Utc
|
||||
.timestamp_opt(created_at_seconds, 0)
|
||||
.single()
|
||||
.expect("valid timestamp"),
|
||||
secrets: vec![],
|
||||
skills: vec![],
|
||||
base_model: None,
|
||||
environment_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_format_does_not_include_available_column() {
|
||||
let header = AgentResponse::header()
|
||||
.into_iter()
|
||||
.map(|cell| cell.content().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
let row = agent("1", "agent", 1).row();
|
||||
|
||||
assert_eq!(
|
||||
header,
|
||||
[
|
||||
"UID",
|
||||
"Name",
|
||||
"Created",
|
||||
"Description",
|
||||
"Secrets",
|
||||
"Skills",
|
||||
"Base model",
|
||||
"Environment",
|
||||
]
|
||||
);
|
||||
assert_eq!(row.len(), header.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_agents_and_hidden_count_filters_disabled_agents() {
|
||||
let agents = vec![
|
||||
agent_with_available("1", "enabled", 1, true),
|
||||
agent_with_available("2", "disabled", 2, false),
|
||||
];
|
||||
|
||||
let (visible_agents, hidden_count) = visible_agents_and_hidden_count(&agents);
|
||||
|
||||
assert_eq!(visible_agents.len(), 1);
|
||||
assert_eq!(visible_agents[0].name, "enabled");
|
||||
assert_eq!(hidden_count, 1);
|
||||
}
|
||||
#[test]
|
||||
fn sort_agents_defaults_to_name_ascending() {
|
||||
let mut agents = vec![agent("2", "zeta", 2), agent("1", "alpha", 1)];
|
||||
|
||||
sort_agents(&mut agents, None, None);
|
||||
|
||||
assert_eq!(agents[0].name, "alpha");
|
||||
assert_eq!(agents[1].name, "zeta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_agents_defaults_created_at_to_descending() {
|
||||
let mut agents = vec![agent("1", "old", 1), agent("2", "new", 2)];
|
||||
|
||||
sort_agents(&mut agents, Some(AgentSortByArg::CreatedAt), None);
|
||||
|
||||
assert_eq!(agents[0].name, "new");
|
||||
assert_eq!(agents[1].name, "old");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_agents_respects_explicit_sort_order_without_sort_field() {
|
||||
let mut agents = vec![agent("1", "alpha", 1), agent("2", "zeta", 2)];
|
||||
|
||||
sort_agents(&mut agents, None, Some(SortOrderArg::Desc));
|
||||
|
||||
assert_eq!(agents[0].name, "zeta");
|
||||
assert_eq!(agents[1].name, "alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_request_omits_unset_fields_and_serializes_clears() {
|
||||
let request = UpdateAgentRequest {
|
||||
description: Some(String::new()),
|
||||
secrets: Some(vec![]),
|
||||
base_model: Some(String::new()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(request).expect("request serializes");
|
||||
|
||||
assert_eq!(
|
||||
json,
|
||||
serde_json::json!({
|
||||
"description": "",
|
||||
"secrets": [],
|
||||
"base_model": "",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_sort_for_json_output() {
|
||||
let args = AgentListArgs {
|
||||
sort_by: Some(AgentSortByArg::Name),
|
||||
sort_order: None,
|
||||
json_output: JsonOutput { filter: None },
|
||||
};
|
||||
|
||||
let err = ensure_json_sort_is_not_requested(OutputFormat::Json, &args.json_output, &args)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.to_string().contains("not supported with JSON output"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_string_deltas_removes_and_appends_without_duplicates() {
|
||||
let values = apply_string_deltas(
|
||||
&["old".to_string(), "keep".to_string()],
|
||||
vec!["new".to_string(), "keep".to_string()],
|
||||
vec!["old".to_string()],
|
||||
);
|
||||
|
||||
assert_eq!(values, ["keep", "new"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_secret_deltas_uses_secret_names() {
|
||||
let values = apply_secret_deltas(
|
||||
&[
|
||||
SecretRef {
|
||||
name: "OLD_TOKEN".to_string(),
|
||||
},
|
||||
SecretRef {
|
||||
name: "KEEP_TOKEN".to_string(),
|
||||
},
|
||||
],
|
||||
vec!["NEW_TOKEN".to_string()],
|
||||
vec!["OLD_TOKEN".to_string()],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
values,
|
||||
[
|
||||
SecretRef {
|
||||
name: "KEEP_TOKEN".to_string()
|
||||
},
|
||||
SecretRef {
|
||||
name: "NEW_TOKEN".to_string()
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
+260
-77
@@ -3,54 +3,50 @@ use std::io::Write as _;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Context as _};
|
||||
use comfy_table::Cell;
|
||||
use futures::{future, StreamExt};
|
||||
use serde::Serialize;
|
||||
use warp_cli::agent::{Harness, OutputFormat, Prompt, RunCloudArgs};
|
||||
use warp_cli::json_filter::JsonOutput;
|
||||
use warp_cli::task::{
|
||||
ArtifactTypeArg, ExecutionLocationArg, ListTasksArgs, MessageCommand, MessageDeliveredArgs,
|
||||
MessageListArgs, MessageReadArgs, MessageSendArgs, MessageWatchArgs, RunSortByArg,
|
||||
RunSourceArg, RunStateArg, TaskGetArgs,
|
||||
};
|
||||
use warp_cli::{GlobalOptions, SortOrderArg};
|
||||
use galaxy_core::channel::ChannelState;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use warpui::platform::TerminationMode;
|
||||
use warpui::r#async::{Spawnable, Timer};
|
||||
use warpui::{AppContext, ModelContext, SingletonEntity};
|
||||
|
||||
use super::common::{parse_ambient_task_id, EnvironmentChoice, ResolveConfigurationError};
|
||||
use crate::ai::agent::{extract_user_query_mode, UserQueryMode};
|
||||
use crate::ai::agent_sdk::driver::attachments::{
|
||||
process_attachment, MAX_ATTACHMENT_COUNT_FOR_CLOUD_QUERY,
|
||||
};
|
||||
use crate::ai::ambient_agents::spawn::{
|
||||
spawn_task, AmbientAgentEvent, SessionJoinInfo, TASK_STATUS_POLLING_DURATION,
|
||||
};
|
||||
use crate::ai::ambient_agents::task::HarnessConfig;
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskState;
|
||||
use crate::ai::ambient_agents::{AgentConfigSnapshot, AmbientAgentTask};
|
||||
use crate::ai::ambient_agents::{
|
||||
AgentConfigSnapshot, AmbientAgentTask, AmbientAgentTaskId, AmbientAgentTaskState,
|
||||
};
|
||||
use crate::ai::artifacts::Artifact;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::server::ids::{ServerId, SyncId};
|
||||
use crate::server::server_api::ai::{
|
||||
AIClient, AgentMessageHeader, AgentRunEvent, AgentSource, ArtifactType, ExecutionLocation,
|
||||
ListAgentMessagesRequest, ReadAgentMessageResponse, RunSortBy, RunSortOrder,
|
||||
SendAgentMessageRequest, SendAgentMessageResponse, SpawnAgentRequest, TaskListFilter,
|
||||
};
|
||||
use crate::server::server_api::ServerApi;
|
||||
use crate::terminal::shared_session;
|
||||
use crate::util::time_format::format_approx_duration_from_now_utc;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::{
|
||||
terminal::shared_session, util::time_format::format_approx_duration_from_now_utc,
|
||||
ServerApiProvider,
|
||||
};
|
||||
use anyhow::{anyhow, Context as _};
|
||||
use comfy_table::Cell;
|
||||
use futures::{future, StreamExt};
|
||||
use serde::Serialize;
|
||||
|
||||
use galaxy_cli::{
|
||||
agent::{Harness, OutputFormat, Prompt, RunCloudArgs},
|
||||
json_filter::JsonOutput,
|
||||
task::{
|
||||
ArtifactTypeArg, ExecutionLocationArg, ListTasksArgs, MessageCommand, MessageDeliveredArgs,
|
||||
MessageListArgs, MessageReadArgs, MessageSendArgs, MessageWatchArgs, RunSortByArg,
|
||||
RunSortOrderArg, RunSourceArg, RunStateArg, TaskGetArgs,
|
||||
},
|
||||
GlobalOptions,
|
||||
};
|
||||
use galaxy_core::channel::ChannelState;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::r#async::Timer;
|
||||
use galaxyui::{
|
||||
platform::TerminationMode, r#async::Spawnable, AppContext, ModelContext, SingletonEntity,
|
||||
};
|
||||
|
||||
use crate::ai::agent_sdk::driver::attachments::{
|
||||
process_attachment, MAX_ATTACHMENT_COUNT_FOR_CLOUD_QUERY,
|
||||
};
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::server::ids::{ServerId, SyncId};
|
||||
|
||||
use super::common::{EnvironmentChoice, ResolveConfigurationError};
|
||||
use crate::ServerApiProvider;
|
||||
|
||||
const MAX_LINE_WIDTH: usize = 90;
|
||||
const STREAM_RETRY_BACKOFF_STEPS: &[u64] = &[1, 2, 5, 10];
|
||||
@@ -185,10 +181,10 @@ fn sort_by_from_arg(arg: RunSortByArg) -> RunSortBy {
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_order_from_arg(arg: RunSortOrderArg) -> RunSortOrder {
|
||||
fn sort_order_from_arg(arg: SortOrderArg) -> RunSortOrder {
|
||||
match arg {
|
||||
RunSortOrderArg::Asc => RunSortOrder::Asc,
|
||||
RunSortOrderArg::Desc => RunSortOrder::Desc,
|
||||
SortOrderArg::Asc => RunSortOrder::Asc,
|
||||
SortOrderArg::Desc => RunSortOrder::Desc,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,11 +262,8 @@ impl AmbientAgentRunner {
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Consider making the server's prompt field optional when skill is provided,
|
||||
// rather than sending an empty string for skill-only invocations.
|
||||
let prompt_string = match prompt {
|
||||
Some(Prompt::PlainText(text)) => text,
|
||||
let prompt = match prompt {
|
||||
Some(Prompt::PlainText(text)) => Some(text),
|
||||
Some(Prompt::SavedPrompt(id)) => {
|
||||
// Resolve the saved prompt to pass along as the ambient agent query.
|
||||
// We look up the prompt text here, rather than passing along the saved prompt ID,
|
||||
@@ -294,7 +287,7 @@ impl AmbientAgentRunner {
|
||||
|
||||
match workflow {
|
||||
Some(cloud_workflow) => match cloud_workflow.model().data.prompt() {
|
||||
Some(prompt_text) => prompt_text.to_string(),
|
||||
Some(prompt_text) => Some(prompt_text.to_string()),
|
||||
None => {
|
||||
super::report_fatal_error(
|
||||
anyhow::anyhow!("'{id}' is not a saved prompt"),
|
||||
@@ -312,8 +305,7 @@ impl AmbientAgentRunner {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Skill-only invocation: use empty prompt, skill provides instructions
|
||||
None => String::new(),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let loaded_file = match args.config_file.file.as_deref() {
|
||||
@@ -327,6 +319,23 @@ impl AmbientAgentRunner {
|
||||
None => None,
|
||||
};
|
||||
|
||||
// The `--runner` CLI flag is gated in `run_agent`, but a config file
|
||||
// can also set `runner_id`, which would otherwise bypass the gate.
|
||||
if loaded_file
|
||||
.as_ref()
|
||||
.and_then(|f| f.file.runner_id.as_ref())
|
||||
.is_some()
|
||||
&& !FeatureFlag::CloudRunners.is_enabled()
|
||||
{
|
||||
super::report_fatal_error(
|
||||
anyhow::anyhow!(
|
||||
"`runner_id` is set in the config file but runner support is not enabled"
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate and process attachments early, before environment selection
|
||||
// This ensures users don't have to go through env selection if attachment validation fails
|
||||
if args.attachment_paths.len() > MAX_ATTACHMENT_COUNT_FOR_CLOUD_QUERY {
|
||||
@@ -416,10 +425,13 @@ impl AmbientAgentRunner {
|
||||
|
||||
let harness_override = (args.harness != Harness::Oz).then_some(HarnessConfig {
|
||||
harness_type: args.harness,
|
||||
model_id: None,
|
||||
reasoning_level: None,
|
||||
});
|
||||
let harness_auth_secrets = args.claude_auth_secret.clone().map(|name| {
|
||||
crate::ai::ambient_agents::task::HarnessAuthSecretsConfig {
|
||||
claude_auth_secret_name: Some(name),
|
||||
codex_auth_secret_name: None,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -428,6 +440,7 @@ impl AmbientAgentRunner {
|
||||
AgentConfigSnapshot {
|
||||
name: args.name,
|
||||
environment_id,
|
||||
runner_id: args.runner,
|
||||
model_id: args.model.model.clone(),
|
||||
base_prompt: None,
|
||||
mcp_servers: cli_mcp_servers,
|
||||
@@ -471,8 +484,16 @@ impl AmbientAgentRunner {
|
||||
None
|
||||
};
|
||||
|
||||
let (prompt, mode) = match prompt {
|
||||
Some(prompt) => {
|
||||
let (prompt, mode) = extract_user_query_mode(prompt);
|
||||
(Some(prompt), mode)
|
||||
}
|
||||
None => (None, UserQueryMode::Normal),
|
||||
};
|
||||
let request = SpawnAgentRequest {
|
||||
prompt: prompt_string,
|
||||
prompt,
|
||||
mode,
|
||||
config,
|
||||
title: None,
|
||||
team: match (args.scope.team, args.scope.personal) {
|
||||
@@ -480,12 +501,17 @@ impl AmbientAgentRunner {
|
||||
(_, true) => Some(false),
|
||||
_ => None,
|
||||
},
|
||||
agent_identity_uid: args.agent_uid,
|
||||
skill,
|
||||
attachments,
|
||||
interactive: None,
|
||||
parent_run_id: None,
|
||||
runtime_skills: vec![],
|
||||
referenced_attachments: vec![],
|
||||
conversation_id: args.conversation,
|
||||
initial_snapshot_token: None,
|
||||
snapshot_disabled: None,
|
||||
orchestration_handoff: None,
|
||||
};
|
||||
|
||||
let should_open = args.open;
|
||||
@@ -640,17 +666,42 @@ impl AmbientAgentRunner {
|
||||
output_format: OutputFormat,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> anyhow::Result<()> {
|
||||
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
|
||||
let provider = ServerApiProvider::as_ref(ctx);
|
||||
let ai_client = provider.get_ai_client();
|
||||
let server_api = provider.get();
|
||||
let scoped_task_id = task_id_for_message_send(&args.sender_run_id)?;
|
||||
|
||||
let future = async move {
|
||||
let response = ai_client
|
||||
.send_agent_message(SendAgentMessageRequest {
|
||||
to: args.to,
|
||||
subject: args.subject,
|
||||
body: args.body,
|
||||
sender_run_id: args.sender_run_id,
|
||||
})
|
||||
.await?;
|
||||
let request = SendAgentMessageRequest {
|
||||
to: args.to,
|
||||
subject: args.subject,
|
||||
body: args.body,
|
||||
sender_run_id: args.sender_run_id,
|
||||
};
|
||||
let log_context = SendAgentMessageLogContext::new(&request, scoped_task_id.as_ref());
|
||||
log_context.log_start();
|
||||
let send_message = async move {
|
||||
match scoped_task_id {
|
||||
Some(task_id) => {
|
||||
server_api
|
||||
.send_agent_message_for_task(&task_id, request)
|
||||
.await
|
||||
}
|
||||
None => ai_client.send_agent_message(request).await,
|
||||
}
|
||||
};
|
||||
let response = match send_message.await {
|
||||
Ok(response) => {
|
||||
log_context.log_success(&response);
|
||||
response
|
||||
}
|
||||
Err(err) => {
|
||||
let err = err.context(log_context.error_context());
|
||||
log_context.log_error(&err);
|
||||
eprintln!("{err:#}");
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
print_send_message_response(&response, output_format)?;
|
||||
Ok(())
|
||||
};
|
||||
@@ -658,25 +709,31 @@ impl AmbientAgentRunner {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn list_messages(
|
||||
&self,
|
||||
args: MessageListArgs,
|
||||
output_format: OutputFormat,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> anyhow::Result<()> {
|
||||
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
|
||||
let provider = ServerApiProvider::as_ref(ctx);
|
||||
let ai_client = provider.get_ai_client();
|
||||
let server_api = provider.get();
|
||||
|
||||
let future = async move {
|
||||
let messages = ai_client
|
||||
.list_agent_messages(
|
||||
&args.run_id,
|
||||
ListAgentMessagesRequest {
|
||||
unread_only: args.unread,
|
||||
since: args.since,
|
||||
limit: args.limit,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let request = ListAgentMessagesRequest {
|
||||
unread_only: args.unread,
|
||||
since: args.since,
|
||||
limit: args.limit,
|
||||
};
|
||||
let messages = match task_id_from_run_id(&args.run_id) {
|
||||
Some(task_id) => {
|
||||
server_api
|
||||
.list_agent_messages_for_task(&task_id, &args.run_id, request)
|
||||
.await?
|
||||
}
|
||||
None => ai_client.list_agent_messages(&args.run_id, request).await?,
|
||||
};
|
||||
super::output::print_list(messages, output_format);
|
||||
Ok(())
|
||||
};
|
||||
@@ -708,10 +765,20 @@ impl AmbientAgentRunner {
|
||||
output_format: OutputFormat,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> anyhow::Result<()> {
|
||||
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
|
||||
let provider = ServerApiProvider::as_ref(ctx);
|
||||
let ai_client = provider.get_ai_client();
|
||||
let server_api = provider.get();
|
||||
let scoped_task_id = task_id_from_oz_run_id_env()?;
|
||||
|
||||
let future = async move {
|
||||
let message = ai_client.read_agent_message(&args.message_id).await?;
|
||||
let message = match scoped_task_id {
|
||||
Some(task_id) => {
|
||||
server_api
|
||||
.read_agent_message_for_task(&task_id, &args.message_id)
|
||||
.await?
|
||||
}
|
||||
None => ai_client.read_agent_message(&args.message_id).await?,
|
||||
};
|
||||
print_read_message_response(&message, output_format)?;
|
||||
Ok(())
|
||||
};
|
||||
@@ -726,10 +793,20 @@ impl AmbientAgentRunner {
|
||||
output_format: OutputFormat,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> anyhow::Result<()> {
|
||||
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
|
||||
let provider = ServerApiProvider::as_ref(ctx);
|
||||
let ai_client = provider.get_ai_client();
|
||||
let server_api = provider.get();
|
||||
let scoped_task_id = task_id_from_oz_run_id_env()?;
|
||||
|
||||
let future = async move {
|
||||
ai_client.mark_message_delivered(&args.message_id).await?;
|
||||
match scoped_task_id {
|
||||
Some(task_id) => {
|
||||
server_api
|
||||
.mark_message_delivered_for_task(&task_id, &args.message_id)
|
||||
.await?
|
||||
}
|
||||
None => ai_client.mark_message_delivered(&args.message_id).await?,
|
||||
}
|
||||
print_mark_message_delivered_result(&args.message_id, output_format)?;
|
||||
Ok(())
|
||||
};
|
||||
@@ -790,6 +867,10 @@ impl AmbientAgentRunner {
|
||||
table.add_row(vec![title_cell]);
|
||||
}
|
||||
|
||||
if let Some(executor) = task.executor_display_name() {
|
||||
table.add_row(vec![format!("Executed as: {executor}")]);
|
||||
}
|
||||
|
||||
// Agent config snapshot (if available)
|
||||
if let Some(config) = task.agent_config_snapshot.as_ref() {
|
||||
let config_str =
|
||||
@@ -933,6 +1014,91 @@ fn write_stream_record<T: Serialize>(record: &T) -> anyhow::Result<()> {
|
||||
stdout.flush().context("unable to flush stdout")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn task_id_from_run_id(run_id: &str) -> Option<AmbientAgentTaskId> {
|
||||
run_id.parse().ok()
|
||||
}
|
||||
|
||||
fn task_id_from_oz_run_id_env() -> anyhow::Result<Option<AmbientAgentTaskId>> {
|
||||
match std::env::var(warp_cli::OZ_RUN_ID_ENV) {
|
||||
Ok(run_id) => parse_ambient_task_id(&run_id, "Invalid OZ_RUN_ID").map(Some),
|
||||
Err(std::env::VarError::NotPresent) => Ok(None),
|
||||
Err(std::env::VarError::NotUnicode(_)) => Err(anyhow!(
|
||||
"{} is set but is not valid Unicode",
|
||||
warp_cli::OZ_RUN_ID_ENV
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn task_id_for_message_send(sender_run_id: &str) -> anyhow::Result<Option<AmbientAgentTaskId>> {
|
||||
match task_id_from_run_id(sender_run_id) {
|
||||
Some(task_id) => Ok(Some(task_id)),
|
||||
None => task_id_from_oz_run_id_env(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SendAgentMessageLogContext {
|
||||
sender_run_id: String,
|
||||
task_id: Option<String>,
|
||||
target_agent_ids: Vec<String>,
|
||||
subject: String,
|
||||
body_len: usize,
|
||||
}
|
||||
|
||||
impl SendAgentMessageLogContext {
|
||||
fn new(request: &SendAgentMessageRequest, task_id: Option<&AmbientAgentTaskId>) -> Self {
|
||||
Self {
|
||||
sender_run_id: request.sender_run_id.clone(),
|
||||
task_id: task_id.map(|task_id| task_id.to_string()),
|
||||
target_agent_ids: request.to.clone(),
|
||||
subject: request.subject.clone(),
|
||||
body_len: request.body.chars().count(),
|
||||
}
|
||||
}
|
||||
|
||||
fn error_context(&self) -> String {
|
||||
format!(
|
||||
"Failed to send agent message (sender_run_id={:?}, task_id={:?}, target_agent_ids={:?})",
|
||||
self.sender_run_id, self.task_id, self.target_agent_ids
|
||||
)
|
||||
}
|
||||
|
||||
fn log_start(&self) {
|
||||
log::info!(
|
||||
"Sending ambient agent message: sender_run_id={:?} task_id={:?} target_agent_ids={:?} subject={:?} body_len={}",
|
||||
self.sender_run_id,
|
||||
self.task_id,
|
||||
self.target_agent_ids,
|
||||
self.subject,
|
||||
self.body_len
|
||||
);
|
||||
}
|
||||
|
||||
fn log_success(&self, response: &SendAgentMessageResponse) {
|
||||
log::info!(
|
||||
"Sent ambient agent message: sender_run_id={:?} task_id={:?} target_agent_ids={:?} subject={:?} body_len={} message_ids={:?}",
|
||||
self.sender_run_id,
|
||||
self.task_id,
|
||||
self.target_agent_ids,
|
||||
self.subject,
|
||||
self.body_len,
|
||||
response.message_ids
|
||||
);
|
||||
}
|
||||
|
||||
fn log_error(&self, err: &anyhow::Error) {
|
||||
log::warn!(
|
||||
"Failed to send ambient agent message: sender_run_id={:?} task_id={:?} target_agent_ids={:?} subject={:?} body_len={} error={err:#}",
|
||||
self.sender_run_id,
|
||||
self.task_id,
|
||||
self.target_agent_ids,
|
||||
self.subject,
|
||||
self.body_len
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn watch_messages_forever(
|
||||
server_api: Arc<ServerApi>,
|
||||
ai_client: Arc<dyn AIClient>,
|
||||
@@ -940,15 +1106,25 @@ async fn watch_messages_forever(
|
||||
) -> anyhow::Result<()> {
|
||||
let run_id = args.run_id;
|
||||
let watched_run_ids = vec![run_id.clone()];
|
||||
let scoped_task_id = task_id_from_run_id(&run_id);
|
||||
let mut last_seen_sequence = args.since_sequence;
|
||||
let mut initial_connect = true;
|
||||
let mut failures = 0usize;
|
||||
|
||||
loop {
|
||||
let mut stream = match server_api
|
||||
.stream_agent_events(&watched_run_ids, last_seen_sequence)
|
||||
.await
|
||||
{
|
||||
let stream_result = match scoped_task_id.as_ref() {
|
||||
Some(task_id) => {
|
||||
server_api
|
||||
.stream_agent_events_for_task(task_id, &watched_run_ids, last_seen_sequence)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
server_api
|
||||
.stream_agent_events(&watched_run_ids, last_seen_sequence)
|
||||
.await
|
||||
}
|
||||
};
|
||||
let mut stream = match stream_result {
|
||||
Ok(stream) => {
|
||||
if !initial_connect {
|
||||
eprintln!(
|
||||
@@ -1004,8 +1180,15 @@ async fn watch_messages_forever(
|
||||
last_seen_sequence = event.sequence;
|
||||
continue;
|
||||
};
|
||||
|
||||
let message = match ai_client.read_agent_message(&message_id).await {
|
||||
let message_result = match scoped_task_id.as_ref() {
|
||||
Some(task_id) => {
|
||||
server_api
|
||||
.read_agent_message_for_task(task_id, &message_id)
|
||||
.await
|
||||
}
|
||||
None => ai_client.read_agent_message(&message_id).await,
|
||||
};
|
||||
let message = match message_result {
|
||||
Ok(message) => message,
|
||||
Err(err) => {
|
||||
failures += 1;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
//! Unit tests for `filter_from_args`. Verifies the clap enums are faithfully translated into
|
||||
//! `TaskListFilter` without dropping any fields.
|
||||
|
||||
//! Unit tests for ambient agent CLI argument mapping and message helpers.
|
||||
use chrono::{TimeZone, Utc};
|
||||
|
||||
use galaxy_cli::json_filter::JsonOutput;
|
||||
use galaxy_cli::task::{
|
||||
ArtifactTypeArg, ExecutionLocationArg, ListTasksArgs, RunSortByArg, RunSortOrderArg,
|
||||
RunSourceArg, RunStateArg,
|
||||
ArtifactTypeArg, ExecutionLocationArg, ListTasksArgs, RunSortByArg, RunSourceArg, RunStateArg,
|
||||
};
|
||||
use warp_cli::SortOrderArg;
|
||||
|
||||
use super::*;
|
||||
use crate::server::server_api::ai::{ArtifactType, ExecutionLocation, RunSortBy, RunSortOrder};
|
||||
|
||||
const TASK_ID: &str = "00000000-0000-0000-0000-000000000001";
|
||||
const OTHER_TASK_ID: &str = "00000000-0000-0000-0000-000000000002";
|
||||
|
||||
/// A `ListTasksArgs` whose fields are all at their defaults.
|
||||
fn empty_args() -> ListTasksArgs {
|
||||
ListTasksArgs {
|
||||
@@ -138,7 +138,7 @@ fn every_field_maps_through() {
|
||||
updated_after: Some(updated_after),
|
||||
query: Some("oz run".to_string()),
|
||||
sort_by: Some(RunSortByArg::CreatedAt),
|
||||
sort_order: Some(RunSortOrderArg::Asc),
|
||||
sort_order: Some(SortOrderArg::Asc),
|
||||
cursor: Some("abcd==".to_string()),
|
||||
json_output: JsonOutput::default(),
|
||||
};
|
||||
@@ -167,3 +167,49 @@ fn every_field_maps_through() {
|
||||
assert_eq!(filter.sort_order, Some(RunSortOrder::Asc));
|
||||
assert_eq!(filter.cursor.as_deref(), Some("abcd=="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_id_from_run_id_accepts_task_uuid() {
|
||||
let task_id = task_id_from_run_id(TASK_ID).expect("valid task id");
|
||||
|
||||
assert_eq!(task_id.to_string(), TASK_ID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_id_from_run_id_ignores_non_task_ids() {
|
||||
assert!(task_id_from_run_id("local-child-run").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn task_id_for_message_send_prefers_sender_run_id() {
|
||||
std::env::set_var(warp_cli::OZ_RUN_ID_ENV, OTHER_TASK_ID);
|
||||
let task_id = task_id_for_message_send(TASK_ID)
|
||||
.expect("valid task id")
|
||||
.expect("task id");
|
||||
std::env::remove_var(warp_cli::OZ_RUN_ID_ENV);
|
||||
|
||||
assert_eq!(task_id.to_string(), TASK_ID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn task_id_for_message_send_falls_back_to_oz_run_id() {
|
||||
std::env::set_var(warp_cli::OZ_RUN_ID_ENV, TASK_ID);
|
||||
let task_id = task_id_for_message_send("local-child-run")
|
||||
.expect("valid env task id")
|
||||
.expect("task id");
|
||||
std::env::remove_var(warp_cli::OZ_RUN_ID_ENV);
|
||||
|
||||
assert_eq!(task_id.to_string(), TASK_ID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn task_id_from_oz_run_id_env_rejects_invalid_value() {
|
||||
std::env::set_var(warp_cli::OZ_RUN_ID_ENV, "not-a-task-id");
|
||||
let err = task_id_from_oz_run_id_env().expect_err("invalid task id");
|
||||
std::env::remove_var(warp_cli::OZ_RUN_ID_ENV);
|
||||
|
||||
assert!(err.to_string().contains("Invalid OZ_RUN_ID"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::fmt;
|
||||
use std::io::{self, IsTerminal as _};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use comfy_table::Cell;
|
||||
use inquire::{Confirm, InquireError, Select};
|
||||
use serde::Serialize;
|
||||
use warp_cli::agent::OutputFormat;
|
||||
use warp_cli::api_key::{
|
||||
ApiKeyCommand, ApiKeyExpirationArgs, ApiKeySortByArg, CreateApiKeyArgs, ExpireApiKeyArgs,
|
||||
ListApiKeysArgs,
|
||||
};
|
||||
use warp_cli::{GlobalOptions, SortOrderArg};
|
||||
use warp_graphql::mutations::expire_api_key::ExpireApiKeyResult;
|
||||
use warp_graphql::mutations::generate_api_key::GenerateApiKeyResult;
|
||||
use warp_graphql::queries::api_keys::ApiKeyProperties;
|
||||
use warp_graphql::scalars::Time;
|
||||
use warpui::platform::TerminationMode;
|
||||
use warpui::{AppContext, ModelContext, SingletonEntity};
|
||||
|
||||
use super::output::{self, TableFormat};
|
||||
use crate::server::ids::ApiKeyUid;
|
||||
use crate::util::time_format::format_approx_duration_from_now_utc;
|
||||
use crate::ServerApiProvider;
|
||||
|
||||
/// Run API key-related commands.
|
||||
pub fn run(
|
||||
ctx: &mut AppContext,
|
||||
global_options: GlobalOptions,
|
||||
command: ApiKeyCommand,
|
||||
) -> Result<()> {
|
||||
let runner = ctx.add_singleton_model(|_ctx| ApiKeyCommandRunner);
|
||||
match command {
|
||||
ApiKeyCommand::List(args) => {
|
||||
runner.update(ctx, |runner, ctx| {
|
||||
runner.list(global_options.output_format, args, ctx)
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
ApiKeyCommand::Create(args) => {
|
||||
runner.update(ctx, |runner, ctx| {
|
||||
runner.create(global_options.output_format, args, ctx)
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
ApiKeyCommand::Expire(args) => {
|
||||
runner.update(ctx, |runner, ctx| {
|
||||
runner.expire(global_options.output_format, args, ctx)
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ApiKeyCommandRunner;
|
||||
|
||||
impl ApiKeyCommandRunner {
|
||||
fn list(
|
||||
&self,
|
||||
output_format: OutputFormat,
|
||||
args: ListApiKeysArgs,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let auth_client = ServerApiProvider::as_ref(ctx).get_auth_client();
|
||||
|
||||
ctx.spawn(
|
||||
async move {
|
||||
let mut keys: Vec<_> = auth_client
|
||||
.list_api_keys()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(ApiKeyInfo::from)
|
||||
.collect();
|
||||
sort_api_keys(&mut keys, args.sort_by, args.sort_order);
|
||||
if args.json_output.force_json_output() {
|
||||
output::print_raw_json(serde_json::to_value(&keys)?, &args.json_output)?;
|
||||
} else {
|
||||
output::print_list(keys, output_format);
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
|_, result: Result<()>, ctx| finish_command(result, ctx),
|
||||
);
|
||||
}
|
||||
|
||||
fn create(
|
||||
&self,
|
||||
output_format: OutputFormat,
|
||||
args: CreateApiKeyArgs,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let auth_client = ServerApiProvider::as_ref(ctx).get_auth_client();
|
||||
|
||||
ctx.spawn(
|
||||
async move {
|
||||
let json_output = args.json_output;
|
||||
let expires_at = expires_at_from_args(args.expiration)?;
|
||||
let agent_uid = args.agent_uid.map(cynic::Id::new);
|
||||
let result = auth_client
|
||||
.create_api_key(args.name, None, agent_uid, expires_at)
|
||||
.await?;
|
||||
let result = match result {
|
||||
GenerateApiKeyResult::GenerateApiKeyOutput(output) => CreatedApiKeyInfo {
|
||||
raw_api_key: output.raw_api_key,
|
||||
api_key: ApiKeyInfo::from(output.api_key),
|
||||
},
|
||||
GenerateApiKeyResult::UserFacingError(e) => {
|
||||
return Err(anyhow!(
|
||||
warp_graphql::client::get_user_facing_error_message(e)
|
||||
));
|
||||
}
|
||||
GenerateApiKeyResult::Unknown => {
|
||||
return Err(anyhow!("failed to create API key"))
|
||||
}
|
||||
};
|
||||
print_created_api_key(result, output_format, json_output)?;
|
||||
Ok(())
|
||||
},
|
||||
|_, result: Result<()>, ctx| finish_command(result, ctx),
|
||||
);
|
||||
}
|
||||
|
||||
fn expire(
|
||||
&self,
|
||||
output_format: OutputFormat,
|
||||
args: ExpireApiKeyArgs,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let key_identifier = args.key_uid;
|
||||
let force = args.force;
|
||||
let json_output = args.json_output;
|
||||
let auth_client = ServerApiProvider::as_ref(ctx).get_auth_client();
|
||||
|
||||
ctx.spawn(
|
||||
async move {
|
||||
let keys = auth_client
|
||||
.list_api_keys()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(ApiKeyInfo::from)
|
||||
.collect();
|
||||
Ok(keys)
|
||||
},
|
||||
move |_, result: Result<Vec<ApiKeyInfo>>, ctx| {
|
||||
let keys = match result {
|
||||
Ok(keys) => keys,
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let key = match resolve_api_key_identifier(&keys, &key_identifier) {
|
||||
Ok(Some(key)) => key,
|
||||
Ok(None) => {
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err, ctx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if !force {
|
||||
if !io::stdin().is_terminal() {
|
||||
super::report_fatal_error(
|
||||
anyhow!(
|
||||
"Refusing to expire API key without confirmation in non-interactive mode (use --force to bypass)"
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let prompt = format!("Expire API key '{key}'?");
|
||||
let should_expire = match Confirm::new(&prompt)
|
||||
.with_default(false)
|
||||
.with_help_message("This action takes effect immediately")
|
||||
.prompt()
|
||||
{
|
||||
Ok(should_expire) => should_expire,
|
||||
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
super::report_fatal_error(err.into(), ctx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if !should_expire {
|
||||
println!("Expiration cancelled");
|
||||
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let uid = ApiKeyUid::from(key.uid);
|
||||
let auth_client = ServerApiProvider::as_ref(ctx).get_auth_client();
|
||||
ctx.spawn(
|
||||
async move {
|
||||
let result = auth_client.expire_api_key(&uid).await?;
|
||||
let expired = match result {
|
||||
ExpireApiKeyResult::ExpireApiKeyOutput(output) => output.success,
|
||||
ExpireApiKeyResult::UserFacingError(e) => {
|
||||
return Err(anyhow!(
|
||||
warp_graphql::client::get_user_facing_error_message(e)
|
||||
));
|
||||
}
|
||||
ExpireApiKeyResult::Unknown => {
|
||||
return Err(anyhow!("failed to expire API key"))
|
||||
}
|
||||
};
|
||||
print_expire_api_key_result(
|
||||
uid.to_string(),
|
||||
expired,
|
||||
output_format,
|
||||
json_output,
|
||||
)?;
|
||||
Ok(())
|
||||
},
|
||||
|_, result: Result<()>, ctx| finish_command(result, ctx),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl warpui::Entity for ApiKeyCommandRunner {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for ApiKeyCommandRunner {}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
struct ApiKeyInfo {
|
||||
uid: String,
|
||||
name: String,
|
||||
key_suffix: String,
|
||||
scope: String,
|
||||
created_at: DateTime<Utc>,
|
||||
last_used_at: Option<DateTime<Utc>>,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl From<ApiKeyProperties> for ApiKeyInfo {
|
||||
fn from(key: ApiKeyProperties) -> Self {
|
||||
Self {
|
||||
uid: key.uid.into_inner(),
|
||||
name: key.name,
|
||||
key_suffix: key.key_suffix,
|
||||
scope: key.owner_type.to_string(),
|
||||
created_at: key.created_at.utc(),
|
||||
last_used_at: key.last_used_at.map(|t| t.utc()),
|
||||
expires_at: key.expires_at.map(|t| t.utc()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ApiKeyInfo {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let name = &self.name;
|
||||
let uid = &self.uid;
|
||||
let created_at = self.created_at.format("%Y-%m-%d %H:%M:%S UTC");
|
||||
write!(f, "{name} ({uid}, created {created_at})")
|
||||
}
|
||||
}
|
||||
|
||||
impl TableFormat for ApiKeyInfo {
|
||||
fn header() -> Vec<Cell> {
|
||||
vec![
|
||||
Cell::new("UID"),
|
||||
Cell::new("Name"),
|
||||
Cell::new("Key"),
|
||||
Cell::new("Scope"),
|
||||
Cell::new("Created"),
|
||||
Cell::new("Last Used"),
|
||||
Cell::new("Expires At"),
|
||||
]
|
||||
}
|
||||
|
||||
fn row(&self) -> Vec<Cell> {
|
||||
vec![
|
||||
Cell::new(&self.uid),
|
||||
Cell::new(&self.name),
|
||||
Cell::new(format!("wk-**{}", self.key_suffix)),
|
||||
Cell::new(&self.scope),
|
||||
Cell::new(format_approx_duration_from_now_utc(self.created_at)),
|
||||
Cell::new(
|
||||
self.last_used_at
|
||||
.map(format_approx_duration_from_now_utc)
|
||||
.unwrap_or_else(|| "Never".to_string()),
|
||||
),
|
||||
Cell::new(
|
||||
self.expires_at
|
||||
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
|
||||
.unwrap_or_else(|| "Never".to_string()),
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_api_key_identifier(
|
||||
keys: &[ApiKeyInfo],
|
||||
key_identifier: &str,
|
||||
) -> Result<Option<ApiKeyInfo>> {
|
||||
if let Some(key) = keys.iter().find(|key| key.uid == key_identifier) {
|
||||
return Ok(Some(key.clone()));
|
||||
}
|
||||
|
||||
let mut matches = keys
|
||||
.iter()
|
||||
.filter(|key| key.name == key_identifier)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
matches.sort_by_key(|key| Reverse(key.created_at));
|
||||
|
||||
if matches.is_empty() {
|
||||
return Err(anyhow!("API key '{key_identifier}' not found"));
|
||||
} else if matches.len() == 1 {
|
||||
return Ok(Some(matches[0].clone()));
|
||||
}
|
||||
|
||||
if io::stdin().is_terminal() {
|
||||
return match Select::new(
|
||||
&format!("Multiple API keys match '{key_identifier}'. Select a key to expire:"),
|
||||
matches,
|
||||
)
|
||||
.prompt()
|
||||
{
|
||||
Ok(key) => Ok(Some(key)),
|
||||
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Ok(None),
|
||||
Err(err) => Err(err.into()),
|
||||
};
|
||||
}
|
||||
println!("Multiple API keys match '{key_identifier}':");
|
||||
for key in matches {
|
||||
println!(" {key}");
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"Multiple API keys match '{key_identifier}'; specify the key by UID"
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct CreatedApiKeyInfo {
|
||||
raw_api_key: String,
|
||||
api_key: ApiKeyInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct ExpiredApiKeyInfo {
|
||||
key_uid: String,
|
||||
expired: bool,
|
||||
}
|
||||
|
||||
fn sort_api_keys(
|
||||
keys: &mut [ApiKeyInfo],
|
||||
sort_by: Option<ApiKeySortByArg>,
|
||||
sort_order: Option<SortOrderArg>,
|
||||
) {
|
||||
let Some(sort_by) = sort_by else {
|
||||
return;
|
||||
};
|
||||
let descending = matches!(sort_order, Some(SortOrderArg::Desc));
|
||||
match sort_by {
|
||||
ApiKeySortByArg::Name => {
|
||||
if descending {
|
||||
keys.sort_by_key(|k| Reverse(k.name.to_lowercase()));
|
||||
} else {
|
||||
keys.sort_by_key(|k| k.name.to_lowercase());
|
||||
}
|
||||
}
|
||||
ApiKeySortByArg::CreatedAt => {
|
||||
if descending {
|
||||
keys.sort_by_key(|k| Reverse(k.created_at));
|
||||
} else {
|
||||
keys.sort_by_key(|k| k.created_at);
|
||||
}
|
||||
}
|
||||
ApiKeySortByArg::LastUsedAt => {
|
||||
if descending {
|
||||
keys.sort_by_key(|k| Reverse(k.last_used_at));
|
||||
} else {
|
||||
keys.sort_by_key(|k| k.last_used_at);
|
||||
}
|
||||
}
|
||||
ApiKeySortByArg::ExpiresAt => {
|
||||
if descending {
|
||||
keys.sort_by_key(|k| Reverse(k.expires_at));
|
||||
} else {
|
||||
keys.sort_by_key(|k| k.expires_at);
|
||||
}
|
||||
}
|
||||
ApiKeySortByArg::Scope => {
|
||||
if descending {
|
||||
keys.sort_by_key(|k| Reverse(k.scope.clone()));
|
||||
} else {
|
||||
keys.sort_by_key(|k| k.scope.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn expires_at_from_args(args: ApiKeyExpirationArgs) -> Result<Option<Time>> {
|
||||
if args.no_expiration {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Some(expires_at) = args.expires_at {
|
||||
return Ok(Some(Time::from(expires_at)));
|
||||
}
|
||||
|
||||
if let Some(expires_in) = args.expires_in {
|
||||
let duration = chrono::Duration::from_std(expires_in.into())
|
||||
.map_err(|_| anyhow!("expiration duration is too large"))?;
|
||||
return Ok(Some(Time::from(Utc::now() + duration)));
|
||||
}
|
||||
|
||||
Err(anyhow!("expiration behavior is required"))
|
||||
}
|
||||
|
||||
fn print_created_api_key(
|
||||
result: CreatedApiKeyInfo,
|
||||
output_format: OutputFormat,
|
||||
json_output: warp_cli::json_filter::JsonOutput,
|
||||
) -> Result<()> {
|
||||
if json_output.force_json_output() {
|
||||
output::print_raw_json(serde_json::to_value(&result)?, &json_output)?;
|
||||
return Ok(());
|
||||
}
|
||||
match output_format {
|
||||
OutputFormat::Json => output::write_json(&result, std::io::stdout())?,
|
||||
OutputFormat::Ndjson => output::write_json_line(&result, std::io::stdout())?,
|
||||
OutputFormat::Pretty | OutputFormat::Text => {
|
||||
println!("API key '{}' created.", result.api_key.name);
|
||||
println!("UID: {}", result.api_key.uid);
|
||||
println!("Raw API key: {}", result.raw_api_key);
|
||||
println!("This secret key is shown only once. Store it securely.");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_expire_api_key_result(
|
||||
key_uid: String,
|
||||
expired: bool,
|
||||
output_format: OutputFormat,
|
||||
json_output: warp_cli::json_filter::JsonOutput,
|
||||
) -> Result<()> {
|
||||
let result = ExpiredApiKeyInfo { key_uid, expired };
|
||||
if json_output.force_json_output() {
|
||||
output::print_raw_json(serde_json::to_value(&result)?, &json_output)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json => output::write_json(&result, std::io::stdout())?,
|
||||
OutputFormat::Ndjson => output::write_json_line(&result, std::io::stdout())?,
|
||||
OutputFormat::Pretty | OutputFormat::Text => {
|
||||
if expired {
|
||||
println!("API key '{}' expired.", result.key_uid);
|
||||
} else {
|
||||
println!("API key '{}' was not expired.", result.key_uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish_command(result: Result<()>, ctx: &mut ModelContext<ApiKeyCommandRunner>) {
|
||||
match result {
|
||||
Ok(()) => ctx.terminate_app(TerminationMode::ForceTerminate, None),
|
||||
Err(err) => super::report_fatal_error(err, ctx),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "api_key_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,115 @@
|
||||
use super::*;
|
||||
|
||||
fn key(name: &str, scope: &str, created_at: DateTime<Utc>) -> ApiKeyInfo {
|
||||
key_with_uid(name, name, scope, created_at)
|
||||
}
|
||||
|
||||
fn key_with_uid(uid: &str, name: &str, scope: &str, created_at: DateTime<Utc>) -> ApiKeyInfo {
|
||||
ApiKeyInfo {
|
||||
uid: uid.to_string(),
|
||||
name: name.to_string(),
|
||||
key_suffix: "abcd".to_string(),
|
||||
scope: scope.to_string(),
|
||||
created_at,
|
||||
last_used_at: None,
|
||||
expires_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_api_keys_sorts_by_name_ascending() {
|
||||
let created_at = Utc::now();
|
||||
let mut keys = vec![
|
||||
key("beta", "Team", created_at),
|
||||
key("alpha", "Personal", created_at),
|
||||
];
|
||||
|
||||
sort_api_keys(
|
||||
&mut keys,
|
||||
Some(ApiKeySortByArg::Name),
|
||||
Some(SortOrderArg::Asc),
|
||||
);
|
||||
|
||||
assert_eq!(keys[0].name, "alpha");
|
||||
assert_eq!(keys[1].name, "beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_api_keys_sorts_by_created_at_descending() {
|
||||
let older = Utc::now() - chrono::Duration::days(1);
|
||||
let newer = Utc::now();
|
||||
let mut keys = vec![key("older", "Team", older), key("newer", "Personal", newer)];
|
||||
|
||||
sort_api_keys(
|
||||
&mut keys,
|
||||
Some(ApiKeySortByArg::CreatedAt),
|
||||
Some(SortOrderArg::Desc),
|
||||
);
|
||||
|
||||
assert_eq!(keys[0].name, "newer");
|
||||
assert_eq!(keys[1].name, "older");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_api_key_identifier_prefers_uid_match() {
|
||||
let created_at = Utc::now();
|
||||
let keys = vec![
|
||||
key_with_uid("target", "other-name", "Team", created_at),
|
||||
key_with_uid("other-uid", "target", "Team", created_at),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
resolve_api_key_identifier(&keys, "target")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
keys[0].clone()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_api_key_identifier_falls_back_to_name_match() {
|
||||
let created_at = Utc::now();
|
||||
let keys = vec![key_with_uid("uid-1", "deploy-key", "Team", created_at)];
|
||||
|
||||
assert_eq!(
|
||||
resolve_api_key_identifier(&keys, "deploy-key")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
keys[0].clone()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_api_key_identifier_errors_for_ambiguous_name_matches() {
|
||||
let created_at = Utc::now();
|
||||
let keys = vec![
|
||||
key_with_uid("uid-1", "deploy-key", "Team", created_at),
|
||||
key_with_uid("uid-2", "deploy-key", "Personal", created_at),
|
||||
];
|
||||
let err = resolve_api_key_identifier(&keys, "deploy-key").unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"Multiple API keys match 'deploy-key'; specify the key by UID"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_api_key_identifier_errors_when_not_found() {
|
||||
let created_at = Utc::now();
|
||||
let keys = vec![key_with_uid("uid-1", "deploy-key", "Team", created_at)];
|
||||
let err = resolve_api_key_identifier(&keys, "missing-key").unwrap_err();
|
||||
|
||||
assert_eq!(err.to_string(), "API key 'missing-key' not found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_display_includes_creation_date() {
|
||||
let created_at = "2026-01-02T03:04:05Z".parse().unwrap();
|
||||
let key = key_with_uid("uid-1", "deploy-key", "Team", created_at);
|
||||
|
||||
assert_eq!(
|
||||
key.to_string(),
|
||||
"deploy-key (uid-1, created 2026-01-02 03:04:05 UTC)"
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use galaxy_cli::agent::OutputFormat;
|
||||
@@ -6,19 +7,18 @@ use galaxy_cli::artifact::{
|
||||
ArtifactCommand, DownloadArtifactArgs, GetArtifactArgs, UploadArtifactArgs,
|
||||
};
|
||||
use galaxy_cli::GlobalOptions;
|
||||
use galaxyui::{platform::TerminationMode, AppContext, ModelContext, SingletonEntity};
|
||||
use serde::Serialize;
|
||||
use galaxyui::platform::TerminationMode;
|
||||
use galaxyui::{AppContext, ModelContext, SingletonEntity};
|
||||
|
||||
use super::artifact_upload::{
|
||||
CompletedFileArtifactUpload, FileArtifactUploadRequest, FileArtifactUploader,
|
||||
};
|
||||
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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use galaxy_cli::agent::OutputFormat;
|
||||
use std::path::PathBuf;
|
||||
|
||||
|
||||
use super::*;
|
||||
|
||||
fn sample_completed_upload() -> CompletedFileArtifactUpload {
|
||||
|
||||
@@ -7,7 +7,6 @@ use std::sync::Arc;
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use blocking::unblock;
|
||||
use galaxy_cli::artifact::UploadArtifactArgs;
|
||||
use mime_guess::from_path;
|
||||
|
||||
use super::common::parse_ambient_task_id;
|
||||
use crate::ai::agent::api::ServerConversationToken;
|
||||
@@ -17,10 +16,11 @@ use crate::server::server_api::ai::{
|
||||
AIClient, CreateFileArtifactUploadRequest, CreateFileArtifactUploadResponse,
|
||||
FileArtifactRecord, FileArtifactUploadTargetInfo,
|
||||
};
|
||||
use crate::server::server_api::harness_support::FileUploadBody;
|
||||
use crate::server::server_api::presigned_upload::upload_file_to_target;
|
||||
use crate::server::server_api::ServerApi;
|
||||
use crate::util::image::{infer_mime_type, MIME_SNIFF_BYTES};
|
||||
|
||||
const MIME_SNIFF_BYTES: usize = 8 * 1024;
|
||||
const OZ_RUN_ID_ENV_VAR: &str = "OZ_RUN_ID";
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
@@ -165,8 +165,7 @@ impl FileArtifactUploader {
|
||||
upload_file_to_target(
|
||||
self.server_api.http_client(),
|
||||
target,
|
||||
&artifact.path,
|
||||
artifact.file_size,
|
||||
FileUploadBody::new(artifact.path.clone()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -234,12 +233,6 @@ 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()))?;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::{env, fs};
|
||||
|
||||
use chrono::Utc;
|
||||
use galaxy_cli::artifact::UploadArtifactArgs;
|
||||
@@ -39,15 +38,14 @@ fn create_conversation_metadata(
|
||||
was_summarized: false,
|
||||
context_window_usage: 0.0,
|
||||
credits_spent: 0.0,
|
||||
platform_credits_spent: 0.0,
|
||||
credits_spent_for_last_block: None,
|
||||
token_usage: vec![],
|
||||
tool_usage_metadata: Default::default(),
|
||||
total_cache_read_tokens: 0,
|
||||
total_cache_write_tokens: 0,
|
||||
total_cache_miss_tokens: 0,
|
||||
total_cost_cents: 0.0,
|
||||
context_window_segments: Vec::new(),
|
||||
},
|
||||
metadata: create_mock_server_metadata(),
|
||||
creator: None,
|
||||
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()),
|
||||
|
||||
@@ -18,7 +18,7 @@ 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::cloud_object::{CloudObject, CloudObjectLookup as _, Owner};
|
||||
use crate::server::cloud_objects::update_manager::UpdateManager;
|
||||
use crate::server::ids::{ServerId, SyncId};
|
||||
use crate::server::server_api::ai::AIClient;
|
||||
@@ -37,7 +37,7 @@ pub fn validate_agent_mode_base_model_id(
|
||||
|
||||
let llm_id: LLMId = model_id.into();
|
||||
let valid_ids = llm_prefs
|
||||
.get_base_llm_choices_for_agent_mode()
|
||||
.get_base_llm_choices_for_agent_mode(ctx)
|
||||
.map(|info| info.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
@@ -329,22 +329,5 @@ impl fmt::Display for EnvironmentChoice {
|
||||
}
|
||||
|
||||
#[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'"));
|
||||
}
|
||||
}
|
||||
#[path = "common_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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'"));
|
||||
}
|
||||
@@ -20,6 +20,8 @@ pub struct AgentConfigSnapshotFile {
|
||||
#[serde(default)]
|
||||
pub environment_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub runner_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub model_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub base_prompt: Option<String>,
|
||||
@@ -93,7 +95,7 @@ fn parse_yaml(input: &str) -> anyhow::Result<AgentConfigSnapshotFile> {
|
||||
}
|
||||
|
||||
fn supported_keys_context() -> String {
|
||||
"Supported keys: name, environment_id, model_id, base_prompt, mcp_servers, host, computer_use_enabled".to_string()
|
||||
"Supported keys: name, environment_id, runner_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.
|
||||
@@ -148,6 +150,7 @@ pub fn merge_with_precedence(
|
||||
|
||||
let name = cli.name.or_else(|| file.name.clone());
|
||||
let environment_id = cli.environment_id.or_else(|| file.environment_id.clone());
|
||||
let runner_id = cli.runner_id.or_else(|| file.runner_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());
|
||||
|
||||
@@ -158,6 +161,7 @@ pub fn merge_with_precedence(
|
||||
AgentConfigSnapshot {
|
||||
name,
|
||||
environment_id,
|
||||
runner_id,
|
||||
model_id,
|
||||
base_prompt,
|
||||
mcp_servers,
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
use std::io::Write as _;
|
||||
|
||||
use serde_json::json;
|
||||
use warp_cli::mcp::MCPSpec;
|
||||
|
||||
use crate::ai::ambient_agents::AgentConfigSnapshot;
|
||||
use galaxy_cli::mcp::MCPSpec;
|
||||
|
||||
fn write_temp(suffix: &str, contents: &str) -> tempfile::NamedTempFile {
|
||||
let mut file = tempfile::Builder::new().suffix(suffix).tempfile().unwrap();
|
||||
@@ -96,6 +96,7 @@ fn merge_precedence_cli_over_file_and_merges_mcp() {
|
||||
let cli = AgentConfigSnapshot {
|
||||
name: Some("cli-name".to_string()),
|
||||
environment_id: None,
|
||||
runner_id: None,
|
||||
model_id: Some("cli-model".to_string()),
|
||||
base_prompt: None,
|
||||
mcp_servers: Some(serde_json::Map::from_iter([(
|
||||
|
||||
+2155
-622
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,8 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use base64::{engine::general_purpose, Engine};
|
||||
use base64::engine::general_purpose;
|
||||
use base64::Engine;
|
||||
use futures::future::join_all;
|
||||
use futures::TryStreamExt as _;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
@@ -30,6 +31,7 @@ pub const MAX_ATTACHMENT_COUNT_FOR_CLOUD_QUERY: usize = 25;
|
||||
///
|
||||
/// Makes a best-effort attempt to download all attachments.
|
||||
/// Individual download failures are logged but don't cause the entire function to fail.
|
||||
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true))]
|
||||
pub(crate) async fn fetch_and_download_attachments(
|
||||
ai_client: Arc<dyn AIClient>,
|
||||
http_client: Arc<ServerApi>,
|
||||
@@ -65,6 +67,7 @@ pub(crate) async fn fetch_and_download_attachments(
|
||||
/// 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`.
|
||||
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true))]
|
||||
pub(crate) async fn fetch_and_download_handoff_snapshot_attachments(
|
||||
ai_client: Arc<dyn AIClient>,
|
||||
http_client: &http_client::Client,
|
||||
@@ -219,13 +222,20 @@ async fn download_handoff_entry(
|
||||
/// 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.
|
||||
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true))]
|
||||
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 {
|
||||
|
||||
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true))]
|
||||
async fn attempt(
|
||||
http_client: &http_client::Client,
|
||||
download_url: &str,
|
||||
file_path: &Path,
|
||||
) -> anyhow::Result<()> {
|
||||
let response = http_client
|
||||
.get(download_url)
|
||||
.send()
|
||||
@@ -254,6 +264,10 @@ async fn download_attachment(
|
||||
.context("Failed to write file")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
with_bounded_retry(&operation, || async {
|
||||
attempt(http_client, download_url, file_path).await
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/// Proactive Bedrock OIDC credential refresh for cloud agent sandboxes.
|
||||
///
|
||||
/// The `OidcManaged` Bedrock credential path mints an OIDC token at agent startup,
|
||||
/// exchanges it for 1-hour STS temporary credentials via `AssumeRoleWithWebIdentity`,
|
||||
/// and stores them in `ApiKeyManager`. Without a proactive refresh, those credentials
|
||||
/// expire after ~1 hour and all subsequent Bedrock LLM calls fail.
|
||||
///
|
||||
/// This module provides a background `refresh_loop` — modelled on `git_credentials`'s
|
||||
/// loop — that proactively re-mints the OIDC token and re-calls STS every 50 minutes,
|
||||
/// well ahead of the 1-hour STS expiry. The loop is raced against the run execution
|
||||
/// future via `futures::select!` and dropped automatically when the run completes.
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use ai::api_keys::{ApiKeyManager, AwsCredentials, AwsCredentialsState};
|
||||
use anyhow::{Context as _, Result};
|
||||
use vec1::vec1;
|
||||
use warp_managed_secrets::client::IdentityTokenOptions;
|
||||
use warp_managed_secrets::ManagedSecretManager;
|
||||
use warpui::{ModelSpawner, SingletonEntity};
|
||||
|
||||
use super::AgentDriver;
|
||||
use crate::ai::aws_credentials::{
|
||||
aws_role_session_name, sts_client, AWS_BEDROCK_STS_AUDIENCE, BEDROCK_IDENTITY_TOKEN_DURATION,
|
||||
};
|
||||
|
||||
/// How long to wait between Bedrock credential refresh attempts — well ahead of the
|
||||
/// 1-hour STS temporary credential expiry, matching the approach used for git credentials.
|
||||
pub(crate) const BEDROCK_CREDENTIALS_REFRESH_INTERVAL: Duration = Duration::from_secs(50 * 60);
|
||||
|
||||
/// Perform one Bedrock OIDC credential refresh attempt.
|
||||
///
|
||||
/// Returns `Ok(())` on success. Returns `Err` when token minting or the STS call
|
||||
/// fails — these are transient failures worth retrying.
|
||||
#[tracing::instrument(
|
||||
name = "bedrock_credentials::try_refresh",
|
||||
skip_all,
|
||||
err,
|
||||
fields(tags.cloud_agent = true, task_id)
|
||||
)]
|
||||
async fn try_refresh(
|
||||
task_id: &str,
|
||||
role_arn: &str,
|
||||
region: &str,
|
||||
foreground: &ModelSpawner<AgentDriver>,
|
||||
) -> Result<()> {
|
||||
// Step 1: Mint a new OIDC identity token via the model context.
|
||||
let token_future = foreground
|
||||
.spawn(|_, ctx| {
|
||||
ManagedSecretManager::handle(ctx)
|
||||
.as_ref(ctx)
|
||||
.issue_task_identity_token(IdentityTokenOptions {
|
||||
audience: AWS_BEDROCK_STS_AUDIENCE.to_string(),
|
||||
requested_duration: BEDROCK_IDENTITY_TOKEN_DURATION,
|
||||
subject_template: vec1!["scoped_principal".to_string()],
|
||||
})
|
||||
})
|
||||
.await
|
||||
.context("Failed to dispatch OIDC token request for Bedrock refresh")?;
|
||||
|
||||
let token = token_future
|
||||
.await
|
||||
.context("Failed to mint OIDC identity token for Bedrock refresh")?;
|
||||
|
||||
// Step 2: Exchange the OIDC token for fresh STS temporary credentials.
|
||||
let client = sts_client(region).await;
|
||||
let session_name = aws_role_session_name(task_id);
|
||||
let sts_creds = client
|
||||
.assume_role_with_web_identity()
|
||||
.role_arn(role_arn)
|
||||
.role_session_name(&session_name)
|
||||
.web_identity_token(&token.token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
log::error!("Bedrock OIDC refresh: STS AssumeRoleWithWebIdentity error: {err:#?}");
|
||||
let detail = err
|
||||
.as_service_error()
|
||||
.map(|e| e.to_string())
|
||||
.unwrap_or_else(|| err.to_string());
|
||||
anyhow::anyhow!("STS AssumeRoleWithWebIdentity failed: {detail}")
|
||||
})?
|
||||
.credentials
|
||||
.context("STS response did not include credentials")?;
|
||||
|
||||
let aws_creds = AwsCredentials::new(
|
||||
sts_creds.access_key_id().to_string(),
|
||||
sts_creds.secret_access_key().to_string(),
|
||||
Some(sts_creds.session_token().to_string()),
|
||||
SystemTime::try_from(*sts_creds.expiration()).ok(),
|
||||
);
|
||||
|
||||
// Step 3: Update ApiKeyManager with the fresh credentials.
|
||||
foreground
|
||||
.spawn(move |_, ctx| {
|
||||
ApiKeyManager::handle(ctx).update(ctx, |manager, ctx| {
|
||||
manager.set_aws_credentials_state(
|
||||
AwsCredentialsState::Loaded {
|
||||
credentials: aws_creds,
|
||||
loaded_at: SystemTime::now(),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
})
|
||||
.await
|
||||
.context("Failed to dispatch Bedrock credential update to ApiKeyManager")?;
|
||||
|
||||
log::info!("Bedrock OIDC: proactive credential refresh succeeded for task {task_id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Infinite async loop that proactively refreshes AWS Bedrock OIDC credentials every
|
||||
/// [`BEDROCK_CREDENTIALS_REFRESH_INTERVAL`], keeping long-running agents authenticated
|
||||
/// for their entire duration.
|
||||
///
|
||||
/// On each iteration:
|
||||
/// 1. Issue a new OIDC identity token via warp-server.
|
||||
/// 2. Call STS `AssumeRoleWithWebIdentity` to get fresh temporary credentials.
|
||||
/// 3. Update `ApiKeyManager` with the new credentials.
|
||||
///
|
||||
/// On transient failure, retries up to three times with exponential backoff
|
||||
/// (1 min, 2 min, 4 min), keeping all retries well within the buffer before
|
||||
/// the 1-hour STS credentials expire. If all retries fail, a warning is logged
|
||||
/// and the next refresh is scheduled after the normal interval.
|
||||
///
|
||||
/// This future never resolves — it is designed to be raced with the run execution
|
||||
/// future via `futures::select!` and dropped automatically when the run completes.
|
||||
pub(crate) async fn refresh_loop(
|
||||
task_id: String,
|
||||
role_arn: String,
|
||||
region: String,
|
||||
foreground: &ModelSpawner<AgentDriver>,
|
||||
) {
|
||||
loop {
|
||||
warpui::r#async::Timer::after(BEDROCK_CREDENTIALS_REFRESH_INTERVAL).await;
|
||||
|
||||
log::info!("Proactively refreshing AWS Bedrock OIDC credentials for task {task_id}");
|
||||
|
||||
let backoff_delays = [
|
||||
Duration::from_secs(60),
|
||||
Duration::from_secs(2 * 60),
|
||||
Duration::from_secs(4 * 60),
|
||||
];
|
||||
let mut attempt = 0usize;
|
||||
loop {
|
||||
match try_refresh(&task_id, &role_arn, ®ion, foreground).await {
|
||||
Ok(()) => break,
|
||||
Err(e) if attempt < backoff_delays.len() => {
|
||||
let delay = backoff_delays[attempt];
|
||||
log::warn!(
|
||||
"Bedrock credentials refresh failed (attempt {}): {e:#}; \
|
||||
retrying in {}s",
|
||||
attempt + 1,
|
||||
delay.as_secs()
|
||||
);
|
||||
warpui::r#async::Timer::after(delay).await;
|
||||
attempt += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Bedrock credentials refresh failed after {} attempts: {e:#}; \
|
||||
credentials may expire before next refresh cycle",
|
||||
attempt + 1
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
use std::{collections::HashMap, ffi::OsString, future::Future, pin::Pin};
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsString;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
use anyhow::Error;
|
||||
use galaxyui::ModelSpawner;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
use std::{collections::HashMap, ffi::OsString, future::Future, pin::Pin, time::Duration};
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsString;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use galaxy_core::safe_info;
|
||||
@@ -7,11 +11,10 @@ use galaxyui::{ModelSpawner, SingletonEntity};
|
||||
use tempfile::{Builder, NamedTempFile};
|
||||
use vec1::Vec1;
|
||||
|
||||
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};
|
||||
use crate::ai::aws_credentials::aws_role_session_name;
|
||||
use crate::ai::cloud_environments::AwsProviderConfig;
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
use std::{collections::HashMap, ffi::OsString, future::Future, pin::Pin, time::Duration};
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsString;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use galaxy_managed_secrets::{GcpCredentials, GcpFederationConfig};
|
||||
|
||||
use crate::ai::cloud_environments::GcpProviderConfig;
|
||||
|
||||
use super::{CloudProvider, CloudProviderSetupError, Result};
|
||||
use crate::ai::cloud_environments::GcpProviderConfig;
|
||||
|
||||
/// Token lifetime for GCP executable-sourced credentials. The GCP client
|
||||
/// libraries handle refreshing automatically, so we keep this short.
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
use std::{collections::HashMap, ffi::OsString, path::PathBuf};
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::aws::AwsCloudProvider;
|
||||
use super::gcp::GcpCloudProvider;
|
||||
use super::{collect_env_vars, load_providers, CloudProvider};
|
||||
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 {
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
future::Future,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::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 galaxy_completer::completer::CommandExitStatus;
|
||||
use galaxy_core::{command::ExitCode, safe_info, safe_warn};
|
||||
use galaxyui::{r#async::FutureExt, ModelContext, ModelSpawner, SingletonEntity};
|
||||
use futures::channel::oneshot;
|
||||
use futures::future::join_all;
|
||||
use repo_metadata::repositories::{DetectedRepositories, RepoDetectionSource};
|
||||
|
||||
use super::{terminal::TerminalDriver, AgentDriverError};
|
||||
use galaxy_cli::agent::Harness;
|
||||
use galaxy_completer::completer::CommandExitStatus;
|
||||
use galaxy_core::command::ExitCode;
|
||||
use galaxy_core::{safe_info, safe_warn};
|
||||
use galaxyui::r#async::FutureExt;
|
||||
use galaxyui::{ModelContext, ModelSpawner, SingletonEntity};
|
||||
|
||||
use super::terminal::TerminalDriver;
|
||||
use super::AgentDriverError;
|
||||
use crate::ai::agent_sdk::setup_observability::{SetupClientEventReporter, SetupStep};
|
||||
use crate::ai::cloud_environments::{AmbientAgentEnvironment, SourceRepo};
|
||||
use crate::terminal::model::session::command_executor::shell_escape_single_quotes;
|
||||
use crate::terminal::shell::ShellType;
|
||||
|
||||
const CODEBASE_INDEX_SYNC_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
@@ -54,20 +57,18 @@ pub fn prepare_environment(
|
||||
working_dir: PathBuf,
|
||||
is_sandbox: bool,
|
||||
harness: Harness,
|
||||
setup_events: SetupClientEventReporter,
|
||||
ctx: &mut ModelContext<TerminalDriver>,
|
||||
) -> impl Future<Output = Result<(), PrepareEnvironmentError>> {
|
||||
let spawner = ctx.spawner();
|
||||
async move {
|
||||
let AmbientAgentEnvironment {
|
||||
github_repos,
|
||||
setup_commands,
|
||||
..
|
||||
} = environment;
|
||||
let source_repos = environment.effective_repos();
|
||||
let setup_commands = environment.setup_commands;
|
||||
|
||||
// Only index the codebase for the Oz harness; third-party harnesses (e.g. Claude)
|
||||
// have their own methods for navigating a codebase.
|
||||
let should_index_codebase = harness == Harness::Oz;
|
||||
let should_subscribe_to_index_updates = should_index_codebase && !github_repos.is_empty();
|
||||
let should_subscribe_to_index_updates = should_index_codebase && !source_repos.is_empty();
|
||||
let repo_channels = Arc::new(Mutex::new(HashMap::<PathBuf, oneshot::Sender<()>>::new()));
|
||||
|
||||
if should_subscribe_to_index_updates {
|
||||
@@ -78,14 +79,15 @@ pub fn prepare_environment(
|
||||
&spawner,
|
||||
working_dir.as_path(),
|
||||
is_sandbox,
|
||||
&github_repos,
|
||||
&source_repos,
|
||||
setup_commands,
|
||||
should_index_codebase,
|
||||
Arc::clone(&repo_channels),
|
||||
setup_events,
|
||||
)
|
||||
.await;
|
||||
|
||||
if should_subscribe_to_index_updates {
|
||||
if should_subscribe_to_index_updates && result.is_err() {
|
||||
let _ = spawner
|
||||
.spawn(|_, ctx| {
|
||||
ctx.unsubscribe_from_model(&CodebaseIndexManager::handle(ctx));
|
||||
@@ -97,14 +99,16 @@ pub fn prepare_environment(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn prepare_environment_impl(
|
||||
spawner: &ModelSpawner<TerminalDriver>,
|
||||
working_dir: &Path,
|
||||
is_sandbox: bool,
|
||||
github_repos: &[GithubRepo],
|
||||
source_repos: &[SourceRepo],
|
||||
setup_commands: Vec<String>,
|
||||
should_index_codebase: bool,
|
||||
repo_channels: Arc<Mutex<HashMap<PathBuf, oneshot::Sender<()>>>>,
|
||||
setup_events: SetupClientEventReporter,
|
||||
) -> Result<(), PrepareEnvironmentError> {
|
||||
let working_dir_string = working_dir.to_string_lossy().to_string();
|
||||
|
||||
@@ -120,175 +124,95 @@ async fn prepare_environment_impl(
|
||||
}
|
||||
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,
|
||||
if !source_repos.is_empty() {
|
||||
setup_events
|
||||
.record_result(SetupStep::EnvironmentRepoClone, async {
|
||||
clone_repos(source_repos, working_dir, spawner).await?;
|
||||
for repo in source_repos {
|
||||
register_cloned_repo(repo, working_dir, is_sandbox, spawner).await?;
|
||||
if !is_sandbox && should_index_codebase {
|
||||
let receiver = index_repo_codebase(
|
||||
&repo.repo,
|
||||
working_dir,
|
||||
Arc::clone(&repo_channels),
|
||||
spawner,
|
||||
)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.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);
|
||||
.await?;
|
||||
if let Some(receiver) = receiver {
|
||||
codebase_context_receivers.push(receiver);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok::<(), PrepareEnvironmentError>(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
if should_index_codebase {
|
||||
record_codebase_indexing(
|
||||
setup_events.clone(),
|
||||
spawner.clone(),
|
||||
codebase_context_receivers,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let has_setup_commands = !setup_commands.is_empty();
|
||||
if has_setup_commands {
|
||||
// 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?;
|
||||
setup_events
|
||||
.record_result(SetupStep::EnvironmentSetupCommands, async {
|
||||
// Set CI=true so setup commands run in a CI-like environment. This should help us run
|
||||
// non-interactive versions of setup commands, as many command line tools recognize the CI
|
||||
// environment variable.
|
||||
execute_command("export CI=true".to_string(), spawner).await?;
|
||||
|
||||
for command in setup_commands {
|
||||
let command_for_error = command.clone();
|
||||
safe_info!(
|
||||
safe: ("Running setup command"),
|
||||
full: ("Running setup command: {command}")
|
||||
);
|
||||
|
||||
let exit_code = execute_command(command, spawner).await?;
|
||||
if exit_code != 0.into() {
|
||||
return Err(PrepareEnvironmentError::SetupCommand {
|
||||
command: command_for_error,
|
||||
});
|
||||
}
|
||||
|
||||
let working_dir_string = working_dir.to_string_lossy().to_string();
|
||||
if let Err(error) = cd_in_terminal(working_dir_string, spawner).await {
|
||||
log::warn!(
|
||||
"Failed to reset working directory after setup command: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
safe_info!(
|
||||
safe: ("Successfully completed setup command"),
|
||||
full: ("Successfully completed setup command: {command_for_error}")
|
||||
);
|
||||
}
|
||||
|
||||
// Unset CI after setup commands complete so the agent session
|
||||
// does not run with CI=true.
|
||||
execute_command("unset CI".to_string(), spawner).await?;
|
||||
Ok::<(), PrepareEnvironmentError>(())
|
||||
})
|
||||
.await?;
|
||||
} else if should_index_codebase && source_repos.is_empty() {
|
||||
let _ = spawner
|
||||
.spawn(|_, ctx| {
|
||||
ctx.unsubscribe_from_model(&CodebaseIndexManager::handle(ctx));
|
||||
})
|
||||
.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!(
|
||||
galaxy_isolation_platform::detect(),
|
||||
Some(
|
||||
galaxy_isolation_platform::IsolationPlatformType::DockerSandbox
|
||||
| galaxy_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 should_index_codebase && source_repos.is_empty() {
|
||||
log::info!("No repositories to index for codebase context");
|
||||
}
|
||||
|
||||
// If there's only one repo in the environment, start the agent in that repo.
|
||||
// This way, it doesn't have to locate the correct repo to work on.
|
||||
if let Some(repo_name) = single_repo_name(github_repos) {
|
||||
if let Some(repo_name) = single_repo_name(source_repos) {
|
||||
safe_info!(
|
||||
safe: ("Changing directory into single repository"),
|
||||
full: ("Changing directory into single repository: {repo_name}")
|
||||
@@ -302,6 +226,269 @@ async fn prepare_environment_impl(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_codebase_indexing(
|
||||
setup_events: SetupClientEventReporter,
|
||||
spawner: ModelSpawner<TerminalDriver>,
|
||||
codebase_context_receivers: Vec<oneshot::Receiver<()>>,
|
||||
) {
|
||||
if codebase_context_receivers.is_empty() {
|
||||
setup_events.record_value_detached(SetupStep::EnvironmentCodebaseIndexing, async move {
|
||||
let _ = spawner
|
||||
.spawn(|_, ctx| {
|
||||
ctx.unsubscribe_from_model(&CodebaseIndexManager::handle(ctx));
|
||||
})
|
||||
.await;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setup_events.record_value_detached(SetupStep::EnvironmentCodebaseIndexing, async move {
|
||||
let repos_indexed = join_all(codebase_context_receivers);
|
||||
if repos_indexed
|
||||
.with_timeout(CODEBASE_INDEX_SYNC_TIMEOUT)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
log::warn!(
|
||||
"Timed out waiting for codebase index sync; continuing without guaranteed codebase context",
|
||||
);
|
||||
}
|
||||
let _ = spawner
|
||||
.spawn(|_, ctx| {
|
||||
ctx.unsubscribe_from_model(&CodebaseIndexManager::handle(ctx));
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
fn build_parallel_clone_command(repos: &[SourceRepo], shell_type: ShellType) -> String {
|
||||
let mut script = String::from(
|
||||
r#"set +e
|
||||
failed=0
|
||||
pids=""
|
||||
tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/warp-clone-logs.XXXXXX")"
|
||||
cleanup_clone_logs() {
|
||||
rm -rf "$tmp_dir"
|
||||
}
|
||||
trap cleanup_clone_logs EXIT
|
||||
clone_repo() {
|
||||
repo_name="$1"
|
||||
repo_url="$2"
|
||||
target="$3"
|
||||
if [ -d "$target" ]; then
|
||||
printf '%s\n' "Repository directory $target already exists, skipping clone..."
|
||||
return 0
|
||||
fi
|
||||
printf '%s\n' "Cloning repository $repo_name..."
|
||||
git clone --filter=tree:0 "$repo_url" "$target"
|
||||
}
|
||||
"#,
|
||||
);
|
||||
|
||||
let mut log_outputs = String::new();
|
||||
for (index, repo) in repos.iter().enumerate() {
|
||||
let repo_name = format!("{}/{}", repo.owner, repo.repo);
|
||||
let repo_url = repo.https_clone_url();
|
||||
let escaped_repo_name = shell_escape_single_quotes(&repo_name, ShellType::Bash);
|
||||
let escaped_repo_url = shell_escape_single_quotes(&repo_url, ShellType::Bash);
|
||||
let escaped_target = shell_escape_single_quotes(&repo.repo, ShellType::Bash);
|
||||
let log_var = format!("log_file_{index}");
|
||||
script.push_str(&format!(
|
||||
"{log_var}=\"$tmp_dir/repo-{index}.log\"\n\
|
||||
clone_repo '{escaped_repo_name}' '{escaped_repo_url}' '{escaped_target}' >\"${log_var}\" 2>&1 &\n"
|
||||
));
|
||||
script.push_str("pids=\"$pids $!\"\n");
|
||||
log_outputs.push_str(&format!(
|
||||
"printf '%s\\n' '===== {escaped_repo_name} ====='\n\
|
||||
if [ -s \"${log_var}\" ]; then\n\
|
||||
\tcat \"${log_var}\"\n\
|
||||
else\n\
|
||||
\tprintf '%s\\n' '(no output)'\n\
|
||||
fi\n"
|
||||
));
|
||||
}
|
||||
|
||||
script.push_str(
|
||||
r#"for pid in $pids; do
|
||||
if ! wait "$pid"; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
"#,
|
||||
);
|
||||
script.push_str(&log_outputs);
|
||||
script.push_str(
|
||||
r#"
|
||||
exit "$failed"
|
||||
"#,
|
||||
);
|
||||
|
||||
let escaped_script = shell_escape_single_quotes(&script, shell_type);
|
||||
format!("sh -c '{escaped_script}'")
|
||||
}
|
||||
|
||||
/// Clone all source repositories to `{working_dir}/{repo.repo}` if they do not already exist.
|
||||
/// Multiple repositories are cloned in parallel to reduce environment setup time.
|
||||
pub(super) async fn clone_repos(
|
||||
repos: &[SourceRepo],
|
||||
working_dir: &Path,
|
||||
spawner: &ModelSpawner<TerminalDriver>,
|
||||
) -> Result<(), PrepareEnvironmentError> {
|
||||
match repos {
|
||||
[] => Ok(()),
|
||||
[repo] => clone_repo(repo, working_dir, spawner).await,
|
||||
repos => {
|
||||
let shell_type = spawner
|
||||
.spawn(|driver, ctx| {
|
||||
driver
|
||||
.active_session_shell_type(ctx)
|
||||
.unwrap_or(ShellType::Bash)
|
||||
})
|
||||
.await
|
||||
.unwrap_or(ShellType::Bash);
|
||||
|
||||
let repo_names = repos
|
||||
.iter()
|
||||
.map(|repo| format!("{}/{}", repo.owner, repo.repo))
|
||||
.collect::<Vec<_>>();
|
||||
safe_info!(
|
||||
safe: ("Cloning repositories via terminal"),
|
||||
full: ("Cloning repositories via terminal: {}", repo_names.join(", "))
|
||||
);
|
||||
|
||||
let command = build_parallel_clone_command(repos, shell_type);
|
||||
let exit_code = execute_command(command, spawner).await?;
|
||||
if exit_code != 0.into() {
|
||||
return Err(PrepareEnvironmentError::CloneRepo {
|
||||
repo_name: repo_names.join(", "),
|
||||
});
|
||||
}
|
||||
|
||||
safe_info!(
|
||||
safe: ("Successfully cloned repositories"),
|
||||
full: ("Successfully cloned repositories: {}", repo_names.join(", "))
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clone a source repository to `{working_dir}/{repo.repo}` if it does not already exist.
|
||||
/// This only performs the clone -- it does NOT register the repo with `DetectedRepositories`.
|
||||
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true, repo = %repo))]
|
||||
pub(super) async fn clone_repo(
|
||||
repo: &SourceRepo,
|
||||
working_dir: &Path,
|
||||
spawner: &ModelSpawner<TerminalDriver>,
|
||||
) -> Result<(), PrepareEnvironmentError> {
|
||||
let repo_name = format!("{}/{}", repo.owner, repo.repo);
|
||||
let repo_url = repo.https_clone_url();
|
||||
// Get the session's shell type for proper escaping, falling back to Bash
|
||||
// when the session is not yet bootstrapped or the spawn fails.
|
||||
let shell_type = spawner
|
||||
.spawn(|driver, ctx| {
|
||||
driver
|
||||
.active_session_shell_type(ctx)
|
||||
.unwrap_or(ShellType::Bash)
|
||||
})
|
||||
.await
|
||||
.unwrap_or(ShellType::Bash);
|
||||
let escaped_url = shell_escape_single_quotes(&repo_url, shell_type);
|
||||
// We do a partial clone here to speed up environment setup time.
|
||||
let command = format!("git clone --filter=tree:0 '{escaped_url}'");
|
||||
|
||||
let repo_dir = working_dir.join(&repo.repo);
|
||||
// Always ask the session whether the repo dir already exists, rather
|
||||
// than stat'ing from the host. The session knows about sandbox-only
|
||||
// paths, and this goes through the silent executor so `test -d` is
|
||||
// not added to the user-visible blocklist. Pass the absolute path
|
||||
// explicitly so the probe doesn't rely on the session's CWD.
|
||||
let dir_exists = terminal_directory_exists(&repo_dir.to_string_lossy(), spawner).await?;
|
||||
|
||||
if dir_exists {
|
||||
safe_warn!(
|
||||
safe: ("We already have a directory with the same repository name in the terminal working directory, skipping clone..."),
|
||||
full: (
|
||||
"We already have a directory with the name {} in the terminal working directory, skipping clone...",
|
||||
repo.repo)
|
||||
);
|
||||
} else {
|
||||
safe_info!(
|
||||
safe: ("Cloning repository via terminal"),
|
||||
full: ("Cloning repository via terminal: {repo_name}")
|
||||
);
|
||||
|
||||
let exit_code = execute_command(command, spawner).await?;
|
||||
if exit_code != 0.into() {
|
||||
return Err(PrepareEnvironmentError::CloneRepo {
|
||||
repo_name: repo_name.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
safe_info!(
|
||||
safe: ("Successfully cloned repository"),
|
||||
full: ("Successfully cloned: {repo_name}")
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register a cloned source repository with `DetectedRepositories` so that the
|
||||
/// skill watcher and other repo-aware subsystems can discover it.
|
||||
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true, repo = %repo, is_sandbox = is_sandbox))]
|
||||
pub(super) async fn register_cloned_repo(
|
||||
repo: &SourceRepo,
|
||||
working_dir: &Path,
|
||||
is_sandbox: bool,
|
||||
spawner: &ModelSpawner<TerminalDriver>,
|
||||
) -> Result<(), PrepareEnvironmentError> {
|
||||
let repo_dir = working_dir.join(&repo.repo);
|
||||
|
||||
// Register the repo with DetectedRepositories so that the skill watcher
|
||||
// and other repo-aware subsystems can discover it before the first query.
|
||||
//
|
||||
// TODO(advait): When the remote code server lands for Docker sandboxes,
|
||||
// sandbox-only working directories will be reachable from the host and
|
||||
// we should register + index them here too (likely via a remote-aware
|
||||
// path instead of `detect_possible_local_git_repo`/`index_directory`, which
|
||||
// both assume a local filesystem). For now, skip so we don't try to
|
||||
// stat paths that only exist inside the sandbox.
|
||||
if is_sandbox {
|
||||
safe_info!(
|
||||
safe: ("Skipping local repo detection for sandbox-only working directory"),
|
||||
full: (
|
||||
"Skipping local repo detection and indexing for sandbox-only working directory {}",
|
||||
working_dir.display()
|
||||
)
|
||||
);
|
||||
} else {
|
||||
let repo_dir_str = repo_dir.to_string_lossy().to_string();
|
||||
let detect_future = spawner
|
||||
.spawn(move |_, ctx| {
|
||||
DetectedRepositories::handle(ctx).update(ctx, |repos, ctx| {
|
||||
repos.detect_possible_local_git_repo(
|
||||
&repo_dir_str,
|
||||
RepoDetectionSource::CloudEnvironmentPrep,
|
||||
ctx,
|
||||
)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)?;
|
||||
// Await detection so the repo is registered in DirectoryWatcher
|
||||
// before the agent's first query.
|
||||
if detect_future.await.is_none() {
|
||||
safe_warn!(
|
||||
safe: ("Repository detection returned no path"),
|
||||
full: ("Repository detection returned no path for {}", repo_dir.display())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn subscribe_to_codebase_index_events(
|
||||
spawner: &ModelSpawner<TerminalDriver>,
|
||||
repo_channels: Arc<Mutex<HashMap<PathBuf, oneshot::Sender<()>>>>,
|
||||
@@ -309,10 +496,11 @@ async fn subscribe_to_codebase_index_events(
|
||||
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) {
|
||||
ctx.subscribe_to_model(&CodebaseIndexManager::handle(ctx), move |_, _, event, ctx| {
|
||||
if !matches!(
|
||||
event,
|
||||
CodebaseIndexManagerEvent::SyncStateUpdated { .. }
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -348,13 +536,13 @@ async fn subscribe_to_codebase_index_events(
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
})
|
||||
.await
|
||||
.map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true, repo = %repo_name))]
|
||||
async fn index_repo_codebase(
|
||||
repo_name: &str,
|
||||
working_dir: &Path,
|
||||
@@ -454,7 +642,7 @@ async fn cd_in_terminal(
|
||||
})
|
||||
}
|
||||
|
||||
fn single_repo_name(repos: &[GithubRepo]) -> Option<String> {
|
||||
fn single_repo_name(repos: &[SourceRepo]) -> Option<String> {
|
||||
if repos.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
use super::single_repo_name;
|
||||
use crate::ai::cloud_environments::GithubRepo;
|
||||
use cloud_object_models::CodeForge;
|
||||
|
||||
use super::{build_parallel_clone_command, single_repo_name};
|
||||
use crate::ai::cloud_environments::SourceRepo;
|
||||
use crate::terminal::shell::ShellType;
|
||||
|
||||
#[test]
|
||||
fn single_repo_name_returns_repo_when_exactly_one_repo() {
|
||||
let repos = vec![GithubRepo::new(
|
||||
let repos = vec![SourceRepo::new(
|
||||
CodeForge::GitHub,
|
||||
"warpdotdev".to_string(),
|
||||
"warp-internal".to_string(),
|
||||
)];
|
||||
@@ -13,12 +17,60 @@ fn single_repo_name_returns_repo_when_exactly_one_repo() {
|
||||
|
||||
#[test]
|
||||
fn single_repo_name_returns_none_for_zero_or_many_repos() {
|
||||
let no_repos = Vec::<GithubRepo>::new();
|
||||
let no_repos = Vec::<SourceRepo>::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()),
|
||||
SourceRepo::new(
|
||||
CodeForge::GitHub,
|
||||
"warpdotdev".to_string(),
|
||||
"warp-internal".to_string(),
|
||||
),
|
||||
SourceRepo::new(
|
||||
CodeForge::GitHub,
|
||||
"warpdotdev".to_string(),
|
||||
"warp-server".to_string(),
|
||||
),
|
||||
];
|
||||
assert_eq!(single_repo_name(&two_repos), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_clone_command_runs_repos_in_background_and_waits() {
|
||||
let repos = vec![
|
||||
SourceRepo::new(
|
||||
CodeForge::GitHub,
|
||||
"warpdotdev".to_string(),
|
||||
"warp".to_string(),
|
||||
),
|
||||
SourceRepo::new(
|
||||
CodeForge::GitLab,
|
||||
"platform/backend".to_string(),
|
||||
"api".to_string(),
|
||||
),
|
||||
];
|
||||
|
||||
let command = build_parallel_clone_command(&repos, ShellType::Bash);
|
||||
|
||||
assert!(command.starts_with("sh -c '"));
|
||||
assert!(command.contains("warpdotdev/warp"));
|
||||
assert!(command.contains("https://github.com/warpdotdev/warp.git"));
|
||||
assert!(command.contains("platform/backend/api"));
|
||||
assert!(command.contains("https://gitlab.com/platform/backend/api.git"));
|
||||
assert_eq!(command.matches("clone_repo").count(), 3);
|
||||
assert_eq!(command.matches("2>&1 &").count(), 2);
|
||||
assert!(command.contains("mktemp -d"));
|
||||
assert!(command.contains("warp-clone-logs"));
|
||||
assert!(command.contains("trap cleanup_clone_logs EXIT"));
|
||||
assert!(command.contains("repo-0.log"));
|
||||
assert!(command.contains("repo-1.log"));
|
||||
assert!(command.contains(">\"$log_file_0\" 2>&1 &"));
|
||||
assert!(command.contains(">\"$log_file_1\" 2>&1 &"));
|
||||
assert!(command.contains("pids=\"$pids $!\""));
|
||||
assert!(command.contains("wait \"$pid\""));
|
||||
assert!(command.contains("===== warpdotdev/warp ====="));
|
||||
assert!(command.contains("cat \"$log_file_0\""));
|
||||
assert!(command.contains("===== platform/backend/api ====="));
|
||||
assert!(command.contains("cat \"$log_file_1\""));
|
||||
assert!(command.contains("exit \"$failed\""));
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::ai::blocklist::task_status_sync_model::classify_renderable_error;
|
||||
use crate::server::server_api::ai::TaskStatusUpdate;
|
||||
use galaxy_graphql::ai::{AgentTaskState, PlatformErrorCode};
|
||||
|
||||
use super::terminal::ShareSessionError;
|
||||
use super::AgentDriverError;
|
||||
use crate::ai::blocklist::local_agent_task_sync_model::classify_renderable_error;
|
||||
use crate::server::server_api::ai::TaskStatusUpdate;
|
||||
|
||||
/// Classify an `AgentDriverError` into a task state and a `TaskStatusUpdate`
|
||||
/// suitable for reporting via `update_agent_task`.
|
||||
@@ -17,10 +17,10 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
|
||||
PlatformErrorCode::InternalError,
|
||||
),
|
||||
),
|
||||
AgentDriverError::BootstrapFailed => (
|
||||
AgentDriverError::BootstrapFailed { error } => (
|
||||
AgentTaskState::Error,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
"Terminal session failed to start. Please try running your task again.",
|
||||
format!("Terminal session failed to start: {error}"),
|
||||
PlatformErrorCode::InternalError,
|
||||
),
|
||||
),
|
||||
@@ -100,13 +100,29 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
|
||||
PlatformErrorCode::EnvironmentSetupFailed,
|
||||
),
|
||||
),
|
||||
AgentDriverError::MCPStartupFailed => (
|
||||
AgentDriverError::ManagedMcpResolutionFailed { uid, message } => (
|
||||
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.",
|
||||
format!("Managed MCP server {uid} could not be resolved: {message}"),
|
||||
PlatformErrorCode::EnvironmentSetupFailed,
|
||||
),
|
||||
),
|
||||
AgentDriverError::MCPStartupFailed { details } => {
|
||||
let server_lines = details
|
||||
.iter()
|
||||
.map(|detail| format!("- {detail}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
(
|
||||
AgentTaskState::Failed,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
format!(
|
||||
"One or more MCP servers failed to start:\n\n{server_lines}\n\nCheck that each server's configuration is valid and that it is reachable from the agent's environment."
|
||||
),
|
||||
PlatformErrorCode::EnvironmentSetupFailed,
|
||||
),
|
||||
)
|
||||
}
|
||||
AgentDriverError::MCPJsonParseError(msg) => (
|
||||
AgentTaskState::Failed,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
@@ -171,7 +187,7 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
|
||||
// --- 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,
|
||||
// fallback — LocalAgentTaskSyncModel 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);
|
||||
@@ -248,7 +264,11 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
|
||||
PlatformErrorCode::InternalError,
|
||||
),
|
||||
),
|
||||
AgentDriverError::ConversationHarnessMismatch { conversation_id, expected, got } => (
|
||||
AgentDriverError::ConversationHarnessMismatch {
|
||||
conversation_id,
|
||||
expected,
|
||||
got,
|
||||
} => (
|
||||
AgentTaskState::Failed,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
format!(
|
||||
@@ -258,7 +278,11 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
|
||||
PlatformErrorCode::EnvironmentSetupFailed,
|
||||
),
|
||||
),
|
||||
AgentDriverError::TaskHarnessMismatch { task_id, expected, got } => (
|
||||
AgentDriverError::TaskHarnessMismatch {
|
||||
task_id,
|
||||
expected,
|
||||
got,
|
||||
} => (
|
||||
AgentTaskState::Failed,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
format!(
|
||||
@@ -268,7 +292,10 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
|
||||
PlatformErrorCode::EnvironmentSetupFailed,
|
||||
),
|
||||
),
|
||||
AgentDriverError::ConversationResumeStateMissing { harness, conversation_id } => (
|
||||
AgentDriverError::ConversationResumeStateMissing {
|
||||
harness,
|
||||
conversation_id,
|
||||
} => (
|
||||
AgentTaskState::Failed,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
format!(
|
||||
@@ -299,6 +326,41 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
|
||||
PlatformErrorCode::EnvironmentSetupFailed,
|
||||
),
|
||||
),
|
||||
AgentDriverError::HarnessAuthCheckFailed { harness, detail } => {
|
||||
let message = format!(
|
||||
"Harness '{harness}' authentication check failed: login credentials \
|
||||
are invalid or expired. Verify that the authentication secret \
|
||||
configured for this harness is correct."
|
||||
);
|
||||
log::error!("Preflight detail for {harness}: {detail}");
|
||||
(
|
||||
AgentTaskState::Failed,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
message,
|
||||
PlatformErrorCode::AuthenticationRequired,
|
||||
),
|
||||
)
|
||||
}
|
||||
AgentDriverError::HarnessRuntimeFailureDetected {
|
||||
harness,
|
||||
pattern,
|
||||
excerpt,
|
||||
} => {
|
||||
let message = format!(
|
||||
"Harness '{harness}' could not make a successful API request. \
|
||||
Matched failure pattern '{pattern}' in harness output: \"{excerpt}\". \
|
||||
This usually means the API key is invalid, out of credits, or the \
|
||||
account is misconfigured."
|
||||
);
|
||||
log::error!("Runtime failure for {harness}: pattern={pattern}, excerpt={excerpt}");
|
||||
(
|
||||
AgentTaskState::Failed,
|
||||
TaskStatusUpdate::with_error_code(
|
||||
message,
|
||||
PlatformErrorCode::AuthenticationRequired,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use galaxy_graphql::ai::{AgentTaskState, PlatformErrorCode};
|
||||
|
||||
use super::classify_driver_error;
|
||||
use crate::ai::agent_sdk::driver::terminal::ShareSessionError;
|
||||
use crate::ai::agent_sdk::driver::terminal::{BootstrapError, ShareSessionError};
|
||||
use crate::ai::agent_sdk::driver::AgentDriverError;
|
||||
|
||||
fn assert_state_and_code(
|
||||
@@ -20,14 +20,58 @@ fn assert_state_and_code(
|
||||
// --- Infrastructure errors → ERROR ---
|
||||
|
||||
#[test]
|
||||
fn bootstrap_failed_is_error_with_internal() {
|
||||
assert_state_and_code(
|
||||
AgentDriverError::BootstrapFailed,
|
||||
AgentTaskState::Error,
|
||||
Some(PlatformErrorCode::InternalError),
|
||||
fn bootstrap_pty_spawn_failed_with_reason_includes_reason_in_message() {
|
||||
let (state, update) = classify_driver_error(&AgentDriverError::BootstrapFailed {
|
||||
error: BootstrapError::PtySpawnFailed {
|
||||
reason: Some("Argument list too long (os error 7)".to_string()),
|
||||
},
|
||||
});
|
||||
assert_eq!(state, AgentTaskState::Error);
|
||||
assert_eq!(update.error_code, Some(PlatformErrorCode::InternalError));
|
||||
assert!(
|
||||
update.message.contains("Argument list too long"),
|
||||
"message should include the specific failure reason: {:?}",
|
||||
update.message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_pty_spawn_failed_without_reason_is_generic() {
|
||||
let (state, update) = classify_driver_error(&AgentDriverError::BootstrapFailed {
|
||||
error: BootstrapError::PtySpawnFailed { reason: None },
|
||||
});
|
||||
assert_eq!(state, AgentTaskState::Error);
|
||||
assert_eq!(update.error_code, Some(PlatformErrorCode::InternalError));
|
||||
assert!(
|
||||
update.message.contains("Shell spawn failed"),
|
||||
"message should describe the spawn failure: {:?}",
|
||||
update.message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_timed_out_is_error_with_internal() {
|
||||
let (state, update) = classify_driver_error(&AgentDriverError::BootstrapFailed {
|
||||
error: BootstrapError::TimedOut,
|
||||
});
|
||||
assert_eq!(state, AgentTaskState::Error);
|
||||
assert_eq!(update.error_code, Some(PlatformErrorCode::InternalError));
|
||||
assert!(
|
||||
update.message.contains("did not start within"),
|
||||
"message should describe the timeout: {:?}",
|
||||
update.message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_internal_error_is_error_with_internal() {
|
||||
let (state, update) = classify_driver_error(&AgentDriverError::BootstrapFailed {
|
||||
error: BootstrapError::InternalError,
|
||||
});
|
||||
assert_eq!(state, AgentTaskState::Error);
|
||||
assert_eq!(update.error_code, Some(PlatformErrorCode::InternalError));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_unavailable_is_error_with_internal() {
|
||||
assert_state_and_code(
|
||||
@@ -72,6 +116,40 @@ fn mcp_server_not_found_is_failed_with_env_setup() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_mcp_resolution_failed_is_failed_with_env_setup() {
|
||||
assert_state_and_code(
|
||||
AgentDriverError::ManagedMcpResolutionFailed {
|
||||
uid: uuid::Uuid::nil(),
|
||||
message: "not active".into(),
|
||||
},
|
||||
AgentTaskState::Failed,
|
||||
Some(PlatformErrorCode::EnvironmentSetupFailed),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_startup_failed_is_failed_with_env_setup_and_per_server_details() {
|
||||
let (state, update) = classify_driver_error(&AgentDriverError::MCPStartupFailed {
|
||||
details: vec![
|
||||
"'devin' failed to start: connection refused".to_string(),
|
||||
"'datadog' did not start within 20s".to_string(),
|
||||
],
|
||||
});
|
||||
assert_eq!(state, AgentTaskState::Failed);
|
||||
assert_eq!(
|
||||
update.error_code,
|
||||
Some(PlatformErrorCode::EnvironmentSetupFailed)
|
||||
);
|
||||
// Each unavailable server is rendered as its own bullet line.
|
||||
assert!(update
|
||||
.message
|
||||
.contains("- 'devin' failed to start: connection refused"));
|
||||
assert!(update
|
||||
.message
|
||||
.contains("- 'datadog' did not start within 20s"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_setup_failed_is_failed() {
|
||||
assert_state_and_code(
|
||||
@@ -99,6 +177,35 @@ fn environment_not_found_is_failed_with_resource_not_found() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversation_harness_mismatch_is_failed_with_env_setup() {
|
||||
let (state, update) = classify_driver_error(&AgentDriverError::ConversationHarnessMismatch {
|
||||
conversation_id: "conv-123".into(),
|
||||
expected: "claude".into(),
|
||||
got: "oz".into(),
|
||||
});
|
||||
assert_eq!(state, AgentTaskState::Failed);
|
||||
assert_eq!(
|
||||
update.error_code,
|
||||
Some(PlatformErrorCode::EnvironmentSetupFailed)
|
||||
);
|
||||
assert!(update.message.contains("conv-123"));
|
||||
assert!(update.message.contains("--harness claude"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversation_resume_state_missing_is_failed_with_resource_not_found() {
|
||||
let (state, update) =
|
||||
classify_driver_error(&AgentDriverError::ConversationResumeStateMissing {
|
||||
harness: "claude".into(),
|
||||
conversation_id: "conv-123".into(),
|
||||
});
|
||||
assert_eq!(state, AgentTaskState::Failed);
|
||||
assert_eq!(update.error_code, Some(PlatformErrorCode::ResourceNotFound));
|
||||
assert!(update.message.contains("conv-123"));
|
||||
assert!(update.message.contains("claude"));
|
||||
}
|
||||
|
||||
// --- ShareSessionFailed variants ---
|
||||
|
||||
#[test]
|
||||
@@ -153,3 +260,41 @@ fn conversation_blocked_is_blocked() {
|
||||
assert_eq!(state, AgentTaskState::Blocked);
|
||||
assert!(update.message.contains("rm -rf /"));
|
||||
}
|
||||
|
||||
// --- Harness auth preflight errors ---
|
||||
|
||||
#[test]
|
||||
fn harness_auth_check_failed_is_failed_with_auth_required() {
|
||||
let (state, update) = classify_driver_error(&AgentDriverError::HarnessAuthCheckFailed {
|
||||
harness: "claude".into(),
|
||||
detail: "exit code 1".into(),
|
||||
});
|
||||
assert_eq!(state, AgentTaskState::Failed);
|
||||
assert_eq!(
|
||||
update.error_code,
|
||||
Some(PlatformErrorCode::AuthenticationRequired)
|
||||
);
|
||||
assert!(update.message.contains("authentication check failed"));
|
||||
assert!(update.message.contains("claude"));
|
||||
}
|
||||
|
||||
// --- Runtime failure detection ---
|
||||
|
||||
#[test]
|
||||
fn harness_runtime_failure_detected_is_failed_with_auth_required() {
|
||||
let (state, update) = classify_driver_error(&AgentDriverError::HarnessRuntimeFailureDetected {
|
||||
harness: "claude".into(),
|
||||
pattern: "credit balance is too low".into(),
|
||||
excerpt: "Error: Your credit balance is too low to make this request.".into(),
|
||||
});
|
||||
assert_eq!(state, AgentTaskState::Failed);
|
||||
assert_eq!(
|
||||
update.error_code,
|
||||
Some(PlatformErrorCode::AuthenticationRequired)
|
||||
);
|
||||
// The user-visible message must surface both the matched pattern and
|
||||
// the excerpt so on-call/users have actionable context.
|
||||
assert!(update.message.contains("claude"));
|
||||
assert!(update.message.contains("credit balance is too low"));
|
||||
assert!(update.message.contains("Your credit balance is too low"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
/// Git credentials management for cloud agent sandboxes.
|
||||
///
|
||||
/// This module handles:
|
||||
/// - Writing provider credentials to `~/.git-credentials`, plus GitHub
|
||||
/// credentials to `~/.config/gh/hosts.yml`, without requiring environment
|
||||
/// variables.
|
||||
/// - One-time git configuration (`credential.helper store`, SSH→HTTPS URL
|
||||
/// rewrites).
|
||||
/// - Configuring the git user identity from the server-returned username/email.
|
||||
/// - An async refresh loop that periodically fetches a fresh token from the
|
||||
/// server and overwrites the credential files, keeping long-running agents
|
||||
/// authenticated for their entire duration.
|
||||
use std::{path::PathBuf, sync::Arc, time::Duration};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
// Use the project's allowed Command wrapper (not std::process::Command, which is
|
||||
// disallowed by clippy rules because it flashes a terminal window on Windows).
|
||||
use command::blocking::Command as BlockingCommand;
|
||||
|
||||
use crate::server::server_api::ai::{AIClient, GitCredential};
|
||||
|
||||
/// How long to wait between credential refresh attempts (~50 minutes, staying
|
||||
/// well ahead of the shortest-lived one-hour token expiry).
|
||||
pub(crate) const GIT_CREDENTIALS_REFRESH_INTERVAL: Duration = Duration::from_secs(50 * 60);
|
||||
|
||||
const DEFAULT_GIT_NAME: &str = "Oz";
|
||||
const DEFAULT_GIT_EMAIL: &str = "oz-agent@warp.dev";
|
||||
const GITHUB_HOST: &str = "github.com";
|
||||
const GH_HOSTS_FILENAME: &str = "hosts.yml";
|
||||
const GLAB_HOST: &str = "gitlab.com";
|
||||
const GLAB_CONFIG_FILENAME: &str = "config.yml";
|
||||
|
||||
fn home_dir() -> Result<PathBuf> {
|
||||
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))
|
||||
}
|
||||
|
||||
/// Write `content` to `path` using owner-only (0600) permissions.
|
||||
///
|
||||
/// On Unix the file is created with mode 0600 so no other user can read the
|
||||
/// credential material. On non-Unix platforms the function falls back to the
|
||||
/// standard write, relying on OS default permissions.
|
||||
fn write_secret_file(path: &std::path::Path, content: &str) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::io::Write as _;
|
||||
use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.with_context(|| format!("Failed to open {} for writing", path.display()))?;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o600))
|
||||
.with_context(|| format!("Failed to set permissions on {}", path.display()))?;
|
||||
file.write_all(content.as_bytes())
|
||||
.with_context(|| format!("Failed to write {}", path.display()))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::fs::write(path, content)
|
||||
.with_context(|| format!("Failed to write {}", path.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn git_credentials_file_content(credentials: &[GitCredential]) -> String {
|
||||
let mut content = String::new();
|
||||
for cred in credentials {
|
||||
let userinfo = match &cred.username {
|
||||
Some(username) => format!("{username}:{}", cred.token),
|
||||
None => format!("x-access-token:{}", cred.token),
|
||||
};
|
||||
content.push_str(&format!("https://{}@{}\n", userinfo, cred.host));
|
||||
}
|
||||
content
|
||||
}
|
||||
|
||||
/// Write `~/.git-credentials` with the given credentials.
|
||||
///
|
||||
/// Each credential entry is formatted as:
|
||||
/// - `https://{username}:{token}@{host}` when a username is present
|
||||
/// - `https://x-access-token:{token}@{host}` for service-account tokens
|
||||
///
|
||||
/// The write is done atomically: a temporary file is written then renamed.
|
||||
fn write_git_credentials_file(credentials: &[GitCredential]) -> Result<()> {
|
||||
if credentials.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let home = home_dir()?;
|
||||
let path = home.join(".git-credentials");
|
||||
let tmp_path = home.join(".git-credentials.tmp");
|
||||
let content = git_credentials_file_content(credentials);
|
||||
write_secret_file(&tmp_path, &content)?;
|
||||
std::fs::rename(&tmp_path, &path).with_context(|| {
|
||||
format!(
|
||||
"Failed to rename {} to {}",
|
||||
tmp_path.display(),
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write `~/.config/gh/hosts.yml` so the `gh` CLI is authenticated.
|
||||
///
|
||||
/// The YAML format is stable for `gh` v2+:
|
||||
/// ```yaml
|
||||
/// github.com:
|
||||
/// oauth_token: TOKEN
|
||||
/// git_protocol: https
|
||||
/// user: USERNAME
|
||||
/// ```
|
||||
///
|
||||
/// The write is atomic: a temporary file is written then renamed.
|
||||
fn write_gh_hosts_yml(credentials: &[GitCredential], home: &std::path::Path) -> Result<()> {
|
||||
let github_credentials = credentials
|
||||
.iter()
|
||||
.filter(|credential| credential.host == GITHUB_HOST)
|
||||
.collect::<Vec<_>>();
|
||||
if github_credentials.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let gh_config_dir = home.join(".config").join("gh");
|
||||
std::fs::create_dir_all(&gh_config_dir)
|
||||
.with_context(|| format!("Failed to create {}", gh_config_dir.display()))?;
|
||||
let path = gh_config_dir.join(GH_HOSTS_FILENAME);
|
||||
let tmp_path = gh_config_dir.join(format!("{GH_HOSTS_FILENAME}.tmp"));
|
||||
|
||||
let mut yaml = String::new();
|
||||
for cred in github_credentials {
|
||||
yaml.push_str(&format!("{}:\n", cred.host));
|
||||
yaml.push_str(&format!(" oauth_token: {}\n", cred.token));
|
||||
yaml.push_str(" git_protocol: https\n");
|
||||
if let Some(username) = &cred.username {
|
||||
yaml.push_str(&format!(" user: {username}\n"));
|
||||
}
|
||||
}
|
||||
|
||||
write_secret_file(&tmp_path, &yaml)?;
|
||||
std::fs::rename(&tmp_path, &path).with_context(|| {
|
||||
format!(
|
||||
"Failed to rename {} to {}",
|
||||
tmp_path.display(),
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write `~/.config/glab-cli/config.yml` so the `glab` CLI is authenticated.
|
||||
///
|
||||
/// The YAML format for glab is:
|
||||
/// ```yaml
|
||||
/// hosts:
|
||||
/// gitlab.com:
|
||||
/// token: TOKEN
|
||||
/// git_protocol: https
|
||||
/// api_protocol: https
|
||||
/// ```
|
||||
///
|
||||
/// The write is atomic: a temporary file is written then renamed.
|
||||
fn write_glab_config(credentials: &[GitCredential], home: &std::path::Path) -> Result<()> {
|
||||
let gitlab_credentials = credentials
|
||||
.iter()
|
||||
.filter(|credential| credential.host == GLAB_HOST)
|
||||
.collect::<Vec<_>>();
|
||||
if gitlab_credentials.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let glab_config_dir = home.join(".config").join("glab-cli");
|
||||
std::fs::create_dir_all(&glab_config_dir)
|
||||
.with_context(|| format!("Failed to create {}", glab_config_dir.display()))?;
|
||||
let path = glab_config_dir.join(GLAB_CONFIG_FILENAME);
|
||||
let tmp_path = glab_config_dir.join(format!("{GLAB_CONFIG_FILENAME}.tmp"));
|
||||
|
||||
let mut yaml = String::new();
|
||||
yaml.push_str("hosts:\n");
|
||||
for cred in gitlab_credentials {
|
||||
yaml.push_str(&format!(" {}:\n", cred.host));
|
||||
yaml.push_str(&format!(" token: {}\n", cred.token));
|
||||
yaml.push_str(" git_protocol: https\n");
|
||||
yaml.push_str(" api_protocol: https\n");
|
||||
}
|
||||
|
||||
write_secret_file(&tmp_path, &yaml)?;
|
||||
std::fs::rename(&tmp_path, &path).with_context(|| {
|
||||
format!(
|
||||
"Failed to rename {} to {}",
|
||||
tmp_path.display(),
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Formats non-sensitive metadata for verifying local credential injection.
|
||||
pub(crate) fn credential_diagnostics(credentials: &[GitCredential]) -> String {
|
||||
credentials
|
||||
.iter()
|
||||
.map(|credential| {
|
||||
format!(
|
||||
"{}(token_present={}, username_present={})",
|
||||
credential.host,
|
||||
!credential.token.is_empty(),
|
||||
credential.username.is_some()
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
pub(crate) fn write_git_credentials(credentials: &[GitCredential]) -> Result<()> {
|
||||
if credentials.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
write_git_credentials_file(credentials)?;
|
||||
let home = home_dir()?;
|
||||
write_gh_hosts_yml(credentials, &home)?;
|
||||
write_glab_config(credentials, &home)?;
|
||||
log::info!(
|
||||
"Wrote {} git credential(s) to the local credential store: {}",
|
||||
credentials.len(),
|
||||
credential_diagnostics(credentials)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn configure_git_credentials(credentials: &[GitCredential]) -> Result<()> {
|
||||
if credentials.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
setup_git_config(credentials);
|
||||
configure_git_identity(credentials);
|
||||
write_git_credentials(credentials)
|
||||
}
|
||||
|
||||
/// Run a git config command, logging a warning on failure rather than
|
||||
/// propagating the error (git may not be installed in all sandboxes).
|
||||
fn run_git_config(key: &str, value: &str) {
|
||||
match BlockingCommand::new("git")
|
||||
.args(["config", "--global", key, value])
|
||||
.output()
|
||||
{
|
||||
Ok(output) if output.status.success() => {}
|
||||
Ok(output) => {
|
||||
log::warn!(
|
||||
"git config --global {key} failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to run git config --global {key}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`run_git_config`] but passes `--add` so the new value is appended to
|
||||
/// any existing values for `key` rather than replacing them.
|
||||
fn run_git_config_add(key: &str, value: &str) {
|
||||
match BlockingCommand::new("git")
|
||||
.args(["config", "--global", "--add", key, value])
|
||||
.output()
|
||||
{
|
||||
Ok(output) if output.status.success() => {}
|
||||
Ok(output) => {
|
||||
log::warn!(
|
||||
"git config --global --add {key} failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to run git config --global --add {key}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one-time git configuration that is set at startup and never needs to
|
||||
/// be refreshed:
|
||||
/// - `credential.helper store` so git reads `~/.git-credentials`
|
||||
/// - SSH→HTTPS URL rewrites for each credential host, covering both the
|
||||
/// scp-style (`git@{host}:`) and explicit-protocol (`ssh://git@{host}/`)
|
||||
/// URL forms, so operations on either form use HTTPS credentials instead
|
||||
/// of looking for an SSH key.
|
||||
pub(crate) fn setup_git_config(credentials: &[GitCredential]) {
|
||||
run_git_config("credential.helper", "store");
|
||||
// Use --add for both forms per host so all values coexist as a
|
||||
// multi-value key rather than each entry overwriting the previous one.
|
||||
for cred in credentials {
|
||||
let host = &cred.host;
|
||||
run_git_config_add(
|
||||
&format!("url.https://{host}/.insteadOf"),
|
||||
&format!("ssh://git@{host}/"),
|
||||
);
|
||||
run_git_config_add(
|
||||
&format!("url.https://{host}/.insteadOf"),
|
||||
&format!("git@{host}:"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure the git user identity from the server-returned credential.
|
||||
///
|
||||
/// Uses the first credential's `username`/`email` fields, falling back to the
|
||||
/// Oz defaults when either is absent (e.g. service-account principals).
|
||||
pub(crate) fn configure_git_identity(credentials: &[GitCredential]) {
|
||||
let (name, email) = credentials
|
||||
.first()
|
||||
.map(|c| {
|
||||
(
|
||||
c.username.as_deref().unwrap_or(DEFAULT_GIT_NAME),
|
||||
c.email.as_deref().unwrap_or(DEFAULT_GIT_EMAIL),
|
||||
)
|
||||
})
|
||||
.unwrap_or((DEFAULT_GIT_NAME, DEFAULT_GIT_EMAIL));
|
||||
|
||||
run_git_config("user.name", name);
|
||||
run_git_config("user.email", email);
|
||||
}
|
||||
|
||||
/// Perform one git credentials refresh attempt.
|
||||
///
|
||||
/// Returns `Ok(())` on success (including when the server returns no
|
||||
/// credentials). Returns `Err` when the workload-token issuance or the server
|
||||
/// API call fails — these are transient failures worth retrying.
|
||||
#[tracing::instrument(name = "git_credentials::try_refresh", skip_all, err, fields(
|
||||
tags.cloud_agent = true,
|
||||
task_id,
|
||||
))]
|
||||
async fn try_refresh(task_id: &str, ai_client: &Arc<dyn AIClient>) -> Result<()> {
|
||||
let workload_token =
|
||||
warp_isolation_platform::issue_workload_token(Some(Duration::from_secs(5 * 60)))
|
||||
.await
|
||||
.context("Failed to issue workload token for git credentials refresh")?
|
||||
.token;
|
||||
|
||||
let credentials = ai_client
|
||||
.get_task_git_credentials(task_id.to_string(), workload_token)
|
||||
.await
|
||||
.context("Failed to fetch git credentials from server")?;
|
||||
|
||||
if credentials.is_empty() {
|
||||
log::debug!("No git credentials returned during refresh; skipping file write");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Err(e) = write_git_credentials(&credentials) {
|
||||
log::warn!("Failed to write refreshed git credentials: {e:#}");
|
||||
} else {
|
||||
log::info!("Git credentials refreshed successfully");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Infinite async loop that refreshes git credentials every
|
||||
/// [`GIT_CREDENTIALS_REFRESH_INTERVAL`].
|
||||
///
|
||||
/// On each iteration:
|
||||
/// 1. Issue a short-lived workload token.
|
||||
/// 2. Call `taskGitCredentials` to get a fresh token from the server.
|
||||
/// 3. Overwrite `~/.git-credentials` and refresh GitHub credentials in
|
||||
/// `~/.config/gh/hosts.yml`.
|
||||
///
|
||||
/// On transient failure, the refresh is retried up to three times with
|
||||
/// exponential backoff (1 min, 2 min, 4 min), keeping all retries within the
|
||||
/// ~10-minute buffer before the one-hour token expires. If all retries fail,
|
||||
/// a warning is logged and the next refresh is scheduled after the normal
|
||||
/// interval.
|
||||
///
|
||||
/// This future never resolves — it is designed to be raced with the harness
|
||||
/// execution future via `futures::select!` and dropped when the harness
|
||||
/// completes.
|
||||
pub(crate) async fn refresh_loop(task_id: String, ai_client: Arc<dyn AIClient>) {
|
||||
loop {
|
||||
warpui::r#async::Timer::after(GIT_CREDENTIALS_REFRESH_INTERVAL).await;
|
||||
|
||||
log::info!("Refreshing git credentials for task {task_id}");
|
||||
|
||||
let backoff_delays = [
|
||||
Duration::from_secs(60),
|
||||
Duration::from_secs(2 * 60),
|
||||
Duration::from_secs(4 * 60),
|
||||
];
|
||||
let mut attempt = 0usize;
|
||||
loop {
|
||||
match try_refresh(&task_id, &ai_client).await {
|
||||
Ok(()) => break,
|
||||
Err(e) if attempt < backoff_delays.len() => {
|
||||
let delay = backoff_delays[attempt];
|
||||
log::warn!(
|
||||
"Git credentials refresh failed (attempt {}): {e:#}; retrying in {}s",
|
||||
attempt + 1,
|
||||
delay.as_secs()
|
||||
);
|
||||
warpui::r#async::Timer::after(delay).await;
|
||||
attempt += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Git credentials refresh failed after {} attempts: {e:#}; \
|
||||
credentials may expire before next refresh cycle",
|
||||
attempt + 1
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "git_credentials_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,204 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn write_gh_hosts_yml_uses_gh_cli_filename() -> Result<()> {
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let gh_config_dir = temp_dir.path().join(".config").join("gh");
|
||||
|
||||
write_gh_hosts_yml(
|
||||
&[GitCredential {
|
||||
token: "token".to_string(),
|
||||
username: Some("octocat".to_string()),
|
||||
email: Some("octocat@example.com".to_string()),
|
||||
host: "github.com".to_string(),
|
||||
}],
|
||||
temp_dir.path(),
|
||||
)?;
|
||||
|
||||
let hosts_path = gh_config_dir.join(GH_HOSTS_FILENAME);
|
||||
assert!(hosts_path.exists());
|
||||
assert!(!gh_config_dir
|
||||
.join(format!("{GH_HOSTS_FILENAME}.tmp"))
|
||||
.exists());
|
||||
|
||||
let hosts = std::fs::read_to_string(hosts_path)?;
|
||||
assert!(hosts.contains("github.com:"));
|
||||
assert!(hosts.contains(" oauth_token: token"));
|
||||
assert!(hosts.contains(" git_protocol: https"));
|
||||
assert!(hosts.contains(" user: octocat"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_gh_hosts_yml_excludes_gitlab_credentials() -> Result<()> {
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let gh_config_dir = temp_dir.path().join(".config").join("gh");
|
||||
|
||||
write_gh_hosts_yml(
|
||||
&[
|
||||
GitCredential {
|
||||
token: "github-token".to_string(),
|
||||
username: Some("octocat".to_string()),
|
||||
email: None,
|
||||
host: "github.com".to_string(),
|
||||
},
|
||||
GitCredential {
|
||||
token: "gitlab-token".to_string(),
|
||||
username: Some("oauth2".to_string()),
|
||||
email: None,
|
||||
host: "gitlab.com".to_string(),
|
||||
},
|
||||
],
|
||||
temp_dir.path(),
|
||||
)?;
|
||||
|
||||
let hosts = std::fs::read_to_string(gh_config_dir.join(GH_HOSTS_FILENAME))?;
|
||||
assert!(hosts.contains("github.com:"));
|
||||
assert!(!hosts.contains("gitlab.com:"));
|
||||
assert!(!hosts.contains("gitlab-token"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_gh_hosts_yml_skips_gitlab_only_credentials() -> Result<()> {
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
|
||||
write_gh_hosts_yml(
|
||||
&[GitCredential {
|
||||
token: "gitlab-token".to_string(),
|
||||
username: Some("oauth2".to_string()),
|
||||
email: None,
|
||||
host: "gitlab.com".to_string(),
|
||||
}],
|
||||
temp_dir.path(),
|
||||
)?;
|
||||
|
||||
assert!(!temp_dir.path().join(".config").join("gh").exists());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_credentials_file_content_includes_each_provider_host() {
|
||||
let content = git_credentials_file_content(&[
|
||||
GitCredential {
|
||||
token: "github-token".to_string(),
|
||||
username: None,
|
||||
email: None,
|
||||
host: "github.com".to_string(),
|
||||
},
|
||||
GitCredential {
|
||||
token: "gitlab-token".to_string(),
|
||||
username: Some("oauth2".to_string()),
|
||||
email: None,
|
||||
host: "gitlab.com".to_string(),
|
||||
},
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
content,
|
||||
"https://x-access-token:github-token@github.com\n\
|
||||
https://oauth2:gitlab-token@gitlab.com\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_diagnostics_reports_presence_without_values() {
|
||||
let diagnostics = credential_diagnostics(&[GitCredential {
|
||||
token: "secret-token".to_string(),
|
||||
username: Some("oauth2".to_string()),
|
||||
email: Some("user@example.com".to_string()),
|
||||
host: "gitlab.com".to_string(),
|
||||
}]);
|
||||
|
||||
assert_eq!(
|
||||
diagnostics,
|
||||
"gitlab.com(token_present=true, username_present=true)"
|
||||
);
|
||||
assert!(!diagnostics.contains("secret-token"));
|
||||
assert!(!diagnostics.contains("oauth2"));
|
||||
assert!(!diagnostics.contains("user@example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_glab_config_uses_glab_cli_filename() -> Result<()> {
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let glab_config_dir = temp_dir.path().join(".config").join("glab-cli");
|
||||
|
||||
write_glab_config(
|
||||
&[GitCredential {
|
||||
token: "gitlab-token".to_string(),
|
||||
username: Some("oauth2".to_string()),
|
||||
email: Some("user@example.com".to_string()),
|
||||
host: "gitlab.com".to_string(),
|
||||
}],
|
||||
temp_dir.path(),
|
||||
)?;
|
||||
|
||||
let config_path = glab_config_dir.join(GLAB_CONFIG_FILENAME);
|
||||
assert!(config_path.exists());
|
||||
assert!(!glab_config_dir
|
||||
.join(format!("{GLAB_CONFIG_FILENAME}.tmp"))
|
||||
.exists());
|
||||
|
||||
let config = std::fs::read_to_string(config_path)?;
|
||||
assert!(config.contains("hosts:"));
|
||||
assert!(config.contains(" gitlab.com:"));
|
||||
assert!(config.contains(" token: gitlab-token"));
|
||||
assert!(config.contains(" git_protocol: https"));
|
||||
assert!(config.contains(" api_protocol: https"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_glab_config_excludes_github_credentials() -> Result<()> {
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let glab_config_dir = temp_dir.path().join(".config").join("glab-cli");
|
||||
|
||||
write_glab_config(
|
||||
&[
|
||||
GitCredential {
|
||||
token: "github-token".to_string(),
|
||||
username: Some("octocat".to_string()),
|
||||
email: None,
|
||||
host: "github.com".to_string(),
|
||||
},
|
||||
GitCredential {
|
||||
token: "gitlab-token".to_string(),
|
||||
username: Some("oauth2".to_string()),
|
||||
email: None,
|
||||
host: "gitlab.com".to_string(),
|
||||
},
|
||||
],
|
||||
temp_dir.path(),
|
||||
)?;
|
||||
|
||||
let config = std::fs::read_to_string(glab_config_dir.join(GLAB_CONFIG_FILENAME))?;
|
||||
assert!(config.contains("gitlab.com:"));
|
||||
assert!(!config.contains("github.com:"));
|
||||
assert!(!config.contains("github-token"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_glab_config_skips_github_only_credentials() -> Result<()> {
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
|
||||
write_glab_config(
|
||||
&[GitCredential {
|
||||
token: "github-token".to_string(),
|
||||
username: Some("octocat".to_string()),
|
||||
email: None,
|
||||
host: "github.com".to_string(),
|
||||
}],
|
||||
temp_dir.path(),
|
||||
)?;
|
||||
|
||||
assert!(!temp_dir.path().join(".config").join("glab-cli").exists());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
@@ -12,42 +13,55 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use tempfile::NamedTempFile;
|
||||
use uuid::Uuid;
|
||||
use galaxy_managed_secrets::ManagedSecretValue;
|
||||
|
||||
use super::super::terminal::{CommandHandle, TerminalDriver};
|
||||
use super::super::{AgentDriver, AgentDriverError};
|
||||
use super::claude_transcript::{
|
||||
claude_config_dir, home_dir_for_claude_config, read_envelope, rehydrate_claude_transcript,
|
||||
ClaudeResumeInfo, ClaudeTranscriptEnvelope,
|
||||
};
|
||||
use super::json_utils::{read_json_file_or_default, write_json_file};
|
||||
use super::{
|
||||
cli_agent_session_status, write_temp_file, HarnessCleanupDisposition, HarnessRunner,
|
||||
JSONMCPServer, ResumePayload, SavePoint, ThirdPartyHarness,
|
||||
};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent_sdk::setup_observability::{
|
||||
OzRunTimelineEvent, SetupClientEventReporter, SetupStep,
|
||||
};
|
||||
use crate::ai::ambient_agents::task::HarnessModelConfig;
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::ai::mcp::JSONTransportType;
|
||||
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;
|
||||
mod wake_driver;
|
||||
|
||||
#[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,
|
||||
parent_bridge_char_count, parent_bridge_event_cursor_file, 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,
|
||||
prime_parent_bridge_staged_for_self_managed_wake, read_parent_bridge_event_cursor,
|
||||
render_parent_bridge_message_block, stage_parent_bridge_message,
|
||||
write_parent_bridge_event_cursor, MessageBridgeHookOutput, MessageBridgeMessageRecord,
|
||||
MESSAGE_BRIDGE_CONTEXT_PREAMBLE,
|
||||
};
|
||||
use parent_bridge::{MessageBridge, MessageBridgeCleanupDisposition};
|
||||
#[cfg(test)]
|
||||
use shell_words::quote as shell_quote;
|
||||
#[cfg(test)]
|
||||
use wake_driver::{ClaudeWakeRemoteContext, CLAUDE_WAKE_PROMPT_FILE_NAME};
|
||||
|
||||
#[cfg(test)]
|
||||
use super::super::OZ_MESSAGE_LISTENER_STATE_ROOT_ENV;
|
||||
|
||||
pub(crate) struct ClaudeHarness;
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
impl ThirdPartyHarness for ClaudeHarness {
|
||||
@@ -63,18 +77,33 @@ impl ThirdPartyHarness for ClaudeHarness {
|
||||
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,
|
||||
}
|
||||
})
|
||||
fn auth_check_command(&self) -> Option<String> {
|
||||
let cli = self.cli_agent().command_prefix();
|
||||
Some(format!("{cli} auth status --json"))
|
||||
}
|
||||
|
||||
fn runtime_error_patterns(&self) -> &'static [&'static str] {
|
||||
&[
|
||||
// Out-of-credits / billing.
|
||||
"Credit balance too low",
|
||||
// Plan/usage limits emitted as `You've hit your <kind> limit`.
|
||||
// We match on the common prefix so the variants (session,
|
||||
// weekly, Opus, etc.) all hit.
|
||||
"You've hit your",
|
||||
// Invalid or malformed API key.
|
||||
"Invalid API key",
|
||||
"This organization has been disabled",
|
||||
"belongs to a disabled organization",
|
||||
// OAuth / login state.
|
||||
"Not logged in",
|
||||
"OAuth token revoked",
|
||||
"OAuth token has expired",
|
||||
// Routines disabled by org policy.
|
||||
"Routines are disabled by your organization's policy",
|
||||
// Generic upstream API failures Claude Code surfaces verbatim.
|
||||
"API Error: Request rejected (429)",
|
||||
"authentication_error",
|
||||
]
|
||||
}
|
||||
|
||||
/// Fetch the Claude Code transcript for the current task's conversation and wrap it
|
||||
@@ -86,28 +115,9 @@ impl ThirdPartyHarness for ClaudeHarness {
|
||||
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 envelope: ClaudeTranscriptEnvelope =
|
||||
super::fetch_transcript_envelope("claude", conversation_id, harness_support_client)
|
||||
.await?;
|
||||
let session_id = envelope.uuid;
|
||||
Ok(Some(ResumePayload::Claude(ClaudeResumeInfo {
|
||||
conversation_id: *conversation_id,
|
||||
@@ -121,24 +131,43 @@ impl ThirdPartyHarness for ClaudeHarness {
|
||||
prompt: &str,
|
||||
system_prompt: Option<&str>,
|
||||
resumption_prompt: Option<&str>,
|
||||
context: Option<&str>,
|
||||
working_dir: &Path,
|
||||
task_id: Option<AmbientAgentTaskId>,
|
||||
server_api: Arc<ServerApi>,
|
||||
terminal_driver: ModelHandle<TerminalDriver>,
|
||||
resume: Option<ResumePayload>,
|
||||
resolved_env_vars: &HashMap<OsString, OsString>,
|
||||
_resolved_secrets: &HashMap<String, ManagedSecretValue>,
|
||||
resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
|
||||
_third_party_harness_model_config: Option<&HarnessModelConfig>,
|
||||
) -> 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,
|
||||
});
|
||||
// Prepare the environment config files.
|
||||
prepare_claude_environment_config(working_dir, resolved_env_vars).map_err(|error| {
|
||||
AgentDriverError::HarnessConfigSetupFailed {
|
||||
harness: self.cli_agent().command_prefix().to_owned(),
|
||||
error,
|
||||
}
|
||||
})?;
|
||||
|
||||
// The ResumePayload shouldn't contain non-Claude information, error if it does.
|
||||
let claude_resume = resume.map(ClaudeResumeInfo::try_from).transpose()?;
|
||||
// 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(),
|
||||
};
|
||||
// and server context are most reliable when prepended directly to the prompt that gets
|
||||
// piped into the CLI. Order: resumption_prompt → context → prompt
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
if let Some(preamble) = resumption_prompt {
|
||||
if !preamble.is_empty() {
|
||||
parts.push(preamble);
|
||||
}
|
||||
}
|
||||
if let Some(ctx) = context {
|
||||
if !ctx.is_empty() {
|
||||
parts.push(ctx);
|
||||
}
|
||||
}
|
||||
parts.push(prompt);
|
||||
let owned_prompt = parts.join("\n\n");
|
||||
Ok(Box::new(ClaudeHarnessRunner::new(
|
||||
self.cli_agent().command_prefix(),
|
||||
&owned_prompt,
|
||||
@@ -148,8 +177,13 @@ impl ThirdPartyHarness for ClaudeHarness {
|
||||
server_api,
|
||||
terminal_driver,
|
||||
claude_resume,
|
||||
resolved_mcp_servers,
|
||||
)?))
|
||||
}
|
||||
|
||||
fn requires_verified_platform_plugin(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Format slug sent to the server when creating a Claude Code conversation.
|
||||
@@ -169,6 +203,7 @@ fn claude_command(
|
||||
session_id: &Uuid,
|
||||
prompt_path: &str,
|
||||
system_prompt_path: Option<&str>,
|
||||
mcp_config_path: Option<&str>,
|
||||
resuming: bool,
|
||||
) -> String {
|
||||
let flag = if resuming { "--resume" } else { "--session-id" };
|
||||
@@ -176,6 +211,9 @@ fn claude_command(
|
||||
if let Some(sp_path) = system_prompt_path {
|
||||
let _ = write!(cmd, " --append-system-prompt-file '{sp_path}'");
|
||||
}
|
||||
if let Some(mcp_path) = mcp_config_path {
|
||||
let _ = write!(cmd, " --mcp-config '{mcp_path}'");
|
||||
}
|
||||
format!("{cmd} < '{prompt_path}'")
|
||||
}
|
||||
|
||||
@@ -198,6 +236,8 @@ struct ClaudeHarnessRunner {
|
||||
_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>,
|
||||
/// Held so the MCP config temp file lives until the CLI exits.
|
||||
_temp_mcp_config_file: Option<NamedTempFile>,
|
||||
client: Arc<dyn HarnessSupportClient>,
|
||||
server_api: Arc<ServerApi>,
|
||||
terminal_driver: ModelHandle<TerminalDriver>,
|
||||
@@ -224,49 +264,44 @@ impl ClaudeHarnessRunner {
|
||||
server_api: Arc<ServerApi>,
|
||||
terminal_driver: ModelHandle<TerminalDriver>,
|
||||
resume: Option<ClaudeResumeInfo>,
|
||||
resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
|
||||
) -> 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 temp_file = write_temp_file("oz_prompt_", prompt, ".txt")?;
|
||||
let prompt_path = temp_file.path().display().to_string();
|
||||
|
||||
let (session_id, preexisting_conversation_id, resuming) = match resume {
|
||||
let (session_id, preexisting_conversation_id) = 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)
|
||||
rehydrate_claude_transcript(&mut envelope, working_dir)
|
||||
.map_err(AgentDriverError::ConfigBuildFailed)?;
|
||||
(session_id, Some(conversation_id))
|
||||
}
|
||||
None => (Uuid::new_v4(), None, false),
|
||||
None => (Uuid::new_v4(), None),
|
||||
};
|
||||
|
||||
let temp_system_prompt_file = system_prompt
|
||||
.map(|sp| write_temp_file("oz_system_prompt_", sp))
|
||||
.map(|sp| write_temp_file("oz_system_prompt_", sp, ".txt"))
|
||||
.transpose()?;
|
||||
let system_prompt_path = temp_system_prompt_file
|
||||
.as_ref()
|
||||
.map(|f| f.path().display().to_string());
|
||||
|
||||
let temp_mcp_config_file = (!resolved_mcp_servers.is_empty())
|
||||
.then(|| {
|
||||
let mcp_json = serialize_claude_mcp_config(resolved_mcp_servers)
|
||||
.map_err(AgentDriverError::ConfigBuildFailed)?;
|
||||
write_temp_file("oz_mcp_config_", &mcp_json, ".json")
|
||||
})
|
||||
.transpose()?;
|
||||
let mcp_config_path = temp_mcp_config_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()
|
||||
@@ -279,11 +314,13 @@ impl ClaudeHarnessRunner {
|
||||
&session_id,
|
||||
&prompt_path,
|
||||
system_prompt_path.as_deref(),
|
||||
resuming,
|
||||
mcp_config_path.as_deref(),
|
||||
preexisting_conversation_id.is_some(),
|
||||
),
|
||||
cli_name: cli_command.to_string(),
|
||||
_temp_prompt_file: temp_file,
|
||||
_temp_system_prompt_file: temp_system_prompt_file,
|
||||
_temp_mcp_config_file: temp_mcp_config_file,
|
||||
client,
|
||||
server_api,
|
||||
terminal_driver,
|
||||
@@ -362,9 +399,33 @@ impl ClaudeHarnessRunner {
|
||||
.await
|
||||
}
|
||||
|
||||
fn cleanup_parent_bridge(&self) -> Result<()> {
|
||||
async fn should_preserve_parent_bridge(
|
||||
&self,
|
||||
cleanup_disposition: HarnessCleanupDisposition,
|
||||
foreground: &ModelSpawner<AgentDriver>,
|
||||
) -> bool {
|
||||
if !matches!(
|
||||
cleanup_disposition,
|
||||
HarnessCleanupDisposition::PreserveResumptionStateIfSupported
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
!matches!(
|
||||
cli_agent_session_status(&self.terminal_driver, foreground).await,
|
||||
Some(crate::terminal::cli_agent_sessions::CLIAgentSessionStatus::Blocked { .. })
|
||||
| Some(crate::terminal::cli_agent_sessions::CLIAgentSessionStatus::InProgress)
|
||||
)
|
||||
}
|
||||
|
||||
fn cleanup_parent_bridge(&self, preserve_state: bool) -> Result<()> {
|
||||
if let Some(parent_bridge) = self.parent_bridge.as_ref() {
|
||||
parent_bridge.cleanup()?;
|
||||
let cleanup_disposition = if preserve_state {
|
||||
MessageBridgeCleanupDisposition::PreserveState
|
||||
} else {
|
||||
MessageBridgeCleanupDisposition::RemoveState
|
||||
};
|
||||
parent_bridge.cleanup(cleanup_disposition)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -373,9 +434,14 @@ impl ClaudeHarnessRunner {
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
impl HarnessRunner for ClaudeHarnessRunner {
|
||||
fn harness_name(&self) -> &str {
|
||||
&self.cli_name
|
||||
}
|
||||
|
||||
async fn start(
|
||||
&self,
|
||||
foreground: &ModelSpawner<AgentDriver>,
|
||||
setup_events: &SetupClientEventReporter,
|
||||
) -> 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.
|
||||
@@ -387,14 +453,17 @@ impl HarnessRunner for ClaudeHarnessRunner {
|
||||
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)
|
||||
})?;
|
||||
let id = setup_events
|
||||
.record_result(SetupStep::ThirdPartyHarnessExternalConversation, async {
|
||||
self.client
|
||||
.create_external_conversation(CLAUDE_CODE_FORMAT)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to create external conversation: {e}");
|
||||
AgentDriverError::ConfigBuildFailed(e)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
log::info!("Created external conversation {id}");
|
||||
id
|
||||
}
|
||||
@@ -414,7 +483,7 @@ impl HarnessRunner for ClaudeHarnessRunner {
|
||||
{
|
||||
Ok(command_handle) => command_handle,
|
||||
Err(err) => {
|
||||
self.cleanup_parent_bridge()
|
||||
self.cleanup_parent_bridge(false)
|
||||
.map_err(AgentDriverError::ConfigBuildFailed)?;
|
||||
return Err(err);
|
||||
}
|
||||
@@ -426,6 +495,10 @@ impl HarnessRunner for ClaudeHarnessRunner {
|
||||
block_id: command_handle.block_id().clone(),
|
||||
};
|
||||
|
||||
setup_events
|
||||
.post_timeline_event(OzRunTimelineEvent::AgentStarted)
|
||||
.await;
|
||||
|
||||
Ok(command_handle)
|
||||
}
|
||||
|
||||
@@ -494,9 +567,16 @@ impl HarnessRunner for ClaudeHarnessRunner {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
async fn cleanup(&self, _foreground: &ModelSpawner<AgentDriver>) -> Result<()> {
|
||||
async fn cleanup(
|
||||
&self,
|
||||
cleanup_disposition: HarnessCleanupDisposition,
|
||||
foreground: &ModelSpawner<AgentDriver>,
|
||||
) -> Result<()> {
|
||||
self.flush_parent_bridge_acks().await?;
|
||||
self.cleanup_parent_bridge()
|
||||
let preserve_state = self
|
||||
.should_preserve_parent_bridge(cleanup_disposition, foreground)
|
||||
.await;
|
||||
self.cleanup_parent_bridge(preserve_state)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,21 +606,31 @@ async fn upload_transcript(
|
||||
.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(
|
||||
pub(crate) fn prepare_claude_environment_config(
|
||||
working_dir: &Path,
|
||||
secrets: &HashMap<String, ManagedSecretValue>,
|
||||
resolved_env_vars: &HashMap<OsString, OsString>,
|
||||
) -> 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_json_path = claude_global_config_path()?;
|
||||
let claude_settings_path = claude_config_dir()?.join(CLAUDE_SETTINGS_FILE_NAME);
|
||||
let api_key_suffix = resolve_anthropic_api_key_suffix(secrets);
|
||||
let api_key_suffix = resolve_anthropic_api_key_suffix(resolved_env_vars);
|
||||
prepare_claude_config(&claude_json_path, working_dir, api_key_suffix.as_deref())?;
|
||||
prepare_claude_settings(&claude_settings_path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// This function is used specifically for determining where to land `.claude.json`.
|
||||
fn claude_global_config_path() -> Result<PathBuf> {
|
||||
if let Ok(dir) = std::env::var("CLAUDE_CONFIG_DIR") {
|
||||
if !dir.is_empty() {
|
||||
return Ok(PathBuf::from(dir).join(CLAUDE_JSON_FILE_NAME));
|
||||
}
|
||||
}
|
||||
|
||||
home_dir_for_claude_config()
|
||||
.map(|home| home.join(CLAUDE_JSON_FILE_NAME))
|
||||
.ok_or_else(|| anyhow::anyhow!("could not determine home directory"))
|
||||
}
|
||||
|
||||
fn prepare_claude_config(
|
||||
claude_json_path: &Path,
|
||||
working_dir: &Path,
|
||||
@@ -628,27 +718,23 @@ struct ClaudeSettings {
|
||||
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.
|
||||
/// Try to get the last 20 chars of the ANTHROPIC_API_KEY, where 20 chars is the
|
||||
/// suffix length that Claude Code truncates keys to.
|
||||
fn resolve_anthropic_api_key_suffix(
|
||||
secrets: &HashMap<String, ManagedSecretValue>,
|
||||
resolved_env_vars: &HashMap<OsString, OsString>,
|
||||
) -> 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);
|
||||
// Worker-injected process env wins.
|
||||
if let Ok(key) = std::env::var(ANTHROPIC_API_KEY_ENV) {
|
||||
if !key.is_empty() {
|
||||
return suffix_of(&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))
|
||||
// Otherwise use the resolved value from the secrets map.
|
||||
resolved_env_vars
|
||||
.get(OsStr::new(ANTHROPIC_API_KEY_ENV))
|
||||
.and_then(|v| v.to_str())
|
||||
.and_then(suffix_of)
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
fn suffix_of(key: &str) -> Option<&str> {
|
||||
@@ -659,6 +745,74 @@ fn suffix_of(key: &str) -> Option<&str> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ClaudeMcpConfig {
|
||||
mcp_servers: HashMap<String, ClaudeMcpServerEntry>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
enum ClaudeMcpServerEntry {
|
||||
#[serde(rename = "stdio")]
|
||||
Stdio {
|
||||
command: String,
|
||||
args: Vec<String>,
|
||||
#[serde(skip_serializing_if = "HashMap::is_empty")]
|
||||
env: HashMap<String, String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
cwd: Option<String>,
|
||||
},
|
||||
#[serde(rename = "http")]
|
||||
Http {
|
||||
url: String,
|
||||
#[serde(skip_serializing_if = "HashMap::is_empty")]
|
||||
headers: HashMap<String, String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ClaudeMcpServerEntry {
|
||||
fn from_json_mcp_server(server: &JSONMCPServer) -> Self {
|
||||
match &server.transport_type {
|
||||
JSONTransportType::CLIServer {
|
||||
command,
|
||||
args,
|
||||
env,
|
||||
working_directory,
|
||||
} => Self::Stdio {
|
||||
command: command.clone(),
|
||||
args: args.clone(),
|
||||
env: env.clone(),
|
||||
cwd: working_directory.clone(),
|
||||
},
|
||||
JSONTransportType::SSEServer { url, headers } => Self::Http {
|
||||
url: url.clone(),
|
||||
headers: headers.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize resolved MCP servers into Claude Code's `--mcp-config` JSON format.
|
||||
///
|
||||
/// Produces `{ "mcpServers": { "name": { "type": "stdio"|"http", ... }, ... } }`.
|
||||
pub(crate) fn serialize_claude_mcp_config(
|
||||
servers: &HashMap<String, JSONMCPServer>,
|
||||
) -> Result<String> {
|
||||
let config = ClaudeMcpConfig {
|
||||
mcp_servers: servers
|
||||
.iter()
|
||||
.map(|(name, server)| {
|
||||
(
|
||||
name.clone(),
|
||||
ClaudeMcpServerEntry::from_json_mcp_server(server),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
serde_json::to_string_pretty(&config).context("Failed to serialize Claude MCP config")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "claude_code_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -25,22 +25,25 @@ use uuid::Uuid;
|
||||
|
||||
use crate::ai::agent_events::{
|
||||
run_agent_event_driver, AgentEventConsumer, AgentEventConsumerControlFlow,
|
||||
AgentEventDriverConfig, MessageHydrator, ServerApiAgentEventSource,
|
||||
AgentEventDriverConfig, AgentMessageEventMetadata, MessageHydrator, ServerApiAgentEventSource,
|
||||
};
|
||||
use crate::ai::agent_sdk::driver::{AgentDriver, OZ_MESSAGE_LISTENER_STATE_ROOT_ENV};
|
||||
use crate::server::server_api::ai::AgentRunEvent;
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::server::server_api::ai::{AIClient, 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_EVENT_CURSOR_FILE_NAME: &str = "event-cursor.json";
|
||||
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";
|
||||
pub(super) const MESSAGE_BRIDGE_CONTEXT_PREAMBLE: &str =
|
||||
"Oz mailbox update for this child run.\nSource: lead agent\nContext type: user-level coordination messages\n";
|
||||
const PARENT_BRIDGE_REMAINING_MESSAGES_NOTE: &str =
|
||||
"\n\nMore lead-agent messages are still staged and will be surfaced on a later turn.";
|
||||
"\n\nAdditional lead agent mailbox messages remain queued and may be surfaced later.";
|
||||
|
||||
pub(super) struct MessageBridge {
|
||||
run_id: String,
|
||||
@@ -48,6 +51,16 @@ pub(super) struct MessageBridge {
|
||||
runtime: Mutex<Option<MessageBridgeRuntime>>,
|
||||
state_lock: AsyncMutex<()>,
|
||||
}
|
||||
|
||||
pub(super) enum MessageBridgeCleanupDisposition {
|
||||
RemoveState,
|
||||
PreserveState,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize, Deserialize)]
|
||||
struct MessageBridgeEventCursor {
|
||||
since_sequence: i64,
|
||||
}
|
||||
struct MessageBridgeRuntime {
|
||||
task: SpawnedFutureHandle,
|
||||
}
|
||||
@@ -55,6 +68,7 @@ struct MessageBridgeRuntime {
|
||||
struct MessageBridgeEventConsumer {
|
||||
run_id: String,
|
||||
state_dir: PathBuf,
|
||||
server_api: Arc<ServerApi>,
|
||||
}
|
||||
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
@@ -91,6 +105,13 @@ impl AgentEventConsumer for MessageBridgeEventConsumer {
|
||||
|
||||
Ok(AgentEventConsumerControlFlow::Continue)
|
||||
}
|
||||
|
||||
async fn persist_cursor(&mut self, sequence: i64) -> anyhow::Result<()> {
|
||||
write_parent_bridge_event_cursor(&self.state_dir, sequence)?;
|
||||
self.server_api
|
||||
.update_event_sequence_on_server(&self.run_id, sequence)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -130,6 +151,12 @@ struct SelectedMessageBridgeMessages {
|
||||
}
|
||||
|
||||
impl MessageBridge {
|
||||
fn hydrator(&self, server_api: Arc<ServerApi>) -> MessageHydrator {
|
||||
match self.run_id.parse::<AmbientAgentTaskId>() {
|
||||
Ok(task_id) => MessageHydrator::for_task(server_api, task_id),
|
||||
Err(_) => MessageHydrator::new(server_api),
|
||||
}
|
||||
}
|
||||
pub(super) fn new(run_id: String, session_id: Uuid) -> Result<Self> {
|
||||
Ok(Self {
|
||||
run_id,
|
||||
@@ -177,8 +204,7 @@ impl MessageBridge {
|
||||
if !self.state_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let hydrator = MessageHydrator::new(server_api);
|
||||
let hydrator = self.hydrator(server_api);
|
||||
let _guard = self.state_lock.lock().await;
|
||||
acknowledge_parent_bridge_hook_output(&hydrator, &self.state_dir).await?;
|
||||
prepare_parent_bridge_hook_output(
|
||||
@@ -193,16 +219,18 @@ impl MessageBridge {
|
||||
if !self.state_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let hydrator = MessageHydrator::new(server_api);
|
||||
let hydrator = self.hydrator(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<()> {
|
||||
pub(super) fn cleanup(&self, disposition: MessageBridgeCleanupDisposition) -> Result<()> {
|
||||
if let Some(runtime) = self.runtime.lock().take() {
|
||||
runtime.task.abort();
|
||||
}
|
||||
if matches!(disposition, MessageBridgeCleanupDisposition::PreserveState) {
|
||||
return Ok(());
|
||||
}
|
||||
match fs::remove_dir_all(&self.state_dir) {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||
@@ -249,6 +277,10 @@ pub(super) fn parent_bridge_hook_output_ack_file(state_dir: &Path) -> PathBuf {
|
||||
state_dir.join(PARENT_BRIDGE_HOOK_OUTPUT_ACK_FILE_NAME)
|
||||
}
|
||||
|
||||
pub(super) fn parent_bridge_event_cursor_file(state_dir: &Path) -> PathBuf {
|
||||
state_dir.join(PARENT_BRIDGE_EVENT_CURSOR_FILE_NAME)
|
||||
}
|
||||
|
||||
fn parent_bridge_message_path(dir: &Path, sequence: i64, message_id: &str) -> PathBuf {
|
||||
dir.join(format!("{sequence:020}-{message_id}.json"))
|
||||
}
|
||||
@@ -277,6 +309,28 @@ pub(super) fn ensure_parent_bridge_state_dir(state_dir: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn read_parent_bridge_event_cursor(state_dir: &Path) -> Result<i64> {
|
||||
let path = parent_bridge_event_cursor_file(state_dir);
|
||||
if !path.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let cursor = serde_json::from_slice::<MessageBridgeEventCursor>(
|
||||
&fs::read(&path).with_context(|| format!("Failed to read {}", path.display()))?,
|
||||
)
|
||||
.with_context(|| format!("Failed to parse {}", path.display()))?;
|
||||
Ok(cursor.since_sequence)
|
||||
}
|
||||
|
||||
pub(super) fn write_parent_bridge_event_cursor(state_dir: &Path, sequence: i64) -> Result<()> {
|
||||
write_parent_bridge_json_atomically(
|
||||
&parent_bridge_event_cursor_file(state_dir),
|
||||
&MessageBridgeEventCursor {
|
||||
since_sequence: sequence,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn stage_parent_bridge_message(
|
||||
state_dir: &Path,
|
||||
record: &MessageBridgeMessageRecord,
|
||||
@@ -288,7 +342,57 @@ pub(super) fn stage_parent_bridge_message(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parent_bridge_max_context_chars() -> usize {
|
||||
pub(super) async fn prime_parent_bridge_staged_for_self_managed_wake(
|
||||
hydrator: &MessageHydrator,
|
||||
state_dir: &Path,
|
||||
wake_message: Option<&AgentMessageEventMetadata>,
|
||||
) -> Result<()> {
|
||||
remove_file_if_exists(&parent_bridge_hook_output_file(state_dir))?;
|
||||
remove_file_if_exists(&parent_bridge_hook_output_ack_file(state_dir))?;
|
||||
move_parent_bridge_surfaced_messages_to_staged(state_dir)?;
|
||||
|
||||
let Some(wake_message) = wake_message else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let record = hydrate_parent_bridge_message_record(
|
||||
hydrator,
|
||||
&MessageBridgeMessageRecord {
|
||||
sequence: wake_message.sequence,
|
||||
message_id: wake_message.message_id.clone(),
|
||||
sender_run_id: String::new(),
|
||||
subject: String::new(),
|
||||
body: String::new(),
|
||||
occurred_at: wake_message.occurred_at.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
stage_parent_bridge_message(state_dir, &record)?;
|
||||
write_parent_bridge_event_cursor(state_dir, wake_message.sequence)
|
||||
}
|
||||
|
||||
fn move_parent_bridge_surfaced_messages_to_staged(state_dir: &Path) -> Result<()> {
|
||||
let surfaced_records = parent_bridge_message_records(&parent_bridge_surfaced_dir(state_dir))?;
|
||||
for (path, record) in surfaced_records {
|
||||
let target =
|
||||
parent_bridge_staged_message_path(state_dir, record.sequence, &record.message_id);
|
||||
if target.exists() {
|
||||
write_parent_bridge_json_atomically(&target, &record)?;
|
||||
remove_file_if_exists(&path)?;
|
||||
} else {
|
||||
fs::rename(&path, &target).with_context(|| {
|
||||
format!(
|
||||
"Failed to move message bridge record {} back to {}",
|
||||
path.display(),
|
||||
target.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) 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())
|
||||
@@ -338,7 +442,7 @@ pub(super) fn render_parent_bridge_message_block(record: &MessageBridgeMessageRe
|
||||
record.subject.as_str()
|
||||
};
|
||||
|
||||
let mut block = String::from("---\nLead-agent message");
|
||||
let mut block = String::from("---\nLead agent mailbox message");
|
||||
if record.sequence != 0 {
|
||||
let _ = write!(block, " #{}", record.sequence);
|
||||
}
|
||||
@@ -579,15 +683,50 @@ async fn run_parent_bridge_forever(
|
||||
state_dir: PathBuf,
|
||||
) -> Result<()> {
|
||||
ensure_parent_bridge_state_dir(&state_dir)?;
|
||||
let since_sequence =
|
||||
read_parent_bridge_resume_cursor(server_api.as_ref(), &run_id, &state_dir).await?;
|
||||
// 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 };
|
||||
// loop and we also persist it inside the session state dir so dormant runs
|
||||
// can resume without replaying already handled events.
|
||||
let config =
|
||||
AgentEventDriverConfig::retry_forever_run_ids(vec![run_id.clone()], since_sequence);
|
||||
let source = ServerApiAgentEventSource::new(server_api.clone());
|
||||
let mut consumer = MessageBridgeEventConsumer {
|
||||
run_id,
|
||||
state_dir,
|
||||
server_api,
|
||||
};
|
||||
run_agent_event_driver(source, config, &mut consumer).await
|
||||
}
|
||||
|
||||
async fn read_parent_bridge_resume_cursor(
|
||||
server_api: &ServerApi,
|
||||
run_id: &str,
|
||||
state_dir: &Path,
|
||||
) -> Result<i64> {
|
||||
// The server cursor is the durable cross-client source of truth, but the
|
||||
// bridge also keeps a local cursor for same-machine recovery. If Warp or
|
||||
// Claude restarts after the bridge has staged events locally but before the
|
||||
// server cursor update is visible, the local cursor prevents replaying
|
||||
// messages already handed to this Claude session.
|
||||
let local_sequence = read_parent_bridge_event_cursor(state_dir)?;
|
||||
let Ok(task_id) = run_id.parse() else {
|
||||
return Ok(local_sequence);
|
||||
};
|
||||
|
||||
let server_sequence = match server_api.get_ambient_agent_task(&task_id).await {
|
||||
Ok(task) => task.last_event_sequence.unwrap_or(0),
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to read server-backed event cursor for Claude message bridge run {run_id}: {err:#}"
|
||||
);
|
||||
0
|
||||
}
|
||||
};
|
||||
|
||||
Ok(local_sequence.max(server_sequence))
|
||||
}
|
||||
|
||||
fn write_parent_bridge_json_atomically<T: Serialize>(path: &Path, value: &T) -> Result<()> {
|
||||
write_parent_bridge_bytes_atomically(path, &serde_json::to_vec(value)?)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use shell_words::quote as shell_quote;
|
||||
use uuid::Uuid;
|
||||
use warp_cli::agent::Harness;
|
||||
use warp_graphql::ai::AgentTaskState;
|
||||
|
||||
use super::super::claude_transcript::{
|
||||
claude_config_dir, write_envelope, write_session_index_entry, ClaudeTranscriptEnvelope,
|
||||
};
|
||||
use super::super::{remove_claude_externally_managed_listener_env_vars, task_env_vars};
|
||||
use super::parent_bridge::{
|
||||
ensure_parent_bridge_state_dir, parent_bridge_root,
|
||||
prime_parent_bridge_staged_for_self_managed_wake,
|
||||
};
|
||||
use super::{claude_command, prepare_claude_environment_config, ClaudeHarness};
|
||||
use crate::ai::agent::conversation::{AIConversation, ConversationStatus};
|
||||
use crate::ai::agent_events::{AgentMessageEventMetadata, MessageHydrator};
|
||||
use crate::ai::ambient_agents::{AmbientAgentTaskId, AmbientAgentTaskState};
|
||||
use crate::server::server_api::ai::AIClient;
|
||||
use crate::server::server_api::harness_support::ResolvePromptRequest;
|
||||
use crate::server::server_api::ServerApi;
|
||||
use crate::terminal::CLIAgent;
|
||||
|
||||
const CLAUDE_WAKE_PROMPT: &str =
|
||||
"A lead agent mailbox message is available for this child run. Review the mailbox context and continue the task.";
|
||||
pub(super) const CLAUDE_WAKE_PROMPT_FILE_NAME: &str = "wake-turn-prompt.txt";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct ClaudeWakeRemoteContext {
|
||||
pub(super) session_id: Uuid,
|
||||
pub(super) envelope: ClaudeTranscriptEnvelope,
|
||||
pub(super) wake_prompt: String,
|
||||
}
|
||||
|
||||
struct ClaudeWakeCandidate {
|
||||
task_id: AmbientAgentTaskId,
|
||||
parent_run_id: Option<String>,
|
||||
working_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl ClaudeHarness {
|
||||
pub(crate) async fn wake_dormant_session(
|
||||
server_api: Arc<ServerApi>,
|
||||
conversation: AIConversation,
|
||||
parent_conversation: Option<AIConversation>,
|
||||
working_dir: Option<PathBuf>,
|
||||
wake_message: Option<AgentMessageEventMetadata>,
|
||||
) -> Result<Option<String>> {
|
||||
let Some(candidate) =
|
||||
Self::local_wake_candidate(&conversation, parent_conversation.as_ref(), working_dir)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let ClaudeWakeCandidate {
|
||||
task_id,
|
||||
parent_run_id,
|
||||
working_dir,
|
||||
} = candidate;
|
||||
|
||||
let task = server_api.get_ambient_agent_task(&task_id).await?;
|
||||
let harness = task
|
||||
.agent_config_snapshot
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.harness.as_ref())
|
||||
.map(|config| config.harness_type);
|
||||
log::info!(
|
||||
"Evaluating dormant Claude wake: task_id={task_id} server_task_state={:?} harness={harness:?}",
|
||||
task.state
|
||||
);
|
||||
if !is_local_wake_task_state_ready(task.state.clone()) || harness != Some(Harness::Claude) {
|
||||
log::info!(
|
||||
"Skipping dormant Claude wake: task_id={task_id} server_task_state={:?} harness={harness:?}",
|
||||
task.state
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let remote = Self::fetch_local_wake_remote_context(task_id, server_api.clone()).await?;
|
||||
let command = Self::prepare_local_wake_command(
|
||||
server_api.clone(),
|
||||
task_id,
|
||||
parent_run_id,
|
||||
working_dir,
|
||||
remote,
|
||||
wake_message,
|
||||
)
|
||||
.await?;
|
||||
|
||||
log::info!("Reopening dormant Claude task before wake command: task_id={task_id}");
|
||||
server_api
|
||||
.update_agent_task(task_id, Some(AgentTaskState::InProgress), None, None, None)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to reopen dormant Claude task {task_id} before wake: {err:#}"
|
||||
)
|
||||
})?;
|
||||
log::info!("Reopened dormant Claude task before wake command: task_id={task_id}");
|
||||
|
||||
Ok(Some(command))
|
||||
}
|
||||
|
||||
fn local_wake_candidate(
|
||||
conversation: &AIConversation,
|
||||
parent_conversation: Option<&AIConversation>,
|
||||
working_dir: Option<PathBuf>,
|
||||
) -> Option<ClaudeWakeCandidate> {
|
||||
let conversation_id = conversation.id();
|
||||
if !matches!(conversation.status(), ConversationStatus::Success) {
|
||||
log::info!(
|
||||
"Skipping dormant Claude wake candidate: conversation_id={conversation_id:?} reason=not_success status={:?}",
|
||||
conversation.status()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if !conversation.is_child_agent_conversation() || conversation.is_remote_child() {
|
||||
log::info!(
|
||||
"Skipping dormant Claude wake candidate: conversation_id={conversation_id:?} reason=not_local_child is_child_agent_conversation={} is_remote_child={}",
|
||||
conversation.is_child_agent_conversation(),
|
||||
conversation.is_remote_child()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let Some(task_id) = conversation.task_id() else {
|
||||
log::info!(
|
||||
"Skipping dormant Claude wake candidate: conversation_id={conversation_id:?} reason=missing_task_id"
|
||||
);
|
||||
return None;
|
||||
};
|
||||
let parent_run_id = conversation
|
||||
.parent_agent_id()
|
||||
.map(str::to_owned)
|
||||
.or_else(|| parent_conversation.and_then(AIConversation::run_id));
|
||||
|
||||
Some(ClaudeWakeCandidate {
|
||||
task_id,
|
||||
parent_run_id,
|
||||
working_dir,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_local_wake_remote_context(
|
||||
task_id: AmbientAgentTaskId,
|
||||
server_api: Arc<ServerApi>,
|
||||
) -> Result<ClaudeWakeRemoteContext> {
|
||||
let resolved = server_api
|
||||
.resolve_prompt_for_task(
|
||||
&task_id,
|
||||
ResolvePromptRequest {
|
||||
skill: None,
|
||||
attachments_dir: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("Failed to resolve Claude wake prompt for task {task_id}"))?;
|
||||
let bytes = server_api
|
||||
.fetch_transcript_for_task(&task_id)
|
||||
.await
|
||||
.with_context(|| format!("Failed to fetch Claude transcript for task {task_id}"))?;
|
||||
let envelope: ClaudeTranscriptEnvelope =
|
||||
serde_json::from_slice(&bytes).with_context(|| {
|
||||
format!("Failed to deserialize Claude transcript for wake task {task_id}")
|
||||
})?;
|
||||
let wake_prompt = match resolved.resumption_prompt {
|
||||
Some(resumption_prompt) if !resumption_prompt.is_empty() => {
|
||||
format!(
|
||||
"{resumption_prompt}
|
||||
|
||||
{CLAUDE_WAKE_PROMPT}"
|
||||
)
|
||||
}
|
||||
_ => CLAUDE_WAKE_PROMPT.to_string(),
|
||||
};
|
||||
Ok(ClaudeWakeRemoteContext {
|
||||
session_id: envelope.uuid,
|
||||
envelope,
|
||||
wake_prompt,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn prepare_local_wake_command(
|
||||
server_api: Arc<ServerApi>,
|
||||
task_id: AmbientAgentTaskId,
|
||||
parent_run_id: Option<String>,
|
||||
working_dir: Option<PathBuf>,
|
||||
mut remote: ClaudeWakeRemoteContext,
|
||||
wake_message: Option<AgentMessageEventMetadata>,
|
||||
) -> Result<String> {
|
||||
let working_dir = working_dir.unwrap_or_else(|| remote.envelope.cwd.clone());
|
||||
prepare_claude_environment_config(&working_dir, &HashMap::new())
|
||||
.context("Failed to prepare Claude environment for wake")?;
|
||||
|
||||
remote.envelope.cwd = working_dir.clone();
|
||||
let config_root = claude_config_dir().context("Failed to resolve Claude config dir")?;
|
||||
write_envelope(&remote.envelope, &config_root)
|
||||
.context("Failed to rehydrate Claude transcript for wake")?;
|
||||
if let Err(error) = write_session_index_entry(remote.session_id, &working_dir, &config_root)
|
||||
{
|
||||
log::warn!("Failed to update Claude sessions-index.json for wake: {error:#}");
|
||||
}
|
||||
|
||||
let state_dir = parent_bridge_root()?.join(remote.session_id.to_string());
|
||||
ensure_parent_bridge_state_dir(&state_dir)?;
|
||||
let hydrator = MessageHydrator::for_task(server_api, task_id);
|
||||
prime_parent_bridge_staged_for_self_managed_wake(
|
||||
&hydrator,
|
||||
&state_dir,
|
||||
wake_message.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
let prompt_path = state_dir.join(CLAUDE_WAKE_PROMPT_FILE_NAME);
|
||||
std::fs::write(&prompt_path, remote.wake_prompt.as_bytes())
|
||||
.with_context(|| format!("Failed to write {}", prompt_path.display()))?;
|
||||
|
||||
let command = claude_command(
|
||||
CLIAgent::Claude.command_prefix(),
|
||||
&remote.session_id,
|
||||
&prompt_path.display().to_string(),
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
);
|
||||
let env_vars = local_wake_task_env_vars(Some(&task_id), parent_run_id.as_deref());
|
||||
|
||||
Ok(prefix_command_with_env_vars(command, env_vars))
|
||||
}
|
||||
}
|
||||
|
||||
fn local_wake_task_env_vars(
|
||||
task_id: Option<&AmbientAgentTaskId>,
|
||||
parent_run_id: Option<&str>,
|
||||
) -> HashMap<OsString, OsString> {
|
||||
let mut env_vars = task_env_vars(task_id, parent_run_id, Harness::Claude);
|
||||
// The local wake command is executed directly in the existing child
|
||||
// terminal, not through `AgentDriver::run_harness`, so Warp does not start
|
||||
// `MessageBridge` for this resumed Claude process. Leave the listener in
|
||||
// the Claude plugin's self-managed mode; otherwise the hook waits for
|
||||
// state files that no managed bridge is producing and the wake message is
|
||||
// never surfaced to Claude.
|
||||
remove_claude_externally_managed_listener_env_vars(&mut env_vars);
|
||||
env_vars
|
||||
}
|
||||
|
||||
fn is_local_wake_task_state_ready(state: AmbientAgentTaskState) -> bool {
|
||||
match state {
|
||||
AmbientAgentTaskState::Succeeded => true,
|
||||
// The local conversation status is already gated on `Success` before
|
||||
// this function is called. The server task update is fire-and-forget,
|
||||
// so it can still report `InProgress` for a short window after the
|
||||
// local Claude process has actually stopped. Treat that stale server
|
||||
// state as wakeable for local children.
|
||||
AmbientAgentTaskState::InProgress => true,
|
||||
AmbientAgentTaskState::Queued
|
||||
| AmbientAgentTaskState::Pending
|
||||
| AmbientAgentTaskState::Claimed
|
||||
| AmbientAgentTaskState::Failed
|
||||
| AmbientAgentTaskState::Error
|
||||
| AmbientAgentTaskState::Blocked
|
||||
| AmbientAgentTaskState::Cancelled
|
||||
| AmbientAgentTaskState::Unknown => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn prefix_command_with_env_vars(command: String, env_vars: HashMap<OsString, OsString>) -> String {
|
||||
if env_vars.is_empty() {
|
||||
return command;
|
||||
}
|
||||
|
||||
let mut env_pairs = env_vars
|
||||
.into_iter()
|
||||
.map(|(key, value)| {
|
||||
(
|
||||
key.to_string_lossy().into_owned(),
|
||||
value.to_string_lossy().into_owned(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
env_pairs.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
|
||||
|
||||
let assignments = env_pairs
|
||||
.into_iter()
|
||||
.map(|(key, value)| format!("{key}={}", shell_quote(&value)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
format!("env {assignments} {command}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "wake_driver_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,24 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn local_wake_task_state_ready_allows_success_and_stale_in_progress() {
|
||||
assert!(is_local_wake_task_state_ready(
|
||||
AmbientAgentTaskState::Succeeded
|
||||
));
|
||||
assert!(is_local_wake_task_state_ready(
|
||||
AmbientAgentTaskState::InProgress
|
||||
));
|
||||
|
||||
for state in [
|
||||
AmbientAgentTaskState::Queued,
|
||||
AmbientAgentTaskState::Pending,
|
||||
AmbientAgentTaskState::Claimed,
|
||||
AmbientAgentTaskState::Failed,
|
||||
AmbientAgentTaskState::Error,
|
||||
AmbientAgentTaskState::Blocked,
|
||||
AmbientAgentTaskState::Cancelled,
|
||||
AmbientAgentTaskState::Unknown,
|
||||
] {
|
||||
assert!(!is_local_wake_task_state_ready(state));
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
use mockall::predicate::eq;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use mockall::predicate::eq;
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
use warp_cli::{OZ_HARNESS_ENV, OZ_PARENT_RUN_ID_ENV, OZ_RUN_ID_ENV};
|
||||
|
||||
use super::*;
|
||||
use crate::ai::agent_events::MessageHydrator;
|
||||
use crate::server::server_api::ai::{MockAIClient, ReadAgentMessageResponse};
|
||||
use crate::ai::agent_events::{AgentMessageEventMetadata, MessageHydrator};
|
||||
use crate::ai::agent_sdk::driver::harness::claude_transcript::{
|
||||
encode_cwd, write_session_index_entry,
|
||||
};
|
||||
use crate::ai::agent_sdk::driver::OZ_MESSAGE_LISTENER_MANAGED_EXTERNALLY_ENV;
|
||||
use crate::server::server_api::ai::{AIClient, MockAIClient, ReadAgentMessageResponse};
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
|
||||
fn sample_parent_bridge_message(
|
||||
sequence: i64,
|
||||
@@ -52,7 +58,7 @@ fn write_surfaced_parent_bridge_message(state_dir: &Path, record: &MessageBridge
|
||||
#[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);
|
||||
let cmd = claude_command("claude", &uuid, "/tmp/prompt.txt", None, None, false);
|
||||
assert!(
|
||||
cmd.contains(&format!("--session-id {uuid}")),
|
||||
"expected --session-id flag in non-resume command, got: {cmd}"
|
||||
@@ -66,7 +72,7 @@ fn claude_command_uses_session_id_when_not_resuming() {
|
||||
#[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);
|
||||
let cmd = claude_command("claude", &uuid, "/tmp/prompt.txt", None, None, true);
|
||||
assert!(
|
||||
cmd.contains(&format!("--resume {uuid}")),
|
||||
"expected --resume flag in resume command, got: {cmd}"
|
||||
@@ -80,7 +86,14 @@ fn claude_command_uses_resume_flag_when_resuming() {
|
||||
#[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);
|
||||
let cmd = claude_command(
|
||||
"claude",
|
||||
&uuid,
|
||||
"/tmp/prompt with spaces.txt",
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
);
|
||||
assert!(
|
||||
cmd.contains("< '/tmp/prompt with spaces.txt'"),
|
||||
"expected single-quoted stdin redirect of the prompt path, got: {cmd}"
|
||||
@@ -91,6 +104,111 @@ fn claude_command_pipes_prompt_path() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_session_index_entry_creates_expected_entry() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cwd = Path::new("/my/project");
|
||||
let session_id = Uuid::new_v4();
|
||||
|
||||
write_session_index_entry(session_id, cwd, tmp.path()).unwrap();
|
||||
|
||||
let index_path = tmp.path().join("sessions-index.json");
|
||||
let index: Value = serde_json::from_slice(&fs::read(index_path).unwrap()).unwrap();
|
||||
let session_key = session_id.to_string();
|
||||
let entry = &index[&session_key];
|
||||
let encoded = encode_cwd(cwd);
|
||||
|
||||
assert_eq!(entry["sessionId"], Value::String(session_key.clone()));
|
||||
assert_eq!(
|
||||
entry["cwd"],
|
||||
Value::String(cwd.to_string_lossy().into_owned())
|
||||
);
|
||||
assert_eq!(entry["projectPath"], Value::String(encoded.clone()));
|
||||
assert_eq!(
|
||||
entry["transcriptPath"],
|
||||
Value::String(format!("projects/{encoded}/{session_id}.jsonl"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_claude_mcp_config_cli_server() {
|
||||
let servers = HashMap::from([(
|
||||
"test-server".to_string(),
|
||||
JSONMCPServer {
|
||||
transport_type: JSONTransportType::CLIServer {
|
||||
command: "node".to_string(),
|
||||
args: vec!["server.js".to_string()],
|
||||
env: HashMap::from([("API_KEY".to_string(), "secret".to_string())]),
|
||||
working_directory: None,
|
||||
},
|
||||
},
|
||||
)]);
|
||||
let json = serialize_claude_mcp_config(&servers).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
let server = &parsed["mcpServers"]["test-server"];
|
||||
assert_eq!(server["type"], "stdio");
|
||||
assert_eq!(server["command"], "node");
|
||||
assert_eq!(server["args"][0], "server.js");
|
||||
assert_eq!(server["env"]["API_KEY"], "secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_claude_mcp_config_cli_server_with_cwd() {
|
||||
let servers = HashMap::from([(
|
||||
"test-server".to_string(),
|
||||
JSONMCPServer {
|
||||
transport_type: JSONTransportType::CLIServer {
|
||||
command: "node".to_string(),
|
||||
args: vec!["server.js".to_string()],
|
||||
env: HashMap::new(),
|
||||
working_directory: Some("/opt/mcp".to_string()),
|
||||
},
|
||||
},
|
||||
)]);
|
||||
let json = serialize_claude_mcp_config(&servers).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
let server = &parsed["mcpServers"]["test-server"];
|
||||
assert_eq!(server["cwd"], "/opt/mcp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_claude_mcp_config_cli_server_omits_cwd_when_none() {
|
||||
let servers = HashMap::from([(
|
||||
"test-server".to_string(),
|
||||
JSONMCPServer {
|
||||
transport_type: JSONTransportType::CLIServer {
|
||||
command: "node".to_string(),
|
||||
args: vec![],
|
||||
env: HashMap::new(),
|
||||
working_directory: None,
|
||||
},
|
||||
},
|
||||
)]);
|
||||
let json = serialize_claude_mcp_config(&servers).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
let server = &parsed["mcpServers"]["test-server"];
|
||||
assert!(server.get("cwd").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_claude_mcp_config_sse_server() {
|
||||
let servers = HashMap::from([(
|
||||
"remote".to_string(),
|
||||
JSONMCPServer {
|
||||
transport_type: JSONTransportType::SSEServer {
|
||||
url: "https://mcp.example.com".to_string(),
|
||||
headers: HashMap::from([("Authorization".to_string(), "Bearer tok".to_string())]),
|
||||
},
|
||||
},
|
||||
)]);
|
||||
let json = serialize_claude_mcp_config(&servers).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
let server = &parsed["mcpServers"]["remote"];
|
||||
assert_eq!(server["type"], "http");
|
||||
assert_eq!(server["url"], "https://mcp.example.com");
|
||||
assert_eq!(server["headers"]["Authorization"], "Bearer tok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn parent_bridge_root_prefers_environment_override() {
|
||||
@@ -119,6 +237,77 @@ fn stage_parent_bridge_message_writes_message_record() {
|
||||
assert!(staged_record.sender_run_id.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parent_bridge_event_cursor_defaults_to_zero_when_missing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let state_dir = tmp.path().join("session-123");
|
||||
ensure_parent_bridge_state_dir(&state_dir).unwrap();
|
||||
|
||||
assert_eq!(read_parent_bridge_event_cursor(&state_dir).unwrap(), 0);
|
||||
assert!(!parent_bridge_event_cursor_file(&state_dir).exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parent_bridge_event_cursor_round_trips() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let state_dir = tmp.path().join("session-123");
|
||||
ensure_parent_bridge_state_dir(&state_dir).unwrap();
|
||||
|
||||
write_parent_bridge_event_cursor(&state_dir, 42).unwrap();
|
||||
|
||||
assert_eq!(read_parent_bridge_event_cursor(&state_dir).unwrap(), 42);
|
||||
assert!(parent_bridge_event_cursor_file(&state_dir).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn message_bridge_cleanup_preserves_state_for_wakeable_runs() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::env::set_var(OZ_MESSAGE_LISTENER_STATE_ROOT_ENV, tmp.path());
|
||||
|
||||
let session_id = Uuid::new_v4();
|
||||
let bridge = MessageBridge::new("run-123".to_string(), session_id).unwrap();
|
||||
let state_dir = tmp.path().join(session_id.to_string());
|
||||
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();
|
||||
write_parent_bridge_event_cursor(&state_dir, 42).unwrap();
|
||||
|
||||
bridge
|
||||
.cleanup(MessageBridgeCleanupDisposition::PreserveState)
|
||||
.unwrap();
|
||||
std::env::remove_var(OZ_MESSAGE_LISTENER_STATE_ROOT_ENV);
|
||||
|
||||
assert!(state_dir.exists());
|
||||
assert!(parent_bridge_staged_message_path(&state_dir, 42, "msg-123").exists());
|
||||
assert_eq!(read_parent_bridge_event_cursor(&state_dir).unwrap(), 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn message_bridge_cleanup_removes_state_for_non_wakeable_runs() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::env::set_var(OZ_MESSAGE_LISTENER_STATE_ROOT_ENV, tmp.path());
|
||||
|
||||
let session_id = Uuid::new_v4();
|
||||
let bridge = MessageBridge::new("run-123".to_string(), session_id).unwrap();
|
||||
let state_dir = tmp.path().join(session_id.to_string());
|
||||
ensure_parent_bridge_state_dir(&state_dir).unwrap();
|
||||
stage_parent_bridge_message(
|
||||
&state_dir,
|
||||
&sample_staged_parent_bridge_message(42, "msg-123"),
|
||||
)
|
||||
.unwrap();
|
||||
write_parent_bridge_event_cursor(&state_dir, 42).unwrap();
|
||||
|
||||
bridge
|
||||
.cleanup(MessageBridgeCleanupDisposition::RemoveState)
|
||||
.unwrap();
|
||||
std::env::remove_var(OZ_MESSAGE_LISTENER_STATE_ROOT_ENV);
|
||||
|
||||
assert!(!state_dir.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepare_parent_bridge_hook_output_moves_selected_messages_to_surfaced_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -481,75 +670,262 @@ fn prepare_claude_config_none_suffix_preserves_existing_responses() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_suffix_from_raw_value_secret() {
|
||||
#[serial_test::serial]
|
||||
fn prepare_claude_environment_config_without_config_dir_uses_home_global_config() {
|
||||
let home_dir = TempDir::new().unwrap();
|
||||
let old_home = std::env::var_os("HOME");
|
||||
let old_config_dir = std::env::var_os("CLAUDE_CONFIG_DIR");
|
||||
std::env::set_var("HOME", home_dir.path());
|
||||
std::env::remove_var("CLAUDE_CONFIG_DIR");
|
||||
|
||||
let working_dir = home_dir.path().join("workspace/project");
|
||||
prepare_claude_environment_config(&working_dir, &HashMap::new()).unwrap();
|
||||
|
||||
assert!(home_dir.path().join(CLAUDE_JSON_FILE_NAME).exists());
|
||||
assert!(home_dir
|
||||
.path()
|
||||
.join(".claude")
|
||||
.join(CLAUDE_SETTINGS_FILE_NAME)
|
||||
.exists());
|
||||
assert!(!home_dir
|
||||
.path()
|
||||
.join(".claude")
|
||||
.join(CLAUDE_JSON_FILE_NAME)
|
||||
.exists());
|
||||
|
||||
match old_home {
|
||||
Some(home) => std::env::set_var("HOME", home),
|
||||
None => std::env::remove_var("HOME"),
|
||||
}
|
||||
match old_config_dir {
|
||||
Some(dir) => std::env::set_var("CLAUDE_CONFIG_DIR", dir),
|
||||
None => std::env::remove_var("CLAUDE_CONFIG_DIR"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn prepare_claude_environment_config_with_config_dir_uses_dir_global_config() {
|
||||
let home_dir = TempDir::new().unwrap();
|
||||
let claude_config_dir = TempDir::new().unwrap();
|
||||
let old_home = std::env::var_os("HOME");
|
||||
let old_config_dir = std::env::var_os("CLAUDE_CONFIG_DIR");
|
||||
std::env::set_var("HOME", home_dir.path());
|
||||
std::env::set_var("CLAUDE_CONFIG_DIR", claude_config_dir.path());
|
||||
|
||||
let working_dir = home_dir.path().join("workspace/project");
|
||||
prepare_claude_environment_config(&working_dir, &HashMap::new()).unwrap();
|
||||
|
||||
assert!(claude_config_dir
|
||||
.path()
|
||||
.join(CLAUDE_JSON_FILE_NAME)
|
||||
.exists());
|
||||
assert!(claude_config_dir
|
||||
.path()
|
||||
.join(CLAUDE_SETTINGS_FILE_NAME)
|
||||
.exists());
|
||||
assert!(!home_dir.path().join(CLAUDE_JSON_FILE_NAME).exists());
|
||||
|
||||
match old_home {
|
||||
Some(home) => std::env::set_var("HOME", home),
|
||||
None => std::env::remove_var("HOME"),
|
||||
}
|
||||
match old_config_dir {
|
||||
Some(dir) => std::env::set_var("CLAUDE_CONFIG_DIR", dir),
|
||||
None => std::env::remove_var("CLAUDE_CONFIG_DIR"),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn resolve_suffix_from_resolved_env_vars() {
|
||||
std::env::remove_var(ANTHROPIC_API_KEY_ENV);
|
||||
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);
|
||||
let resolved = HashMap::from([(OsString::from("ANTHROPIC_API_KEY"), OsString::from(key))]);
|
||||
let suffix = resolve_anthropic_api_key_suffix(&resolved);
|
||||
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]
|
||||
#[serial_test::serial]
|
||||
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);
|
||||
std::env::remove_var(ANTHROPIC_API_KEY_ENV);
|
||||
let resolved = HashMap::from([(OsString::from("ANTHROPIC_API_KEY"), OsString::from("short"))]);
|
||||
assert_eq!(resolve_anthropic_api_key_suffix(&resolved), 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"),
|
||||
#[serial_test::serial]
|
||||
fn resolve_suffix_returns_none_when_empty() {
|
||||
std::env::remove_var(ANTHROPIC_API_KEY_ENV);
|
||||
assert_eq!(resolve_anthropic_api_key_suffix(&HashMap::new()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn prepare_local_wake_command_rehydrates_transcript_with_self_managed_listener() {
|
||||
let home_dir = TempDir::new().unwrap();
|
||||
let claude_config_dir = TempDir::new().unwrap();
|
||||
let bridge_state_root = TempDir::new().unwrap();
|
||||
let working_dir = home_dir.path().join("workspace/project");
|
||||
fs::create_dir_all(&working_dir).unwrap();
|
||||
|
||||
std::env::set_var("HOME", home_dir.path());
|
||||
std::env::set_var("CLAUDE_CONFIG_DIR", claude_config_dir.path());
|
||||
std::env::set_var(OZ_MESSAGE_LISTENER_STATE_ROOT_ENV, bridge_state_root.path());
|
||||
|
||||
let session_id = Uuid::new_v4();
|
||||
let remote = ClaudeWakeRemoteContext {
|
||||
session_id,
|
||||
envelope: ClaudeTranscriptEnvelope {
|
||||
cwd: Path::new("/stale/cwd").to_path_buf(),
|
||||
uuid: session_id,
|
||||
claude_version: None,
|
||||
entries: vec![serde_json::json!({"type": "assistant", "text": "done"})],
|
||||
subagents: HashMap::new(),
|
||||
todos: HashMap::new(),
|
||||
},
|
||||
wake_prompt: "resume prompt\n\nwake prompt".to_string(),
|
||||
};
|
||||
let task_id: AmbientAgentTaskId = "550e8400-e29b-41d4-a716-446655440010".parse().unwrap();
|
||||
let parent_run_id = "parent-run-456".to_string();
|
||||
|
||||
let command = futures::executor::block_on(ClaudeHarness::prepare_local_wake_command(
|
||||
ServerApiProvider::new_for_test().get(),
|
||||
task_id,
|
||||
Some(parent_run_id.clone()),
|
||||
Some(working_dir.clone()),
|
||||
remote,
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let state_dir = bridge_state_root.path().join(session_id.to_string());
|
||||
let prompt_path = state_dir.join(CLAUDE_WAKE_PROMPT_FILE_NAME);
|
||||
|
||||
assert!(command.contains("--resume"));
|
||||
assert!(command.starts_with("env "));
|
||||
assert!(command.contains(&session_id.to_string()));
|
||||
assert!(command.contains(CLAUDE_WAKE_PROMPT_FILE_NAME));
|
||||
assert!(command.contains(&format!(
|
||||
"{OZ_RUN_ID_ENV}={}",
|
||||
shell_quote(&task_id.to_string())
|
||||
)));
|
||||
assert!(command.contains(&format!(
|
||||
"{OZ_PARENT_RUN_ID_ENV}={}",
|
||||
shell_quote(&parent_run_id)
|
||||
)));
|
||||
assert!(command.contains(&format!("{OZ_HARNESS_ENV}={}", shell_quote("claude"))));
|
||||
assert!(!command.contains(OZ_MESSAGE_LISTENER_MANAGED_EXTERNALLY_ENV));
|
||||
assert!(!command.contains("OZ_PARENT_LISTENER_MANAGED_EXTERNALLY"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(&prompt_path).unwrap(),
|
||||
"resume prompt\n\nwake prompt"
|
||||
);
|
||||
assert!(!parent_bridge_hook_output_file(&state_dir).exists());
|
||||
|
||||
let restored_envelope =
|
||||
read_envelope(session_id, &working_dir, claude_config_dir.path()).unwrap();
|
||||
assert_eq!(restored_envelope.cwd, working_dir);
|
||||
assert_eq!(
|
||||
restored_envelope.entries,
|
||||
vec![serde_json::json!({"type": "assistant", "text": "done"})]
|
||||
);
|
||||
assert!(claude_config_dir
|
||||
.path()
|
||||
.join(CLAUDE_JSON_FILE_NAME)
|
||||
.exists());
|
||||
assert!(claude_config_dir
|
||||
.path()
|
||||
.join(CLAUDE_SETTINGS_FILE_NAME)
|
||||
.exists());
|
||||
|
||||
std::env::remove_var("HOME");
|
||||
std::env::remove_var("CLAUDE_CONFIG_DIR");
|
||||
std::env::remove_var(OZ_MESSAGE_LISTENER_STATE_ROOT_ENV);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prime_parent_bridge_staged_for_self_managed_wake_keeps_message_in_staged() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let state_dir = tmp.path().join("session-123");
|
||||
ensure_parent_bridge_state_dir(&state_dir).unwrap();
|
||||
|
||||
let stale = sample_parent_bridge_message(
|
||||
41,
|
||||
"stale-msg",
|
||||
"Old direction",
|
||||
"This message should be returned to staged.",
|
||||
);
|
||||
write_surfaced_parent_bridge_message(&state_dir, &stale);
|
||||
fs::write(parent_bridge_hook_output_file(&state_dir), "stale context").unwrap();
|
||||
fs::write(parent_bridge_hook_output_ack_file(&state_dir), "").unwrap();
|
||||
|
||||
let wake_message = AgentMessageEventMetadata {
|
||||
sequence: 42,
|
||||
message_id: "msg-123".to_string(),
|
||||
occurred_at: "2026-04-17T15:47:00Z".to_string(),
|
||||
};
|
||||
let expected = sample_parent_bridge_message(
|
||||
42,
|
||||
"msg-123",
|
||||
"Please pivot",
|
||||
"Inspect the failing tests first.",
|
||||
);
|
||||
|
||||
let mut ai_client = MockAIClient::new();
|
||||
let expected_message = expected.clone();
|
||||
ai_client
|
||||
.expect_read_agent_message()
|
||||
.with(eq("msg-123"))
|
||||
.times(1)
|
||||
.returning(move |_| {
|
||||
Ok(ReadAgentMessageResponse {
|
||||
message_id: expected_message.message_id.clone(),
|
||||
sender_run_id: expected_message.sender_run_id.clone(),
|
||||
subject: expected_message.subject.clone(),
|
||||
body: expected_message.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 AIClient>);
|
||||
prime_parent_bridge_staged_for_self_managed_wake(&hydrator, &state_dir, Some(&wake_message))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(read_parent_bridge_event_cursor(&state_dir).unwrap(), 42);
|
||||
assert!(!parent_bridge_hook_output_file(&state_dir).exists());
|
||||
assert!(!parent_bridge_hook_output_ack_file(&state_dir).exists());
|
||||
assert!(parent_bridge_staged_message_path(&state_dir, 41, "stale-msg").exists());
|
||||
assert!(!parent_bridge_surfaced_message_path(&state_dir, 41, "stale-msg").exists());
|
||||
|
||||
let staged_path = parent_bridge_staged_message_path(&state_dir, 42, "msg-123");
|
||||
assert!(staged_path.exists());
|
||||
assert!(!parent_bridge_surfaced_message_path(&state_dir, 42, "msg-123").exists());
|
||||
|
||||
let staged_record: MessageBridgeMessageRecord =
|
||||
serde_json::from_slice(&fs::read(&staged_path).unwrap()).unwrap();
|
||||
assert_eq!(staged_record.subject, expected.subject);
|
||||
assert_eq!(staged_record.body, expected.body);
|
||||
assert_eq!(staged_record.occurred_at, wake_message.occurred_at);
|
||||
}
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn suffix_uses_worker_injected_env_when_present() {
|
||||
let worker_key = "sk-ant-api03-WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW-worker-suffix!";
|
||||
std::env::set_var(ANTHROPIC_API_KEY_ENV, worker_key);
|
||||
// Even when the resolved map has a different value, the worker env wins.
|
||||
let resolved = HashMap::from([(
|
||||
OsString::from("ANTHROPIC_API_KEY"),
|
||||
OsString::from(
|
||||
"sk-ant-api03-RRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRR-resolved-val!",
|
||||
),
|
||||
)]);
|
||||
assert_eq!(resolve_anthropic_api_key_suffix(&secrets), None);
|
||||
let suffix = resolve_anthropic_api_key_suffix(&resolved);
|
||||
let expected = &worker_key[worker_key.len() - 20..];
|
||||
assert_eq!(suffix.as_deref(), Some(expected));
|
||||
std::env::remove_var(ANTHROPIC_API_KEY_ENV);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
//! 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::fs::{create_dir_all, write};
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
@@ -24,6 +25,7 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::json_utils::entries_to_jsonl;
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
|
||||
/// JSON envelope sent to the server representing a complete Claude Code session.
|
||||
@@ -66,6 +68,11 @@ pub(crate) struct ClaudeResumeInfo {
|
||||
pub(crate) envelope: ClaudeTranscriptEnvelope,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ClaudeLocalContinuation {
|
||||
pub(crate) command: String,
|
||||
}
|
||||
|
||||
/// Encode a filesystem path as a Claude config directory name, matching the
|
||||
/// Claude CLI convention of replacing every `/` with `-`.
|
||||
///
|
||||
@@ -83,11 +90,23 @@ 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()
|
||||
home_dir_for_claude_config()
|
||||
.map(|h| h.join(".claude"))
|
||||
.ok_or_else(|| anyhow::anyhow!("could not determine home directory"))
|
||||
}
|
||||
|
||||
/// In tests on Windows, `dirs::home_dir()` ignores `HOME`, so we check it
|
||||
/// manually so that tests can override the home directory.
|
||||
pub(super) fn home_dir_for_claude_config() -> Option<PathBuf> {
|
||||
#[cfg(test)]
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
if !home.is_empty() {
|
||||
return Some(PathBuf::from(home));
|
||||
}
|
||||
}
|
||||
dirs::home_dir()
|
||||
}
|
||||
|
||||
/// Assemble a [`ClaudeTranscriptEnvelope`] from the Claude config directory.
|
||||
///
|
||||
/// Reads:
|
||||
@@ -184,12 +203,12 @@ pub(crate) fn write_envelope(
|
||||
) -> Result<()> {
|
||||
let encoded = encode_cwd(&envelope.cwd);
|
||||
let projects_dir = config_root.join("projects").join(&encoded);
|
||||
std::fs::create_dir_all(&projects_dir)
|
||||
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)?)
|
||||
write(&session_file, entries_to_jsonl(&envelope.entries)?)
|
||||
.with_context(|| format!("Failed to write {}", session_file.display()))?;
|
||||
|
||||
// Subagent JSONLs.
|
||||
@@ -197,11 +216,11 @@ pub(crate) fn write_envelope(
|
||||
let subagents_dir = projects_dir
|
||||
.join(envelope.uuid.to_string())
|
||||
.join("subagents");
|
||||
std::fs::create_dir_all(&subagents_dir)
|
||||
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)?)
|
||||
write(&path, entries_to_jsonl(entries)?)
|
||||
.with_context(|| format!("Failed to write {}", path.display()))?;
|
||||
}
|
||||
}
|
||||
@@ -209,11 +228,11 @@ pub(crate) fn write_envelope(
|
||||
// Per-agent todo lists.
|
||||
if !envelope.todos.is_empty() {
|
||||
let todos_dir = config_root.join("todos");
|
||||
std::fs::create_dir_all(&todos_dir)
|
||||
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)?)
|
||||
write(&path, serde_json::to_vec(value)?)
|
||||
.with_context(|| format!("Failed to write {}", path.display()))?;
|
||||
}
|
||||
}
|
||||
@@ -221,10 +240,108 @@ pub(crate) fn write_envelope(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn rehydrate_claude_transcript(
|
||||
envelope: &mut ClaudeTranscriptEnvelope,
|
||||
local_cwd: &Path,
|
||||
) -> Result<ClaudeLocalContinuation> {
|
||||
envelope.cwd = local_cwd.to_path_buf();
|
||||
let session_id = envelope.uuid;
|
||||
let config_root = claude_config_dir().context("Failed to resolve Claude config dir")?;
|
||||
write_envelope(envelope, &config_root).context("Failed to rehydrate Claude transcript")?;
|
||||
if let Err(e) = write_session_index_entry(session_id, local_cwd, &config_root) {
|
||||
log::warn!("Failed to update Claude sessions-index.json: {e:#}");
|
||||
}
|
||||
|
||||
Ok(ClaudeLocalContinuation {
|
||||
command: format!("claude --resume {session_id}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Write a [`ClaudeTranscriptEnvelope`] to a project directory derived from `storage_cwd`,
|
||||
/// without mutating `envelope.cwd`.
|
||||
///
|
||||
/// Used by the local continuation path so the transcript's recorded working directory
|
||||
/// (the original cloud cwd) is preserved as-is while the file is placed under
|
||||
/// `~/.claude/projects/<encoded(storage_cwd)>/` where Claude's per-project lookup can find it.
|
||||
/// Cloud resume uses [`write_envelope`] instead, which derives the path from `envelope.cwd`.
|
||||
///
|
||||
/// Creates:
|
||||
/// - `<config_root>/projects/<encoded(storage_cwd)>/<uuid>.jsonl` — main transcript
|
||||
/// - `<config_root>/projects/<encoded(storage_cwd)>/<uuid>/subagents/<stem>.jsonl` — subagents
|
||||
/// - `<config_root>/todos/<stem>.json` — per-agent todo lists (same location as cloud resume)
|
||||
pub(crate) fn write_envelope_for_local_continuation(
|
||||
envelope: &ClaudeTranscriptEnvelope,
|
||||
storage_cwd: &Path,
|
||||
config_root: &Path,
|
||||
) -> Result<()> {
|
||||
let projects_dir = config_root.join("projects").join(encode_cwd(storage_cwd));
|
||||
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));
|
||||
write(&session_file, entries_to_jsonl(&envelope.entries)?)
|
||||
.with_context(|| format!("Failed to write {}", session_file.display()))?;
|
||||
|
||||
// Subagent JSONLs — same relative layout as write_envelope.
|
||||
if !envelope.subagents.is_empty() {
|
||||
let subagents_dir = projects_dir
|
||||
.join(envelope.uuid.to_string())
|
||||
.join("subagents");
|
||||
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"));
|
||||
write(&path, entries_to_jsonl(entries)?)
|
||||
.with_context(|| format!("Failed to write {}", path.display()))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Per-agent todo lists are written to the same global location as cloud resume.
|
||||
if !envelope.todos.is_empty() {
|
||||
let todos_dir = config_root.join("todos");
|
||||
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"));
|
||||
write(&path, serde_json::to_vec(value)?)
|
||||
.with_context(|| format!("Failed to write {}", path.display()))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rehydrate a Claude transcript downloaded from a remote cloud run for local continuation.
|
||||
///
|
||||
/// Unlike [`rehydrate_claude_transcript`] (used by the cloud resume harness runner), this
|
||||
/// function does **not** mutate the envelope's `cwd` field — the remote session's original
|
||||
/// working directory is preserved as-is in the transcript. The session file is stored under
|
||||
/// `~/.claude/projects/<encoded(home_dir)>/` so Claude's per-project session lookup finds it
|
||||
/// when the user runs `claude --resume <uuid>` from their home directory.
|
||||
pub(crate) fn rehydrate_claude_transcript_from_reader(
|
||||
reader: impl Read,
|
||||
) -> Result<ClaudeLocalContinuation> {
|
||||
let envelope: ClaudeTranscriptEnvelope =
|
||||
serde_json::from_reader(reader).context("Failed to parse Claude transcript envelope")?;
|
||||
let session_id = envelope.uuid;
|
||||
let config_root = claude_config_dir().context("Failed to resolve Claude config dir")?;
|
||||
let home_dir = home_dir_for_claude_config()
|
||||
.ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?;
|
||||
write_envelope_for_local_continuation(&envelope, &home_dir, &config_root)
|
||||
.context("Failed to rehydrate Claude transcript for local continuation")?;
|
||||
if let Err(e) = write_session_index_entry(session_id, &home_dir, &config_root) {
|
||||
log::warn!("Failed to update Claude sessions-index.json: {e:#}");
|
||||
}
|
||||
Ok(ClaudeLocalContinuation {
|
||||
command: format!("claude --resume {session_id}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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
|
||||
@@ -243,7 +360,7 @@ pub(crate) fn write_session_index_entry(
|
||||
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.
|
||||
// we'd rather clobber an unparsable 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,
|
||||
@@ -281,10 +398,9 @@ pub(crate) fn write_session_index_entry(
|
||||
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()))?;
|
||||
create_dir_all(parent).with_context(|| format!("Failed to create {}", parent.display()))?;
|
||||
}
|
||||
std::fs::write(
|
||||
write(
|
||||
&index_path,
|
||||
serde_json::to_vec_pretty(&Value::Object(index))
|
||||
.context("Failed to serialize sessions-index.json")?,
|
||||
@@ -293,16 +409,6 @@ pub(crate) fn write_session_index_entry(
|
||||
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
|
||||
|
||||
@@ -0,0 +1,939 @@
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
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 galaxy_core::features::FeatureFlag;
|
||||
use warp_managed_secrets::ManagedSecretValue;
|
||||
use warpui::{ModelHandle, ModelSpawner, SingletonEntity};
|
||||
|
||||
use super::super::terminal::{CommandHandle, TerminalDriver};
|
||||
use super::super::{AgentDriver, AgentDriverError};
|
||||
use super::claude_transcript::read_jsonl;
|
||||
use super::codex_transcript::{
|
||||
codex_sessions_root, find_session_file, parse_session_meta, rehydrate_codex_transcript,
|
||||
CodexResumeInfo, CodexTranscriptEnvelope,
|
||||
};
|
||||
use super::json_utils::read_json_file_or_default;
|
||||
use super::{
|
||||
write_temp_file, HarnessRunner, JSONMCPServer, ResumePayload, SavePoint, ThirdPartyHarness,
|
||||
};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent_sdk::setup_observability::{
|
||||
OzRunTimelineEvent, SetupClientEventReporter, SetupStep,
|
||||
};
|
||||
use crate::ai::ambient_agents::task::HarnessModelConfig;
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::ai::mcp::JSONTransportType;
|
||||
use crate::server::server_api::harness_support::{upload_to_target, HarnessSupportClient};
|
||||
use crate::server::server_api::ServerApi;
|
||||
use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel;
|
||||
use crate::terminal::model::block::BlockId;
|
||||
use crate::terminal::CLIAgent;
|
||||
|
||||
pub(crate) struct CodexHarness;
|
||||
|
||||
/// Format slug sent to the server when creating a Codex conversation.
|
||||
const CODEX_CLI_FORMAT: &str = "codex_cli";
|
||||
/// Slash command Codex's TUI recognises as a graceful shutdown.
|
||||
const CODEX_EXIT_COMMAND: &str = "/exit";
|
||||
/// Allow the Warp-installed Codex plugin hooks to run in vetted driver sessions
|
||||
/// without requiring an unattended `/hooks` review step.
|
||||
const CODEX_BYPASS_HOOK_TRUST_FLAG: &str = "--dangerously-bypass-hook-trust";
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
impl ThirdPartyHarness for CodexHarness {
|
||||
fn harness(&self) -> Harness {
|
||||
Harness::Codex
|
||||
}
|
||||
|
||||
fn cli_agent(&self) -> CLIAgent {
|
||||
CLIAgent::Codex
|
||||
}
|
||||
|
||||
fn install_docs_url(&self) -> Option<&'static str> {
|
||||
Some("https://developers.openai.com/codex/cli")
|
||||
}
|
||||
|
||||
fn auth_check_command(&self) -> Option<String> {
|
||||
let cli = self.cli_agent().command_prefix();
|
||||
Some(format!("{cli} login status"))
|
||||
}
|
||||
|
||||
fn runtime_error_patterns(&self) -> &'static [&'static str] {
|
||||
&[
|
||||
// Quota / billing.
|
||||
"Quota exceeded. Check your plan and billing details.",
|
||||
"You've hit your usage limit",
|
||||
// Upstream HTTP failures Codex surfaces verbatim. The 401 form
|
||||
// matches invalid-API-key and wrong-endpoint variants.
|
||||
"unexpected status 401",
|
||||
"Incorrect API key provided",
|
||||
"invalid API key",
|
||||
// Region/endpoint block (Anthropic-style global vs US-only
|
||||
// routing surfaced through Codex's upstream client).
|
||||
"Access blocked by Cloudflare",
|
||||
// OAuth refresh failures — all five Codex variants share this
|
||||
// substring (see upstream session/token messages).
|
||||
"could not be refreshed",
|
||||
]
|
||||
}
|
||||
|
||||
fn requires_verified_platform_plugin(&self) -> bool {
|
||||
FeatureFlag::CodexPlugin.is_enabled()
|
||||
}
|
||||
|
||||
/// Fetch the codex transcript for the current task's conversation and wrap it into a
|
||||
/// [`ResumePayload::Codex`].
|
||||
async fn fetch_resume_payload(
|
||||
&self,
|
||||
conversation_id: &AIConversationId,
|
||||
harness_support_client: Arc<dyn HarnessSupportClient>,
|
||||
) -> Result<Option<ResumePayload>, AgentDriverError> {
|
||||
let envelope: CodexTranscriptEnvelope =
|
||||
super::fetch_transcript_envelope("codex", conversation_id, harness_support_client)
|
||||
.await?;
|
||||
let session_id = envelope.session_id;
|
||||
Ok(Some(ResumePayload::Codex(CodexResumeInfo {
|
||||
conversation_id: *conversation_id,
|
||||
session_id,
|
||||
envelope,
|
||||
})))
|
||||
}
|
||||
|
||||
fn build_runner(
|
||||
&self,
|
||||
prompt: &str,
|
||||
system_prompt: Option<&str>,
|
||||
resumption_prompt: Option<&str>,
|
||||
context: Option<&str>,
|
||||
working_dir: &Path,
|
||||
_task_id: Option<AmbientAgentTaskId>,
|
||||
server_api: Arc<ServerApi>,
|
||||
terminal_driver: ModelHandle<TerminalDriver>,
|
||||
resume: Option<ResumePayload>,
|
||||
resolved_env_vars: &HashMap<OsString, OsString>,
|
||||
resolved_secrets: &HashMap<String, ManagedSecretValue>,
|
||||
resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
|
||||
third_party_harness_model_config: Option<&HarnessModelConfig>,
|
||||
) -> Result<Box<dyn HarnessRunner>, AgentDriverError> {
|
||||
// Prepare the environment config files.
|
||||
prepare_codex_environment_config(
|
||||
working_dir,
|
||||
system_prompt,
|
||||
resolved_env_vars,
|
||||
resolved_secrets,
|
||||
resolved_mcp_servers,
|
||||
third_party_harness_model_config,
|
||||
)
|
||||
.map_err(|error| AgentDriverError::HarnessConfigSetupFailed {
|
||||
harness: self.cli_agent().command_prefix().to_owned(),
|
||||
error,
|
||||
})?;
|
||||
|
||||
// The ResumePayload shouldn't contain non-Codex information, error if it does.
|
||||
let codex_resume = resume.map(CodexResumeInfo::try_from).transpose()?;
|
||||
|
||||
// Mirror Claude harness behavior: prepend the resumption preamble and server context
|
||||
// to the user-turn prompt so codex treats it as immediate intent.
|
||||
// Order: resumption_prompt → context → prompt
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
if let Some(preamble) = resumption_prompt {
|
||||
if !preamble.is_empty() {
|
||||
parts.push(preamble);
|
||||
}
|
||||
}
|
||||
if let Some(ctx) = context {
|
||||
if !ctx.is_empty() {
|
||||
parts.push(ctx);
|
||||
}
|
||||
}
|
||||
parts.push(prompt);
|
||||
let owned_prompt = parts.join("\n\n");
|
||||
let client: Arc<dyn HarnessSupportClient> = server_api;
|
||||
Ok(Box::new(CodexHarnessRunner::new(
|
||||
self.cli_agent().command_prefix(),
|
||||
&owned_prompt,
|
||||
system_prompt,
|
||||
working_dir,
|
||||
client,
|
||||
terminal_driver,
|
||||
codex_resume,
|
||||
)?))
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the shell command that launches the Codex TUI.
|
||||
///
|
||||
/// `--dangerously-bypass-approvals-and-sandbox` disables both the sandbox and approval
|
||||
/// prompts so the agent can run autonomously.
|
||||
/// `--dangerously-bypass-hook-trust` allows the orchestration plugin hooks installed by
|
||||
/// Warp to run without a manual hook review in unattended driver sessions. Driver setup
|
||||
/// verifies the Codex platform plugin before launching commands with this flag.
|
||||
/// `Some(session_id)` indicates that we want to resume that prior session. Unlike claude,
|
||||
/// codex does not support assigning a session_id to a new conversation.
|
||||
fn codex_command(cli_name: &str, session_id: Option<&Uuid>, prompt_path: &str) -> String {
|
||||
match session_id {
|
||||
Some(session_id) => format!(
|
||||
"{cli_name} resume --dangerously-bypass-approvals-and-sandbox {CODEX_BYPASS_HOOK_TRUST_FLAG} {session_id} \
|
||||
\"$(cat '{prompt_path}')\""
|
||||
),
|
||||
None => {
|
||||
format!(
|
||||
"{cli_name} --dangerously-bypass-approvals-and-sandbox {CODEX_BYPASS_HOOK_TRUST_FLAG} \"$(cat '{prompt_path}')\""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CodexRunnerState {
|
||||
Preexec,
|
||||
Running {
|
||||
conversation_id: AIConversationId,
|
||||
block_id: BlockId,
|
||||
},
|
||||
}
|
||||
|
||||
struct CodexHarnessRunner {
|
||||
command: String,
|
||||
cli_name: 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<CodexRunnerState>,
|
||||
/// Codex session UUID. Populated lazily by [`HarnessRunner::handle_session_update`]
|
||||
/// once the codex hooks emit `SessionStart`. Set once (using `OnceLock`).
|
||||
session_id: OnceLock<Uuid>,
|
||||
/// Path to the codex session rollout JSONL file. Populated by the first
|
||||
/// successful [`find_session_file`] walk so that subsequent saves skip the YYYY/MM/DD
|
||||
/// directory walk and read the JSONL file directly.
|
||||
transcript_path: OnceLock<PathBuf>,
|
||||
/// Optionally supply an existing conversation ID.
|
||||
preexisting_conversation_id: Option<AIConversationId>,
|
||||
}
|
||||
|
||||
impl CodexHarnessRunner {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn new(
|
||||
cli_command: &str,
|
||||
prompt: &str,
|
||||
_system_prompt: Option<&str>,
|
||||
_working_dir: &Path,
|
||||
client: Arc<dyn HarnessSupportClient>,
|
||||
terminal_driver: ModelHandle<TerminalDriver>,
|
||||
resume: Option<CodexResumeInfo>,
|
||||
) -> Result<Self, AgentDriverError> {
|
||||
let temp_file = write_temp_file("oz_prompt_", prompt, ".txt")?;
|
||||
let prompt_path = temp_file.path().display().to_string();
|
||||
|
||||
let (session_id, preexisting_conversation_id, transcript_path) = match resume {
|
||||
Some(CodexResumeInfo {
|
||||
conversation_id,
|
||||
session_id,
|
||||
mut envelope,
|
||||
}) => {
|
||||
let continuation = rehydrate_codex_transcript(&mut envelope, _working_dir)
|
||||
.map_err(AgentDriverError::ConfigBuildFailed)?;
|
||||
(
|
||||
Some(session_id),
|
||||
Some(conversation_id),
|
||||
Some(continuation.transcript_path),
|
||||
)
|
||||
}
|
||||
None => (None, None, None),
|
||||
};
|
||||
|
||||
let command = codex_command(cli_command, session_id.as_ref(), &prompt_path);
|
||||
|
||||
let session_id_cell: OnceLock<Uuid> = OnceLock::new();
|
||||
if let Some(id) = session_id {
|
||||
let _ = session_id_cell.set(id);
|
||||
}
|
||||
let transcript_path_cell: OnceLock<PathBuf> = OnceLock::new();
|
||||
if let Some(p) = transcript_path {
|
||||
let _ = transcript_path_cell.set(p);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
command,
|
||||
cli_name: cli_command.to_string(),
|
||||
_temp_prompt_file: temp_file,
|
||||
client,
|
||||
terminal_driver,
|
||||
state: Mutex::new(CodexRunnerState::Preexec),
|
||||
session_id: session_id_cell,
|
||||
transcript_path: transcript_path_cell,
|
||||
preexisting_conversation_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Return the filepath for the session transcript, walking the codex sessions tree to find it on the
|
||||
/// first save call.
|
||||
async fn resolve_transcript_path(&self) -> Option<PathBuf> {
|
||||
if let Some(cached) = self.transcript_path.get() {
|
||||
return Some(cached.clone());
|
||||
}
|
||||
let session_id = self.session_id.get().copied()?;
|
||||
let resolved = tokio::task::spawn_blocking(move || -> Option<PathBuf> {
|
||||
let root = codex_sessions_root().ok()?;
|
||||
find_session_file(&root, session_id)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()?;
|
||||
let _ = self.transcript_path.set(resolved.clone());
|
||||
Some(resolved)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
impl HarnessRunner for CodexHarnessRunner {
|
||||
fn harness_name(&self) -> &str {
|
||||
&self.cli_name
|
||||
}
|
||||
|
||||
async fn start(
|
||||
&self,
|
||||
foreground: &ModelSpawner<AgentDriver>,
|
||||
setup_events: &SetupClientEventReporter,
|
||||
) -> Result<CommandHandle, AgentDriverError> {
|
||||
// Resume runs reuse the prior server conversation id; fresh runs mint a new one.
|
||||
let conversation_id = match self.preexisting_conversation_id {
|
||||
Some(id) => {
|
||||
log::info!("Resuming external conversation {id}");
|
||||
id
|
||||
}
|
||||
None => {
|
||||
let id = setup_events
|
||||
.record_result(SetupStep::ThirdPartyHarnessExternalConversation, async {
|
||||
self.client
|
||||
.create_external_conversation(CODEX_CLI_FORMAT)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to create external conversation: {e}");
|
||||
AgentDriverError::ConfigBuildFailed(e)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
log::info!("Created external conversation {id}");
|
||||
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?;
|
||||
|
||||
*self.state.lock() = CodexRunnerState::Running {
|
||||
conversation_id,
|
||||
block_id: command_handle.block_id().clone(),
|
||||
};
|
||||
|
||||
setup_events
|
||||
.post_timeline_event(OzRunTimelineEvent::AgentStarted)
|
||||
.await;
|
||||
|
||||
Ok(command_handle)
|
||||
}
|
||||
|
||||
async fn exit(&self, foreground: &ModelSpawner<AgentDriver>) -> Result<()> {
|
||||
log::info!("Sending /exit to Codex CLI");
|
||||
let terminal_driver = self.terminal_driver.clone();
|
||||
foreground
|
||||
.spawn(move |_, ctx| {
|
||||
terminal_driver.update(ctx, |driver, ctx| {
|
||||
driver.send_text_to_cli(CODEX_EXIT_COMMAND.to_string(), ctx);
|
||||
});
|
||||
})
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Agent driver dropped while sending /exit"))
|
||||
}
|
||||
|
||||
/// Capture the codex session ID from the `SessionStart` event picked up by the `CLIAgentSessionsModel`.
|
||||
///
|
||||
/// Relies on codex hooks being set up to emit this event correctly.
|
||||
async fn handle_session_update(&self, foreground: &ModelSpawner<AgentDriver>) -> Result<()> {
|
||||
if self.session_id.get().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let terminal_driver = self.terminal_driver.clone();
|
||||
let session_id_str = foreground
|
||||
.spawn(move |_, ctx| {
|
||||
let terminal_view_id = terminal_driver.as_ref(ctx).terminal_view().id();
|
||||
CLIAgentSessionsModel::handle(ctx)
|
||||
.as_ref(ctx)
|
||||
.session(terminal_view_id)
|
||||
.and_then(|s| s.session_context.session_id.clone())
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let Some(session_id_str) = session_id_str else {
|
||||
return Ok(());
|
||||
};
|
||||
match Uuid::parse_str(&session_id_str) {
|
||||
Ok(uuid) => {
|
||||
log::info!("Captured codex session id {uuid}");
|
||||
let _ = self.session_id.set(uuid);
|
||||
}
|
||||
Err(e) => log::warn!("Failed to parse codex session id '{session_id_str}': {e}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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, Codex not in progress");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (conversation_id, block_id) = match &*self.state.lock() {
|
||||
CodexRunnerState::Preexec => {
|
||||
log::warn!("save_conversation called before start");
|
||||
return Ok(());
|
||||
}
|
||||
CodexRunnerState::Running {
|
||||
conversation_id,
|
||||
block_id,
|
||||
} => (*conversation_id, block_id.clone()),
|
||||
};
|
||||
|
||||
let session_id = self.session_id.get().copied();
|
||||
let rollout_path = self.resolve_transcript_path().await;
|
||||
let client = self.client.as_ref();
|
||||
|
||||
let is_final = matches!(save_point, SavePoint::Final);
|
||||
futures::try_join!(
|
||||
super::upload_current_block_snapshot(
|
||||
foreground,
|
||||
&self.terminal_driver,
|
||||
client,
|
||||
conversation_id,
|
||||
block_id,
|
||||
),
|
||||
upload_transcript(client, conversation_id, session_id, rollout_path, is_final),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload the codex session transcript to the server. No-ops if the session UUID hasn't
|
||||
/// been captured yet or no rollout file is on disk yet.
|
||||
async fn upload_transcript(
|
||||
client: &dyn HarnessSupportClient,
|
||||
conversation_id: AIConversationId,
|
||||
session_id: Option<Uuid>,
|
||||
transcript_path: Option<PathBuf>,
|
||||
is_final: bool,
|
||||
) -> Result<()> {
|
||||
let Some(session_id) = session_id else {
|
||||
if is_final {
|
||||
log::warn!(
|
||||
"Codex session id still unknown at final save; transcript was never uploaded"
|
||||
);
|
||||
} else {
|
||||
log::debug!("Codex session id not yet known; skipping transcript upload");
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
let Some(transcript_path) = transcript_path else {
|
||||
if is_final {
|
||||
log::warn!(
|
||||
"No codex rollout file found at final save for session {session_id}; transcript was never uploaded"
|
||||
);
|
||||
} else {
|
||||
log::debug!("No codex rollout file yet for session {session_id}");
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
log::info!("Uploading codex transcript to conversation {conversation_id}");
|
||||
|
||||
let body = tokio::task::spawn_blocking(move || -> Result<Vec<u8>> {
|
||||
let entries = read_jsonl(&transcript_path)?;
|
||||
let metadata = parse_session_meta(entries.first()).unwrap_or_default();
|
||||
let envelope = CodexTranscriptEnvelope::new(session_id, metadata, entries);
|
||||
serde_json::to_vec(&envelope).context("Failed to serialize codex transcript")
|
||||
})
|
||||
.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?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const CODEX_CONFIG_DIR: &str = ".codex";
|
||||
const CODEX_HOME_ENV: &str = "CODEX_HOME";
|
||||
const CODEX_AGENTS_OVERRIDE_FILE_NAME: &str = "AGENTS.override.md";
|
||||
const CODEX_AUTH_FILE_NAME: &str = "auth.json";
|
||||
const CODEX_CONFIG_TOML_FILE_NAME: &str = "config.toml";
|
||||
const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY";
|
||||
const CODEX_AUTH_MODE_API_KEY: &str = "apikey";
|
||||
/// Lowercase string Codex's `TrustLevel` enum serializes to (codex
|
||||
/// `protocol/src/config_types.rs::TrustLevel`).
|
||||
const CODEX_TRUST_LEVEL_TRUSTED: &str = "trusted";
|
||||
/// Top-level config key codex reads to override the built-in `openai` provider's base URL
|
||||
/// (codex `core/src/config/mod.rs`).
|
||||
const CODEX_OPENAI_BASE_URL_KEY: &str = "openai_base_url";
|
||||
const CODEX_CHECK_FOR_UPDATE_ON_STARTUP_KEY: &str = "check_for_update_on_startup";
|
||||
const CODEX_MODEL_KEY: &str = "model";
|
||||
const CODEX_MODEL_REASONING_EFFORT_KEY: &str = "model_reasoning_effort";
|
||||
/// Target model for the `[notice.model_migrations]` table that suppresses Codex's
|
||||
/// "choose a newer model" upgrade prompt at session launch. We stamp this for any
|
||||
/// pinned model id (even when it already matches the target) so the unattended
|
||||
/// cloud run never blocks on the prompt.
|
||||
///
|
||||
/// TODO: Ideally, we would make this server-driven so we don't depend on a client
|
||||
/// release to change this.
|
||||
const CODEX_MODEL_MIGRATIONS_TARGET: &str = "gpt-5.4";
|
||||
fn prepare_codex_environment_config(
|
||||
working_dir: &Path,
|
||||
system_prompt: Option<&str>,
|
||||
resolved_env_vars: &HashMap<OsString, OsString>,
|
||||
resolved_secrets: &HashMap<String, ManagedSecretValue>,
|
||||
resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
|
||||
third_party_harness_model_config: Option<&HarnessModelConfig>,
|
||||
) -> Result<()> {
|
||||
let codex_dir = codex_config_dir()?;
|
||||
|
||||
if let Some(prompt) = system_prompt {
|
||||
write_codex_agents_override(&codex_dir, prompt)?;
|
||||
}
|
||||
|
||||
match resolve_openai_api_key(resolved_env_vars) {
|
||||
Some(api_key) => prepare_codex_auth(&codex_dir.join(CODEX_AUTH_FILE_NAME), &api_key)?,
|
||||
None => log::info!("No OPENAI_API_KEY available; skipping Codex auth.json seed"),
|
||||
}
|
||||
|
||||
// Resolve the base URL directly from the typed OpenAI secret. This avoids
|
||||
// leaking base_url into the child process environment and ensures we only
|
||||
// apply it when the typed secret is the active API key source.
|
||||
let openai_base_url = resolve_openai_base_url_from_secret(resolved_secrets, resolved_env_vars);
|
||||
|
||||
prepare_codex_config_toml(
|
||||
&codex_dir.join(CODEX_CONFIG_TOML_FILE_NAME),
|
||||
working_dir,
|
||||
resolved_mcp_servers,
|
||||
third_party_harness_model_config,
|
||||
openai_base_url.as_deref(),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn codex_config_dir() -> Result<PathBuf> {
|
||||
if let Ok(dir) = std::env::var(CODEX_HOME_ENV) {
|
||||
if !dir.is_empty() {
|
||||
return Ok(PathBuf::from(dir));
|
||||
}
|
||||
}
|
||||
dirs::home_dir()
|
||||
.map(|home| home.join(CODEX_CONFIG_DIR))
|
||||
.ok_or_else(|| anyhow::anyhow!("could not determine home directory"))
|
||||
}
|
||||
|
||||
fn write_codex_agents_override(codex_dir: &Path, system_prompt: &str) -> Result<()> {
|
||||
fs::create_dir_all(codex_dir).with_context(|| {
|
||||
format!(
|
||||
"Failed to create Codex config dir at {}",
|
||||
codex_dir.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
// Note: this currently works because we are only doing this for cloud agents; if we enable
|
||||
// this for local runs we'll want to make sure we don't clobber any existing file overrides.
|
||||
let prompt_path = codex_dir.join(CODEX_AGENTS_OVERRIDE_FILE_NAME);
|
||||
fs::write(&prompt_path, system_prompt).with_context(|| {
|
||||
format!(
|
||||
"Failed to write Codex system prompt to {}",
|
||||
prompt_path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Mirrors the subset of Codex's `AuthDotJson` (codex `login/src/auth/storage.rs`) that we
|
||||
/// need to seed. Unknown fields (`tokens`, `last_refresh`, `agent_identity`, ...) are
|
||||
/// preserved via `extra` so we don't clobber an existing login.
|
||||
#[derive(Default, Deserialize, Serialize, Debug)]
|
||||
struct CodexAuthDotJson {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
auth_mode: Option<String>,
|
||||
#[serde(
|
||||
rename = "OPENAI_API_KEY",
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
openai_api_key: Option<String>,
|
||||
#[serde(flatten)]
|
||||
extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
fn prepare_codex_auth(auth_path: &Path, api_key: &str) -> Result<()> {
|
||||
let mut auth: CodexAuthDotJson = read_json_file_or_default(auth_path)?;
|
||||
auth.openai_api_key = Some(api_key.to_owned());
|
||||
if auth.auth_mode.is_none() {
|
||||
auth.auth_mode = Some(CODEX_AUTH_MODE_API_KEY.to_owned());
|
||||
}
|
||||
write_codex_auth_json(auth_path, &auth)
|
||||
}
|
||||
|
||||
/// Write Codex's `auth.json` with restrictive (0o600) permissions, mirroring how
|
||||
/// codex sets up this file itself.
|
||||
fn write_codex_auth_json(path: &Path, auth: &CodexAuthDotJson) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(auth).context("Failed to serialize Codex auth.json")?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::io::Write as _;
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.with_context(|| format!("Failed to open {} for writing", path.display()))?;
|
||||
file.set_permissions(fs::Permissions::from_mode(0o600))
|
||||
.with_context(|| format!("Failed to set permissions on {}", path.display()))?;
|
||||
file.write_all(&bytes)
|
||||
.with_context(|| format!("Failed to write {}", path.display()))?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
fs::write(path, &bytes).with_context(|| format!("Failed to write {}", path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the OpenAI API key for Codex auth.
|
||||
///
|
||||
/// Checks the worker-injected process env first (not in the resolved map since
|
||||
/// `build_secret_env_vars` skips env vars already present in the process env),
|
||||
/// then falls back to the resolved secret env vars map.
|
||||
fn resolve_openai_api_key(resolved_env_vars: &HashMap<OsString, OsString>) -> Option<String> {
|
||||
// Worker-injected process env wins.
|
||||
if let Ok(value) = std::env::var(OPENAI_API_KEY_ENV) {
|
||||
let trimmed = value.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_owned());
|
||||
}
|
||||
}
|
||||
// Otherwise use the resolved value from the secrets map.
|
||||
resolved_env_vars
|
||||
.get(OsStr::new(OPENAI_API_KEY_ENV))
|
||||
.and_then(|v| v.to_str())
|
||||
.map(|s| s.trim().to_owned())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Returns the OpenAI base URL from the typed secret, if applicable.
|
||||
///
|
||||
/// The base URL is only used when the typed `OpenaiApiKey` secret is the active
|
||||
/// source of `OPENAI_API_KEY`. If a worker-injected process env already provides
|
||||
/// the API key, the typed-secret base URL is not applied (the worker controls
|
||||
/// both the key and endpoint).
|
||||
fn resolve_openai_base_url_from_secret(
|
||||
secrets: &HashMap<String, ManagedSecretValue>,
|
||||
resolved_env_vars: &HashMap<OsString, OsString>,
|
||||
) -> Option<String> {
|
||||
// If the worker already injected an API key, the typed secret lost
|
||||
// precedence — do not apply its base URL.
|
||||
if std::env::var(OPENAI_API_KEY_ENV)
|
||||
.ok()
|
||||
.is_some_and(|v| !v.trim().is_empty())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
// Only apply when the resolved env vars actually contain OPENAI_API_KEY
|
||||
// from the typed secret (i.e. the secret was not skipped).
|
||||
resolved_env_vars.get(OsStr::new(OPENAI_API_KEY_ENV))?;
|
||||
|
||||
secrets.values().find_map(|secret| match secret {
|
||||
ManagedSecretValue::OpenaiApiKey { base_url, .. } => base_url
|
||||
.as_ref()
|
||||
.map(|s| s.trim().to_owned())
|
||||
.filter(|s| !s.is_empty()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Edit `~/.codex/config.toml` via `toml_edit` to seed the harness defaults
|
||||
/// while preserving anything that might already exist there. We handle:
|
||||
/// - project trust: for a working dir and all of its git repo subdirectories,
|
||||
/// set the projects to `trusted`.
|
||||
/// - base URL: when `openai_base_url` is provided (from the secret's `base_url`
|
||||
/// field), write it to config.toml. When absent, skip the key entirely so
|
||||
/// Codex uses the provider's default global endpoint.
|
||||
/// - update checks: disable Codex's startup update prompt for unattended runs.
|
||||
/// - model override: when a non-default harness model config is
|
||||
/// supplied, write the top-level `model` key so Codex pins the chosen model
|
||||
/// for new sessions.
|
||||
fn prepare_codex_config_toml(
|
||||
config_toml_path: &Path,
|
||||
working_dir: &Path,
|
||||
resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
|
||||
third_party_harness_model_config: Option<&HarnessModelConfig>,
|
||||
openai_base_url: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let existing = match fs::read_to_string(config_toml_path) {
|
||||
Ok(content) => content,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||
Err(e) => {
|
||||
return Err(anyhow::Error::from(e).context(format!(
|
||||
"Failed to read Codex config.toml at {}",
|
||||
config_toml_path.display()
|
||||
)));
|
||||
}
|
||||
};
|
||||
let mut doc: toml_edit::DocumentMut = existing.parse().with_context(|| {
|
||||
format!(
|
||||
"Failed to parse Codex config.toml at {}",
|
||||
config_toml_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
// Only write openai_base_url when the secret specifies one.
|
||||
if let Some(url) = openai_base_url {
|
||||
set_codex_openai_base_url(&mut doc, url);
|
||||
}
|
||||
set_codex_check_for_update_on_startup(&mut doc, false);
|
||||
set_codex_model(&mut doc, third_party_harness_model_config);
|
||||
set_codex_model_reasoning_effort(&mut doc, third_party_harness_model_config);
|
||||
|
||||
let canonical = working_dir.canonicalize().with_context(|| {
|
||||
format!(
|
||||
"Failed to canonicalize Codex working dir at {}",
|
||||
working_dir.display()
|
||||
)
|
||||
})?;
|
||||
let project_key = canonical.to_string_lossy().into_owned();
|
||||
set_codex_project_trust_level(&mut doc, &project_key, CODEX_TRUST_LEVEL_TRUSTED);
|
||||
|
||||
// Codex's trust check is not recursive (see openai/codex#19426) -- since we
|
||||
// clone the git repos into workspace/ for cloud agents, we usually have git
|
||||
// repo children that we also want to trust.
|
||||
for child_repo in find_child_git_repos(&canonical) {
|
||||
let key = child_repo.to_string_lossy().into_owned();
|
||||
set_codex_project_trust_level(&mut doc, &key, CODEX_TRUST_LEVEL_TRUSTED);
|
||||
}
|
||||
|
||||
write_codex_mcp_servers(&mut doc, resolved_mcp_servers);
|
||||
|
||||
if let Some(parent) = config_toml_path.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| {
|
||||
format!("Failed to create Codex config dir at {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
fs::write(config_toml_path, doc.to_string()).with_context(|| {
|
||||
format!(
|
||||
"Failed to write Codex config.toml at {}",
|
||||
config_toml_path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Set the top-level `openai_base_url` key, overwriting any existing value.
|
||||
fn set_codex_openai_base_url(doc: &mut toml_edit::DocumentMut, base_url: &str) {
|
||||
doc[CODEX_OPENAI_BASE_URL_KEY] = toml_edit::value(base_url);
|
||||
}
|
||||
|
||||
fn set_codex_check_for_update_on_startup(doc: &mut toml_edit::DocumentMut, enabled: bool) {
|
||||
doc[CODEX_CHECK_FOR_UPDATE_ON_STARTUP_KEY] = toml_edit::value(enabled);
|
||||
}
|
||||
|
||||
fn set_codex_model_reasoning_effort(
|
||||
doc: &mut toml_edit::DocumentMut,
|
||||
third_party_harness_model_config: Option<&HarnessModelConfig>,
|
||||
) {
|
||||
let Some(reasoning_level) = third_party_harness_model_config
|
||||
.and_then(|config| config.reasoning_level.as_deref())
|
||||
.filter(|level| !level.is_empty())
|
||||
else {
|
||||
doc.remove(CODEX_MODEL_REASONING_EFFORT_KEY);
|
||||
return;
|
||||
};
|
||||
doc[CODEX_MODEL_REASONING_EFFORT_KEY] = toml_edit::value(reasoning_level);
|
||||
}
|
||||
|
||||
fn set_codex_model(
|
||||
doc: &mut toml_edit::DocumentMut,
|
||||
third_party_harness_model_config: Option<&HarnessModelConfig>,
|
||||
) {
|
||||
let Some(model_id) = third_party_harness_model_config
|
||||
.map(|config| config.model_id.as_str())
|
||||
.filter(|id| !id.is_empty() && *id != "default")
|
||||
else {
|
||||
// No model specified or "default" selected — remove any pre-existing
|
||||
// key so Codex uses its own default.
|
||||
doc.remove(CODEX_MODEL_KEY);
|
||||
return;
|
||||
};
|
||||
doc[CODEX_MODEL_KEY] = toml_edit::value(model_id);
|
||||
|
||||
// Codex's TUI prompts the user to upgrade older models on session launch even when
|
||||
// a `model` key has been pinned. Stamping a migration entry keyed on the chosen
|
||||
// model id suppresses that prompt for the unattended cloud run. We do this
|
||||
// unconditionally rather than enumerating a list of "old" models on the client:
|
||||
// mapping the migration target to itself (e.g. `gpt-5.4 = "gpt-5.4"`) is a no-op
|
||||
// for Codex, and keeping the client free of model-version knowledge means we
|
||||
// don't have to ship a client update every time Anthropic/OpenAI ages out a model.
|
||||
set_codex_model_migration(doc, model_id, CODEX_MODEL_MIGRATIONS_TARGET);
|
||||
}
|
||||
|
||||
fn set_codex_model_migration(
|
||||
doc: &mut toml_edit::DocumentMut,
|
||||
from_model_id: &str,
|
||||
to_model_id: &str,
|
||||
) {
|
||||
if !doc.contains_table("notice") {
|
||||
let mut notice_tbl = toml_edit::Table::new();
|
||||
notice_tbl.set_implicit(true);
|
||||
doc.insert("notice", toml_edit::Item::Table(notice_tbl));
|
||||
}
|
||||
let migrations_tbl = doc["notice"]
|
||||
.as_table_mut()
|
||||
.expect("notice table inserted above")
|
||||
.entry("model_migrations")
|
||||
.or_insert_with(toml_edit::table)
|
||||
.as_table_mut()
|
||||
.expect("model_migrations entry is a table");
|
||||
migrations_tbl.set_implicit(false);
|
||||
migrations_tbl[from_model_id] = toml_edit::value(to_model_id);
|
||||
}
|
||||
|
||||
/// Return immediate subdirectories of `dir` that contain a `.git`.
|
||||
fn find_child_git_repos(dir: &Path) -> Vec<std::path::PathBuf> {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
let path = entry.path();
|
||||
(path.is_dir() && path.join(".git").exists()).then_some(path)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Insert/update `[projects."<project_key>"] trust_level = <trust_level>`.
|
||||
///
|
||||
/// Codex itself always writes `projects` as an explicit table, so we don't
|
||||
/// handle the inline-table form here.
|
||||
fn set_codex_project_trust_level(
|
||||
doc: &mut toml_edit::DocumentMut,
|
||||
project_key: &str,
|
||||
trust_level: &str,
|
||||
) {
|
||||
if !doc.contains_table("projects") {
|
||||
let mut projects_tbl = toml_edit::Table::new();
|
||||
projects_tbl.set_implicit(true);
|
||||
doc.insert("projects", toml_edit::Item::Table(projects_tbl));
|
||||
}
|
||||
let proj_tbl = doc["projects"]
|
||||
.as_table_mut()
|
||||
.expect("projects table inserted above")
|
||||
.entry(project_key)
|
||||
.or_insert_with(toml_edit::table)
|
||||
.as_table_mut()
|
||||
.expect("project entry is a table");
|
||||
proj_tbl.set_implicit(false);
|
||||
proj_tbl["trust_level"] = toml_edit::value(trust_level);
|
||||
}
|
||||
|
||||
/// Write resolved MCP servers into `[mcp_servers.<name>]` sections in the Codex config.
|
||||
fn write_codex_mcp_servers(
|
||||
doc: &mut toml_edit::DocumentMut,
|
||||
servers: &HashMap<String, JSONMCPServer>,
|
||||
) {
|
||||
if servers.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !doc.contains_table("mcp_servers") {
|
||||
let mut tbl = toml_edit::Table::new();
|
||||
tbl.set_implicit(true);
|
||||
doc.insert("mcp_servers", toml_edit::Item::Table(tbl));
|
||||
}
|
||||
let mcp_tbl = doc["mcp_servers"]
|
||||
.as_table_mut()
|
||||
.expect("mcp_servers table inserted above");
|
||||
|
||||
for (name, server) in servers {
|
||||
let entry = mcp_tbl
|
||||
.entry(name)
|
||||
.or_insert_with(toml_edit::table)
|
||||
.as_table_mut()
|
||||
.expect("mcp_servers entry is a table");
|
||||
entry.set_implicit(false);
|
||||
|
||||
match &server.transport_type {
|
||||
JSONTransportType::CLIServer {
|
||||
command,
|
||||
args,
|
||||
env,
|
||||
working_directory,
|
||||
} => {
|
||||
entry["command"] = toml_edit::value(command.as_str());
|
||||
if !args.is_empty() {
|
||||
let mut arr = toml_edit::Array::new();
|
||||
for arg in args {
|
||||
arr.push(arg.as_str());
|
||||
}
|
||||
entry["args"] = toml_edit::value(arr);
|
||||
}
|
||||
if !env.is_empty() {
|
||||
let mut env_tbl = toml_edit::InlineTable::new();
|
||||
for (k, v) in env {
|
||||
env_tbl.insert(k, v.as_str().into());
|
||||
}
|
||||
entry["env"] = toml_edit::value(env_tbl);
|
||||
}
|
||||
if let Some(cwd) = working_directory {
|
||||
entry["cwd"] = toml_edit::value(cwd.as_str());
|
||||
}
|
||||
}
|
||||
JSONTransportType::SSEServer { url, headers } => {
|
||||
entry["url"] = toml_edit::value(url.as_str());
|
||||
if !headers.is_empty() {
|
||||
let mut hdrs_tbl = toml_edit::InlineTable::new();
|
||||
for (k, v) in headers {
|
||||
hdrs_tbl.insert(k, v.as_str().into());
|
||||
}
|
||||
entry["http_headers"] = toml_edit::value(hdrs_tbl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "codex_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,849 @@
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::super::codex_transcript::CodexTranscriptEnvelope;
|
||||
use super::*;
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::server::server_api::harness_support::MockHarnessSupportClient;
|
||||
|
||||
#[test]
|
||||
fn prepare_codex_auth_writes_fresh_file_with_api_key_mode() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let auth_path = tmp.path().join(".codex/auth.json");
|
||||
|
||||
prepare_codex_auth(&auth_path, "sk-test-key").unwrap();
|
||||
|
||||
let auth: Value = serde_json::from_slice(&fs::read(&auth_path).unwrap()).unwrap();
|
||||
assert_eq!(auth["OPENAI_API_KEY"], "sk-test-key");
|
||||
assert_eq!(auth["auth_mode"], "apikey");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_codex_auth_preserves_unrelated_fields() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let auth_path = tmp.path().join("auth.json");
|
||||
fs::write(
|
||||
&auth_path,
|
||||
r#"{"tokens":{"access_token":"tok"},"last_refresh":"2026-01-01T00:00:00Z"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
prepare_codex_auth(&auth_path, "sk-new-key").unwrap();
|
||||
|
||||
let auth: Value = serde_json::from_slice(&fs::read(&auth_path).unwrap()).unwrap();
|
||||
assert_eq!(auth["OPENAI_API_KEY"], "sk-new-key");
|
||||
assert_eq!(auth["auth_mode"], "apikey");
|
||||
assert_eq!(auth["tokens"]["access_token"], "tok");
|
||||
assert_eq!(auth["last_refresh"], "2026-01-01T00:00:00Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_codex_auth_does_not_overwrite_existing_auth_mode() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let auth_path = tmp.path().join("auth.json");
|
||||
fs::write(&auth_path, r#"{"auth_mode":"Chatgpt"}"#).unwrap();
|
||||
|
||||
prepare_codex_auth(&auth_path, "sk-new-key").unwrap();
|
||||
|
||||
let auth: Value = serde_json::from_slice(&fs::read(&auth_path).unwrap()).unwrap();
|
||||
assert_eq!(auth["auth_mode"], "Chatgpt");
|
||||
assert_eq!(auth["OPENAI_API_KEY"], "sk-new-key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_codex_auth_overwrites_stale_openai_api_key() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let auth_path = tmp.path().join("auth.json");
|
||||
fs::write(
|
||||
&auth_path,
|
||||
r#"{"auth_mode":"apikey","OPENAI_API_KEY":"sk-old"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
prepare_codex_auth(&auth_path, "sk-new").unwrap();
|
||||
|
||||
let auth: Value = serde_json::from_slice(&fs::read(&auth_path).unwrap()).unwrap();
|
||||
assert_eq!(auth["OPENAI_API_KEY"], "sk-new");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn prepare_codex_auth_writes_with_0600_perms() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let auth_path = tmp.path().join(".codex/auth.json");
|
||||
|
||||
prepare_codex_auth(&auth_path, "sk-test-key").unwrap();
|
||||
|
||||
let mode = fs::metadata(&auth_path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_openai_api_key_returns_value_from_resolved_map() {
|
||||
let resolved = HashMap::from([(
|
||||
OsString::from("OPENAI_API_KEY"),
|
||||
OsString::from("sk-from-secret"),
|
||||
)]);
|
||||
assert_eq!(
|
||||
resolve_openai_api_key(&resolved).as_deref(),
|
||||
Some("sk-from-secret")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn resolve_openai_api_key_falls_back_to_env_var() {
|
||||
let prev = std::env::var(OPENAI_API_KEY_ENV).ok();
|
||||
std::env::set_var(OPENAI_API_KEY_ENV, "sk-from-env");
|
||||
|
||||
let result = resolve_openai_api_key(&HashMap::new());
|
||||
|
||||
match prev {
|
||||
Some(v) => std::env::set_var(OPENAI_API_KEY_ENV, v),
|
||||
None => std::env::remove_var(OPENAI_API_KEY_ENV),
|
||||
}
|
||||
assert_eq!(result.as_deref(), Some("sk-from-env"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn resolve_openai_api_key_returns_none_when_map_and_env_empty() {
|
||||
let prev = std::env::var(OPENAI_API_KEY_ENV).ok();
|
||||
std::env::remove_var(OPENAI_API_KEY_ENV);
|
||||
|
||||
let result = resolve_openai_api_key(&HashMap::new());
|
||||
|
||||
if let Some(v) = prev {
|
||||
std::env::set_var(OPENAI_API_KEY_ENV, v);
|
||||
}
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn resolve_openai_api_key_prefers_env_over_resolved_map() {
|
||||
// Worker-injected env var wins over the resolved secret map because
|
||||
// build_secret_env_vars skips secrets that collide with process env.
|
||||
let prev = std::env::var(OPENAI_API_KEY_ENV).ok();
|
||||
std::env::set_var(OPENAI_API_KEY_ENV, "sk-from-env");
|
||||
let resolved = HashMap::from([(
|
||||
OsString::from("OPENAI_API_KEY"),
|
||||
OsString::from("sk-from-secret"),
|
||||
)]);
|
||||
|
||||
let result = resolve_openai_api_key(&resolved);
|
||||
|
||||
match prev {
|
||||
Some(v) => std::env::set_var(OPENAI_API_KEY_ENV, v),
|
||||
None => std::env::remove_var(OPENAI_API_KEY_ENV),
|
||||
}
|
||||
assert_eq!(result.as_deref(), Some("sk-from-env"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn resolve_openai_api_key_uses_resolved_map_when_env_empty() {
|
||||
let prev = std::env::var(OPENAI_API_KEY_ENV).ok();
|
||||
std::env::set_var(OPENAI_API_KEY_ENV, " ");
|
||||
let resolved = HashMap::from([(
|
||||
OsString::from("OPENAI_API_KEY"),
|
||||
OsString::from("sk-from-secret"),
|
||||
)]);
|
||||
|
||||
let result = resolve_openai_api_key(&resolved);
|
||||
|
||||
match prev {
|
||||
Some(v) => std::env::set_var(OPENAI_API_KEY_ENV, v),
|
||||
None => std::env::remove_var(OPENAI_API_KEY_ENV),
|
||||
}
|
||||
assert_eq!(result.as_deref(), Some("sk-from-secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn prepare_codex_environment_config_honors_codex_home() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let codex_home = tmp.path().join("codex-home");
|
||||
let working_dir = tmp.path().join("workspace");
|
||||
fs::create_dir_all(&working_dir).unwrap();
|
||||
let prev_codex_home = std::env::var(CODEX_HOME_ENV).ok();
|
||||
let prev_openai_api_key = std::env::var(OPENAI_API_KEY_ENV).ok();
|
||||
std::env::set_var(CODEX_HOME_ENV, &codex_home);
|
||||
std::env::remove_var(OPENAI_API_KEY_ENV);
|
||||
let resolved = HashMap::from([(
|
||||
OsString::from(OPENAI_API_KEY_ENV),
|
||||
OsString::from("sk-from-secret"),
|
||||
)]);
|
||||
|
||||
let model_config = harness_model_config("gpt-5.5", None);
|
||||
let result = prepare_codex_environment_config(
|
||||
&working_dir,
|
||||
Some("system prompt"),
|
||||
&resolved,
|
||||
&HashMap::new(),
|
||||
&HashMap::new(),
|
||||
Some(&model_config),
|
||||
);
|
||||
|
||||
match prev_codex_home {
|
||||
Some(v) => std::env::set_var(CODEX_HOME_ENV, v),
|
||||
None => std::env::remove_var(CODEX_HOME_ENV),
|
||||
}
|
||||
match prev_openai_api_key {
|
||||
Some(v) => std::env::set_var(OPENAI_API_KEY_ENV, v),
|
||||
None => std::env::remove_var(OPENAI_API_KEY_ENV),
|
||||
}
|
||||
|
||||
result.unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(codex_home.join(CODEX_AGENTS_OVERRIDE_FILE_NAME)).unwrap(),
|
||||
"system prompt"
|
||||
);
|
||||
let auth: Value =
|
||||
serde_json::from_slice(&fs::read(codex_home.join(CODEX_AUTH_FILE_NAME)).unwrap()).unwrap();
|
||||
assert_eq!(auth["OPENAI_API_KEY"], "sk-from-secret");
|
||||
let cfg = read_codex_config(&codex_home.join(CODEX_CONFIG_TOML_FILE_NAME));
|
||||
assert_eq!(cfg["model"].as_str(), Some("gpt-5.5"));
|
||||
assert!(!cfg.contains_key("openai_base_url"));
|
||||
assert!(!tmp.path().join(CODEX_CONFIG_DIR).exists());
|
||||
}
|
||||
|
||||
fn read_codex_config(path: &std::path::Path) -> toml::Table {
|
||||
let content = fs::read_to_string(path).unwrap();
|
||||
toml::from_str(&content).unwrap()
|
||||
}
|
||||
|
||||
fn harness_model_config(model_id: &str, reasoning_level: Option<&str>) -> HarnessModelConfig {
|
||||
HarnessModelConfig {
|
||||
model_id: model_id.to_string(),
|
||||
reasoning_level: reasoning_level.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_codex_config_toml_writes_fresh_config() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config_path = tmp.path().join(".codex/config.toml");
|
||||
let working_dir = tmp.path().join("workspace/proj");
|
||||
fs::create_dir_all(&working_dir).unwrap();
|
||||
|
||||
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None, None).unwrap();
|
||||
|
||||
let canonical = working_dir.canonicalize().unwrap();
|
||||
let key = canonical.to_string_lossy().into_owned();
|
||||
let cfg = read_codex_config(&config_path);
|
||||
assert_eq!(cfg["check_for_update_on_startup"].as_bool(), Some(false));
|
||||
assert_eq!(
|
||||
cfg["projects"][&key]["trust_level"].as_str(),
|
||||
Some("trusted")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_codex_config_toml_preserves_unrelated_keys() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
let working_dir = tmp.path().join("workspace");
|
||||
fs::create_dir_all(&working_dir).unwrap();
|
||||
fs::write(
|
||||
&config_path,
|
||||
"model = \"gpt-5\"\n\n[projects.\"/other/path\"]\ntrust_level = \"trusted\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Pass `None` — the `model` key is intentionally removed (managed
|
||||
// key), but unrelated keys like existing project entries are kept.
|
||||
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None, None).unwrap();
|
||||
|
||||
let canonical = working_dir.canonicalize().unwrap();
|
||||
let key = canonical.to_string_lossy().into_owned();
|
||||
let cfg = read_codex_config(&config_path);
|
||||
// `model` is a managed key — removed when no override is provided.
|
||||
assert!(!cfg.contains_key("model"));
|
||||
assert_eq!(
|
||||
cfg["projects"]["/other/path"]["trust_level"].as_str(),
|
||||
Some("trusted")
|
||||
);
|
||||
assert_eq!(
|
||||
cfg["projects"][&key]["trust_level"].as_str(),
|
||||
Some("trusted")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_codex_config_toml_is_idempotent() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
let working_dir = tmp.path().join("workspace");
|
||||
fs::create_dir_all(&working_dir).unwrap();
|
||||
|
||||
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None, None).unwrap();
|
||||
let after_first = fs::read_to_string(&config_path).unwrap();
|
||||
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None, None).unwrap();
|
||||
let after_second = fs::read_to_string(&config_path).unwrap();
|
||||
|
||||
assert_eq!(after_first, after_second);
|
||||
let canonical = working_dir.canonicalize().unwrap();
|
||||
let key = canonical.to_string_lossy().into_owned();
|
||||
let cfg: toml::Table = toml::from_str(&after_second).unwrap();
|
||||
let projects = cfg["projects"].as_table().unwrap();
|
||||
assert_eq!(projects.len(), 1);
|
||||
assert_eq!(projects[&key]["trust_level"].as_str(), Some("trusted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_codex_config_toml_upgrades_untrusted_entry() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
let working_dir = tmp.path().join("workspace");
|
||||
fs::create_dir_all(&working_dir).unwrap();
|
||||
let canonical = working_dir.canonicalize().unwrap();
|
||||
let key = canonical.to_string_lossy().into_owned();
|
||||
// Use a TOML literal-string key ('...') so Windows backslashes in `key`
|
||||
// (e.g. `\\?\C:\...`) are not interpreted as escape sequences.
|
||||
fs::write(
|
||||
&config_path,
|
||||
format!("[projects.'{key}']\ntrust_level = \"untrusted\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None, None).unwrap();
|
||||
|
||||
let cfg = read_codex_config(&config_path);
|
||||
assert_eq!(
|
||||
cfg["projects"][&key]["trust_level"].as_str(),
|
||||
Some("trusted")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_codex_config_toml_trusts_multiple_child_repos() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
let working_dir = tmp.path().join("workspace");
|
||||
let repo_a = working_dir.join("a");
|
||||
let repo_b = working_dir.join("b");
|
||||
fs::create_dir_all(repo_a.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_b.join(".git")).unwrap();
|
||||
|
||||
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None, None).unwrap();
|
||||
|
||||
let cfg = read_codex_config(&config_path);
|
||||
let projects = cfg["projects"].as_table().unwrap();
|
||||
let canonical_a = repo_a.canonicalize().unwrap();
|
||||
let canonical_b = repo_b.canonicalize().unwrap();
|
||||
assert_eq!(
|
||||
projects[canonical_a.to_str().unwrap()]["trust_level"].as_str(),
|
||||
Some("trusted")
|
||||
);
|
||||
assert_eq!(
|
||||
projects[canonical_b.to_str().unwrap()]["trust_level"].as_str(),
|
||||
Some("trusted")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_codex_config_toml_overwrites_stale_openai_base_url() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
let working_dir = tmp.path().join("workspace");
|
||||
fs::create_dir_all(&working_dir).unwrap();
|
||||
fs::write(
|
||||
&config_path,
|
||||
"openai_base_url = \"https://api.openai.com/v1\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
prepare_codex_config_toml(
|
||||
&config_path,
|
||||
&working_dir,
|
||||
&HashMap::new(),
|
||||
None,
|
||||
Some("https://custom.api.openai.com/v1"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let cfg = read_codex_config(&config_path);
|
||||
assert_eq!(
|
||||
cfg["openai_base_url"].as_str(),
|
||||
Some("https://custom.api.openai.com/v1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_codex_mcp_servers_cli_server() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
let working_dir = tmp.path().join("workspace");
|
||||
fs::create_dir_all(&working_dir).unwrap();
|
||||
|
||||
let servers = HashMap::from([(
|
||||
"my-mcp".to_string(),
|
||||
JSONMCPServer {
|
||||
transport_type: JSONTransportType::CLIServer {
|
||||
command: "npx".to_string(),
|
||||
args: vec!["-y".to_string(), "@some/mcp".to_string()],
|
||||
env: HashMap::from([("TOKEN".to_string(), "abc".to_string())]),
|
||||
working_directory: None,
|
||||
},
|
||||
},
|
||||
)]);
|
||||
prepare_codex_config_toml(&config_path, &working_dir, &servers, None, None).unwrap();
|
||||
|
||||
let cfg = read_codex_config(&config_path);
|
||||
let mcp = &cfg["mcp_servers"]["my-mcp"];
|
||||
assert_eq!(mcp["command"].as_str(), Some("npx"));
|
||||
let args: Vec<&str> = mcp["args"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| v.as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(args, vec!["-y", "@some/mcp"]);
|
||||
assert_eq!(mcp["env"]["TOKEN"].as_str(), Some("abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_codex_mcp_servers_sse_server() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
let working_dir = tmp.path().join("workspace");
|
||||
fs::create_dir_all(&working_dir).unwrap();
|
||||
|
||||
let servers = HashMap::from([(
|
||||
"remote-mcp".to_string(),
|
||||
JSONMCPServer {
|
||||
transport_type: JSONTransportType::SSEServer {
|
||||
url: "https://mcp.example.com/sse".to_string(),
|
||||
headers: HashMap::from([("X-Key".to_string(), "val".to_string())]),
|
||||
},
|
||||
},
|
||||
)]);
|
||||
prepare_codex_config_toml(&config_path, &working_dir, &servers, None, None).unwrap();
|
||||
|
||||
let cfg = read_codex_config(&config_path);
|
||||
let mcp = &cfg["mcp_servers"]["remote-mcp"];
|
||||
assert_eq!(mcp["url"].as_str(), Some("https://mcp.example.com/sse"));
|
||||
assert_eq!(mcp["http_headers"]["X-Key"].as_str(), Some("val"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_codex_mcp_servers_cli_server_with_cwd() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
let working_dir = tmp.path().join("workspace");
|
||||
fs::create_dir_all(&working_dir).unwrap();
|
||||
|
||||
let servers = HashMap::from([(
|
||||
"my-mcp".to_string(),
|
||||
JSONMCPServer {
|
||||
transport_type: JSONTransportType::CLIServer {
|
||||
command: "node".to_string(),
|
||||
args: vec!["server.js".to_string()],
|
||||
env: HashMap::new(),
|
||||
working_directory: Some("/opt/mcp-server".to_string()),
|
||||
},
|
||||
},
|
||||
)]);
|
||||
prepare_codex_config_toml(&config_path, &working_dir, &servers, None, None).unwrap();
|
||||
|
||||
let cfg = read_codex_config(&config_path);
|
||||
let mcp = &cfg["mcp_servers"]["my-mcp"];
|
||||
assert_eq!(mcp["command"].as_str(), Some("node"));
|
||||
assert_eq!(mcp["cwd"].as_str(), Some("/opt/mcp-server"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_codex_mcp_servers_cli_server_without_cwd_omits_key() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
let working_dir = tmp.path().join("workspace");
|
||||
fs::create_dir_all(&working_dir).unwrap();
|
||||
|
||||
let servers = HashMap::from([(
|
||||
"my-mcp".to_string(),
|
||||
JSONMCPServer {
|
||||
transport_type: JSONTransportType::CLIServer {
|
||||
command: "npx".to_string(),
|
||||
args: vec![],
|
||||
env: HashMap::new(),
|
||||
working_directory: None,
|
||||
},
|
||||
},
|
||||
)]);
|
||||
prepare_codex_config_toml(&config_path, &working_dir, &servers, None, None).unwrap();
|
||||
|
||||
let cfg = read_codex_config(&config_path);
|
||||
let mcp = &cfg["mcp_servers"]["my-mcp"];
|
||||
assert!(mcp.get("cwd").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_codex_config_toml_writes_model_when_specified() {
|
||||
// A non-default model id is written to the top-level `model` key so Codex pins it
|
||||
// for new sessions launched from this `~/.codex/config.toml`. Even for the
|
||||
// current target model, we stamp a self-referential migration entry so the
|
||||
// upgrade prompt is suppressed regardless of what the user selected.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
let working_dir = tmp.path().join("workspace");
|
||||
fs::create_dir_all(&working_dir).unwrap();
|
||||
|
||||
prepare_codex_config_toml(
|
||||
&config_path,
|
||||
&working_dir,
|
||||
&HashMap::new(),
|
||||
Some(&harness_model_config("gpt-5.5", None)),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let cfg = read_codex_config(&config_path);
|
||||
assert_eq!(cfg["model"].as_str(), Some("gpt-5.5"));
|
||||
assert_eq!(
|
||||
cfg["notice"]["model_migrations"]["gpt-5.5"].as_str(),
|
||||
Some(CODEX_MODEL_MIGRATIONS_TARGET),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_codex_config_toml_writes_model_migration_for_older_model() {
|
||||
// For an older model id, the migration entry maps it to the current target
|
||||
// so Codex's "choose a newer model" prompt is suppressed at session launch.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
let working_dir = tmp.path().join("workspace");
|
||||
fs::create_dir_all(&working_dir).unwrap();
|
||||
|
||||
prepare_codex_config_toml(
|
||||
&config_path,
|
||||
&working_dir,
|
||||
&HashMap::new(),
|
||||
Some(&harness_model_config("gpt-5.2", None)),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let cfg = read_codex_config(&config_path);
|
||||
assert_eq!(cfg["model"].as_str(), Some("gpt-5.2"));
|
||||
assert_eq!(
|
||||
cfg["notice"]["model_migrations"]["gpt-5.2"].as_str(),
|
||||
Some(CODEX_MODEL_MIGRATIONS_TARGET),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_codex_config_toml_skips_model_for_default_sentinel() {
|
||||
// The literal "default" sentinel means "let Codex pick its own default model";
|
||||
// we should NOT write a `model` key (or a migration entry) in that case.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
let working_dir = tmp.path().join("workspace");
|
||||
fs::create_dir_all(&working_dir).unwrap();
|
||||
|
||||
prepare_codex_config_toml(
|
||||
&config_path,
|
||||
&working_dir,
|
||||
&HashMap::new(),
|
||||
Some(&harness_model_config("default", None)),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let cfg = read_codex_config(&config_path);
|
||||
assert!(
|
||||
cfg.get("model").is_none(),
|
||||
"`model` should not be written for the default sentinel"
|
||||
);
|
||||
assert!(
|
||||
cfg.get("notice").is_none(),
|
||||
"`[notice]` table should not be written without a pinned model id"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_codex_config_toml_skips_model_when_none() {
|
||||
// No model id supplied means the user didn't pick one; we should not write a
|
||||
// `model` key or any `[notice.model_migrations]` entries.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
let working_dir = tmp.path().join("workspace");
|
||||
fs::create_dir_all(&working_dir).unwrap();
|
||||
|
||||
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None, None).unwrap();
|
||||
|
||||
let cfg = read_codex_config(&config_path);
|
||||
assert!(
|
||||
cfg.get("model").is_none(),
|
||||
"`model` should not be written when no override is supplied"
|
||||
);
|
||||
assert!(
|
||||
cfg.get("notice").is_none(),
|
||||
"`[notice]` table should not be written without a pinned model id"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_codex_config_toml_writes_model_reasoning_effort_when_specified() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
let working_dir = tmp.path().join("workspace");
|
||||
fs::create_dir_all(&working_dir).unwrap();
|
||||
|
||||
prepare_codex_config_toml(
|
||||
&config_path,
|
||||
&working_dir,
|
||||
&HashMap::new(),
|
||||
Some(&harness_model_config("gpt-5.5", Some("medium"))),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let cfg = read_codex_config(&config_path);
|
||||
assert_eq!(cfg["model"].as_str(), Some("gpt-5.5"));
|
||||
assert_eq!(cfg["model_reasoning_effort"].as_str(), Some("medium"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_codex_config_toml_removes_stale_model_reasoning_effort_when_none() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
let working_dir = tmp.path().join("workspace");
|
||||
fs::create_dir_all(&working_dir).unwrap();
|
||||
fs::write(&config_path, "model_reasoning_effort = \"high\"\n").unwrap();
|
||||
|
||||
prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None, None).unwrap();
|
||||
|
||||
let cfg = read_codex_config(&config_path);
|
||||
assert!(cfg.get("model_reasoning_effort").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_child_git_repos_returns_only_repo_children() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let workspace = tmp.path().join("workspace");
|
||||
let repo = workspace.join("repo");
|
||||
let other = workspace.join("other");
|
||||
fs::create_dir_all(repo.join(".git")).unwrap();
|
||||
fs::create_dir_all(&other).unwrap();
|
||||
|
||||
let found = find_child_git_repos(&workspace);
|
||||
let canonical_repo = repo.canonicalize().unwrap();
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].canonicalize().unwrap(), canonical_repo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_child_git_repos_returns_empty_when_dir_missing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let missing = tmp.path().join("does-not-exist");
|
||||
assert!(find_child_git_repos(&missing).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_command_with_session_id_invokes_resume_subcommand() {
|
||||
let uuid = Uuid::new_v4();
|
||||
let cmd = codex_command("codex", Some(&uuid), "/tmp/prompt.txt");
|
||||
assert!(
|
||||
cmd.contains(&format!(
|
||||
"resume --dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust {uuid}"
|
||||
)),
|
||||
"resume command should pass UUID to `resume`: {cmd}"
|
||||
);
|
||||
assert!(
|
||||
cmd.contains("\"$(cat '/tmp/prompt.txt')\""),
|
||||
"resume command should pipe prompt: {cmd}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_command_without_session_id_bypasses_hook_trust() {
|
||||
let cmd = codex_command("codex", None, "/tmp/prompt.txt");
|
||||
assert!(
|
||||
cmd.contains("--dangerously-bypass-approvals-and-sandbox"),
|
||||
"command should bypass approvals and sandbox: {cmd}"
|
||||
);
|
||||
assert!(
|
||||
cmd.contains("--dangerously-bypass-hook-trust"),
|
||||
"command should bypass hook trust for driver-installed hooks: {cmd}"
|
||||
);
|
||||
assert!(
|
||||
cmd.contains("\"$(cat '/tmp/prompt.txt')\""),
|
||||
"command should pipe prompt: {cmd}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_resume_payload_maps_404_to_resume_state_missing() {
|
||||
let mut mock = MockHarnessSupportClient::new();
|
||||
mock.expect_fetch_transcript()
|
||||
.returning(|| Err(anyhow::anyhow!("upstream returned status 404")));
|
||||
let conversation_id = AIConversationId::new();
|
||||
|
||||
let result = CodexHarness
|
||||
.fetch_resume_payload(&conversation_id, Arc::new(mock))
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Err(AgentDriverError::ConversationResumeStateMissing { harness, .. }) => {
|
||||
assert_eq!(harness, "codex");
|
||||
}
|
||||
other => panic!("expected ConversationResumeStateMissing, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_resume_payload_maps_other_errors_to_load_failed() {
|
||||
let mut mock = MockHarnessSupportClient::new();
|
||||
mock.expect_fetch_transcript()
|
||||
.returning(|| Err(anyhow::anyhow!("connection reset")));
|
||||
let conversation_id = AIConversationId::new();
|
||||
|
||||
let result = CodexHarness
|
||||
.fetch_resume_payload(&conversation_id, Arc::new(mock))
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(AgentDriverError::ConversationLoadFailed(_))),
|
||||
"expected ConversationLoadFailed, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn resolve_openai_base_url_from_secret_returns_base_url_when_typed_secret_active() {
|
||||
// When the typed OpenAI secret is the active API key source, the base URL
|
||||
// should be extracted from the structured secret.
|
||||
let prev = std::env::var(OPENAI_API_KEY_ENV).ok();
|
||||
std::env::remove_var(OPENAI_API_KEY_ENV);
|
||||
|
||||
let secrets = HashMap::from([(
|
||||
"openai-key".to_string(),
|
||||
ManagedSecretValue::openai_api_key(
|
||||
"sk-test",
|
||||
Some("https://us.api.openai.com/v1".to_string()),
|
||||
),
|
||||
)]);
|
||||
let resolved_env =
|
||||
HashMap::from([(OsString::from("OPENAI_API_KEY"), OsString::from("sk-test"))]);
|
||||
|
||||
let result = resolve_openai_base_url_from_secret(&secrets, &resolved_env);
|
||||
|
||||
if let Some(v) = prev {
|
||||
std::env::set_var(OPENAI_API_KEY_ENV, v);
|
||||
}
|
||||
assert_eq!(result.as_deref(), Some("https://us.api.openai.com/v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn resolve_openai_base_url_from_secret_returns_none_when_worker_env_wins() {
|
||||
// When a worker-injected OPENAI_API_KEY already exists in process env,
|
||||
// the typed-secret base_url should NOT be applied.
|
||||
let prev = std::env::var(OPENAI_API_KEY_ENV).ok();
|
||||
std::env::set_var(OPENAI_API_KEY_ENV, "sk-worker-key");
|
||||
|
||||
let secrets = HashMap::from([(
|
||||
"openai-key".to_string(),
|
||||
ManagedSecretValue::openai_api_key(
|
||||
"sk-secret",
|
||||
Some("https://us.api.openai.com/v1".to_string()),
|
||||
),
|
||||
)]);
|
||||
let resolved_env = HashMap::new();
|
||||
|
||||
let result = resolve_openai_base_url_from_secret(&secrets, &resolved_env);
|
||||
|
||||
match prev {
|
||||
Some(v) => std::env::set_var(OPENAI_API_KEY_ENV, v),
|
||||
None => std::env::remove_var(OPENAI_API_KEY_ENV),
|
||||
}
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn resolve_openai_base_url_from_secret_returns_none_when_no_base_url() {
|
||||
// When the typed OpenAI secret has no base_url, None is returned.
|
||||
let prev = std::env::var(OPENAI_API_KEY_ENV).ok();
|
||||
std::env::remove_var(OPENAI_API_KEY_ENV);
|
||||
|
||||
let secrets = HashMap::from([(
|
||||
"openai-key".to_string(),
|
||||
ManagedSecretValue::openai_api_key("sk-test", None),
|
||||
)]);
|
||||
let resolved_env =
|
||||
HashMap::from([(OsString::from("OPENAI_API_KEY"), OsString::from("sk-test"))]);
|
||||
|
||||
let result = resolve_openai_base_url_from_secret(&secrets, &resolved_env);
|
||||
|
||||
if let Some(v) = prev {
|
||||
std::env::set_var(OPENAI_API_KEY_ENV, v);
|
||||
}
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn resolve_openai_base_url_from_secret_returns_none_when_api_key_not_in_resolved() {
|
||||
// When OPENAI_API_KEY is not in the resolved env vars (e.g. the secret was
|
||||
// skipped due to collision), the base URL should not be applied.
|
||||
let prev = std::env::var(OPENAI_API_KEY_ENV).ok();
|
||||
std::env::remove_var(OPENAI_API_KEY_ENV);
|
||||
|
||||
let secrets = HashMap::from([(
|
||||
"openai-key".to_string(),
|
||||
ManagedSecretValue::openai_api_key(
|
||||
"sk-test",
|
||||
Some("https://us.api.openai.com/v1".to_string()),
|
||||
),
|
||||
)]);
|
||||
let resolved_env = HashMap::new();
|
||||
|
||||
let result = resolve_openai_base_url_from_secret(&secrets, &resolved_env);
|
||||
|
||||
if let Some(v) = prev {
|
||||
std::env::set_var(OPENAI_API_KEY_ENV, v);
|
||||
}
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_resume_payload_returns_codex_variant_on_success() {
|
||||
let uuid = Uuid::new_v4();
|
||||
let envelope = CodexTranscriptEnvelope {
|
||||
cwd: "/cloud/work".into(),
|
||||
session_id: uuid,
|
||||
codex_version: Some("0.55.0".to_string()),
|
||||
session_start_timestamp: None,
|
||||
entries: vec![serde_json::json!({"type": "event_msg"})],
|
||||
};
|
||||
let bytes = serde_json::to_vec(&envelope).unwrap();
|
||||
|
||||
let mut mock = MockHarnessSupportClient::new();
|
||||
mock.expect_fetch_transcript()
|
||||
.returning(move || Ok(bytes::Bytes::from(bytes.clone())));
|
||||
let conversation_id = AIConversationId::new();
|
||||
|
||||
let payload = CodexHarness
|
||||
.fetch_resume_payload(&conversation_id, Arc::new(mock))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
match payload {
|
||||
ResumePayload::Codex(info) => {
|
||||
assert_eq!(info.session_id, uuid);
|
||||
assert_eq!(info.conversation_id, conversation_id);
|
||||
assert_eq!(info.envelope.codex_version.as_deref(), Some("0.55.0"));
|
||||
}
|
||||
other => panic!("expected ResumePayload::Codex, got {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//! Codex session transcript envelope + rehydration helpers.
|
||||
//!
|
||||
//! Owns:
|
||||
//! - [`CodexTranscriptEnvelope`] — the on-wire/on-GCS shape of a saved Codex rollout
|
||||
//! (parsed JSONL entries plus session-level metadata). Reader/writer functions
|
||||
//! interoperate with Codex's own `~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl`
|
||||
//! layout (codex `rollout/src/recorder.rs`).
|
||||
//! - [`CodexResumeInfo`] — everything the harness runner needs to resume an existing
|
||||
//! Codex conversation: the Warp server conversation id to reuse, the codex session
|
||||
//! uuid (`ThreadId`) to pass to `codex resume`, and the decoded envelope to rehydrate
|
||||
//! onto disk.
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Datelike, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::json_utils::entries_to_jsonl;
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
|
||||
/// Env var codex honors to override `~/.codex` (see codex `core/src/config/mod.rs`).
|
||||
const CODEX_HOME_ENV: &str = "CODEX_HOME";
|
||||
const CODEX_HOME_DIRNAME: &str = ".codex";
|
||||
/// Subdirectory under `$CODEX_HOME` where rollouts live.
|
||||
const CODEX_SESSIONS_SUBDIR: &str = "sessions";
|
||||
|
||||
/// JSON envelope sent to the server representing a complete Codex session.
|
||||
///
|
||||
/// The transcript is the parsed JSONL content of the rollout file; codex's resume
|
||||
/// path re-reads this JSONL line by line.
|
||||
#[derive(Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub(crate) struct CodexTranscriptEnvelope {
|
||||
/// The directory the codex session started in (recovered from the `SessionMeta` line).
|
||||
pub(crate) cwd: PathBuf,
|
||||
/// Codex session/thread UUID. Matches the trailing `-<uuid>` in the rollout filename.
|
||||
pub(crate) session_id: Uuid,
|
||||
/// `cli_version` from `SessionMeta`, surfaced separately for the server.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) codex_version: Option<String>,
|
||||
/// Timestamp from the `SessionMeta` line, used to derive the YYYY/MM/DD directory
|
||||
/// path when writing the rollout file back to disk.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) session_start_timestamp: Option<DateTime<Utc>>,
|
||||
/// Parsed JSONL entries.
|
||||
pub(crate) entries: Vec<Value>,
|
||||
}
|
||||
|
||||
impl CodexTranscriptEnvelope {
|
||||
pub(crate) fn new(session_id: Uuid, meta: CodexSessionMetadata, entries: Vec<Value>) -> Self {
|
||||
Self {
|
||||
cwd: meta.cwd,
|
||||
session_id,
|
||||
codex_version: meta.codex_version,
|
||||
session_start_timestamp: meta.session_start_timestamp,
|
||||
entries,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Session-level metadata pulled from the rollout's `SessionMeta` line.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct CodexSessionMetadata {
|
||||
pub(crate) cwd: PathBuf,
|
||||
pub(crate) codex_version: Option<String>,
|
||||
pub(crate) session_start_timestamp: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Everything needed to resume an existing Codex conversation.
|
||||
///
|
||||
/// Built from a `--conversation` id after the client fetches the stored envelope from
|
||||
/// the server. Passed into `CodexHarnessRunner::new` so the runner reuses the existing
|
||||
/// session and server conversation ids instead of minting fresh ones.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct CodexResumeInfo {
|
||||
/// Warp server-side conversation id. Reused so subsequent transcript/block-snapshot
|
||||
/// uploads overwrite the same GCS objects.
|
||||
pub(crate) conversation_id: AIConversationId,
|
||||
/// Codex session uuid passed to `codex resume <session_id>`. Matches `envelope.session_id`.
|
||||
pub(crate) session_id: Uuid,
|
||||
/// Envelope fetched from the server, written back to disk before launching codex.
|
||||
pub(crate) envelope: CodexTranscriptEnvelope,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct CodexLocalContinuation {
|
||||
pub(crate) command: String,
|
||||
pub(crate) transcript_path: PathBuf,
|
||||
}
|
||||
|
||||
/// Resolve the codex sessions root, honoring `$CODEX_HOME` then falling back to `~/.codex`.
|
||||
pub(crate) fn codex_sessions_root() -> anyhow::Result<PathBuf> {
|
||||
let home = if let Ok(dir) = std::env::var(CODEX_HOME_ENV) {
|
||||
PathBuf::from(dir)
|
||||
} else {
|
||||
dirs::home_dir()
|
||||
.ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?
|
||||
.join(CODEX_HOME_DIRNAME)
|
||||
};
|
||||
Ok(home.join(CODEX_SESSIONS_SUBDIR))
|
||||
}
|
||||
|
||||
/// Walk `<sessions_root>/YYYY/MM/DD/` looking for a `rollout-*-<session_id>.jsonl`.
|
||||
///
|
||||
/// Returns `None` if `sessions_root` doesn't exist yet or no matching file is found.
|
||||
pub(crate) fn find_session_file(sessions_root: &Path, session_id: Uuid) -> Option<PathBuf> {
|
||||
if !sessions_root.exists() {
|
||||
return None;
|
||||
}
|
||||
let suffix = format!("-{session_id}.jsonl");
|
||||
for year_dir in read_subdirs(sessions_root) {
|
||||
for month_dir in read_subdirs(&year_dir) {
|
||||
for day_dir in read_subdirs(&month_dir) {
|
||||
let entries = match fs::read_dir(&day_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if name.starts_with("rollout-") && name.ends_with(&suffix) {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn read_subdirs(parent: &Path) -> impl Iterator<Item = PathBuf> {
|
||||
fs::read_dir(parent)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
let entry = entry.ok()?;
|
||||
entry.file_type().ok()?.is_dir().then(|| entry.path())
|
||||
})
|
||||
}
|
||||
|
||||
/// Pull `cwd` and `cli_version` out of the first JSONL line if it's a `SessionMeta`.
|
||||
pub(crate) fn parse_session_meta(first: Option<&Value>) -> Option<CodexSessionMetadata> {
|
||||
let entry = first?;
|
||||
if entry.get("type").and_then(|v| v.as_str()) != Some("session_meta") {
|
||||
return None;
|
||||
}
|
||||
let payload = entry.get("payload")?;
|
||||
let cwd = PathBuf::from(payload.get("cwd").and_then(|v| v.as_str())?);
|
||||
let codex_version = payload
|
||||
.get("cli_version")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_owned);
|
||||
let session_start_timestamp = payload
|
||||
.get("timestamp")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc));
|
||||
Some(CodexSessionMetadata {
|
||||
cwd,
|
||||
codex_version,
|
||||
session_start_timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
/// Write `envelope` back under `<sessions_root>/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl`.
|
||||
///
|
||||
/// YYYY/MM/DD and `<ts>` come from `envelope.session_start_timestamp`. Falls back to
|
||||
/// today's UTC date if absent — codex's lookup is by UUID so the precise path doesn't
|
||||
/// matter for resume to work.
|
||||
pub(crate) fn write_envelope(
|
||||
envelope: &CodexTranscriptEnvelope,
|
||||
sessions_root: &Path,
|
||||
) -> Result<PathBuf> {
|
||||
let timestamp = envelope.session_start_timestamp.unwrap_or_else(Utc::now);
|
||||
let day_dir = sessions_root
|
||||
.join(format!("{:04}", timestamp.year()))
|
||||
.join(format!("{:02}", timestamp.month()))
|
||||
.join(format!("{:02}", timestamp.day()));
|
||||
fs::create_dir_all(&day_dir)
|
||||
.with_context(|| format!("Failed to create {}", day_dir.display()))?;
|
||||
// Codex's filename format: `[year]-[month]-[day]T[hour]-[minute]-[second]`
|
||||
// (codex `rollout/src/recorder.rs::precompute_log_file_info`).
|
||||
let date_str = timestamp.format("%Y-%m-%dT%H-%M-%S").to_string();
|
||||
let file_path = day_dir.join(format!(
|
||||
"rollout-{date_str}-{session_id}.jsonl",
|
||||
session_id = envelope.session_id
|
||||
));
|
||||
fs::write(&file_path, entries_to_jsonl(&envelope.entries)?)
|
||||
.with_context(|| format!("Failed to write {}", file_path.display()))?;
|
||||
Ok(file_path)
|
||||
}
|
||||
|
||||
pub(crate) fn rehydrate_codex_transcript(
|
||||
envelope: &mut CodexTranscriptEnvelope,
|
||||
local_cwd: &Path,
|
||||
) -> Result<CodexLocalContinuation> {
|
||||
envelope.cwd = local_cwd.to_path_buf();
|
||||
if let Some(Value::Object(entry)) = envelope.entries.first_mut() {
|
||||
if entry.get("type").and_then(|value| value.as_str()) == Some("session_meta") {
|
||||
if let Some(Value::Object(payload)) = entry.get_mut("payload") {
|
||||
payload.insert(
|
||||
"cwd".to_string(),
|
||||
Value::String(local_cwd.to_string_lossy().to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let session_id = envelope.session_id;
|
||||
let sessions_root = codex_sessions_root().context("Failed to resolve codex sessions root")?;
|
||||
let transcript_path =
|
||||
write_envelope(envelope, &sessions_root).context("Failed to rehydrate codex transcript")?;
|
||||
|
||||
Ok(CodexLocalContinuation {
|
||||
command: format!("codex resume {session_id}"),
|
||||
transcript_path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Rehydrate a Codex transcript downloaded from a remote cloud run for local continuation.
|
||||
///
|
||||
/// Unlike [`rehydrate_codex_transcript`] (used by the cloud resume harness runner), this
|
||||
/// function does **not** mutate the envelope's `cwd` field or patch the `session_meta` payload
|
||||
/// — the remote session's working directory is preserved as-is in the transcript.
|
||||
pub(crate) fn rehydrate_codex_transcript_from_reader(
|
||||
reader: impl Read,
|
||||
) -> Result<CodexLocalContinuation> {
|
||||
let envelope: CodexTranscriptEnvelope =
|
||||
serde_json::from_reader(reader).context("Failed to parse codex transcript envelope")?;
|
||||
let session_id = envelope.session_id;
|
||||
let sessions_root = codex_sessions_root().context("Failed to resolve codex sessions root")?;
|
||||
// Write as-is: no cwd mutation, no session_meta patch.
|
||||
let transcript_path = write_envelope(&envelope, &sessions_root)
|
||||
.context("Failed to rehydrate codex transcript")?;
|
||||
Ok(CodexLocalContinuation {
|
||||
command: format!("codex resume {session_id}"),
|
||||
transcript_path,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "codex_transcript_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,205 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::super::claude_transcript::read_jsonl;
|
||||
use super::*;
|
||||
|
||||
/// Walk `sessions_root` for `session_id`'s rollout and assemble an envelope.
|
||||
fn read_envelope(
|
||||
session_id: Uuid,
|
||||
sessions_root: &Path,
|
||||
) -> Result<Option<CodexTranscriptEnvelope>> {
|
||||
let Some(path) = find_session_file(sessions_root, session_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let entries = read_jsonl(&path)?;
|
||||
let meta = parse_session_meta(entries.first()).unwrap_or_default();
|
||||
Ok(Some(CodexTranscriptEnvelope::new(
|
||||
session_id, meta, entries,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Minimal SessionMeta line in the same shape codex writes (codex
|
||||
/// `protocol/src/protocol.rs::RolloutItem`): `{type, payload}`.
|
||||
fn session_meta_line(uuid: Uuid, cwd: &str, timestamp: &str, cli_version: &str) -> String {
|
||||
serde_json::json!({
|
||||
"type": "session_meta",
|
||||
"payload": {
|
||||
"id": uuid.to_string(),
|
||||
"timestamp": timestamp,
|
||||
"cwd": cwd,
|
||||
"originator": "test",
|
||||
"cli_version": cli_version,
|
||||
},
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn codex_sessions_root_honors_codex_home_env() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let prev = std::env::var(CODEX_HOME_ENV).ok();
|
||||
std::env::set_var(CODEX_HOME_ENV, tmp.path());
|
||||
|
||||
let root = codex_sessions_root().unwrap();
|
||||
|
||||
match prev {
|
||||
Some(v) => std::env::set_var(CODEX_HOME_ENV, v),
|
||||
None => std::env::remove_var(CODEX_HOME_ENV),
|
||||
}
|
||||
assert_eq!(root, tmp.path().join(CODEX_SESSIONS_SUBDIR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_session_file_walks_yyyy_mm_dd_tree() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let uuid = Uuid::new_v4();
|
||||
let day = tmp.path().join("2026").join("04").join("30");
|
||||
fs::create_dir_all(&day).unwrap();
|
||||
let file = day.join(format!("rollout-ignored-ts-{uuid}.jsonl"));
|
||||
fs::write(&file, "").unwrap();
|
||||
|
||||
let found = find_session_file(tmp.path(), uuid);
|
||||
assert_eq!(found, Some(file));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_session_file_returns_none_when_no_match() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let day = tmp.path().join("2026").join("04").join("30");
|
||||
fs::create_dir_all(&day).unwrap();
|
||||
fs::write(
|
||||
day.join(format!("rollout-ignored-ts-{}.jsonl", Uuid::new_v4())),
|
||||
"",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(find_session_file(tmp.path(), Uuid::new_v4()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_session_file_returns_none_when_root_missing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
assert!(find_session_file(&tmp.path().join("missing"), Uuid::new_v4()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_envelope_recovers_cwd_and_version_from_session_meta() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let uuid = Uuid::new_v4();
|
||||
let day = tmp.path().join("2026").join("04").join("30");
|
||||
fs::create_dir_all(&day).unwrap();
|
||||
let meta = session_meta_line(uuid, "/work/proj", "2026-04-30T01:54:20.000Z", "0.55.0");
|
||||
let body = format!("{meta}\n{{\"type\":\"event_msg\",\"payload\":{{\"x\":1}}}}\n");
|
||||
fs::write(day.join(format!("rollout-ignored-ts-{uuid}.jsonl")), body).unwrap();
|
||||
|
||||
let envelope = read_envelope(uuid, tmp.path()).unwrap().unwrap();
|
||||
assert_eq!(envelope.session_id, uuid);
|
||||
assert_eq!(envelope.cwd, std::path::PathBuf::from("/work/proj"));
|
||||
assert_eq!(envelope.codex_version.as_deref(), Some("0.55.0"));
|
||||
assert_eq!(envelope.entries.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_envelope_returns_none_when_missing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
assert!(read_envelope(Uuid::new_v4(), tmp.path()).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_envelope_uses_session_meta_timestamp_for_path() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let uuid = Uuid::new_v4();
|
||||
let envelope = CodexTranscriptEnvelope {
|
||||
cwd: "/work".into(),
|
||||
session_id: uuid,
|
||||
codex_version: Some("0.55.0".to_string()),
|
||||
session_start_timestamp: Some(
|
||||
chrono::DateTime::parse_from_rfc3339("2026-04-30T01:54:20.000Z")
|
||||
.unwrap()
|
||||
.with_timezone(&chrono::Utc),
|
||||
),
|
||||
entries: vec![
|
||||
serde_json::from_str::<serde_json::Value>(&session_meta_line(
|
||||
uuid,
|
||||
"/work",
|
||||
"2026-04-30T01:54:20.000Z",
|
||||
"0.55.0",
|
||||
))
|
||||
.unwrap(),
|
||||
],
|
||||
};
|
||||
|
||||
let path = write_envelope(&envelope, tmp.path()).unwrap();
|
||||
|
||||
let expected = tmp
|
||||
.path()
|
||||
.join("2026")
|
||||
.join("04")
|
||||
.join("30")
|
||||
.join(format!("rollout-2026-04-30T01-54-20-{uuid}.jsonl"));
|
||||
assert_eq!(path, expected);
|
||||
assert!(path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_envelope_round_trip_preserves_entries() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let uuid = Uuid::new_v4();
|
||||
let entries = vec![
|
||||
serde_json::from_str::<serde_json::Value>(&session_meta_line(
|
||||
uuid,
|
||||
"/work",
|
||||
"2026-04-30T01:54:20.000Z",
|
||||
"0.55.0",
|
||||
))
|
||||
.unwrap(),
|
||||
serde_json::json!({"type": "event_msg", "payload": {"x": 1}}),
|
||||
];
|
||||
let original = CodexTranscriptEnvelope {
|
||||
cwd: "/work".into(),
|
||||
session_id: uuid,
|
||||
codex_version: Some("0.55.0".to_string()),
|
||||
session_start_timestamp: Some(
|
||||
chrono::DateTime::parse_from_rfc3339("2026-04-30T01:54:20.000Z")
|
||||
.unwrap()
|
||||
.with_timezone(&chrono::Utc),
|
||||
),
|
||||
entries: entries.clone(),
|
||||
};
|
||||
|
||||
write_envelope(&original, tmp.path()).unwrap();
|
||||
let decoded = read_envelope(uuid, tmp.path()).unwrap().unwrap();
|
||||
|
||||
assert_eq!(decoded.session_id, uuid);
|
||||
assert_eq!(decoded.entries, entries);
|
||||
// `cwd` and `codex_version` are recovered from the SessionMeta line.
|
||||
assert_eq!(decoded.cwd, std::path::PathBuf::from("/work"));
|
||||
assert_eq!(decoded.codex_version.as_deref(), Some("0.55.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_envelope_falls_back_to_today_when_timestamp_missing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let uuid = Uuid::new_v4();
|
||||
// No SessionMeta first line — just one event entry.
|
||||
let envelope = CodexTranscriptEnvelope {
|
||||
cwd: "/work".into(),
|
||||
session_id: uuid,
|
||||
codex_version: None,
|
||||
session_start_timestamp: None,
|
||||
entries: vec![serde_json::json!({"type": "event_msg"})],
|
||||
};
|
||||
|
||||
let path = write_envelope(&envelope, tmp.path()).unwrap();
|
||||
|
||||
// Path should still be under sessions_root and findable by uuid.
|
||||
assert!(path.starts_with(tmp.path()));
|
||||
let found = find_session_file(tmp.path(), uuid);
|
||||
assert_eq!(found, Some(path));
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsString;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -12,18 +13,24 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
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, HarnessCleanupDisposition, HarnessRunner, JSONMCPServer, ResumePayload,
|
||||
SavePoint, ThirdPartyHarness,
|
||||
};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent_sdk::setup_observability::{
|
||||
OzRunTimelineEvent, SetupClientEventReporter, SetupStep,
|
||||
};
|
||||
use crate::ai::ambient_agents::task::HarnessModelConfig;
|
||||
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.
|
||||
@@ -46,38 +53,42 @@ impl ThirdPartyHarness for GeminiHarness {
|
||||
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>,
|
||||
context: Option<&str>,
|
||||
working_dir: &Path,
|
||||
_task_id: Option<AmbientAgentTaskId>,
|
||||
server_api: Arc<ServerApi>,
|
||||
terminal_driver: ModelHandle<TerminalDriver>,
|
||||
_resume: Option<ResumePayload>,
|
||||
_resolved_env_vars: &HashMap<OsString, OsString>,
|
||||
_resolved_secrets: &HashMap<String, ManagedSecretValue>,
|
||||
_resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
|
||||
_third_party_harness_model_config: Option<&HarnessModelConfig>,
|
||||
) -> Result<Box<dyn HarnessRunner>, AgentDriverError> {
|
||||
// Prepare the environment config files.
|
||||
prepare_gemini_environment_config(working_dir, system_prompt).map_err(|error| {
|
||||
AgentDriverError::HarnessConfigSetupFailed {
|
||||
harness: self.cli_agent().command_prefix().to_owned(),
|
||||
error,
|
||||
}
|
||||
})?;
|
||||
|
||||
// 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.
|
||||
// Prepend server context to the prompt if available.
|
||||
let effective_prompt = match context {
|
||||
Some(ctx) if !ctx.is_empty() => format!("{ctx}\n\n{prompt}"),
|
||||
_ => prompt.to_string(),
|
||||
};
|
||||
let client: Arc<dyn HarnessSupportClient> = server_api;
|
||||
Ok(Box::new(GeminiHarnessRunner::new(
|
||||
self.cli_agent().command_prefix(),
|
||||
prompt,
|
||||
&effective_prompt,
|
||||
system_prompt,
|
||||
working_dir,
|
||||
client,
|
||||
@@ -104,6 +115,8 @@ enum GeminiRunnerState {
|
||||
|
||||
struct GeminiHarnessRunner {
|
||||
command: String,
|
||||
/// The CLI name used to invoke Gemini.
|
||||
cli_name: String,
|
||||
/// Held so the temp file is cleaned up when the runner is dropped.
|
||||
_temp_prompt_file: NamedTempFile,
|
||||
client: Arc<dyn HarnessSupportClient>,
|
||||
@@ -120,11 +133,12 @@ impl GeminiHarnessRunner {
|
||||
client: Arc<dyn HarnessSupportClient>,
|
||||
terminal_driver: ModelHandle<TerminalDriver>,
|
||||
) -> Result<Self, AgentDriverError> {
|
||||
let temp_file = write_temp_file("oz_prompt_", prompt)?;
|
||||
let temp_file = write_temp_file("oz_prompt_", prompt, ".txt")?;
|
||||
let prompt_path = temp_file.path().display().to_string();
|
||||
|
||||
Ok(Self {
|
||||
command: gemini_command(cli_command, &prompt_path),
|
||||
cli_name: cli_command.to_string(),
|
||||
_temp_prompt_file: temp_file,
|
||||
client,
|
||||
terminal_driver,
|
||||
@@ -136,19 +150,27 @@ impl GeminiHarnessRunner {
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
impl HarnessRunner for GeminiHarnessRunner {
|
||||
fn harness_name(&self) -> &str {
|
||||
&self.cli_name
|
||||
}
|
||||
|
||||
async fn start(
|
||||
&self,
|
||||
foreground: &ModelSpawner<AgentDriver>,
|
||||
setup_events: &SetupClientEventReporter,
|
||||
) -> 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)
|
||||
})?;
|
||||
let conversation_id = setup_events
|
||||
.record_result(SetupStep::ThirdPartyHarnessExternalConversation, async {
|
||||
self.client
|
||||
.create_external_conversation(GEMINI_CLI_FORMAT)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to create external conversation: {e}");
|
||||
AgentDriverError::ConfigBuildFailed(e)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
log::info!("Created external conversation {conversation_id}");
|
||||
|
||||
let command = self.command.clone();
|
||||
@@ -166,6 +188,10 @@ impl HarnessRunner for GeminiHarnessRunner {
|
||||
block_id: command_handle.block_id().clone(),
|
||||
};
|
||||
|
||||
setup_events
|
||||
.post_timeline_event(OzRunTimelineEvent::AgentStarted)
|
||||
.await;
|
||||
|
||||
Ok(command_handle)
|
||||
}
|
||||
|
||||
@@ -215,6 +241,14 @@ impl HarnessRunner for GeminiHarnessRunner {
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn cleanup(
|
||||
&self,
|
||||
_cleanup_disposition: HarnessCleanupDisposition,
|
||||
_foreground: &ModelSpawner<AgentDriver>,
|
||||
) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_gemini_environment_config(
|
||||
|
||||
@@ -10,6 +10,7 @@ use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Read a JSON file as `T`, or return `T::default()` if the file does not exist.
|
||||
///
|
||||
@@ -54,3 +55,13 @@ where
|
||||
)
|
||||
.with_context(|| format!("Failed to write {}", path.display()))
|
||||
}
|
||||
|
||||
/// Serialize a slice of JSON values as a JSONL byte string (one value per line).
|
||||
pub(super) 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)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsString;
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::fmt;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
@@ -12,15 +12,6 @@ use galaxy_cli::agent::Harness;
|
||||
use galaxy_managed_secrets::ManagedSecretValue;
|
||||
use galaxyui::{ModelHandle, ModelSpawner, SingletonEntity};
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
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 galaxy_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,
|
||||
@@ -33,24 +24,97 @@ use super::{
|
||||
LEGACY_OZ_PARENT_STATE_ROOT_ENV, OZ_MESSAGE_LISTENER_MANAGED_EXTERNALLY_ENV,
|
||||
OZ_MESSAGE_LISTENER_STATE_ROOT_ENV,
|
||||
};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent_sdk::setup_observability::SetupClientEventReporter;
|
||||
use crate::ai::ambient_agents::task::HarnessModelConfig;
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::ai::mcp::JSONMCPServer;
|
||||
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;
|
||||
|
||||
mod claude_code;
|
||||
pub(crate) mod claude_code;
|
||||
pub(crate) mod claude_transcript;
|
||||
mod codex;
|
||||
pub(crate) mod codex_transcript;
|
||||
mod gemini;
|
||||
mod json_utils;
|
||||
|
||||
mod telemetry;
|
||||
pub(crate) use claude_code::ClaudeHarness;
|
||||
use claude_transcript::ClaudeResumeInfo;
|
||||
use codex::CodexHarness;
|
||||
use codex_transcript::CodexResumeInfo;
|
||||
use gemini::GeminiHarness;
|
||||
pub(crate) use telemetry::ThirdPartyHarnessTelemetryEvent;
|
||||
|
||||
/// 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`].
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum ResumePayload {
|
||||
/// Claude Code session state fetched from the server's transcript endpoint.
|
||||
Claude(ClaudeResumeInfo),
|
||||
/// Codex session state fetched from the server's transcript endpoint.
|
||||
Codex(CodexResumeInfo),
|
||||
}
|
||||
|
||||
impl TryFrom<ResumePayload> for ClaudeResumeInfo {
|
||||
type Error = AgentDriverError;
|
||||
|
||||
fn try_from(payload: ResumePayload) -> Result<Self, Self::Error> {
|
||||
match payload {
|
||||
ResumePayload::Claude(info) => Ok(info),
|
||||
_ => {
|
||||
log::error!("ClaudeHarness given non-Claude ResumePayload variant");
|
||||
Err(AgentDriverError::InvalidRuntimeState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<ResumePayload> for CodexResumeInfo {
|
||||
type Error = AgentDriverError;
|
||||
|
||||
fn try_from(payload: ResumePayload) -> Result<Self, Self::Error> {
|
||||
match payload {
|
||||
ResumePayload::Codex(info) => Ok(info),
|
||||
_ => {
|
||||
log::error!("CodexHarness given non-Codex ResumePayload variant");
|
||||
Err(AgentDriverError::InvalidRuntimeState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the harness transcript for `conversation_id` and deserialize it into `E`.
|
||||
pub(super) async fn fetch_transcript_envelope<E: serde::de::DeserializeOwned>(
|
||||
harness_label: &str,
|
||||
conversation_id: &AIConversationId,
|
||||
client: Arc<dyn HarnessSupportClient>,
|
||||
) -> Result<E, AgentDriverError> {
|
||||
let bytes = 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: harness_label.to_string(),
|
||||
conversation_id: conversation_id.to_string(),
|
||||
}
|
||||
} else {
|
||||
AgentDriverError::ConversationLoadFailed(format!("{err:#}"))
|
||||
}
|
||||
})?;
|
||||
serde_json::from_slice(&bytes).map_err(|err| {
|
||||
AgentDriverError::ConversationLoadFailed(format!(
|
||||
"Failed to deserialize {harness_label} transcript for {conversation_id}: {err:#}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Trait for third-party agent harnesses that execute prompts via their own CLIs.
|
||||
@@ -77,14 +141,27 @@ pub(crate) trait ThirdPartyHarness: Send + Sync {
|
||||
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(())
|
||||
/// Shell command to verify authentication credentials are valid.
|
||||
/// Exit code 0 = pass; non-zero = fail.
|
||||
fn auth_check_command(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Substrings to scan for in the running harness block's output. A hit
|
||||
/// indicates the harness can't make a successful API request (e.g.
|
||||
/// invalid key, no billing, quota exhausted). The driver matches
|
||||
/// case-insensitively against the block's plaintext via the same DFA
|
||||
/// machinery used by the find feature.
|
||||
fn runtime_error_patterns(&self) -> &'static [&'static str] {
|
||||
&[]
|
||||
}
|
||||
|
||||
/// Whether this harness must verify its Oz platform plugin before launch.
|
||||
/// Codex opts into this because its unattended launch command bypasses hook
|
||||
/// trust globally, so we should fail setup instead of running without the
|
||||
/// Warp-installed orchestration hooks at the required version.
|
||||
fn requires_verified_platform_plugin(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Fetch the harness-specific resume payload for an existing conversation.
|
||||
@@ -107,25 +184,34 @@ pub(crate) trait ThirdPartyHarness: Send + Sync {
|
||||
|
||||
/// 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.
|
||||
/// Responsible for all harness-specific setup: writing config files (auth,
|
||||
/// trust, system prompt, MCP, etc.) and constructing the runner that will
|
||||
/// execute the CLI command.
|
||||
///
|
||||
/// `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.
|
||||
/// `resolved_env_vars` contains already-resolved secret env vars (worker
|
||||
/// env > typed secrets > raw values precedence already applied).
|
||||
///
|
||||
/// `resolved_secrets` provides the raw typed managed secrets so harnesses
|
||||
/// can read structured fields (e.g. `base_url`) without relying on env vars.
|
||||
///
|
||||
/// If `resume` is `Some`, the harness matches on its own [`ResumePayload`]
|
||||
/// variant and reuses stored session/conversation ids.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_runner(
|
||||
&self,
|
||||
prompt: &str,
|
||||
system_prompt: Option<&str>,
|
||||
resumption_prompt: Option<&str>,
|
||||
context: Option<&str>,
|
||||
working_dir: &Path,
|
||||
task_id: Option<AmbientAgentTaskId>,
|
||||
server_api: Arc<ServerApi>,
|
||||
terminal_driver: ModelHandle<TerminalDriver>,
|
||||
resume: Option<ResumePayload>,
|
||||
resolved_env_vars: &HashMap<OsString, OsString>,
|
||||
resolved_secrets: &HashMap<String, ManagedSecretValue>,
|
||||
resolved_mcp_servers: &HashMap<String, JSONMCPServer>,
|
||||
third_party_harness_model_config: Option<&HarnessModelConfig>,
|
||||
) -> Result<Box<dyn HarnessRunner>, AgentDriverError>;
|
||||
}
|
||||
|
||||
@@ -165,12 +251,30 @@ pub(crate) fn harness_kind(harness: Harness) -> Result<HarnessKind, AgentDriverE
|
||||
match harness {
|
||||
Harness::Oz => Ok(HarnessKind::Oz),
|
||||
Harness::Claude => Ok(HarnessKind::ThirdParty(Box::new(ClaudeHarness))),
|
||||
Harness::Codex => Ok(HarnessKind::ThirdParty(Box::new(CodexHarness))),
|
||||
Harness::OpenCode => Ok(HarnessKind::Unsupported(Harness::OpenCode)),
|
||||
Harness::Gemini => Ok(HarnessKind::ThirdParty(Box::new(GeminiHarness))),
|
||||
Harness::Unknown => Err(AgentDriverError::InvalidRuntimeState),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the harness's auth-check preflight command, if any.
|
||||
///
|
||||
/// The viewer uses this to recognize preflight blocks via exact string
|
||||
/// equality (so they stay grouped under "Set up environment commands"
|
||||
/// rather than being mistaken for the main harness invocation, which
|
||||
/// shares the same CLI prefix).
|
||||
///
|
||||
/// Returns `None` for [`Harness::Oz`], for unsupported harnesses, and
|
||||
/// for any third-party harness whose `auth_check_command` returns `None`
|
||||
/// (e.g. Gemini today).
|
||||
pub(crate) fn auth_check_command_for(harness: Harness) -> Option<String> {
|
||||
let HarnessKind::ThirdParty(third_party) = harness_kind(harness).ok()? else {
|
||||
return None;
|
||||
};
|
||||
third_party.auth_check_command()
|
||||
}
|
||||
|
||||
/// 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(
|
||||
@@ -302,6 +406,17 @@ fn task_env_vars_for_harness_name(
|
||||
env_vars
|
||||
}
|
||||
|
||||
pub(crate) fn remove_claude_externally_managed_listener_env_vars(
|
||||
env_vars: &mut HashMap<OsString, OsString>,
|
||||
) {
|
||||
for env_name in [
|
||||
OZ_MESSAGE_LISTENER_MANAGED_EXTERNALLY_ENV,
|
||||
LEGACY_OZ_PARENT_LISTENER_MANAGED_EXTERNALLY_ENV,
|
||||
] {
|
||||
env_vars.remove(OsStr::new(env_name));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn task_env_vars(
|
||||
task_id: Option<&AmbientAgentTaskId>,
|
||||
parent_run_id: Option<&str>,
|
||||
@@ -310,6 +425,34 @@ pub(crate) fn task_env_vars(
|
||||
task_env_vars_for_harness_name(task_id, parent_run_id, selected_harness)
|
||||
}
|
||||
|
||||
/// Returns environment variables that configure the model for a third-party harness.
|
||||
/// Returns an empty map for Oz or when no model is specified.
|
||||
///
|
||||
/// We use the `ANTHROPIC_MODEL` env var rather than the `--model` CLI flag because
|
||||
/// the env var is the most reliable mechanism and avoids precedence conflicts with
|
||||
/// Claude Code's `settings.json`.
|
||||
pub(crate) fn harness_model_env_vars(
|
||||
selected_harness: Harness,
|
||||
third_party_harness_model_config: Option<&HarnessModelConfig>,
|
||||
) -> HashMap<OsString, OsString> {
|
||||
let mut env_vars = HashMap::new();
|
||||
let Some(model_id) = third_party_harness_model_config
|
||||
.map(|config| config.model_id.as_str())
|
||||
.filter(|id| !id.is_empty())
|
||||
else {
|
||||
return env_vars;
|
||||
};
|
||||
|
||||
match selected_harness {
|
||||
Harness::Claude => {
|
||||
env_vars.insert(OsString::from("ANTHROPIC_MODEL"), OsString::from(model_id));
|
||||
}
|
||||
Harness::Oz | Harness::OpenCode | Harness::Gemini | Harness::Codex | Harness::Unknown => {}
|
||||
}
|
||||
|
||||
env_vars
|
||||
}
|
||||
|
||||
/// Indicates when the harness conversation is being saved.
|
||||
/// Implementations may use this to customize the saved data, such as
|
||||
/// recording additional metadata on completion.
|
||||
@@ -322,6 +465,18 @@ pub(crate) enum SavePoint {
|
||||
PostTurn,
|
||||
}
|
||||
|
||||
/// Controls how much harness-owned state should survive cleanup after the CLI
|
||||
/// exits.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum HarnessCleanupDisposition {
|
||||
/// Tear down all harness-owned resume and wake state.
|
||||
DropResumptionState,
|
||||
/// The harness exited cleanly and its final save completed, so wake/resume
|
||||
/// state may be preserved if the harness-specific runtime also considers
|
||||
/// the run complete.
|
||||
PreserveResumptionStateIfSupported,
|
||||
}
|
||||
|
||||
/// Stateful per-run representation of an external harness produced
|
||||
/// by [`ThirdPartyHarness::build_runner`].
|
||||
///
|
||||
@@ -333,6 +488,8 @@ pub(crate) enum SavePoint {
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
pub(crate) trait HarnessRunner: Send + Sync {
|
||||
fn harness_name(&self) -> &str;
|
||||
|
||||
/// Create the external conversation on the server and start the harness
|
||||
/// command in the terminal.
|
||||
///
|
||||
@@ -342,6 +499,7 @@ pub(crate) trait HarnessRunner: Send + Sync {
|
||||
async fn start(
|
||||
&self,
|
||||
foreground: &ModelSpawner<AgentDriver>,
|
||||
setup_events: &SetupClientEventReporter,
|
||||
) -> Result<CommandHandle, AgentDriverError>;
|
||||
|
||||
/// Save the current conversation state (transcript upload, etc.).
|
||||
@@ -359,7 +517,11 @@ pub(crate) trait HarnessRunner: Send + Sync {
|
||||
}
|
||||
|
||||
/// Clean up any harness-owned background state after the harness exits.
|
||||
async fn cleanup(&self, _foreground: &ModelSpawner<AgentDriver>) -> Result<()> {
|
||||
async fn cleanup(
|
||||
&self,
|
||||
_cleanup_disposition: HarnessCleanupDisposition,
|
||||
_foreground: &ModelSpawner<AgentDriver>,
|
||||
) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -370,20 +532,29 @@ pub(crate) async fn has_running_cli_agent(
|
||||
terminal_driver: &ModelHandle<TerminalDriver>,
|
||||
foreground: &ModelSpawner<AgentDriver>,
|
||||
) -> bool {
|
||||
matches!(
|
||||
cli_agent_session_status(terminal_driver, foreground).await,
|
||||
Some(CLIAgentSessionStatus::InProgress)
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the tracked CLI agent session status for the terminal, if any.
|
||||
pub(crate) async fn cli_agent_session_status(
|
||||
terminal_driver: &ModelHandle<TerminalDriver>,
|
||||
foreground: &ModelSpawner<AgentDriver>,
|
||||
) -> Option<CLIAgentSessionStatus> {
|
||||
let driver = terminal_driver.clone();
|
||||
let Ok(running) = foreground
|
||||
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)
|
||||
.map(|session| session.status.clone())
|
||||
})
|
||||
.await
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
running
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Create a [`NamedTempFile`] with the given prefix and write `content` into it.
|
||||
@@ -393,10 +564,11 @@ pub(crate) async fn has_running_cli_agent(
|
||||
pub(super) fn write_temp_file(
|
||||
prefix: &str,
|
||||
content: &str,
|
||||
suffix: &str,
|
||||
) -> Result<NamedTempFile, AgentDriverError> {
|
||||
let mut file = tempfile::Builder::new()
|
||||
.prefix(prefix)
|
||||
.suffix(".txt")
|
||||
.suffix(suffix)
|
||||
.tempfile()
|
||||
.map_err(|e| {
|
||||
AgentDriverError::ConfigBuildFailed(anyhow::anyhow!(
|
||||
@@ -457,5 +629,5 @@ pub(super) async fn upload_current_block_snapshot(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_test.rs"]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
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"));
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use warp_cli::agent::Harness;
|
||||
|
||||
use super::{auth_check_command_for, 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"));
|
||||
}
|
||||
|
||||
// --- Runtime error pattern tests ---
|
||||
|
||||
#[test]
|
||||
fn claude_runtime_error_patterns_returns_slice() {
|
||||
use super::claude_code::ClaudeHarness;
|
||||
use super::ThirdPartyHarness;
|
||||
// Patterns are initially empty until validated needles are filled in.
|
||||
// The trait method must still be callable.
|
||||
let _: &[&str] = ClaudeHarness.runtime_error_patterns();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_runtime_error_patterns_returns_slice() {
|
||||
use super::codex::CodexHarness;
|
||||
let _: &[&str] = CodexHarness.runtime_error_patterns();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_runtime_error_patterns_is_empty_by_default() {
|
||||
use super::gemini::GeminiHarness;
|
||||
assert!(GeminiHarness.runtime_error_patterns().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_check_command_for_gemini_is_none() {
|
||||
assert!(auth_check_command_for(Harness::Gemini).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_check_command_for_oz_is_none() {
|
||||
assert!(auth_check_command_for(Harness::Oz).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_check_command_for_unsupported_is_none() {
|
||||
// OpenCode is mapped to HarnessKind::Unsupported and therefore has no
|
||||
// auth check command of its own.
|
||||
assert!(auth_check_command_for(Harness::OpenCode).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_check_command_for_unknown_is_none() {
|
||||
// Harness::Unknown causes harness_kind to return Err; the helper still
|
||||
// returns None instead of panicking.
|
||||
assert!(auth_check_command_for(Harness::Unknown).is_none());
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use serde_json::{json, Value};
|
||||
use strum_macros::{EnumDiscriminants, EnumIter};
|
||||
use galaxy_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
|
||||
|
||||
/// Telemetry events emitted by the third-party harness runtime layer.
|
||||
#[derive(Debug, EnumDiscriminants)]
|
||||
#[strum_discriminants(derive(EnumIter))]
|
||||
pub(crate) enum ThirdPartyHarnessTelemetryEvent {
|
||||
/// The runtime output scanner observed one of the harness's known
|
||||
/// failure substrings. Fires once per detection, before any suppression
|
||||
/// logic, so dashboards can compare raw trigger volume vs. detections
|
||||
/// that actually fail the run.
|
||||
RuntimeErrorDetected {
|
||||
/// CLI command prefix for the harness whose block was scanned
|
||||
/// (e.g. `"claude"`, `"codex"`).
|
||||
harness: String,
|
||||
/// The originating needle from `runtime_error_patterns` that hit.
|
||||
pattern: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl TelemetryEvent for ThirdPartyHarnessTelemetryEvent {
|
||||
fn name(&self) -> &'static str {
|
||||
ThirdPartyHarnessTelemetryEventDiscriminants::from(self).name()
|
||||
}
|
||||
|
||||
fn payload(&self) -> Option<Value> {
|
||||
match self {
|
||||
ThirdPartyHarnessTelemetryEvent::RuntimeErrorDetected { harness, pattern } => {
|
||||
Some(json!({
|
||||
"harness": harness,
|
||||
"pattern": pattern,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
ThirdPartyHarnessTelemetryEventDiscriminants::from(self).description()
|
||||
}
|
||||
|
||||
fn enablement_state(&self) -> EnablementState {
|
||||
ThirdPartyHarnessTelemetryEventDiscriminants::from(self).enablement_state()
|
||||
}
|
||||
|
||||
fn contains_ugc(&self) -> bool {
|
||||
match self {
|
||||
ThirdPartyHarnessTelemetryEvent::RuntimeErrorDetected { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn event_descs() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>> {
|
||||
galaxy_core::telemetry::enum_events::<Self>()
|
||||
}
|
||||
}
|
||||
|
||||
impl TelemetryEventDesc for ThirdPartyHarnessTelemetryEventDiscriminants {
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
ThirdPartyHarnessTelemetryEventDiscriminants::RuntimeErrorDetected => {
|
||||
"AmbientAgents.ThirdPartyHarness.RuntimeError.Detected"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
match self {
|
||||
ThirdPartyHarnessTelemetryEventDiscriminants::RuntimeErrorDetected => {
|
||||
"Runtime output scanner detected a known failure substring in a third-party \
|
||||
harness block."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn enablement_state(&self) -> EnablementState {
|
||||
match self {
|
||||
ThirdPartyHarnessTelemetryEventDiscriminants::RuntimeErrorDetected => {
|
||||
EnablementState::Always
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
galaxy_core::register_telemetry_event!(ThirdPartyHarnessTelemetryEvent);
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Background monitor that scans the running harness block for known runtime
|
||||
//! failure substrings (e.g. invalid API key, exhausted credits) and reports
|
||||
//! the first hit so the driver can fail the task fast instead of letting the
|
||||
//! harness hang.
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use regex::escape;
|
||||
use warpui::ModelSpawner;
|
||||
|
||||
use super::terminal::BlockOutputMatch;
|
||||
use super::AgentDriver;
|
||||
use crate::terminal::cli_agent_sessions::CLIAgentSessionStatus;
|
||||
use crate::terminal::model::block::BlockId;
|
||||
use crate::terminal::model::find::RegexDFAs;
|
||||
|
||||
const SCAN_INTERVALS: &[Duration] = &[
|
||||
Duration::from_secs(5),
|
||||
Duration::from_secs(5),
|
||||
Duration::from_secs(5),
|
||||
Duration::from_secs(5),
|
||||
Duration::from_secs(5),
|
||||
Duration::from_secs(5),
|
||||
Duration::from_secs(15),
|
||||
Duration::from_secs(15),
|
||||
Duration::from_secs(15),
|
||||
Duration::from_secs(15),
|
||||
];
|
||||
|
||||
const STALL_POLL_INTERVAL: Duration = Duration::from_secs(10);
|
||||
|
||||
const STALL_CONFIRMATION_BUDGET: Duration = Duration::from_secs(60);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct DetectedHarnessError {
|
||||
pub pattern: String,
|
||||
pub excerpt: String,
|
||||
}
|
||||
|
||||
/// Build a combined case-insensitive DFA from the harness's static patterns.
|
||||
pub(crate) fn build_dfas(patterns: &[&'static str]) -> Option<RegexDFAs> {
|
||||
if patterns.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let escaped: Vec<String> = patterns.iter().map(|p| escape(p)).collect();
|
||||
let refs: Vec<&str> = escaped.iter().map(String::as_str).collect();
|
||||
match RegexDFAs::new_many(
|
||||
&refs, false, // enable_unicode_word_boundary
|
||||
false, // case_sensitive
|
||||
) {
|
||||
Ok(dfas) => Some(dfas),
|
||||
Err(err) => {
|
||||
log::warn!("Failed to build harness output DFAs: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a `matched_text` produced by the combined DFA back to the originating
|
||||
/// needle. The DFA matched `(p1|p2|…)`, so the matched substring is exactly
|
||||
/// one of `patterns` up to case — we lowercase-compare to identify it.
|
||||
pub(crate) fn pattern_for_match(
|
||||
matched_text: &str,
|
||||
patterns: &[&'static str],
|
||||
) -> Option<&'static str> {
|
||||
let matched_lower = matched_text.to_lowercase();
|
||||
patterns
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|p| p.to_lowercase() == matched_lower)
|
||||
}
|
||||
|
||||
fn outputs_stalled(before: Option<&str>, after: Option<&str>) -> bool {
|
||||
matches!((before, after), (Some(a), Some(b)) if a == b)
|
||||
}
|
||||
|
||||
async fn find_match_once(
|
||||
block_id: &BlockId,
|
||||
dfas: &Arc<RegexDFAs>,
|
||||
foreground: &ModelSpawner<AgentDriver>,
|
||||
) -> Option<BlockOutputMatch> {
|
||||
let block_id_for_tick = block_id.clone();
|
||||
let dfas_for_tick = Arc::clone(dfas);
|
||||
foreground
|
||||
.spawn(move |me, ctx| {
|
||||
me.terminal_driver
|
||||
.as_ref(ctx)
|
||||
.find_first_match_in_block_output(&block_id_for_tick, &dfas_for_tick, ctx)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
async fn fetch_plaintext(
|
||||
block_id: &BlockId,
|
||||
foreground: &ModelSpawner<AgentDriver>,
|
||||
) -> Option<String> {
|
||||
let block_id_for_tick = block_id.clone();
|
||||
foreground
|
||||
.spawn(move |me, ctx| {
|
||||
me.terminal_driver
|
||||
.as_ref(ctx)
|
||||
.block_output_plaintext(&block_id_for_tick, ctx)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Run the stall-confirmation loop after a pattern hit.
|
||||
async fn confirm_stall(
|
||||
block_id: &BlockId,
|
||||
dfas: &Arc<RegexDFAs>,
|
||||
foreground: &ModelSpawner<AgentDriver>,
|
||||
) -> (Option<BlockOutputMatch>, Duration) {
|
||||
let mut previous = fetch_plaintext(block_id, foreground).await;
|
||||
let mut elapsed = Duration::ZERO;
|
||||
while elapsed < STALL_CONFIRMATION_BUDGET {
|
||||
warpui::r#async::Timer::after(STALL_POLL_INTERVAL).await;
|
||||
elapsed += STALL_POLL_INTERVAL;
|
||||
let current = fetch_plaintext(block_id, foreground).await;
|
||||
if outputs_stalled(previous.as_deref(), current.as_deref()) {
|
||||
// Output settled. Re-run the DFA so we report the post-dwell
|
||||
// match in case the row positions shifted while we waited.
|
||||
return (find_match_once(block_id, dfas, foreground).await, elapsed);
|
||||
}
|
||||
previous = current;
|
||||
}
|
||||
(None, elapsed)
|
||||
}
|
||||
|
||||
/// Watch the given block for harness output errors on the
|
||||
/// [`SCAN_INTERVALS`] cadence.
|
||||
///
|
||||
/// On every pattern hit, run a stall-confirmation loop (up to
|
||||
/// [`STALL_CONFIRMATION_BUDGET`]) and only resolve with
|
||||
/// `Some(DetectedHarnessError)` when the harness output stabilizes with
|
||||
/// the pattern still present. If the harness keeps producing output
|
||||
/// (e.g. spinner frames during an automatic retry), the detection is
|
||||
/// dropped and the scanner resumes normal polling.
|
||||
///
|
||||
/// Returns `None` when the schedule completes without a confirmed hit
|
||||
/// (or when `patterns` is empty / DFA construction fails).
|
||||
/// Cancellation-safe: dropping the future stops both loops.
|
||||
pub(crate) async fn watch_block_for_errors(
|
||||
block_id: BlockId,
|
||||
patterns: &'static [&'static str],
|
||||
foreground: &ModelSpawner<AgentDriver>,
|
||||
) -> Option<DetectedHarnessError> {
|
||||
if patterns.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let dfas = Arc::new(build_dfas(patterns)?);
|
||||
|
||||
// Total observation budget = sum of all scan intervals. Used as an
|
||||
// early-exit guard after stall confirmation in case a flaky harness
|
||||
// has burned most of the window into confirmation loops.
|
||||
let total_budget: Duration = SCAN_INTERVALS.iter().copied().sum();
|
||||
let mut elapsed = Duration::ZERO;
|
||||
|
||||
for &interval in SCAN_INTERVALS {
|
||||
warpui::r#async::Timer::after(interval).await;
|
||||
elapsed += interval;
|
||||
|
||||
if find_match_once(&block_id, &dfas, foreground)
|
||||
.await
|
||||
.is_none()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Candidate match observed. Confirm via the stall loop so we
|
||||
// don't false-positive while the harness is mid-retry.
|
||||
let (confirmed, confirmation_elapsed) = confirm_stall(&block_id, &dfas, foreground).await;
|
||||
elapsed += confirmation_elapsed;
|
||||
|
||||
let Some(hit) = confirmed else {
|
||||
if elapsed >= total_budget {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
|
||||
// The DFA matched one of our needles, so `pattern_for_match`
|
||||
// should always resolve. Fall back to the matched substring
|
||||
// verbatim if it somehow doesn't.
|
||||
let pattern = pattern_for_match(&hit.matched_text, patterns)
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| hit.matched_text.clone());
|
||||
return Some(DetectedHarnessError {
|
||||
pattern,
|
||||
excerpt: cap_excerpt(&hit.excerpt),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn should_suppress_runtime_failure(status: Option<&CLIAgentSessionStatus>) -> bool {
|
||||
matches!(status, Some(CLIAgentSessionStatus::Success))
|
||||
}
|
||||
|
||||
/// Cap excerpt length so we don't blow up status messages or logs with
|
||||
/// terminal-width rows.
|
||||
const EXCERPT_MAX_LEN: usize = 240;
|
||||
fn cap_excerpt(excerpt: &str) -> String {
|
||||
if excerpt.chars().count() <= EXCERPT_MAX_LEN {
|
||||
return excerpt.to_owned();
|
||||
}
|
||||
let mut out: String = excerpt.chars().take(EXCERPT_MAX_LEN).collect();
|
||||
out.push('…');
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "harness_output_monitor_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,79 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use super::{outputs_stalled, pattern_for_match, STALL_CONFIRMATION_BUDGET, STALL_POLL_INTERVAL};
|
||||
|
||||
#[test]
|
||||
fn pattern_for_match_returns_originating_needle() {
|
||||
let patterns: &[&'static str] = &["credit balance is too low", "invalid_api_key"];
|
||||
// The DFA gives us the matched substring (e.g. lifted from the grid as
|
||||
// mixed case); we must map back to the original `'static` needle.
|
||||
let resolved = pattern_for_match("Credit Balance Is Too Low", patterns);
|
||||
assert_eq!(resolved, Some("credit balance is too low"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_for_match_is_case_insensitive() {
|
||||
let patterns: &[&'static str] = &["INVALID_API_KEY"];
|
||||
let resolved = pattern_for_match("invalid_api_key", patterns);
|
||||
assert_eq!(resolved, Some("INVALID_API_KEY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_for_match_returns_none_when_no_match() {
|
||||
let patterns: &[&'static str] = &["needle a", "needle b"];
|
||||
assert!(pattern_for_match("entirely different text", patterns).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_for_match_picks_first_matching_needle() {
|
||||
// When two needles lowercase-equal the same matched text (degenerate
|
||||
// case), we deterministically return the first one in the slice.
|
||||
let patterns: &[&'static str] = &["Foo", "foo"];
|
||||
let resolved = pattern_for_match("FOO", patterns);
|
||||
assert_eq!(resolved, Some("Foo"));
|
||||
}
|
||||
|
||||
// --- outputs_stalled / stall confirmation ---
|
||||
|
||||
#[test]
|
||||
fn outputs_stalled_returns_true_when_inputs_equal() {
|
||||
let snapshot = "Error: credit balance is too low\n";
|
||||
assert!(outputs_stalled(Some(snapshot), Some(snapshot)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outputs_stalled_returns_false_when_inputs_differ_by_any_byte() {
|
||||
// Spinner case: a single character differs between frames. The
|
||||
// confirmation loop must treat this as "still moving" and not
|
||||
// mistakenly declare the harness stalled.
|
||||
let before = "Retrying \u{2807}";
|
||||
let after = "Retrying \u{2826}";
|
||||
assert!(!outputs_stalled(Some(before), Some(after)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outputs_stalled_returns_false_when_either_input_is_none() {
|
||||
// A failed snapshot fetch must default to "not confirmed" rather than
|
||||
// killing the harness on a transient lookup error.
|
||||
assert!(!outputs_stalled(None, Some("data")));
|
||||
assert!(!outputs_stalled(Some("data"), None));
|
||||
assert!(!outputs_stalled(None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stall_confirmation_budget_matches_six_poll_intervals() {
|
||||
// The loop guarantees up to BUDGET/INTERVAL iterations. Pin the ratio
|
||||
// so a careless tweak to either constant can't accidentally turn the
|
||||
// confirmation into a single comparison or extend it indefinitely.
|
||||
assert_eq!(
|
||||
STALL_CONFIRMATION_BUDGET.as_secs() / STALL_POLL_INTERVAL.as_secs(),
|
||||
6
|
||||
);
|
||||
// And that they're cleanly divisible (no leftover sub-interval window).
|
||||
assert_eq!(
|
||||
STALL_CONFIRMATION_BUDGET.as_secs() % STALL_POLL_INTERVAL.as_secs(),
|
||||
0
|
||||
);
|
||||
// Sanity: STALL_POLL_INTERVAL must be non-zero or the loop would spin.
|
||||
assert!(STALL_POLL_INTERVAL > Duration::ZERO);
|
||||
}
|
||||
@@ -1,26 +1,22 @@
|
||||
pub mod text {
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
fmt,
|
||||
io::{self, Write},
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use std::fmt;
|
||||
use std::io::{self, Write};
|
||||
|
||||
const CANCELLED_MESSAGE: &str = "<cancelled>";
|
||||
|
||||
use ai::agent::action_result::{FetchConversationResult, ReadSkillResult, UseComputerResult};
|
||||
use itertools::Itertools;
|
||||
|
||||
use crate::{
|
||||
ai::agent::{
|
||||
AIAgentActionType, AIAgentInput, AIAgentOutput, AIAgentOutputMessageType, AIAgentTodo,
|
||||
ArtifactCreatedData, CallMCPToolResult, FileGlobResult, FileGlobV2Result, GrepResult,
|
||||
ReadFilesResult, ReadMCPResourceResult, RequestCommandOutputResult,
|
||||
RequestFileEditsResult, SearchCodebaseResult, SuggestNewConversationResult,
|
||||
SuggestPromptResult, TodoOperation, UploadArtifactResult, WebFetchStatus,
|
||||
WebSearchStatus, WriteToLongRunningShellCommandResult,
|
||||
},
|
||||
AIAgentActionResultType,
|
||||
use crate::ai::agent::{
|
||||
AIAgentActionType, AIAgentInput, AIAgentOutput, AIAgentOutputMessageType, AIAgentTodo,
|
||||
ArtifactCreatedData, CallMCPToolResult, FileGlobResult, FileGlobV2Result, GrepResult,
|
||||
ReadFilesResult, ReadMCPResourceResult, RequestCommandOutputResult, RequestFileEditsResult,
|
||||
SearchCodebaseResult, SuggestNewConversationResult, SuggestPromptResult, TodoOperation,
|
||||
UploadArtifactResult, WebFetchStatus, WebSearchStatus,
|
||||
WriteToLongRunningShellCommandResult,
|
||||
};
|
||||
use crate::AIAgentActionResultType;
|
||||
|
||||
/// Format an agent input as a human-readable string. For action results, it's assumed that
|
||||
/// the action is shown immediately before this result.
|
||||
@@ -42,7 +38,8 @@ pub mod text {
|
||||
| AIAgentInput::StartFromAmbientRunPrompt { .. }
|
||||
| AIAgentInput::MessagesReceivedFromAgents { .. }
|
||||
| AIAgentInput::PassiveSuggestionResult { .. }
|
||||
| AIAgentInput::EventsFromAgents { .. } => {
|
||||
| AIAgentInput::EventsFromAgents { .. }
|
||||
| AIAgentInput::OrchestrationConfigUpdate { .. } => {
|
||||
// Do not include the user query, since it's already provided as input to the agent.
|
||||
Ok(())
|
||||
}
|
||||
@@ -303,6 +300,10 @@ pub mod text {
|
||||
// SendMessageToAgent is a client-side orchestration action, not used in SDK
|
||||
AIAgentActionResultType::SendMessageToAgent(_) => Ok(()),
|
||||
AIAgentActionResultType::AskUserQuestion(_) => Ok(()),
|
||||
// RunAgents is a desktop-client-only action; not used in the SDK.
|
||||
AIAgentActionResultType::RunAgents(_) => Ok(()),
|
||||
// No user-visible payload to emit.
|
||||
AIAgentActionResultType::WaitForEvents(_) => Ok(()),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -427,6 +428,9 @@ pub mod text {
|
||||
)?;
|
||||
}
|
||||
AIAgentActionType::AskUserQuestion { .. } => (),
|
||||
// RunAgents is desktop-client-only; SDK driver renders nothing.
|
||||
AIAgentActionType::RunAgents(_) => (),
|
||||
AIAgentActionType::WaitForEvents { .. } => (),
|
||||
},
|
||||
AIAgentOutputMessageType::TodoOperation(operation) => match operation {
|
||||
TodoOperation::UpdateTodos { todos } => {
|
||||
@@ -559,26 +563,20 @@ pub mod text {
|
||||
}
|
||||
|
||||
pub mod json {
|
||||
use crate::{
|
||||
ai::agent::{
|
||||
AIAgentActionType, AIAgentInput, AIAgentOutput, AIAgentOutputMessage,
|
||||
AIAgentOutputMessageType, AIAgentTodo, ArtifactCreatedData, CallMCPToolResult,
|
||||
FileContext, FileGlobResult, FileGlobV2Result, GrepResult, ReadFilesResult,
|
||||
ReadMCPResourceResult, RequestCommandOutputResult, RequestFileEditsResult,
|
||||
SearchCodebaseResult, SubagentCall, TodoOperation, UploadArtifactResult,
|
||||
WriteToLongRunningShellCommandResult,
|
||||
},
|
||||
AIAgentActionResultType,
|
||||
};
|
||||
use std::borrow::Cow;
|
||||
use std::ops::Range;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::ai::agent::comment::ReviewComment;
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
io::{self, Write},
|
||||
ops::Range,
|
||||
use crate::ai::agent::{
|
||||
AIAgentActionType, AIAgentInput, AIAgentOutput, AIAgentOutputMessage,
|
||||
AIAgentOutputMessageType, AIAgentTodo, ArtifactCreatedData, CallMCPToolResult, FileContext,
|
||||
FileGlobResult, FileGlobV2Result, GrepResult, ReadFilesResult, ReadMCPResourceResult,
|
||||
RequestCommandOutputResult, RequestFileEditsResult, SearchCodebaseResult, SubagentCall,
|
||||
TodoOperation, UploadArtifactResult, WriteToLongRunningShellCommandResult,
|
||||
};
|
||||
use crate::code::buffer_location::LocalOrRemotePath;
|
||||
|
||||
/// JSON representation of messages in an agent conversation. This is intentionally not 1:1 with our internal `AIAgent*` types - it's
|
||||
/// a stable interface for callers.
|
||||
@@ -742,7 +740,7 @@ pub mod json {
|
||||
#[derive(Serialize)]
|
||||
struct JsonComment<'a> {
|
||||
comment_text: &'a str,
|
||||
file_path: Option<&'a Path>,
|
||||
file_path: Option<String>,
|
||||
line_number: Option<usize>,
|
||||
head_title: Option<&'a str>,
|
||||
}
|
||||
@@ -791,7 +789,8 @@ pub mod json {
|
||||
| AIAgentInput::StartFromAmbientRunPrompt { .. }
|
||||
| AIAgentInput::MessagesReceivedFromAgents { .. }
|
||||
| AIAgentInput::EventsFromAgents { .. }
|
||||
| AIAgentInput::PassiveSuggestionResult { .. } => None,
|
||||
| AIAgentInput::PassiveSuggestionResult { .. }
|
||||
| AIAgentInput::OrchestrationConfigUpdate { .. } => None,
|
||||
// These input types should not occur in a SDK-run agent.
|
||||
AIAgentInput::ResumeConversation { .. }
|
||||
| AIAgentInput::TriggerPassiveSuggestion { .. } => None,
|
||||
@@ -1108,6 +1107,10 @@ pub mod json {
|
||||
| AIAgentActionType::SendMessageToAgent { .. }
|
||||
| AIAgentActionType::TransferShellCommandControlToUser { .. } => None,
|
||||
AIAgentActionType::AskUserQuestion { .. } => None,
|
||||
// RunAgents is desktop-client-only; SDK has no JSON
|
||||
// representation for it.
|
||||
AIAgentActionType::RunAgents(_) => None,
|
||||
AIAgentActionType::WaitForEvents { .. } => None,
|
||||
},
|
||||
AIAgentOutputMessageType::TodoOperation(operation) => match operation {
|
||||
TodoOperation::UpdateTodos { todos } => Some(JsonMessage::UpdateTodos {
|
||||
@@ -1169,7 +1172,11 @@ pub mod json {
|
||||
fn from(review_comment: &'a ReviewComment) -> Self {
|
||||
Self {
|
||||
comment_text: review_comment.content.as_str(),
|
||||
file_path: review_comment.diff.file_path.as_deref(),
|
||||
file_path: review_comment
|
||||
.diff
|
||||
.file_path
|
||||
.as_ref()
|
||||
.map(LocalOrRemotePath::display_path),
|
||||
line_number: review_comment.diff.line_number,
|
||||
head_title: review_comment.head_title.as_deref(),
|
||||
}
|
||||
@@ -1286,10 +1293,12 @@ pub mod json {
|
||||
}
|
||||
}
|
||||
|
||||
use std::io::{self, BufWriter, Write};
|
||||
|
||||
use galaxy_core::channel::ChannelState;
|
||||
|
||||
use crate::ai::agent::{AIAgentText, AIAgentTextSection};
|
||||
use crate::code::editor_management::CodeSource;
|
||||
use galaxy_core::channel::ChannelState;
|
||||
use std::io::{self, BufWriter, Write};
|
||||
|
||||
/// Constructs the Oz dashboard URL for a given run ID.
|
||||
fn run_url(run_id: &str) -> String {
|
||||
@@ -1328,8 +1337,8 @@ fn format_agent_text<W: Write>(text: &AIAgentText, w: &mut W) -> io::Result<()>
|
||||
}
|
||||
|
||||
match source {
|
||||
Some(CodeSource::ProjectRules { path }) => {
|
||||
writeln!(w, " rules_path={}", path.display())?;
|
||||
Some(CodeSource::ProjectRules { location }) => {
|
||||
writeln!(w, " rules_path={}", location.display_path())?;
|
||||
}
|
||||
Some(CodeSource::Link {
|
||||
path,
|
||||
@@ -1348,12 +1357,13 @@ fn format_agent_text<W: Write>(text: &AIAgentText, w: &mut W) -> io::Result<()>
|
||||
|
||||
writeln!(w)?;
|
||||
}
|
||||
Some(CodeSource::Skill { path, .. }) => {
|
||||
writeln!(w, " skill_path={}", path.display())?;
|
||||
Some(CodeSource::Skill { location, .. }) => {
|
||||
writeln!(w, " skill_path={}", location.display_path())?;
|
||||
}
|
||||
Some(CodeSource::AIAction { .. })
|
||||
| Some(CodeSource::New { .. })
|
||||
| Some(CodeSource::FileTree { .. })
|
||||
| Some(CodeSource::CommandPalette { .. })
|
||||
| Some(CodeSource::Finder { .. })
|
||||
| None => {}
|
||||
}
|
||||
|
||||
@@ -30,15 +30,23 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context as _, Result};
|
||||
use command::r#async::Command;
|
||||
use command::Stdio;
|
||||
use futures::future::join_all;
|
||||
use tokio::fs::{self as tokio_fs, OpenOptions};
|
||||
use tokio::io::AsyncWriteExt as _;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use galaxy_core::report_error;
|
||||
use galaxyui::r#async::executor::Background;
|
||||
use galaxyui::r#async::FutureExt as _;
|
||||
|
||||
use crate::ai::agent_sdk::retry::with_bounded_retry;
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::server::server_api::ai::{
|
||||
AIClient, InitialSnapshotToken, SnapshotUploadFileInfo as AiSnapshotUploadFileInfo,
|
||||
UploadLocalHandoffSnapshotRequest,
|
||||
};
|
||||
use crate::server::server_api::harness_support::{
|
||||
upload_to_target, HarnessSupportClient, SnapshotFileInfo, SnapshotUploadRequest, UploadTarget,
|
||||
};
|
||||
@@ -99,6 +107,15 @@ struct DeclarationLine {
|
||||
path: String,
|
||||
}
|
||||
|
||||
/// Serialize-only sibling of [`DeclarationLine`] used by the writer task to emit `file`
|
||||
/// entries with a fixed `version` and `kind`.
|
||||
#[derive(serde::Serialize)]
|
||||
struct FileDeclaration<'a> {
|
||||
version: u32,
|
||||
kind: &'a str,
|
||||
path: &'a str,
|
||||
}
|
||||
|
||||
/// Invoke `snapshot-declarations.sh` to (re)generate the declarations file consumed by the
|
||||
/// rest of the upload pipeline.
|
||||
///
|
||||
@@ -107,10 +124,11 @@ struct DeclarationLine {
|
||||
/// from `task_id`. The script appends to the file if it already exists, so
|
||||
/// repeated invocations within a single run accumulate repos instead of clobbering.
|
||||
///
|
||||
/// A missing env var, a missing script, a non-zero exit status, a spawn failure, or a runtime
|
||||
/// exceeding `script_timeout` are each logged at `log::error!` and returned without aborting the
|
||||
/// caller — if a previous invocation already produced a declarations file on disk it remains
|
||||
/// usable; otherwise the upload pipeline becomes a no-op.
|
||||
/// A missing env var is expected in some paths and is logged at `log::info!`. A missing script,
|
||||
/// a non-zero exit status, a spawn failure, or a runtime exceeding `script_timeout` are each
|
||||
/// logged at `log::error!` and returned without aborting the caller — if a previous invocation
|
||||
/// already produced a declarations file on disk it remains usable; otherwise the upload pipeline
|
||||
/// becomes a no-op.
|
||||
///
|
||||
/// Exposed as a standalone helper so future call sites can trigger declarations generation at
|
||||
/// other points in the run lifecycle (e.g. periodic mid-run snapshots).
|
||||
@@ -120,7 +138,7 @@ pub(super) async fn run_declarations_script(
|
||||
script_timeout: Duration,
|
||||
) {
|
||||
let Some(script_path) = std::env::var_os(DECLARATIONS_SCRIPT_PATH_ENV_VAR) else {
|
||||
log::error!(
|
||||
log::info!(
|
||||
"{DECLARATIONS_SCRIPT_PATH_ENV_VAR} is not set; skipping snapshot declarations script (task {task_id})"
|
||||
);
|
||||
return;
|
||||
@@ -199,7 +217,7 @@ fn resolve_declarations_path(task_id: Option<&AmbientAgentTaskId>) -> PathBuf {
|
||||
/// 3. `{DEFAULT_DECLARATIONS_DIR}/{DEFAULT_DECLARATIONS_FILENAME}` as a final fallback.
|
||||
fn resolve_declarations_path_with_override(
|
||||
task_id: Option<&AmbientAgentTaskId>,
|
||||
override_path: Option<std::ffi::OsString>,
|
||||
override_path: Option<OsString>,
|
||||
) -> PathBuf {
|
||||
if let Some(override_path) = override_path {
|
||||
return PathBuf::from(override_path);
|
||||
@@ -216,31 +234,28 @@ fn resolve_declarations_path_with_override(
|
||||
///
|
||||
/// Returns `None` when the file is missing, unreadable, or yields no valid entries; logs a
|
||||
/// WARN describing why in each case. A returned `Some(entries)` is guaranteed non-empty.
|
||||
fn read_and_parse_declarations(
|
||||
path: &Path,
|
||||
task_id: &AmbientAgentTaskId,
|
||||
) -> Option<Vec<DeclarationEntry>> {
|
||||
fn read_and_parse_declarations(path: &Path) -> Option<Vec<DeclarationEntry>> {
|
||||
let contents = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
log::warn!(
|
||||
"Snapshot declarations file not found at '{}'; skipping upload (task {task_id})",
|
||||
"Snapshot declarations file not found at '{}'; skipping upload",
|
||||
path.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to read snapshot declarations file '{}': {e:#}; skipping upload (task {task_id})",
|
||||
"Failed to read snapshot declarations file '{}': {e:#}; skipping upload",
|
||||
path.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let entries = parse_declarations(&contents, task_id);
|
||||
let entries = parse_declarations(&contents);
|
||||
if entries.is_empty() {
|
||||
log::warn!(
|
||||
"Snapshot declarations file '{}' has no valid entries; skipping upload (task {task_id})",
|
||||
"Snapshot declarations file '{}' has no valid entries; skipping upload",
|
||||
path.display()
|
||||
);
|
||||
return None;
|
||||
@@ -254,7 +269,7 @@ fn read_and_parse_declarations(
|
||||
/// and `path` (absolute path). Blank lines are ignored. Malformed lines (invalid JSON, missing
|
||||
/// fields, unsupported versions, unknown kind, non-absolute path) are logged at WARN and skipped;
|
||||
/// they never abort parsing.
|
||||
fn parse_declarations(contents: &str, task_id: &AmbientAgentTaskId) -> Vec<DeclarationEntry> {
|
||||
fn parse_declarations(contents: &str) -> Vec<DeclarationEntry> {
|
||||
let mut entries = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for (index, raw) in contents.lines().enumerate() {
|
||||
@@ -267,26 +282,26 @@ fn parse_declarations(contents: &str, task_id: &AmbientAgentTaskId) -> Vec<Decla
|
||||
Ok(declaration) => declaration,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Malformed snapshot declarations JSONL line {line_number}: {e:#}: {raw:?} (task {task_id})"
|
||||
"Malformed snapshot declarations JSONL line {line_number}: {e:#}: {raw:?}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if declaration.version != Some(DECLARATION_VERSION) {
|
||||
log::warn!(
|
||||
"Malformed snapshot declarations line {line_number} (missing or unsupported version): {raw:?} (task {task_id})"
|
||||
"Malformed snapshot declarations line {line_number} (missing or unsupported version): {raw:?}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if declaration.path.is_empty() {
|
||||
log::warn!(
|
||||
"Malformed snapshot declarations line {line_number} (missing path): {raw:?} (task {task_id})"
|
||||
"Malformed snapshot declarations line {line_number} (missing path): {raw:?}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if !Path::new(&declaration.path).is_absolute() {
|
||||
log::warn!(
|
||||
"Malformed snapshot declarations line {line_number} (non-absolute path): {raw:?} (task {task_id})"
|
||||
"Malformed snapshot declarations line {line_number} (non-absolute path): {raw:?}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -295,7 +310,7 @@ fn parse_declarations(contents: &str, task_id: &AmbientAgentTaskId) -> Vec<Decla
|
||||
"file" => EntryKind::File,
|
||||
other => {
|
||||
log::warn!(
|
||||
"Malformed snapshot declarations line {line_number} (unknown kind '{other}'): {raw:?} (task {task_id})"
|
||||
"Malformed snapshot declarations line {line_number} (unknown kind '{other}'): {raw:?}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -311,6 +326,243 @@ fn parse_declarations(contents: &str, task_id: &AmbientAgentTaskId) -> Vec<Decla
|
||||
entries
|
||||
}
|
||||
|
||||
/// Drop `file` declarations whose path is already covered by a declared `repo` path so the
|
||||
/// gather step does not double-upload files the repo patch already carries.
|
||||
fn drop_files_covered_by_repos(entries: Vec<DeclarationEntry>) -> Vec<DeclarationEntry> {
|
||||
let repo_paths: Vec<PathBuf> = entries
|
||||
.iter()
|
||||
.filter(|entry| entry.kind == EntryKind::Repo)
|
||||
.map(|entry| PathBuf::from(&entry.path))
|
||||
.collect();
|
||||
if repo_paths.is_empty() {
|
||||
return entries;
|
||||
}
|
||||
entries
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
if entry.kind != EntryKind::File {
|
||||
return true;
|
||||
}
|
||||
let file_path = Path::new(&entry.path);
|
||||
for repo in &repo_paths {
|
||||
if file_path.starts_with(repo) {
|
||||
log::info!(
|
||||
"Dropping file declaration '{}' covered by repo '{}'",
|
||||
entry.path,
|
||||
repo.display()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// --- Declarations writer: SDK driver → declarations file ---
|
||||
|
||||
/// Commands accepted by the async declarations writer task.
|
||||
enum WriterCommand {
|
||||
/// Append `file` entries for the given paths to the declarations file.
|
||||
Append(Vec<String>),
|
||||
/// Acknowledge once every previously-queued command has finished its fs writes.
|
||||
Flush(oneshot::Sender<()>),
|
||||
}
|
||||
|
||||
/// Handle used by the SDK driver to enqueue `file` declaration appends from the subscription
|
||||
/// thread without ever touching the filesystem inline.
|
||||
///
|
||||
/// The handle owns an unbounded `mpsc` sender into a dedicated writer task spawned by
|
||||
/// [`DeclarationsWriterHandle::new`]. The writer task owns the `seen: HashSet<String>` and
|
||||
/// the resolved declarations path, and processes commands sequentially, which serializes
|
||||
/// writes within the process. Handles are cheaply cloneable because the underlying sender is;
|
||||
/// dropping every handle closes the channel and lets the writer task exit cleanly.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct DeclarationsWriterHandle {
|
||||
tx: mpsc::UnboundedSender<WriterCommand>,
|
||||
}
|
||||
|
||||
impl DeclarationsWriterHandle {
|
||||
/// Spawn the writer task on `background` and return a fire-and-forget handle.
|
||||
pub(super) fn new(
|
||||
task_id: AmbientAgentTaskId,
|
||||
working_dir: PathBuf,
|
||||
background: &Background,
|
||||
) -> Self {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let declarations_path = resolve_declarations_path(Some(&task_id));
|
||||
background
|
||||
.spawn(writer_task(rx, declarations_path, working_dir, task_id))
|
||||
.detach();
|
||||
Self { tx }
|
||||
}
|
||||
|
||||
/// Test-facing constructor that bypasses env-var-dependent path resolution and uses
|
||||
/// `tokio::spawn` directly so tests can run without standing up a `Background`.
|
||||
#[cfg(all(test, not(windows)))]
|
||||
pub(super) fn new_at_path(
|
||||
declarations_path: PathBuf,
|
||||
working_dir: PathBuf,
|
||||
task_id: AmbientAgentTaskId,
|
||||
) -> Self {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
tokio::spawn(writer_task(rx, declarations_path, working_dir, task_id));
|
||||
Self { tx }
|
||||
}
|
||||
|
||||
/// Enqueue `paths` for appending as `file` entries.
|
||||
///
|
||||
/// Non-blocking; the subscription handler can call this from a sync context. Empty
|
||||
/// input is a no-op.
|
||||
pub(super) fn append(&self, paths: Vec<String>) {
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = self.tx.send(WriterCommand::Append(paths)) {
|
||||
log::warn!("Declarations writer channel closed; dropping append: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Awaits until every previously-queued `append` has finished its fs writes.
|
||||
///
|
||||
/// Called once from `AgentDriver::run_snapshot_upload` immediately before
|
||||
/// `snapshot::run_declarations_script`, so no driver-side write is in flight when the
|
||||
/// bash script starts its own appends.
|
||||
pub(super) async fn flush(&self) {
|
||||
let (ack_tx, ack_rx) = oneshot::channel();
|
||||
if self.tx.send(WriterCommand::Flush(ack_tx)).is_err() {
|
||||
// Writer task has already exited; nothing is queued, nothing to drain.
|
||||
return;
|
||||
}
|
||||
if ack_rx.await.is_err() {
|
||||
log::warn!("Declarations writer flush oneshot dropped without ack");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writer task loop: owns the `seen` set, lazily opens the file per write, and services
|
||||
/// `Append` and `Flush` commands in order.
|
||||
async fn writer_task(
|
||||
mut rx: mpsc::UnboundedReceiver<WriterCommand>,
|
||||
declarations_path: PathBuf,
|
||||
working_dir: PathBuf,
|
||||
task_id: AmbientAgentTaskId,
|
||||
) {
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
while let Some(cmd) = rx.recv().await {
|
||||
match cmd {
|
||||
WriterCommand::Append(paths) => {
|
||||
for path in paths {
|
||||
process_append_path(
|
||||
path,
|
||||
&declarations_path,
|
||||
&working_dir,
|
||||
&task_id,
|
||||
&mut seen,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
WriterCommand::Flush(ack) => {
|
||||
let _ = ack.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize, preempt against existing repos, and write one JSONL line for `raw_path`.
|
||||
/// All failures log at WARN and return without advancing `seen`.
|
||||
async fn process_append_path(
|
||||
raw_path: String,
|
||||
declarations_path: &Path,
|
||||
working_dir: &Path,
|
||||
task_id: &AmbientAgentTaskId,
|
||||
seen: &mut HashSet<String>,
|
||||
) {
|
||||
let candidate = Path::new(&raw_path);
|
||||
let absolute = if candidate.is_absolute() {
|
||||
candidate.to_path_buf()
|
||||
} else {
|
||||
working_dir.join(candidate)
|
||||
};
|
||||
if !absolute.is_absolute() {
|
||||
log::warn!(
|
||||
"Skipping non-absolute file-edit path {absolute:?} for declarations (task {task_id})"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let Some(absolute_str) = absolute.to_str().map(str::to_owned) else {
|
||||
log::warn!(
|
||||
"Skipping non-UTF-8 file-edit path {absolute:?} for declarations (task {task_id})"
|
||||
);
|
||||
return;
|
||||
};
|
||||
if seen.contains(&absolute_str) {
|
||||
return;
|
||||
}
|
||||
if path_is_under_existing_repo(&absolute).await {
|
||||
log::debug!(
|
||||
"Skipping file declaration for '{absolute_str}': already inside an existing git repo (task {task_id})"
|
||||
);
|
||||
seen.insert(absolute_str);
|
||||
return;
|
||||
}
|
||||
match append_declaration_line(declarations_path, &absolute_str).await {
|
||||
Ok(()) => {
|
||||
seen.insert(absolute_str);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to append file declaration for '{absolute_str}': {e:#} (task {task_id})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk ancestors of `path` and return `true` if any of them already contains a `.git`
|
||||
/// directory. Cheap enough to run per path: one `stat(2)` per ancestor up to `/`.
|
||||
async fn path_is_under_existing_repo(path: &Path) -> bool {
|
||||
let mut current = path.parent();
|
||||
while let Some(dir) = current {
|
||||
let git_dir = dir.join(".git");
|
||||
if tokio_fs::try_exists(&git_dir).await.unwrap_or(false) {
|
||||
return true;
|
||||
}
|
||||
current = dir.parent();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Open the declarations file in append-create mode and write one JSONL line for `path`.
|
||||
/// The serialized shape matches the schema the parser expects.
|
||||
async fn append_declaration_line(declarations_path: &Path, path: &str) -> Result<()> {
|
||||
if let Some(parent) = declarations_path.parent() {
|
||||
tokio_fs::create_dir_all(parent)
|
||||
.await
|
||||
.with_context(|| format!("create_dir_all {}", parent.display()))?;
|
||||
}
|
||||
let mut line = serde_json::to_string(&FileDeclaration {
|
||||
version: DECLARATION_VERSION,
|
||||
kind: "file",
|
||||
path,
|
||||
})
|
||||
.context("serialize file declaration")?;
|
||||
line.push('\n');
|
||||
let mut file = OpenOptions::new()
|
||||
.append(true)
|
||||
.create(true)
|
||||
.open(declarations_path)
|
||||
.await
|
||||
.with_context(|| format!("open declarations file {}", declarations_path.display()))?;
|
||||
file.write_all(line.as_bytes())
|
||||
.await
|
||||
.with_context(|| format!("write declarations file {}", declarations_path.display()))?;
|
||||
file.flush()
|
||||
.await
|
||||
.with_context(|| format!("flush declarations file {}", declarations_path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Gather phase: upload blobs and per-entry results ---
|
||||
|
||||
struct SnapshotUploadFile {
|
||||
@@ -447,7 +699,7 @@ pub(super) async fn upload_snapshot_from_declarations(
|
||||
task_id: &AmbientAgentTaskId,
|
||||
) {
|
||||
let declarations_path = resolve_declarations_path(Some(task_id));
|
||||
let _ = upload_snapshot_from_declarations_file(&declarations_path, client, task_id).await;
|
||||
let _ = upload_snapshot_from_declarations_file(&declarations_path, client).await;
|
||||
}
|
||||
|
||||
/// Internal entry that reads from an explicit path and returns the structured outcome so tests
|
||||
@@ -456,13 +708,10 @@ pub(super) async fn upload_snapshot_from_declarations(
|
||||
async fn upload_snapshot_from_declarations_file(
|
||||
path: &Path,
|
||||
client: Arc<dyn HarnessSupportClient>,
|
||||
task_id: &AmbientAgentTaskId,
|
||||
) -> Option<SnapshotOutcome> {
|
||||
log::info!(
|
||||
"Snapshot upload starting from {} (task {task_id})",
|
||||
path.display()
|
||||
);
|
||||
let declarations = read_and_parse_declarations(path, task_id)?;
|
||||
log::info!("Snapshot upload starting from {}", path.display());
|
||||
let declarations = read_and_parse_declarations(path)?;
|
||||
let declarations = drop_files_covered_by_repos(declarations);
|
||||
let (repo_count, file_count) = declarations
|
||||
.iter()
|
||||
.fold((0usize, 0usize), |(r, f), e| match e.kind {
|
||||
@@ -470,14 +719,149 @@ async fn upload_snapshot_from_declarations_file(
|
||||
EntryKind::File => (r, f + 1),
|
||||
});
|
||||
log::info!(
|
||||
"Snapshot declarations: {} entries ({repo_count} repo, {file_count} file) (task {task_id})",
|
||||
"Snapshot declarations: {} entries ({repo_count} repo, {file_count} file)",
|
||||
declarations.len(),
|
||||
);
|
||||
let outcome = run_pipeline(declarations, client, task_id).await?;
|
||||
log_snapshot_outcome(&outcome, task_id);
|
||||
let outcome = run_pipeline(declarations, client).await?;
|
||||
log_snapshot_outcome(&outcome);
|
||||
Some(outcome)
|
||||
}
|
||||
|
||||
/// Build the snapshot for a local-to-cloud handoff: gather repo patches and orphan file
|
||||
/// contents, allocate an initial snapshot token plus presigned upload URLs via
|
||||
/// `AIClient::upload_local_handoff_snapshot`, and upload the artifacts.
|
||||
///
|
||||
/// Returns:
|
||||
/// - `Ok(Some(initial_snapshot_token))` when a token was minted **and the manifest landed in GCS**.
|
||||
/// Individual blob uploads may still have failed; the manifest catalogues their status so the
|
||||
/// cloud agent rehydrates against whatever did land, matching the cloud→cloud best-effort
|
||||
/// posture.
|
||||
/// - `Ok(None)` when the workspace was empty (no repos, no orphan files) **or** when the
|
||||
/// manifest itself failed to upload. Without the manifest the snapshot is unusable, so
|
||||
/// callers should spawn the cloud agent without an initial snapshot token instead of pointing
|
||||
/// it at an incomplete prefix. Manifest-upload failures are also routed through
|
||||
/// `report_error!` so on-call alerting catches the silent regression.
|
||||
/// - `Err(_)` only for hard failures of `upload_local_handoff_snapshot` itself (auth, etc.).
|
||||
pub(crate) async fn upload_snapshot_for_handoff(
|
||||
repo_paths: Vec<PathBuf>,
|
||||
orphan_file_paths: Vec<PathBuf>,
|
||||
client: Arc<dyn AIClient>,
|
||||
http: &http_client::Client,
|
||||
) -> Result<Option<InitialSnapshotToken>> {
|
||||
if repo_paths.is_empty() && orphan_file_paths.is_empty() {
|
||||
log::info!("Handoff snapshot has no declarations; skipping upload");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let declarations: Vec<DeclarationEntry> = repo_paths
|
||||
.into_iter()
|
||||
.map(|path| DeclarationEntry {
|
||||
kind: EntryKind::Repo,
|
||||
path: path.display().to_string(),
|
||||
})
|
||||
.chain(orphan_file_paths.into_iter().map(|path| DeclarationEntry {
|
||||
kind: EntryKind::File,
|
||||
path: path.display().to_string(),
|
||||
}))
|
||||
.collect();
|
||||
|
||||
let GatheredSnapshot {
|
||||
manifest_filename,
|
||||
mut upload_files,
|
||||
mut repos,
|
||||
mut files,
|
||||
mut pre_upload_entries,
|
||||
} = gather_snapshot_entries(declarations).await;
|
||||
|
||||
apply_per_run_cap(
|
||||
&mut upload_files,
|
||||
&mut repos,
|
||||
&mut files,
|
||||
&mut pre_upload_entries,
|
||||
);
|
||||
|
||||
let mut file_infos: Vec<SnapshotFileInfo> = upload_files
|
||||
.iter()
|
||||
.map(|file| SnapshotFileInfo {
|
||||
filename: file.filename.clone(),
|
||||
mime_type: file.mime_type.clone(),
|
||||
})
|
||||
.collect();
|
||||
file_infos.push(SnapshotFileInfo {
|
||||
filename: manifest_filename.clone(),
|
||||
mime_type: "application/json".to_string(),
|
||||
});
|
||||
|
||||
let upload_request = UploadLocalHandoffSnapshotRequest {
|
||||
files: file_infos
|
||||
.iter()
|
||||
.map(|file| AiSnapshotUploadFileInfo {
|
||||
filename: file.filename.clone(),
|
||||
mime_type: file.mime_type.clone(),
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let response = client
|
||||
.upload_local_handoff_snapshot(upload_request)
|
||||
.await
|
||||
.context("failed to allocate initial snapshot token")?;
|
||||
log::info!(
|
||||
"Initial snapshot token allocated; expires_at={}, uploads={}",
|
||||
response.expires_at,
|
||||
response.uploads.len(),
|
||||
);
|
||||
let initial_snapshot_token = response.initial_snapshot_token;
|
||||
|
||||
// Server returns `uploads` aligned by index with the request `files` array (and does
|
||||
// not echo per-entry filenames), so we zip them positionally into a filename-keyed map.
|
||||
// Any request file the server omits lands in `upload_entry` with no target and is
|
||||
// marked `skipped` downstream.
|
||||
if response.uploads.len() != file_infos.len() {
|
||||
log::warn!(
|
||||
"Handoff snapshot upload-target response length {} does not match request length {}; \
|
||||
extras will be marked skipped",
|
||||
response.uploads.len(),
|
||||
file_infos.len(),
|
||||
);
|
||||
}
|
||||
let mut target_map: HashMap<String, UploadTarget> = HashMap::new();
|
||||
for (file, target) in file_infos.iter().zip(response.uploads.into_iter()) {
|
||||
target_map.insert(file.filename.clone(), target);
|
||||
}
|
||||
|
||||
let Some(outcome) = upload_prepared_snapshot_files(
|
||||
http,
|
||||
manifest_filename,
|
||||
upload_files,
|
||||
repos,
|
||||
files,
|
||||
pre_upload_entries,
|
||||
target_map,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
// Manifest serialization failed (already reported via `report_error!` inside
|
||||
// the helper). Without a manifest the snapshot is unusable, so refuse the token.
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let summary = SnapshotSummary::from_entries(&outcome.entries, outcome.manifest_uploaded);
|
||||
log_snapshot_outcome(&outcome);
|
||||
if !summary.manifest_uploaded {
|
||||
// Without the manifest the cloud agent has no catalogue to rehydrate from, even
|
||||
// when individual blobs landed. Alert on-call and refuse the token so we don't
|
||||
// silently spawn a cloud agent with no recoverable state.
|
||||
report_error!(anyhow::anyhow!(
|
||||
"Handoff snapshot manifest failed to upload (blobs: {}/{}); cloud agent will start with no rehydration content",
|
||||
summary.uploaded,
|
||||
summary.total,
|
||||
));
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(initial_snapshot_token))
|
||||
}
|
||||
|
||||
/// Core upload pipeline.
|
||||
///
|
||||
/// Gather/read/upload failures are captured in [`SnapshotOutcome::entries`] and never abort
|
||||
@@ -486,7 +870,6 @@ async fn upload_snapshot_from_declarations_file(
|
||||
async fn run_pipeline(
|
||||
declarations: Vec<DeclarationEntry>,
|
||||
client: Arc<dyn HarnessSupportClient>,
|
||||
task_id: &AmbientAgentTaskId,
|
||||
) -> Option<SnapshotOutcome> {
|
||||
let GatheredSnapshot {
|
||||
manifest_filename,
|
||||
@@ -494,11 +877,10 @@ async fn run_pipeline(
|
||||
repos,
|
||||
files,
|
||||
pre_upload_entries,
|
||||
} = gather_snapshot_entries(declarations, task_id).await;
|
||||
} = gather_snapshot_entries(declarations).await;
|
||||
|
||||
upload_gathered_snapshot(
|
||||
client,
|
||||
task_id,
|
||||
manifest_filename,
|
||||
upload_files,
|
||||
repos,
|
||||
@@ -516,10 +898,7 @@ struct GatheredSnapshot {
|
||||
pre_upload_entries: Vec<EntryResult>,
|
||||
}
|
||||
|
||||
async fn gather_snapshot_entries(
|
||||
declarations: Vec<DeclarationEntry>,
|
||||
task_id: &AmbientAgentTaskId,
|
||||
) -> GatheredSnapshot {
|
||||
async fn gather_snapshot_entries(declarations: Vec<DeclarationEntry>) -> GatheredSnapshot {
|
||||
let mut used_filenames = HashSet::new();
|
||||
let manifest_filename = unique_filename("snapshot_state.json", &mut used_filenames);
|
||||
|
||||
@@ -542,7 +921,6 @@ async fn gather_snapshot_entries(
|
||||
&mut upload_files,
|
||||
&mut repos,
|
||||
&mut pre_upload_entries,
|
||||
task_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -553,7 +931,6 @@ async fn gather_snapshot_entries(
|
||||
&mut upload_files,
|
||||
&mut files,
|
||||
&mut pre_upload_entries,
|
||||
task_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -571,7 +948,6 @@ async fn gather_snapshot_entries(
|
||||
|
||||
async fn upload_gathered_snapshot(
|
||||
client: Arc<dyn HarnessSupportClient>,
|
||||
task_id: &AmbientAgentTaskId,
|
||||
manifest_filename: String,
|
||||
mut upload_files: Vec<SnapshotUploadFile>,
|
||||
mut repos: Vec<RepoManifestEntry>,
|
||||
@@ -587,7 +963,6 @@ async fn upload_gathered_snapshot(
|
||||
&mut repos,
|
||||
&mut files,
|
||||
&mut pre_upload_entries,
|
||||
task_id,
|
||||
);
|
||||
|
||||
// Ask the server for presigned URLs for every filename we intend to upload —
|
||||
@@ -619,16 +994,14 @@ async fn upload_gathered_snapshot(
|
||||
Err(e) => {
|
||||
// Pipeline-abort: route through report_error! so Sentry captures the structured
|
||||
// error chain and on-call alerting can fire.
|
||||
report_error!(e.context(format!(
|
||||
"Failed to get snapshot upload targets; skipping upload (task {task_id})"
|
||||
)));
|
||||
report_error!(e.context("Failed to get snapshot upload targets; skipping upload"));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if targets.len() != chunk.len() {
|
||||
log::warn!(
|
||||
"Snapshot upload-target response length {} does not match request length {}; \
|
||||
extras will be marked skipped (task {task_id})",
|
||||
extras will be marked skipped",
|
||||
targets.len(),
|
||||
chunk.len(),
|
||||
);
|
||||
@@ -637,14 +1010,33 @@ async fn upload_gathered_snapshot(
|
||||
target_map.insert(file.filename.clone(), target);
|
||||
}
|
||||
}
|
||||
upload_prepared_snapshot_files(
|
||||
client.http_client(),
|
||||
manifest_filename,
|
||||
upload_files,
|
||||
repos,
|
||||
files,
|
||||
pre_upload_entries,
|
||||
target_map,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn upload_prepared_snapshot_files(
|
||||
http: &http_client::Client,
|
||||
manifest_filename: String,
|
||||
upload_files: Vec<SnapshotUploadFile>,
|
||||
mut repos: Vec<RepoManifestEntry>,
|
||||
mut files: Vec<FileManifestEntry>,
|
||||
pre_upload_entries: Vec<EntryResult>,
|
||||
target_map: HashMap<String, UploadTarget>,
|
||||
) -> Option<SnapshotOutcome> {
|
||||
// Upload non-manifest blobs concurrently, each with bounded retries on transient errors.
|
||||
let http = client.http_client();
|
||||
let upload_futures = upload_files
|
||||
.iter()
|
||||
.map(|file| upload_entry(http, file, &target_map, task_id));
|
||||
.map(|file| upload_entry(http, file, &target_map));
|
||||
let upload_entries: Vec<EntryResult> = join_all(upload_futures).await;
|
||||
fold_upload_results(&mut repos, &mut files, &upload_entries, task_id);
|
||||
fold_upload_results(&mut repos, &mut files, &upload_entries);
|
||||
|
||||
// Build and upload the manifest last, with the real outcomes baked in.
|
||||
let manifest = SnapshotManifest {
|
||||
@@ -656,9 +1048,8 @@ async fn upload_gathered_snapshot(
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
// Pipeline-abort: route through report_error! so Sentry captures it.
|
||||
report_error!(anyhow::Error::from(e).context(format!(
|
||||
"Failed to serialize snapshot manifest; skipping upload (task {task_id})"
|
||||
)));
|
||||
report_error!(anyhow::Error::from(e)
|
||||
.context("Failed to serialize snapshot manifest; skipping upload"));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
@@ -671,9 +1062,7 @@ async fn upload_gathered_snapshot(
|
||||
Err(e) => {
|
||||
// Capture the full chain for the manifest's `error` field, then surface it
|
||||
// to Sentry via report_error!.
|
||||
let e = e.context(format!(
|
||||
"Failed to upload manifest '{manifest_filename}' (task {task_id})"
|
||||
));
|
||||
let e = e.context(format!("Failed to upload manifest '{manifest_filename}'"));
|
||||
let msg = format!("{e:#}");
|
||||
report_error!(e);
|
||||
(false, Some(msg))
|
||||
@@ -714,7 +1103,6 @@ async fn gather_repo(
|
||||
upload_files: &mut Vec<SnapshotUploadFile>,
|
||||
repos: &mut Vec<RepoManifestEntry>,
|
||||
pre_upload_entries: &mut Vec<EntryResult>,
|
||||
task_id: &AmbientAgentTaskId,
|
||||
) {
|
||||
let repo = Path::new(repo_path);
|
||||
let metadata = repo_metadata(repo).await;
|
||||
@@ -756,7 +1144,7 @@ async fn gather_repo(
|
||||
}
|
||||
Err(e) => {
|
||||
let err_str = format!("{e:#}");
|
||||
log::warn!("Failed to snapshot repo '{repo_path}': {err_str} (task {task_id})");
|
||||
log::warn!("Failed to snapshot repo '{repo_path}': {err_str}");
|
||||
repos.push(RepoManifestEntry {
|
||||
path: repo_path.to_string(),
|
||||
repo_name: metadata.repo_name,
|
||||
@@ -783,7 +1171,6 @@ async fn gather_file(
|
||||
upload_files: &mut Vec<SnapshotUploadFile>,
|
||||
files: &mut Vec<FileManifestEntry>,
|
||||
pre_upload_entries: &mut Vec<EntryResult>,
|
||||
task_id: &AmbientAgentTaskId,
|
||||
) {
|
||||
let path = Path::new(file_path);
|
||||
match tokio::fs::read(path).await {
|
||||
@@ -812,7 +1199,7 @@ async fn gather_file(
|
||||
}
|
||||
Err(e) => {
|
||||
let err_str = format!("Failed to read file '{file_path}': {e:#}");
|
||||
log::warn!("{err_str} (task {task_id})");
|
||||
log::warn!("{err_str}");
|
||||
files.push(FileManifestEntry {
|
||||
path: file_path.to_string(),
|
||||
snapshot_file: None,
|
||||
@@ -836,13 +1223,9 @@ async fn upload_entry(
|
||||
http: &http_client::Client,
|
||||
file: &SnapshotUploadFile,
|
||||
target_map: &HashMap<String, UploadTarget>,
|
||||
task_id: &AmbientAgentTaskId,
|
||||
) -> EntryResult {
|
||||
let Some(target) = target_map.get(&file.filename) else {
|
||||
log::warn!(
|
||||
"No upload target for file '{}', skipping (task {task_id})",
|
||||
file.filename
|
||||
);
|
||||
log::warn!("No upload target for file '{}', skipping", file.filename);
|
||||
return EntryResult {
|
||||
label: file.filename.clone(),
|
||||
status: EntryStatus::Skipped,
|
||||
@@ -860,10 +1243,7 @@ async fn upload_entry(
|
||||
},
|
||||
Err(e) => {
|
||||
let msg = format!("{e:#}");
|
||||
log::warn!(
|
||||
"Failed to upload '{}': {msg} (task {task_id})",
|
||||
file.filename
|
||||
);
|
||||
log::warn!("Failed to upload '{}': {msg}", file.filename);
|
||||
EntryResult {
|
||||
label: file.filename.clone(),
|
||||
status: EntryStatus::Failed,
|
||||
@@ -879,7 +1259,6 @@ fn fold_upload_results(
|
||||
repos: &mut [RepoManifestEntry],
|
||||
files: &mut [FileManifestEntry],
|
||||
upload_entries: &[EntryResult],
|
||||
task_id: &AmbientAgentTaskId,
|
||||
) {
|
||||
for entry in upload_entries {
|
||||
if let Some(repo_entry) = repos
|
||||
@@ -903,7 +1282,7 @@ fn fold_upload_results(
|
||||
}
|
||||
EntryStatus::GatherFailed | EntryStatus::ReadFailed => {
|
||||
log::error!(
|
||||
"fold_upload_results: unexpected pre-upload status {:?} for repo patch '{}' (task {task_id})",
|
||||
"fold_upload_results: unexpected pre-upload status {:?} for repo patch '{}'",
|
||||
entry.status,
|
||||
entry.label
|
||||
);
|
||||
@@ -930,7 +1309,7 @@ fn fold_upload_results(
|
||||
}
|
||||
EntryStatus::GatherFailed | EntryStatus::ReadFailed => {
|
||||
log::error!(
|
||||
"fold_upload_results: unexpected pre-upload status {:?} for file '{}' (task {task_id})",
|
||||
"fold_upload_results: unexpected pre-upload status {:?} for file '{}'",
|
||||
entry.status,
|
||||
entry.label
|
||||
);
|
||||
@@ -949,7 +1328,6 @@ fn apply_per_run_cap(
|
||||
repos: &mut [RepoManifestEntry],
|
||||
files: &mut [FileManifestEntry],
|
||||
pre_upload_entries: &mut Vec<EntryResult>,
|
||||
task_id: &AmbientAgentTaskId,
|
||||
) {
|
||||
let blob_limit = MAX_SNAPSHOT_FILES_PER_RUN.saturating_sub(1);
|
||||
if upload_files.len() <= blob_limit {
|
||||
@@ -958,7 +1336,7 @@ fn apply_per_run_cap(
|
||||
let total_including_manifest = upload_files.len() + 1;
|
||||
let dropped = upload_files.split_off(blob_limit);
|
||||
log::warn!(
|
||||
"Snapshot exceeds per-run cap of {MAX_SNAPSHOT_FILES_PER_RUN} files ({total_including_manifest} declared); dropping {} blob(s) from upload (task {task_id})",
|
||||
"Snapshot exceeds per-run cap of {MAX_SNAPSHOT_FILES_PER_RUN} files ({total_including_manifest} declared); dropping {} blob(s) from upload",
|
||||
dropped.len(),
|
||||
);
|
||||
let err_msg = format!("exceeded per-run snapshot cap of {MAX_SNAPSHOT_FILES_PER_RUN} files");
|
||||
@@ -1011,13 +1389,14 @@ fn merge_content_type(target: &UploadTarget, mime_type: &str) -> UploadTarget {
|
||||
url: target.url.clone(),
|
||||
method: target.method.clone(),
|
||||
headers,
|
||||
fields: target.fields.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Log the final outcome at INFO when everything uploaded, WARN otherwise. The log line
|
||||
/// includes per-entry statuses so operators can diagnose partial state without parsing any
|
||||
/// downstream logs.
|
||||
fn log_snapshot_outcome(outcome: &SnapshotOutcome, task_id: &AmbientAgentTaskId) {
|
||||
fn log_snapshot_outcome(outcome: &SnapshotOutcome) {
|
||||
let summary = SnapshotSummary::from_entries(&outcome.entries, outcome.manifest_uploaded);
|
||||
let manifest_bit = if summary.manifest_uploaded {
|
||||
"manifest: uploaded"
|
||||
@@ -1025,7 +1404,7 @@ fn log_snapshot_outcome(outcome: &SnapshotOutcome, task_id: &AmbientAgentTaskId)
|
||||
"manifest: failed"
|
||||
};
|
||||
let header = format!(
|
||||
"Snapshot upload: {}/{} uploaded (failed: {}, skipped: {}, gather_failed: {}, read_failed: {}; {manifest_bit}) (task {task_id})",
|
||||
"Snapshot upload: {}/{} uploaded (failed: {}, skipped: {}, gather_failed: {}, read_failed: {}; {manifest_bit})",
|
||||
summary.uploaded,
|
||||
summary.total,
|
||||
summary.failed,
|
||||
|
||||
@@ -86,6 +86,10 @@ impl HarnessSupportClient for TestClient {
|
||||
unimplemented!("not used by upload_snapshot_from_declarations_file")
|
||||
}
|
||||
|
||||
async fn fetch_transcript(&self) -> Result<bytes::Bytes> {
|
||||
unimplemented!("not used by upload_snapshot_from_declarations_file")
|
||||
}
|
||||
|
||||
async fn get_block_snapshot_upload_target(
|
||||
&self,
|
||||
_conversation_id: &AIConversationId,
|
||||
@@ -112,6 +116,18 @@ impl HarnessSupportClient for TestClient {
|
||||
unimplemented!("not used by upload_snapshot_from_declarations_file")
|
||||
}
|
||||
|
||||
async fn report_clean_shutdown(&self) -> Result<()> {
|
||||
unimplemented!("not used by upload_snapshot_from_declarations_file")
|
||||
}
|
||||
|
||||
async fn report_error_shutdown(
|
||||
&self,
|
||||
_error_category: String,
|
||||
_error_message: String,
|
||||
) -> Result<()> {
|
||||
unimplemented!("not used by upload_snapshot_from_declarations_file")
|
||||
}
|
||||
|
||||
async fn get_snapshot_upload_targets(
|
||||
&self,
|
||||
request: &SnapshotUploadRequest,
|
||||
@@ -131,6 +147,7 @@ impl HarnessSupportClient for TestClient {
|
||||
url: format!("{}/upload/{}", self.server_base_url, f.filename),
|
||||
method: "PUT".to_string(),
|
||||
headers: HashMap::new(),
|
||||
fields: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
let keep = targets.len().saturating_sub(self.drop_trailing_targets);
|
||||
@@ -138,10 +155,6 @@ impl HarnessSupportClient for TestClient {
|
||||
Ok(targets)
|
||||
}
|
||||
|
||||
async fn fetch_transcript(&self) -> Result<bytes::Bytes> {
|
||||
unimplemented!("not used by upload_snapshot_from_declarations_file")
|
||||
}
|
||||
|
||||
fn http_client(&self) -> &http_client::Client {
|
||||
&self.http
|
||||
}
|
||||
@@ -248,7 +261,6 @@ fn run(
|
||||
.block_on(upload_snapshot_from_declarations_file(
|
||||
&declarations_path,
|
||||
client,
|
||||
&fake_task_id(),
|
||||
))
|
||||
.expect("pipeline returned None");
|
||||
let summary = SnapshotSummary::from_entries(&outcome.entries, outcome.manifest_uploaded);
|
||||
@@ -276,7 +288,7 @@ fn parse_declarations_ignores_blank_lines() {
|
||||
"{\"version\":1,\"kind\":\"file\",\"path\":\"/abs/file.txt\"}\n",
|
||||
"\n",
|
||||
);
|
||||
let entries = parse_declarations(contents, &fake_task_id());
|
||||
let entries = parse_declarations(contents);
|
||||
assert_eq!(
|
||||
entries,
|
||||
vec![
|
||||
@@ -302,7 +314,7 @@ fn parse_declarations_skips_malformed_lines_without_aborting() {
|
||||
"{\"version\":1,\"kind\":\"file\"}\n",
|
||||
"{\"version\":1,\"kind\":\"file\",\"path\":\"/abs/also-good\",\"extra\":true}\n",
|
||||
);
|
||||
let entries = parse_declarations(contents, &fake_task_id());
|
||||
let entries = parse_declarations(contents);
|
||||
assert_eq!(
|
||||
entries,
|
||||
vec![
|
||||
@@ -325,7 +337,7 @@ fn parse_declarations_skips_missing_or_unsupported_versions() {
|
||||
"{\"version\":2,\"kind\":\"repo\",\"path\":\"/abs/unsupported-version\"}\n",
|
||||
"{\"version\":1,\"kind\":\"file\",\"path\":\"/abs/good\"}\n",
|
||||
);
|
||||
let entries = parse_declarations(contents, &fake_task_id());
|
||||
let entries = parse_declarations(contents);
|
||||
assert_eq!(
|
||||
entries,
|
||||
vec![DeclarationEntry {
|
||||
@@ -342,7 +354,7 @@ fn parse_declarations_tolerates_crlf_line_endings() {
|
||||
"{\"version\":1,\"kind\":\"repo\",\"path\":\"/abs/good\"}\r\n",
|
||||
"{\"version\":1,\"kind\":\"file\",\"path\":\"/abs/also-good\"}\r\n",
|
||||
);
|
||||
let entries = parse_declarations(contents, &fake_task_id());
|
||||
let entries = parse_declarations(contents);
|
||||
assert_eq!(
|
||||
entries,
|
||||
vec![
|
||||
@@ -365,7 +377,7 @@ fn parse_declarations_skips_lines_with_empty_path() {
|
||||
"{\"version\":1,\"kind\":\"file\",\"path\":\" \"}\n",
|
||||
"{\"version\":1,\"kind\":\"repo\",\"path\":\"/abs/still-good\"}\n",
|
||||
);
|
||||
let entries = parse_declarations(contents, &fake_task_id());
|
||||
let entries = parse_declarations(contents);
|
||||
assert_eq!(
|
||||
entries,
|
||||
vec![DeclarationEntry {
|
||||
@@ -382,7 +394,7 @@ fn parse_declarations_deduplicates_kind_path_pairs() {
|
||||
"{\"version\":1,\"kind\":\"repo\",\"path\":\"/abs/repo\"}\n",
|
||||
"{\"version\":1,\"kind\":\"file\",\"path\":\"/abs/repo\"}\n",
|
||||
);
|
||||
let entries = parse_declarations(contents, &fake_task_id());
|
||||
let entries = parse_declarations(contents);
|
||||
assert_eq!(
|
||||
entries,
|
||||
vec![
|
||||
@@ -406,11 +418,7 @@ fn upload_skipped_when_declarations_file_missing() {
|
||||
let client = TestClient::new(server.url());
|
||||
let outcome = Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(upload_snapshot_from_declarations_file(
|
||||
&missing,
|
||||
client,
|
||||
&fake_task_id(),
|
||||
));
|
||||
.block_on(upload_snapshot_from_declarations_file(&missing, client));
|
||||
assert!(
|
||||
outcome.is_none(),
|
||||
"missing declarations file should skip the upload"
|
||||
@@ -426,11 +434,7 @@ fn upload_skipped_when_declarations_file_empty() {
|
||||
let client = TestClient::new(server.url());
|
||||
let outcome = Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(upload_snapshot_from_declarations_file(
|
||||
&decl,
|
||||
client,
|
||||
&fake_task_id(),
|
||||
));
|
||||
.block_on(upload_snapshot_from_declarations_file(&decl, client));
|
||||
assert!(outcome.is_none(), "empty declarations file should skip");
|
||||
}
|
||||
|
||||
@@ -447,11 +451,7 @@ fn upload_skipped_when_declarations_file_has_no_valid_jsonl_entries() {
|
||||
let client = TestClient::new(server.url());
|
||||
let outcome = Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(upload_snapshot_from_declarations_file(
|
||||
&decl,
|
||||
client,
|
||||
&fake_task_id(),
|
||||
));
|
||||
.block_on(upload_snapshot_from_declarations_file(&decl, client));
|
||||
assert!(
|
||||
outcome.is_none(),
|
||||
"declarations file with no valid entries should skip"
|
||||
@@ -631,10 +631,12 @@ fn e2e_gather_failed_entry_captured_in_manifest() {
|
||||
#[test]
|
||||
fn e2e_read_failed_for_missing_file_continues_pipeline() {
|
||||
// Point a `file` entry at a path that doesn't exist → read_failed, with a clean repo also
|
||||
// included so we verify the pipeline didn't abort after the read failure.
|
||||
// included so we verify the pipeline didn't abort after the read failure. Keep the missing
|
||||
// file outside the repo so the repo-overlap filter does not strip it before gather.
|
||||
let tempdir = snaptest_tempdir();
|
||||
init_git_repo(tempdir.path(), false);
|
||||
let missing_file = tempdir.path().join("does-not-exist.txt");
|
||||
let missing_dir = snaptest_tempdir();
|
||||
let missing_file = missing_dir.path().join("does-not-exist.txt");
|
||||
let decl_dir = snaptest_tempdir();
|
||||
|
||||
let mut server = Server::new();
|
||||
@@ -978,7 +980,6 @@ fn e2e_get_snapshot_upload_targets_failure_returns_none() {
|
||||
.block_on(upload_snapshot_from_declarations_file(
|
||||
&declarations_path,
|
||||
client,
|
||||
&fake_task_id(),
|
||||
));
|
||||
assert!(
|
||||
outcome.is_none(),
|
||||
@@ -1022,7 +1023,6 @@ fn e2e_short_response_leaves_trailing_file_without_target() {
|
||||
.block_on(upload_snapshot_from_declarations_file(
|
||||
&declarations_path,
|
||||
client,
|
||||
&fake_task_id(),
|
||||
))
|
||||
.expect("pipeline returned None");
|
||||
let summary = SnapshotSummary::from_entries(&outcome.entries, outcome.manifest_uploaded);
|
||||
@@ -1182,3 +1182,284 @@ fn e2e_per_run_cap_drops_excess_blobs_as_skipped() {
|
||||
}
|
||||
upload_mock.assert();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// REMOTE-1465: repo-overlap dedup + DeclarationsWriterHandle.
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
|
||||
/// Build a `DeclarationEntry` without exposing the private type to call sites.
|
||||
fn repo_entry(path: &str) -> DeclarationEntry {
|
||||
DeclarationEntry {
|
||||
kind: EntryKind::Repo,
|
||||
path: path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn file_entry(path: &str) -> DeclarationEntry {
|
||||
DeclarationEntry {
|
||||
kind: EntryKind::File,
|
||||
path: path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_files_covered_by_repos_keeps_everything_when_no_repos_declared() {
|
||||
let entries = vec![
|
||||
file_entry("/abs/outside.txt"),
|
||||
file_entry("/other/also-outside.txt"),
|
||||
];
|
||||
let after = drop_files_covered_by_repos(entries.clone());
|
||||
assert_eq!(after, entries);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_files_covered_by_repos_drops_file_inside_repo_keeps_file_outside() {
|
||||
let entries = vec![
|
||||
repo_entry("/workspace/my-repo"),
|
||||
file_entry("/workspace/my-repo/src/foo.rs"),
|
||||
file_entry("/tmp/outside.txt"),
|
||||
];
|
||||
let after = drop_files_covered_by_repos(entries);
|
||||
assert_eq!(
|
||||
after,
|
||||
vec![
|
||||
repo_entry("/workspace/my-repo"),
|
||||
file_entry("/tmp/outside.txt"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_files_covered_by_repos_handles_nested_repo_paths() {
|
||||
// A file under /a/b/sub should be filtered by either /a or /a/b/sub.
|
||||
let entries = vec![
|
||||
repo_entry("/a"),
|
||||
repo_entry("/a/b/sub"),
|
||||
file_entry("/a/b/sub/file.txt"),
|
||||
file_entry("/a/top.txt"),
|
||||
file_entry("/unrelated.txt"),
|
||||
];
|
||||
let after = drop_files_covered_by_repos(entries);
|
||||
assert_eq!(
|
||||
after,
|
||||
vec![
|
||||
repo_entry("/a"),
|
||||
repo_entry("/a/b/sub"),
|
||||
file_entry("/unrelated.txt"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse the declarations file written by `DeclarationsWriterHandle` into the paths we care
|
||||
/// about for assertions, ignoring any lines the helper tests weren't asked to produce.
|
||||
fn parsed_file_paths(path: &Path) -> Vec<String> {
|
||||
let contents = fs::read_to_string(path).unwrap_or_default();
|
||||
let entries = parse_declarations(&contents);
|
||||
entries
|
||||
.into_iter()
|
||||
.filter(|e| e.kind == EntryKind::File)
|
||||
.map(|e| e.path)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declarations_writer_appends_unique_absolute_paths_once() {
|
||||
let tempdir = snaptest_tempdir();
|
||||
let workspace = tempdir.path().join("workspace");
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
let decl_path = tempdir.path().join("declarations.jsonl");
|
||||
let task_id = fake_task_id();
|
||||
|
||||
let rt = Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let handle =
|
||||
DeclarationsWriterHandle::new_at_path(decl_path.clone(), workspace.clone(), task_id);
|
||||
let path_one = workspace.join("one.txt");
|
||||
let path_two = workspace.join("two.txt");
|
||||
handle.append(vec![
|
||||
path_one.to_string_lossy().into_owned(),
|
||||
path_two.to_string_lossy().into_owned(),
|
||||
// Duplicate: should still only produce one entry per unique path.
|
||||
path_one.to_string_lossy().into_owned(),
|
||||
]);
|
||||
handle.flush().await;
|
||||
// A second batch that re-declares path_one should also be a no-op.
|
||||
handle.append(vec![path_one.to_string_lossy().into_owned()]);
|
||||
handle.flush().await;
|
||||
});
|
||||
|
||||
let paths = parsed_file_paths(&decl_path);
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec![
|
||||
workspace.join("one.txt").to_string_lossy().into_owned(),
|
||||
workspace.join("two.txt").to_string_lossy().into_owned(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declarations_writer_resolves_relative_paths_against_working_dir() {
|
||||
let tempdir = snaptest_tempdir();
|
||||
let workspace = tempdir.path().join("workspace");
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
let decl_path = tempdir.path().join("declarations.jsonl");
|
||||
let task_id = fake_task_id();
|
||||
|
||||
let rt = Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let handle =
|
||||
DeclarationsWriterHandle::new_at_path(decl_path.clone(), workspace.clone(), task_id);
|
||||
handle.append(vec!["notes/relative.txt".to_string()]);
|
||||
handle.flush().await;
|
||||
});
|
||||
|
||||
let paths = parsed_file_paths(&decl_path);
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec![workspace
|
||||
.join("notes/relative.txt")
|
||||
.to_string_lossy()
|
||||
.into_owned()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declarations_writer_continues_after_per_path_write_failures() {
|
||||
// Pre-create a directory at the declarations file path so the first append's open call
|
||||
// fails. Once we remove the directory, a subsequent append must succeed, proving the
|
||||
// writer task absorbed the failure and kept servicing commands.
|
||||
let tempdir = snaptest_tempdir();
|
||||
let workspace = tempdir.path().join("workspace");
|
||||
fs::create_dir_all(&workspace).unwrap();
|
||||
let decl_path = tempdir.path().join("declarations.jsonl");
|
||||
fs::create_dir(&decl_path).unwrap();
|
||||
let task_id = fake_task_id();
|
||||
|
||||
let rt = Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let handle =
|
||||
DeclarationsWriterHandle::new_at_path(decl_path.clone(), workspace.clone(), task_id);
|
||||
handle.append(vec![workspace
|
||||
.join("first.txt")
|
||||
.to_string_lossy()
|
||||
.into_owned()]);
|
||||
handle.flush().await;
|
||||
// Replace the staged directory so the next append's open call can succeed.
|
||||
fs::remove_dir(&decl_path).unwrap();
|
||||
handle.append(vec![workspace
|
||||
.join("second.txt")
|
||||
.to_string_lossy()
|
||||
.into_owned()]);
|
||||
handle.flush().await;
|
||||
});
|
||||
|
||||
let paths = parsed_file_paths(&decl_path);
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec![workspace.join("second.txt").to_string_lossy().into_owned()],
|
||||
"writer task should absorb the first failure and process the second append"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declarations_writer_preempts_paths_inside_existing_repo() {
|
||||
let tempdir = snaptest_tempdir();
|
||||
// Simulate an existing repo by creating the `.git` directory the ancestor walker checks.
|
||||
let repo = tempdir.path().join("existing-repo");
|
||||
fs::create_dir_all(repo.join(".git")).unwrap();
|
||||
let inside = repo.join("inside.txt");
|
||||
let outside = tempdir.path().join("outside.txt");
|
||||
let decl_path = tempdir.path().join("declarations.jsonl");
|
||||
let task_id = fake_task_id();
|
||||
|
||||
let rt = Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let handle = DeclarationsWriterHandle::new_at_path(
|
||||
decl_path.clone(),
|
||||
tempdir.path().to_path_buf(),
|
||||
task_id,
|
||||
);
|
||||
handle.append(vec![
|
||||
inside.to_string_lossy().into_owned(),
|
||||
outside.to_string_lossy().into_owned(),
|
||||
]);
|
||||
handle.flush().await;
|
||||
});
|
||||
|
||||
let paths = parsed_file_paths(&decl_path);
|
||||
assert_eq!(paths, vec![outside.to_string_lossy().into_owned()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_repo_plus_inside_and_outside_files_filters_overlap() {
|
||||
// The writer-written declarations file feeds straight into the upload pipeline. Pair one
|
||||
// `repo` with two `file` entries (one inside the repo, one outside). The gather-time
|
||||
// overlap filter should drop the inside-repo file entry before upload so only the repo's
|
||||
// patch + the outside-repo file + the manifest land on the server.
|
||||
let repo_dir = snaptest_tempdir();
|
||||
init_git_repo(repo_dir.path(), true);
|
||||
let inside_file = repo_dir.path().join("new-untracked.txt");
|
||||
fs::write(&inside_file, b"tracked-or-not, handled by the patch\n").unwrap();
|
||||
|
||||
let outside_dir = snaptest_tempdir();
|
||||
let outside_file = outside_dir.path().join("standalone_log.txt");
|
||||
fs::write(&outside_file, b"agent-produced log\n").unwrap();
|
||||
|
||||
let decl_dir = snaptest_tempdir();
|
||||
let decl_path = decl_dir.path().join("snapshot-declarations.jsonl");
|
||||
let contents = format!(
|
||||
concat!(
|
||||
"{{\"version\":1,\"kind\":\"repo\",\"path\":{repo:?}}}\n",
|
||||
"{{\"version\":1,\"kind\":\"file\",\"path\":{inside:?}}}\n",
|
||||
"{{\"version\":1,\"kind\":\"file\",\"path\":{outside:?}}}\n",
|
||||
),
|
||||
repo = repo_dir.path().to_string_lossy(),
|
||||
inside = inside_file.to_string_lossy(),
|
||||
outside = outside_file.to_string_lossy(),
|
||||
);
|
||||
fs::write(&decl_path, contents).unwrap();
|
||||
|
||||
let mut server = Server::new();
|
||||
let patch_mock = server
|
||||
.mock("PUT", upload_path(r".+\.patch"))
|
||||
.with_status(200)
|
||||
.expect(1)
|
||||
.create();
|
||||
let file_mock = server
|
||||
.mock("PUT", upload_path("standalone_log\\.txt"))
|
||||
.with_status(200)
|
||||
.expect(1)
|
||||
.create();
|
||||
let manifest_mock = server
|
||||
.mock("PUT", upload_path("snapshot_state\\.json"))
|
||||
.match_body(Matcher::PartialJson(serde_json::json!({
|
||||
"files": [
|
||||
{
|
||||
"path": outside_file.to_string_lossy(),
|
||||
"status": "uploaded",
|
||||
"uploaded": true,
|
||||
}
|
||||
],
|
||||
})))
|
||||
.with_status(200)
|
||||
.expect(1)
|
||||
.create();
|
||||
|
||||
let client = TestClient::new(server.url());
|
||||
let outcome = Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(upload_snapshot_from_declarations_file(&decl_path, client))
|
||||
.expect("pipeline returned None");
|
||||
let summary = SnapshotSummary::from_entries(&outcome.entries, outcome.manifest_uploaded);
|
||||
|
||||
assert!(summary.all_uploaded(), "expected all uploads to succeed");
|
||||
// repo patch + outside file + manifest = 3 uploaded entries total; the inside-repo file
|
||||
// entry was filtered before gather so it never hits the entries list.
|
||||
assert_eq!(summary.uploaded, 3);
|
||||
assert_eq!(summary.total, 3);
|
||||
assert!(outcome.manifest_uploaded);
|
||||
patch_mock.assert();
|
||||
file_mock.assert();
|
||||
manifest_mock.assert();
|
||||
}
|
||||
|
||||
@@ -1,47 +1,82 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
ffi::OsString,
|
||||
future::Future,
|
||||
path::PathBuf,
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
task::{Context, Poll},
|
||||
time::Duration,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsString;
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::channel::oneshot;
|
||||
use session_sharing_protocol::common::{Role, SessionId};
|
||||
use session_sharing_protocol::sharer::SessionRetentionReason;
|
||||
use galaxy_cli::share::{ShareAccessLevel, ShareRequest, ShareSubject};
|
||||
use galaxy_completer::completer::CommandOutput;
|
||||
use galaxy_core::command::ExitCode;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_terminal::model::grid::Dimensions;
|
||||
use galaxy_util::path::ShellFamily;
|
||||
use galaxyui::{
|
||||
r#async::FutureExt, AppContext, Entity, ModelContext, ModelHandle, SingletonEntity as _,
|
||||
ViewHandle,
|
||||
};
|
||||
use session_sharing_protocol::common::{Role, SessionId};
|
||||
use session_sharing_protocol::sharer::SessionSourceType;
|
||||
|
||||
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 galaxyui::r#async::FutureExt;
|
||||
use galaxyui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity as _, ViewHandle};
|
||||
|
||||
use super::AgentDriverError;
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::ai::attachment_utils::attachments_download_dir;
|
||||
use crate::pane_group::NewTerminalOptions;
|
||||
use crate::root_view::{open_new_with_workspace_source, NewWorkspaceSource};
|
||||
use crate::terminal::model::block::{BlockId, SerializedBlock};
|
||||
use crate::terminal::model::find::RegexDFAs;
|
||||
use crate::terminal::model::grid::RespectDisplayedOutput;
|
||||
use crate::terminal::model::index::Point;
|
||||
use crate::terminal::model::session::ExecuteCommandOptions;
|
||||
use crate::terminal::model::RespectObfuscatedSecrets;
|
||||
use crate::terminal::shared_session::{self, IsSharedSessionCreator, SharedSessionSource};
|
||||
use crate::terminal::shell::ShellType;
|
||||
use crate::terminal::view::{ConversationRestorationInNewPaneType, Event};
|
||||
use crate::terminal::TerminalView;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
|
||||
/// Describes why a terminal session bootstrap failed.
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum BootstrapError {
|
||||
/// The PTY or shell process failed before the bootstrap script completed.
|
||||
/// When `reason` is `Some`, the message is
|
||||
/// "Shell spawn failed: {reason}. Check the Warp logs for details."
|
||||
/// When `reason` is `None`, it is
|
||||
/// "Shell spawn failed. Check the Warp logs for details."
|
||||
PtySpawnFailed { reason: Option<String> },
|
||||
/// The bootstrap script did not complete within the expected time.
|
||||
TimedOut,
|
||||
/// An unexpected internal error in the bootstrap channel
|
||||
/// (e.g. the sender was dropped without sending).
|
||||
InternalError,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for BootstrapError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
BootstrapError::PtySpawnFailed { reason: Some(r) } => {
|
||||
write!(
|
||||
f,
|
||||
"Shell spawn failed: {r}. Check the Warp logs for details."
|
||||
)
|
||||
}
|
||||
BootstrapError::PtySpawnFailed { reason: None } => {
|
||||
write!(f, "Shell spawn failed. Check the Warp logs for details.")
|
||||
}
|
||||
BootstrapError::TimedOut => write!(
|
||||
f,
|
||||
"Terminal session did not start within the expected time. \
|
||||
Check the Warp logs for details."
|
||||
),
|
||||
BootstrapError::InternalError => {
|
||||
write!(f, "An unexpected internal error occurred during bootstrap.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for BootstrapError {}
|
||||
|
||||
/// Describes why an agent's session-sharing request failed.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -66,7 +101,8 @@ pub(crate) enum ShareSessionError {
|
||||
}
|
||||
|
||||
const TERMINAL_SESSION_BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const TERMINAL_SESSION_SHARE_DELAY: Duration = Duration::from_secs(10);
|
||||
/// The total time to wait for session sharing to start, including retries.
|
||||
const TERMINAL_SESSION_SHARE_DELAY: Duration = Duration::from_secs(20);
|
||||
|
||||
/// Options for creating the terminal view before constructing a [`TerminalDriver`].
|
||||
pub(crate) struct TerminalDriverOptions {
|
||||
@@ -97,7 +133,12 @@ pub(crate) enum TerminalDriverEvent {
|
||||
/// - Detecting block completion
|
||||
pub(crate) struct TerminalDriver {
|
||||
terminal_view: ViewHandle<TerminalView>,
|
||||
session_bootstrapped: Condition,
|
||||
/// Sender half of the bootstrap result channel. Exactly one of
|
||||
/// `SessionBootstrapped`, `PtySpawnFailed`, or `Exited` (pre-bootstrap)
|
||||
/// will send the outcome; all others no-op after the first send.
|
||||
bootstrap_tx: Option<oneshot::Sender<Result<(), BootstrapError>>>,
|
||||
/// Receiver half consumed by `wait_for_session_bootstrapped`.
|
||||
bootstrap_rx: Option<oneshot::Receiver<Result<(), BootstrapError>>>,
|
||||
/// The session ID once sharing has been established.
|
||||
shared_session_id: Option<SessionId>,
|
||||
/// Receiver for the session sharing result. Present when sharing is expected
|
||||
@@ -126,9 +167,7 @@ fn create_terminal_view(
|
||||
) -> 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()),
|
||||
},
|
||||
source: SharedSessionSource::ambient_agent(options.task_id.map(|t| t.to_string())),
|
||||
}
|
||||
} else {
|
||||
IsSharedSessionCreator::No
|
||||
@@ -192,8 +231,6 @@ impl TerminalDriver {
|
||||
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.
|
||||
@@ -213,6 +250,7 @@ impl TerminalDriver {
|
||||
let _ = tx.send(Err(ShareSessionError::Disabled));
|
||||
(None, Some(rx))
|
||||
} else {
|
||||
log::info!("Waiting for requested session sharing to start");
|
||||
let (tx, rx) = oneshot::channel();
|
||||
(Some(tx), Some(rx))
|
||||
}
|
||||
@@ -234,13 +272,14 @@ impl TerminalDriver {
|
||||
});
|
||||
}
|
||||
|
||||
ctx.subscribe_to_view(&terminal_view, move |me, event, ctx| {
|
||||
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 (bootstrap_tx_inner, bootstrap_rx) = oneshot::channel::<Result<(), BootstrapError>>();
|
||||
|
||||
// If bootstrap already completed before we subscribed, resolve the
|
||||
// channel immediately so `wait_for_session_bootstrapped` doesn't block.
|
||||
let already_bootstrapped = terminal_view.read(ctx, |terminal, _| {
|
||||
terminal
|
||||
.model
|
||||
@@ -248,13 +287,17 @@ impl TerminalDriver {
|
||||
.block_list()
|
||||
.is_bootstrapping_precmd_done()
|
||||
});
|
||||
if already_bootstrapped {
|
||||
session_bootstrapped.set();
|
||||
}
|
||||
let bootstrap_tx = if already_bootstrapped {
|
||||
let _ = bootstrap_tx_inner.send(Ok(()));
|
||||
None
|
||||
} else {
|
||||
Some(bootstrap_tx_inner)
|
||||
};
|
||||
|
||||
Self {
|
||||
terminal_view,
|
||||
session_bootstrapped,
|
||||
bootstrap_tx,
|
||||
bootstrap_rx: Some(bootstrap_rx),
|
||||
shared_session_id: None,
|
||||
session_share_rx,
|
||||
pending_share_requests: Vec::new(),
|
||||
@@ -362,6 +405,62 @@ impl TerminalDriver {
|
||||
.map(SerializedBlock::from)
|
||||
}
|
||||
|
||||
/// Full visible plaintext of `block_id`'s output grid (no ANSI escape
|
||||
/// sequences; secrets obfuscated). Used by the harness output monitor
|
||||
/// to detect whether the block has stalled — two byte-identical
|
||||
/// snapshots taken N seconds apart imply the harness has produced no
|
||||
/// new output and no spinner activity.
|
||||
///
|
||||
/// We intentionally pass `None` for `max_rows` so we compare the entire
|
||||
/// visible output; capping the row count could falsely report "stalled"
|
||||
/// when content scrolled below the cap actually changed.
|
||||
pub fn block_output_plaintext(&self, block_id: &BlockId, ctx: &AppContext) -> Option<String> {
|
||||
let terminal = self.terminal_view.as_ref(ctx);
|
||||
let model = terminal.model.lock();
|
||||
let block = model.block_list().block_with_id(block_id)?;
|
||||
Some(block.output_grid().contents_to_string(
|
||||
false, // include_escape_sequences
|
||||
None, // max_rows: full visible output
|
||||
))
|
||||
}
|
||||
|
||||
pub fn find_first_match_in_block_output(
|
||||
&self,
|
||||
block_id: &BlockId,
|
||||
dfas: &RegexDFAs,
|
||||
ctx: &AppContext,
|
||||
) -> Option<BlockOutputMatch> {
|
||||
let terminal = self.terminal_view.as_ref(ctx);
|
||||
let model = terminal.model.lock();
|
||||
let block = model.block_list().block_with_id(block_id)?;
|
||||
let grid = block.output_grid();
|
||||
let m = grid.find(dfas).next()?;
|
||||
let handler = grid.grid_handler();
|
||||
let matched_text = handler.bounds_to_string(
|
||||
*m.start(),
|
||||
*m.end(),
|
||||
false, // include_esc_sequences
|
||||
RespectObfuscatedSecrets::Yes,
|
||||
false, // force_secrets_obfuscated
|
||||
RespectDisplayedOutput::Yes,
|
||||
);
|
||||
let cols = handler.columns();
|
||||
let row_start = Point::new(m.start().row, 0);
|
||||
let row_end = Point::new(m.end().row, cols.saturating_sub(1));
|
||||
let excerpt = handler.bounds_to_string(
|
||||
row_start,
|
||||
row_end,
|
||||
false,
|
||||
RespectObfuscatedSecrets::Yes,
|
||||
false,
|
||||
RespectDisplayedOutput::Yes,
|
||||
);
|
||||
Some(BlockOutputMatch {
|
||||
matched_text: matched_text.trim().to_owned(),
|
||||
excerpt: excerpt.trim().to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute a command in the terminal and return a future that resolves to a
|
||||
/// [`CommandHandle`] once the command starts executing.
|
||||
pub fn execute_command(
|
||||
@@ -487,21 +586,32 @@ impl TerminalDriver {
|
||||
|
||||
/// Returns a future that resolves when the session has bootstrapped.
|
||||
///
|
||||
/// This only waits for the `SessionBootstrapped` terminal view event.
|
||||
/// The bootstrap result channel carries `Ok(())` on success or
|
||||
/// `Err(BootstrapError)` on failure. If the channel times out, the error
|
||||
/// is `BootstrapError::TimedOut`.
|
||||
pub fn wait_for_session_bootstrapped(
|
||||
&self,
|
||||
) -> impl Future<Output = Result<(), AgentDriverError>> {
|
||||
let session_bootstrapped = self.session_bootstrapped.clone();
|
||||
&mut self,
|
||||
) -> impl Future<Output = Result<(), BootstrapError>> {
|
||||
let bootstrap_rx = self.bootstrap_rx.take();
|
||||
|
||||
async move {
|
||||
session_bootstrapped
|
||||
.wait()
|
||||
.with_timeout(TERMINAL_SESSION_BOOTSTRAP_TIMEOUT)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
log::error!("Timed out waiting for session bootstrap");
|
||||
AgentDriverError::BootstrapFailed
|
||||
})
|
||||
let result = if let Some(rx) = bootstrap_rx {
|
||||
// Map channel cancellation (sender dropped without sending)
|
||||
// to InternalError — this shouldn't happen in practice.
|
||||
let inner = async move { rx.await.unwrap_or(Err(BootstrapError::InternalError)) };
|
||||
match inner.with_timeout(TERMINAL_SESSION_BOOTSTRAP_TIMEOUT).await {
|
||||
Ok(result) => result,
|
||||
Err(_timeout) => Err(BootstrapError::TimedOut),
|
||||
}
|
||||
} else {
|
||||
// bootstrap_rx already consumed — shouldn't happen in normal flow.
|
||||
Err(BootstrapError::InternalError)
|
||||
};
|
||||
|
||||
if let Err(ref e) = result {
|
||||
log::error!("Terminal bootstrap failed: {e}");
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,6 +655,42 @@ impl TerminalDriver {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extend_shared_session_retention(
|
||||
&mut self,
|
||||
reason: SessionRetentionReason,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.terminal_view.update(ctx, |terminal, ctx| {
|
||||
if !terminal
|
||||
.model
|
||||
.lock()
|
||||
.shared_session_status()
|
||||
.is_active_sharer()
|
||||
{
|
||||
log::warn!(
|
||||
"Tried to extend shared session retention before sharing was active: {reason:?}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
log::info!("Emitting request to extend shared session retention: {reason:?}");
|
||||
ctx.emit(Event::ExtendSessionRetention { reason });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The first DFA match returned by
|
||||
/// [`TerminalDriver::find_first_match_in_block_output`].
|
||||
///
|
||||
/// `matched_text` is the exact substring from the grid (no ANSI escapes),
|
||||
/// used by the harness output monitor to map the hit back to the originating
|
||||
/// pattern. `excerpt` is the full row(s) containing the match, also as
|
||||
/// plaintext, suitable for surfacing in user-visible error messages.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct BlockOutputMatch {
|
||||
pub matched_text: String,
|
||||
pub excerpt: String,
|
||||
}
|
||||
|
||||
/// A handle to a running terminal command.
|
||||
@@ -584,7 +730,28 @@ impl TerminalDriver {
|
||||
) {
|
||||
match event {
|
||||
crate::terminal::view::Event::SessionBootstrapped => {
|
||||
self.session_bootstrapped.set();
|
||||
if let Some(tx) = self.bootstrap_tx.take() {
|
||||
let _ = tx.send(Ok(()));
|
||||
}
|
||||
}
|
||||
crate::terminal::view::Event::PtySpawnFailed { reason } => {
|
||||
// Signal the bootstrap waiter immediately so it doesn't wait
|
||||
// for the full 60 s timeout when a spawn failure has already
|
||||
// been confirmed.
|
||||
if let Some(tx) = self.bootstrap_tx.take() {
|
||||
let _ = tx.send(Err(BootstrapError::PtySpawnFailed {
|
||||
reason: Some(reason.clone()),
|
||||
}));
|
||||
}
|
||||
}
|
||||
crate::terminal::view::Event::Exited => {
|
||||
// The shell process exited before bootstrap completed —
|
||||
// cancel the wait immediately rather than sitting out the
|
||||
// full 60 s timeout. No specific reason is known at this
|
||||
// point; the logs will have details.
|
||||
if let Some(tx) = self.bootstrap_tx.take() {
|
||||
let _ = tx.send(Err(BootstrapError::PtySpawnFailed { reason: None }));
|
||||
}
|
||||
}
|
||||
crate::terminal::view::Event::SlowBootstrap => {
|
||||
ctx.emit(TerminalDriverEvent::SlowBootstrap);
|
||||
@@ -646,3 +813,7 @@ impl TerminalDriver {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "terminal_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use session_sharing_protocol::sharer::SessionRetentionReason;
|
||||
use warpui::App;
|
||||
|
||||
use super::TerminalDriver;
|
||||
use crate::terminal::shared_session::SharedSessionStatus;
|
||||
use crate::terminal::view::Event;
|
||||
use crate::test_util::add_window_with_terminal;
|
||||
use crate::test_util::terminal::initialize_app_for_terminal_view;
|
||||
|
||||
#[test]
|
||||
fn extend_shared_session_retention_emits_event_for_active_sharer() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app_for_terminal_view(&mut app);
|
||||
|
||||
let terminal_view = add_window_with_terminal(&mut app, None);
|
||||
let terminal_driver =
|
||||
app.update(|ctx| TerminalDriver::create_from_existing_view(terminal_view.clone(), ctx));
|
||||
let emitted_reasons = Rc::new(RefCell::new(Vec::new()));
|
||||
|
||||
app.update(|ctx| {
|
||||
let emitted_reasons = emitted_reasons.clone();
|
||||
ctx.subscribe_to_view(&terminal_view, move |_, event, _| {
|
||||
if let Event::ExtendSessionRetention { reason } = event {
|
||||
emitted_reasons.borrow_mut().push(*reason);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
terminal_driver.update(&mut app, |driver, ctx| {
|
||||
driver.extend_shared_session_retention(SessionRetentionReason::SetupFailed, ctx);
|
||||
});
|
||||
|
||||
assert!(
|
||||
emitted_reasons.borrow().is_empty(),
|
||||
"retention should not be extended before session sharing is active"
|
||||
);
|
||||
|
||||
terminal_view.update(&mut app, |view, _| {
|
||||
view.model
|
||||
.lock()
|
||||
.set_shared_session_status(SharedSessionStatus::ActiveSharer);
|
||||
});
|
||||
|
||||
terminal_driver.update(&mut app, |driver, ctx| {
|
||||
driver.extend_shared_session_retention(SessionRetentionReason::SetupFailed, ctx);
|
||||
});
|
||||
|
||||
let emitted_reasons = emitted_reasons.borrow();
|
||||
assert_eq!(emitted_reasons.len(), 1);
|
||||
assert!(matches!(
|
||||
emitted_reasons[0],
|
||||
SessionRetentionReason::SetupFailed
|
||||
));
|
||||
});
|
||||
}
|
||||
@@ -1,24 +1,49 @@
|
||||
use std::{ffi::OsString, sync::Arc, time::Duration};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use cloud_object_models::CodeForge;
|
||||
use futures::channel::oneshot;
|
||||
use futures::executor::block_on;
|
||||
use repo_metadata::{DirectoryWatcher, RepoMetadataEvent, RepoMetadataModel, RepositoryIdentifier};
|
||||
use tempfile::TempDir;
|
||||
use galaxy_cli::agent::Harness;
|
||||
use galaxy_cli::mcp::MCPSpec;
|
||||
use galaxy_cli::skill::SkillSpec;
|
||||
use galaxy_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 galaxy_core::channel::ChannelState;
|
||||
use galaxy_graphql::mutations::create_managed_mcp_client_config::{
|
||||
CreateManagedMcpClientConfigOutput, ManagedMcpTransportKind,
|
||||
};
|
||||
use galaxy_graphql::response_context::ResponseContext;
|
||||
use galaxy_managed_secrets::ManagedSecretValue;
|
||||
use galaxy_util::standardized_path::StandardizedPath;
|
||||
use galaxyui::{App, SingletonEntity as _};
|
||||
|
||||
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,
|
||||
build_secret_env_vars, AgentDriver, AgentDriverError, 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;
|
||||
use crate::ai::agent::{
|
||||
task::TaskId, AIAgentActionResult, AIAgentActionResultType, AIAgentInput, AIAgentOutput,
|
||||
AIAgentActionResult, AIAgentActionResultType, AIAgentInput, AIAgentOutput,
|
||||
AIAgentOutputMessage, ArtifactCreatedData, MessageId, UploadArtifactResult,
|
||||
};
|
||||
use crate::ai::agent_sdk::task_env_vars;
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::ai::cloud_environments::{GithubRepo, SourceRepo};
|
||||
use crate::ai::mcp::parsing::normalize_mcp_json;
|
||||
use crate::ai::{agent_sdk::task_env_vars, ambient_agents::AmbientAgentTaskId};
|
||||
use crate::ai::mcp::JSONTransportType;
|
||||
use crate::ai::skills::SkillManager;
|
||||
use crate::server::server_api::managed_mcp::MockManagedMcpClient;
|
||||
use crate::test_util::terminal::{add_window_with_terminal, initialize_app_for_terminal_view};
|
||||
|
||||
#[test]
|
||||
fn test_normalize_single_cli_server() {
|
||||
@@ -109,6 +134,239 @@ fn test_normalize_sse_server_with_headers() {
|
||||
);
|
||||
}
|
||||
|
||||
fn managed_client_config_output(mcp_config_json: &str) -> CreateManagedMcpClientConfigOutput {
|
||||
CreateManagedMcpClientConfigOutput {
|
||||
transport_kind: ManagedMcpTransportKind::Command,
|
||||
mcp_config_json: mcp_config_json.to_string(),
|
||||
proxy_url: None,
|
||||
proxy_token: None,
|
||||
authorization_header_name: None,
|
||||
authorization_header_value: None,
|
||||
expires_at: None,
|
||||
response_context: ResponseContext {
|
||||
server_version: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_secret(value: &str) -> ManagedSecretValue {
|
||||
ManagedSecretValue::RawValue {
|
||||
value: value.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_installations(
|
||||
installations: Vec<crate::ai::mcp::TemplatableMCPServerInstallation>,
|
||||
secrets: HashMap<String, ManagedSecretValue>,
|
||||
) -> HashMap<String, crate::ai::mcp::JSONMCPServer> {
|
||||
AgentDriver::mcp_installations_to_json(installations, &secrets).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_resolver_local_uuid_does_not_call_managed_client() {
|
||||
let uuid = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
|
||||
let mock = MockManagedMcpClient::new();
|
||||
let local_installed_uuids = HashSet::from([uuid]);
|
||||
|
||||
let resolved = block_on(AgentDriver::resolve_mcp_specs_with_local_uuids(
|
||||
&[MCPSpec::Uuid(uuid)],
|
||||
&local_installed_uuids,
|
||||
Arc::new(mock),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resolved.local_uuids, vec![uuid]);
|
||||
assert!(resolved.ephemeral_installations.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_resolver_non_local_uuid_calls_managed_client() {
|
||||
let uuid = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
|
||||
let config_json =
|
||||
r#"{"mcpServers":{"GitHub MCP":{"command":"npx","env":{"API_TOKEN":"{{API_TOKEN}}"}}}}"#;
|
||||
let mut mock = MockManagedMcpClient::new();
|
||||
mock.expect_create_managed_mcp_client_config()
|
||||
.times(1)
|
||||
.returning(move |requested_uid| {
|
||||
assert_eq!(requested_uid, uuid);
|
||||
Ok(managed_client_config_output(config_json))
|
||||
});
|
||||
|
||||
let resolved = block_on(AgentDriver::resolve_mcp_specs_with_local_uuids(
|
||||
&[MCPSpec::Uuid(uuid)],
|
||||
&HashSet::new(),
|
||||
Arc::new(mock),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert!(resolved.local_uuids.is_empty());
|
||||
assert_eq!(resolved.ephemeral_installations.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_command_config_env_placeholder_uses_local_secret() {
|
||||
let installations = AgentDriver::installations_from_managed_client_config_json(
|
||||
r#"{"mcpServers":{"GitHub MCP":{"command":"npx","env":{"API_TOKEN":"{{API_TOKEN}}"}}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let rendered = render_installations(
|
||||
installations,
|
||||
HashMap::from([("API_TOKEN".to_string(), raw_secret("real"))]),
|
||||
);
|
||||
|
||||
match &rendered["GitHub MCP"].transport_type {
|
||||
JSONTransportType::CLIServer { env, .. } => {
|
||||
assert_eq!(env.get("API_TOKEN").map(String::as_str), Some("real"));
|
||||
}
|
||||
other => panic!("expected CLI server, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_command_config_arg_placeholder_uses_local_secret() {
|
||||
let installations = AgentDriver::installations_from_managed_client_config_json(
|
||||
r#"{"mcpServers":{"GitHub MCP":{"command":"npx","args":["--token={{API_TOKEN}}"]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let rendered = render_installations(
|
||||
installations,
|
||||
HashMap::from([("API_TOKEN".to_string(), raw_secret("real"))]),
|
||||
);
|
||||
|
||||
match &rendered["GitHub MCP"].transport_type {
|
||||
JSONTransportType::CLIServer { args, .. } => {
|
||||
assert_eq!(args, &vec!["--token=real".to_string()]);
|
||||
}
|
||||
other => panic!("expected CLI server, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_command_config_preserves_literal_env_when_synthesizing_arg_placeholder() {
|
||||
let installations = AgentDriver::installations_from_managed_client_config_json(
|
||||
r#"{"mcpServers":{"GitHub MCP":{"command":"npx","args":["--token={{API_TOKEN}}"],"env":{"LOG_LEVEL":"info"}}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let rendered = render_installations(
|
||||
installations,
|
||||
HashMap::from([("API_TOKEN".to_string(), raw_secret("real"))]),
|
||||
);
|
||||
|
||||
match &rendered["GitHub MCP"].transport_type {
|
||||
JSONTransportType::CLIServer { args, env, .. } => {
|
||||
assert_eq!(args, &vec!["--token=real".to_string()]);
|
||||
assert_eq!(env.get("LOG_LEVEL").map(String::as_str), Some("info"));
|
||||
}
|
||||
other => panic!("expected CLI server, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_url_config_preserves_proxy_url_and_header() {
|
||||
let installations = AgentDriver::installations_from_managed_client_config_json(
|
||||
r#"{"mcpServers":{"GitHub MCP":{"url":"https://proxy.example/mcp","headers":{"Authorization":"Bearer proxy-token"}}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let rendered = render_installations(installations, HashMap::new());
|
||||
|
||||
match &rendered["GitHub MCP"].transport_type {
|
||||
JSONTransportType::SSEServer { url, headers } => {
|
||||
assert_eq!(url, "https://proxy.example/mcp");
|
||||
assert_eq!(
|
||||
headers.get("Authorization").map(String::as_str),
|
||||
Some("Bearer proxy-token")
|
||||
);
|
||||
}
|
||||
other => panic!("expected SSE server, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_url_config_preserves_header_despite_colliding_local_secret() {
|
||||
// A server-rendered proxy header must not be overwritten by a local secret that
|
||||
// happens to share the header's key name (`apply_secrets` implicit key-name match).
|
||||
let installations = AgentDriver::installations_from_managed_client_config_json(
|
||||
r#"{"mcpServers":{"GitHub MCP":{"url":"https://proxy.example/mcp","headers":{"Authorization":"Bearer proxy-token"}}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let rendered = render_installations(
|
||||
installations,
|
||||
HashMap::from([("Authorization".to_string(), raw_secret("local-secret"))]),
|
||||
);
|
||||
|
||||
match &rendered["GitHub MCP"].transport_type {
|
||||
JSONTransportType::SSEServer { url, headers } => {
|
||||
assert_eq!(url, "https://proxy.example/mcp");
|
||||
assert_eq!(
|
||||
headers.get("Authorization").map(String::as_str),
|
||||
Some("Bearer proxy-token")
|
||||
);
|
||||
}
|
||||
other => panic!("expected SSE server, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_command_config_preserves_literal_env_despite_colliding_local_secret() {
|
||||
// A literal env value rendered by the server must survive even when a local secret
|
||||
// shares the env key name.
|
||||
let installations = AgentDriver::installations_from_managed_client_config_json(
|
||||
r#"{"mcpServers":{"GitHub MCP":{"command":"npx","env":{"LOG_LEVEL":"info"}}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let rendered = render_installations(
|
||||
installations,
|
||||
HashMap::from([("LOG_LEVEL".to_string(), raw_secret("debug"))]),
|
||||
);
|
||||
|
||||
match &rendered["GitHub MCP"].transport_type {
|
||||
JSONTransportType::CLIServer { env, .. } => {
|
||||
assert_eq!(env.get("LOG_LEVEL").map(String::as_str), Some("info"));
|
||||
}
|
||||
other => panic!("expected CLI server, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_command_config_missing_secret_leaves_placeholder() {
|
||||
let installations = AgentDriver::installations_from_managed_client_config_json(
|
||||
r#"{"mcpServers":{"GitHub MCP":{"command":"npx","args":["--token={{API_TOKEN}}"]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let rendered = render_installations(installations, HashMap::new());
|
||||
|
||||
match &rendered["GitHub MCP"].transport_type {
|
||||
JSONTransportType::CLIServer { args, .. } => {
|
||||
assert_eq!(args, &vec!["--token={{API_TOKEN}}".to_string()]);
|
||||
}
|
||||
other => panic!("expected CLI server, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_resolution_failure_includes_uid_and_message() {
|
||||
let uuid = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
|
||||
let mut mock = MockManagedMcpClient::new();
|
||||
mock.expect_create_managed_mcp_client_config()
|
||||
.times(1)
|
||||
.returning(|_| Err(anyhow::anyhow!("not active")));
|
||||
|
||||
let err = block_on(AgentDriver::resolve_mcp_specs_with_local_uuids(
|
||||
&[MCPSpec::Uuid(uuid)],
|
||||
&HashSet::new(),
|
||||
Arc::new(mock),
|
||||
))
|
||||
.unwrap_err();
|
||||
|
||||
match err {
|
||||
AgentDriverError::ManagedMcpResolutionFailed { uid, message } => {
|
||||
assert_eq!(uid, uuid);
|
||||
assert!(message.contains("not active"));
|
||||
}
|
||||
other => panic!("expected managed MCP resolution failure, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── IdleTimeoutSender tests ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
@@ -177,6 +435,53 @@ fn idle_timeout_sender_later_send_after_supersedes_earlier() {
|
||||
assert_eq!(rx.try_recv().unwrap(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_timeout_sender_complete_with_optional_idle_none_sends_immediately() {
|
||||
// `complete_with_optional_idle(None, value)` routes to `end_run_now` and
|
||||
// delivers `value` synchronously.
|
||||
let (tx, mut rx) = oneshot::channel::<i32>();
|
||||
let idle_timeout = IdleTimeoutSender::new(tx);
|
||||
idle_timeout.complete_with_optional_idle(None, 7);
|
||||
assert_eq!(rx.try_recv().unwrap(), Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_timeout_sender_complete_with_optional_idle_some_defers_then_delivers() {
|
||||
// `complete_with_optional_idle(Some(d), value)` routes to `end_run_after`
|
||||
// and defers delivery by `d`.
|
||||
let (tx, mut rx) = oneshot::channel::<i32>();
|
||||
let idle_timeout = IdleTimeoutSender::new(tx);
|
||||
idle_timeout.complete_with_optional_idle(Some(Duration::from_millis(50)), 7);
|
||||
|
||||
// Not delivered yet.
|
||||
assert_eq!(rx.try_recv().unwrap(), None);
|
||||
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
assert_eq!(rx.try_recv().unwrap(), Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_timeout_sender_complete_with_optional_idle_some_then_cancel_invalidates_timer() {
|
||||
// Cross-path cancellation: the Stage 2c skip-initial-turn driver path
|
||||
// schedules a deferred `Success` via `complete_with_optional_idle(Some(_), _)`
|
||||
// *before* the history subscription is wired up; a later
|
||||
// `AppendedExchange` in that subscription closure invalidates the timer
|
||||
// via `cancel_idle_timeout()`. The shared `Arc<AtomicUsize>` generation
|
||||
// counter is what makes that work across the two logical code paths.
|
||||
// This test exercises the same sequence in isolation: schedule via the
|
||||
// helper, then cancel via the unrelated `cancel_idle_timeout` entry point,
|
||||
// and verify the value is never delivered.
|
||||
let (tx, mut rx) = oneshot::channel::<i32>();
|
||||
let idle_timeout = IdleTimeoutSender::new(tx);
|
||||
idle_timeout.complete_with_optional_idle(Some(Duration::from_millis(50)), 7);
|
||||
idle_timeout.cancel_idle_timeout();
|
||||
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
// Sender was never consumed by the cancelled timer, so the channel is
|
||||
// still open but empty.
|
||||
assert_eq!(rx.try_recv().unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_env_vars_include_parent_run_id_when_present() {
|
||||
let task_id: AmbientAgentTaskId = "550e8400-e29b-41d4-a716-446655440000".parse().unwrap();
|
||||
@@ -386,3 +691,474 @@ fn json_format_input_omits_filepath_and_description_for_proto_upload_result() {
|
||||
assert!(value.get("filepath").is_none());
|
||||
assert!(value.get("description").is_none());
|
||||
}
|
||||
|
||||
// ── build_secret_env_vars tests ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn raw_value_only_writes_under_secret_name() {
|
||||
std::env::remove_var("MY_SECRET");
|
||||
let secrets = HashMap::from([(
|
||||
"MY_SECRET".to_string(),
|
||||
ManagedSecretValue::raw_value("s3cret"),
|
||||
)]);
|
||||
let env_vars = build_secret_env_vars(&secrets);
|
||||
assert_eq!(
|
||||
env_vars.get(&OsString::from("MY_SECRET")),
|
||||
Some(&OsString::from("s3cret"))
|
||||
);
|
||||
assert_eq!(env_vars.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn anthropic_api_key_writes_anthropic_env_var() {
|
||||
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||
let secrets = HashMap::from([(
|
||||
"my-custom-name".to_string(),
|
||||
ManagedSecretValue::anthropic_api_key("sk-ant-test-key"),
|
||||
)]);
|
||||
let env_vars = build_secret_env_vars(&secrets);
|
||||
assert_eq!(
|
||||
env_vars.get(&OsString::from("ANTHROPIC_API_KEY")),
|
||||
Some(&OsString::from("sk-ant-test-key"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn typed_secret_overrides_raw_value_with_same_env_name() {
|
||||
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||
let typed_key = "sk-ant-typed-key-abcdef";
|
||||
let raw_key = "sk-ant-raw-key-ghijkl";
|
||||
let secrets = HashMap::from([
|
||||
(
|
||||
"my-auth".to_string(),
|
||||
ManagedSecretValue::anthropic_api_key(typed_key),
|
||||
),
|
||||
(
|
||||
"ANTHROPIC_API_KEY".to_string(),
|
||||
ManagedSecretValue::raw_value(raw_key),
|
||||
),
|
||||
]);
|
||||
// Run multiple times to defeat HashMap iteration order flakiness.
|
||||
for _ in 0..20 {
|
||||
let env_vars = build_secret_env_vars(&secrets);
|
||||
assert_eq!(
|
||||
env_vars.get(&OsString::from("ANTHROPIC_API_KEY")),
|
||||
Some(&OsString::from(typed_key)),
|
||||
"Typed secret must always override RawValue with the same env name"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn bedrock_api_key_writes_all_bedrock_env_vars() {
|
||||
std::env::remove_var("AWS_BEARER_TOKEN_BEDROCK");
|
||||
std::env::remove_var("CLAUDE_CODE_USE_BEDROCK");
|
||||
std::env::remove_var("AWS_REGION");
|
||||
let secrets = HashMap::from([
|
||||
(
|
||||
"bedrock-secret".to_string(),
|
||||
ManagedSecretValue::anthropic_bedrock_api_key("token-123", "us-west-2"),
|
||||
),
|
||||
(
|
||||
"AWS_REGION".to_string(),
|
||||
ManagedSecretValue::raw_value("eu-west-1"),
|
||||
),
|
||||
]);
|
||||
let env_vars = build_secret_env_vars(&secrets);
|
||||
assert_eq!(
|
||||
env_vars.get(&OsString::from("AWS_BEARER_TOKEN_BEDROCK")),
|
||||
Some(&OsString::from("token-123"))
|
||||
);
|
||||
assert_eq!(
|
||||
env_vars.get(&OsString::from("CLAUDE_CODE_USE_BEDROCK")),
|
||||
Some(&OsString::from("1"))
|
||||
);
|
||||
assert_eq!(
|
||||
env_vars.get(&OsString::from("AWS_REGION")),
|
||||
Some(&OsString::from("us-west-2")),
|
||||
"Typed Bedrock secret should win over RawValue for AWS_REGION"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn bedrock_access_key_writes_all_aws_env_vars() {
|
||||
std::env::remove_var("AWS_ACCESS_KEY_ID");
|
||||
std::env::remove_var("AWS_SECRET_ACCESS_KEY");
|
||||
std::env::remove_var("AWS_SESSION_TOKEN");
|
||||
std::env::remove_var("CLAUDE_CODE_USE_BEDROCK");
|
||||
std::env::remove_var("AWS_REGION");
|
||||
let secrets = HashMap::from([(
|
||||
"bedrock-access".to_string(),
|
||||
ManagedSecretValue::anthropic_bedrock_access_key(
|
||||
"AKID",
|
||||
"secret-key",
|
||||
Some("session-tok".to_string()),
|
||||
"ap-southeast-1",
|
||||
),
|
||||
)]);
|
||||
let env_vars = build_secret_env_vars(&secrets);
|
||||
assert_eq!(
|
||||
env_vars.get(&OsString::from("AWS_ACCESS_KEY_ID")),
|
||||
Some(&OsString::from("AKID"))
|
||||
);
|
||||
assert_eq!(
|
||||
env_vars.get(&OsString::from("AWS_SECRET_ACCESS_KEY")),
|
||||
Some(&OsString::from("secret-key"))
|
||||
);
|
||||
assert_eq!(
|
||||
env_vars.get(&OsString::from("AWS_SESSION_TOKEN")),
|
||||
Some(&OsString::from("session-tok"))
|
||||
);
|
||||
assert_eq!(
|
||||
env_vars.get(&OsString::from("CLAUDE_CODE_USE_BEDROCK")),
|
||||
Some(&OsString::from("1"))
|
||||
);
|
||||
assert_eq!(
|
||||
env_vars.get(&OsString::from("AWS_REGION")),
|
||||
Some(&OsString::from("ap-southeast-1"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn raw_value_skipped_when_process_env_already_set() {
|
||||
std::env::set_var("WORKER_TOKEN", "injected-value");
|
||||
let secrets = HashMap::from([(
|
||||
"WORKER_TOKEN".to_string(),
|
||||
ManagedSecretValue::raw_value("managed-value"),
|
||||
)]);
|
||||
let env_vars = build_secret_env_vars(&secrets);
|
||||
// The worker-injected env var wins; env_vars should NOT contain it
|
||||
// because the child inherits the process env directly.
|
||||
assert!(!env_vars.contains_key(&OsString::from("WORKER_TOKEN")));
|
||||
std::env::remove_var("WORKER_TOKEN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn worker_injected_env_wins_over_typed_secret() {
|
||||
std::env::set_var("ANTHROPIC_API_KEY", "worker-key");
|
||||
let secrets = HashMap::from([(
|
||||
"my-auth".to_string(),
|
||||
ManagedSecretValue::anthropic_api_key("managed-key"),
|
||||
)]);
|
||||
let env_vars = build_secret_env_vars(&secrets);
|
||||
// The typed secret should be skipped entirely; the child inherits
|
||||
// ANTHROPIC_API_KEY from the process env.
|
||||
assert!(!env_vars.contains_key(&OsString::from("ANTHROPIC_API_KEY")));
|
||||
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn worker_injected_env_skips_entire_bedrock_secret() {
|
||||
// Only AWS_REGION is worker-injected; the entire Bedrock secret should
|
||||
// be atomically skipped — no partial insertion.
|
||||
std::env::set_var("AWS_REGION", "us-east-1");
|
||||
std::env::remove_var("AWS_BEARER_TOKEN_BEDROCK");
|
||||
std::env::remove_var("CLAUDE_CODE_USE_BEDROCK");
|
||||
let secrets = HashMap::from([(
|
||||
"bedrock-secret".to_string(),
|
||||
ManagedSecretValue::anthropic_bedrock_api_key("token-456", "eu-central-1"),
|
||||
)]);
|
||||
let env_vars = build_secret_env_vars(&secrets);
|
||||
assert!(
|
||||
!env_vars.contains_key(&OsString::from("AWS_BEARER_TOKEN_BEDROCK")),
|
||||
"Entire Bedrock secret must be skipped when any field is worker-injected"
|
||||
);
|
||||
assert!(!env_vars.contains_key(&OsString::from("CLAUDE_CODE_USE_BEDROCK")));
|
||||
assert!(!env_vars.contains_key(&OsString::from("AWS_REGION")));
|
||||
std::env::remove_var("AWS_REGION");
|
||||
}
|
||||
|
||||
// ── Skill-loading integration test ───────────────────────────────────────────
|
||||
|
||||
/// Verifies that `load_environment_skills` loads every skill from an env repo
|
||||
/// while `load_global_skills` loads only the explicitly requested subset from a
|
||||
/// global-only repo.
|
||||
///
|
||||
/// The test writes real SKILL.md files on disk, seeds `RepoMetadataModel` with a
|
||||
/// minimal file-tree for the env repo so the indexing wait resolves immediately,
|
||||
/// and drives both loading methods through a live `AgentDriver` model via a
|
||||
/// `ModelSpawner`.
|
||||
#[test]
|
||||
fn split_loading_env_loads_all_global_loads_subset() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app_for_terminal_view(&mut app);
|
||||
|
||||
// Create real skill files on disk.
|
||||
let temp = TempDir::new().unwrap();
|
||||
let working_dir = dunce::canonicalize(temp.path()).unwrap();
|
||||
|
||||
// Environment repo: three skills. All should be loaded.
|
||||
let env_repo = working_dir.join("env-repo");
|
||||
write_skill_file(&env_repo, "build");
|
||||
write_skill_file(&env_repo, "test-skill");
|
||||
write_skill_file(&env_repo, "deploy");
|
||||
|
||||
// Global-only repo: three skills; only "linter" is explicitly requested.
|
||||
let global_repo = working_dir.join("global-repo");
|
||||
write_skill_file(&global_repo, "linter");
|
||||
write_skill_file(&global_repo, "formatter");
|
||||
write_skill_file(&global_repo, "docs");
|
||||
|
||||
// Trigger a real filesystem scan of the env repo so `repository_indexed`
|
||||
// resolves immediately once indexing completes.
|
||||
let env_repo_std = StandardizedPath::from_local_canonicalized(&env_repo).unwrap();
|
||||
let repo_handle = DirectoryWatcher::handle(&app).update(&mut app, |watcher, ctx| {
|
||||
watcher.add_directory(env_repo_std.clone(), ctx).unwrap()
|
||||
});
|
||||
let (indexed_tx, indexed_rx) = futures::channel::oneshot::channel::<()>();
|
||||
let tx_cell = std::rc::Rc::new(std::cell::RefCell::new(Some(indexed_tx)));
|
||||
let env_repo_for_event = env_repo_std.clone();
|
||||
app.update(|ctx| {
|
||||
let tx_cell = tx_cell.clone();
|
||||
ctx.subscribe_to_model(
|
||||
&RepoMetadataModel::handle(ctx),
|
||||
move |_, event: &RepoMetadataEvent, _ctx| {
|
||||
if let RepoMetadataEvent::RepositoryUpdated {
|
||||
id: RepositoryIdentifier::Local(path),
|
||||
} = event
|
||||
{
|
||||
if *path == env_repo_for_event {
|
||||
if let Some(tx) = tx_cell.borrow_mut().take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
RepoMetadataModel::handle(&app).update(&mut app, |model: &mut RepoMetadataModel, ctx| {
|
||||
model.index_directory(repo_handle, ctx).unwrap();
|
||||
});
|
||||
indexed_rx.await.expect("env repo should be indexed");
|
||||
|
||||
// Construct a minimal AgentDriver backed by a stub terminal view.
|
||||
let terminal_view = add_window_with_terminal(&mut app, None);
|
||||
let driver_handle = app.add_model(|ctx| {
|
||||
let terminal_driver =
|
||||
super::terminal::TerminalDriver::create_from_existing_view(terminal_view, ctx);
|
||||
AgentDriver::new_for_test(working_dir.clone(), terminal_driver, ctx)
|
||||
});
|
||||
|
||||
// Run both loading methods through the driver's ModelSpawner.
|
||||
let (done_tx, done_rx) = futures::channel::oneshot::channel::<()>();
|
||||
let env_repos = vec![SourceRepo::new(
|
||||
CodeForge::GitHub,
|
||||
"org".to_string(),
|
||||
"env-repo".to_string(),
|
||||
)];
|
||||
let global_repos = vec![GithubRepo::new(
|
||||
"org".to_string(),
|
||||
"global-repo".to_string(),
|
||||
)];
|
||||
let global_specs: Vec<SkillSpec> = ["org/global-repo:linter".to_string()]
|
||||
.iter()
|
||||
.map(|s| s.parse().unwrap())
|
||||
.collect();
|
||||
driver_handle.update(&mut app, |_, ctx| {
|
||||
let spawner = ctx.spawner();
|
||||
ctx.spawn(
|
||||
async move {
|
||||
AgentDriver::load_environment_skills(&spawner, env_repos).await;
|
||||
AgentDriver::load_global_skills(&spawner, global_specs, global_repos).await;
|
||||
let _ = done_tx.send(());
|
||||
},
|
||||
|_, _, _| {},
|
||||
);
|
||||
});
|
||||
done_rx.await.expect("loading task should complete");
|
||||
|
||||
// Verify SkillManager contains the right skills.
|
||||
// is_cloud_environment=true (set by both loaders), so get_skills_for_working_directory
|
||||
// with cwd=None returns all registered skills.
|
||||
let skill_names = SkillManager::handle(&app).read(&app, |manager: &SkillManager, ctx| {
|
||||
manager
|
||||
.get_skills_for_working_directory(None, ctx)
|
||||
.into_iter()
|
||||
.map(|s| s.name.clone())
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
assert!(
|
||||
skill_names.contains(&"build".to_string()),
|
||||
"env skill 'build' should be loaded; got: {skill_names:?}"
|
||||
);
|
||||
assert!(
|
||||
skill_names.contains(&"test-skill".to_string()),
|
||||
"env skill 'test-skill' should be loaded; got: {skill_names:?}"
|
||||
);
|
||||
assert!(
|
||||
skill_names.contains(&"deploy".to_string()),
|
||||
"env skill 'deploy' should be loaded; got: {skill_names:?}"
|
||||
);
|
||||
assert!(
|
||||
skill_names.contains(&"linter".to_string()),
|
||||
"requested global skill 'linter' should be loaded; got: {skill_names:?}"
|
||||
);
|
||||
assert!(
|
||||
!skill_names.contains(&"formatter".to_string()),
|
||||
"unrequested global skill 'formatter' should NOT be loaded; got: {skill_names:?}"
|
||||
);
|
||||
assert!(
|
||||
!skill_names.contains(&"docs".to_string()),
|
||||
"unrequested global skill 'docs' should NOT be loaded; got: {skill_names:?}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Verifies that when a repo is in both the environment list and the global skill
|
||||
/// specs, all skills from that repo are loaded (environment wins), the targeted
|
||||
/// global skill is present, and no skill is registered more than once.
|
||||
#[test]
|
||||
fn overlap_repo_in_env_and_global_loads_all_skills_without_duplicates() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app_for_terminal_view(&mut app);
|
||||
|
||||
let temp = TempDir::new().unwrap();
|
||||
let working_dir = dunce::canonicalize(temp.path()).unwrap();
|
||||
|
||||
// A single repo with three skills, appearing in both the environment
|
||||
// and a global spec that targets only one of them.
|
||||
let shared_repo = working_dir.join("shared-repo");
|
||||
write_skill_file(&shared_repo, "deploy");
|
||||
write_skill_file(&shared_repo, "lint");
|
||||
write_skill_file(&shared_repo, "test-cmd");
|
||||
|
||||
// Index the repo so `load_environment_skills` can scan it.
|
||||
let shared_repo_std = StandardizedPath::from_local_canonicalized(&shared_repo).unwrap();
|
||||
let repo_handle = DirectoryWatcher::handle(&app).update(&mut app, |watcher, ctx| {
|
||||
watcher.add_directory(shared_repo_std.clone(), ctx).unwrap()
|
||||
});
|
||||
let (indexed_tx, indexed_rx) = futures::channel::oneshot::channel::<()>();
|
||||
let tx_cell = std::rc::Rc::new(std::cell::RefCell::new(Some(indexed_tx)));
|
||||
let shared_repo_for_event = shared_repo_std.clone();
|
||||
app.update(|ctx| {
|
||||
let tx_cell = tx_cell.clone();
|
||||
ctx.subscribe_to_model(
|
||||
&RepoMetadataModel::handle(ctx),
|
||||
move |_, event: &RepoMetadataEvent, _ctx| {
|
||||
if let RepoMetadataEvent::RepositoryUpdated {
|
||||
id: RepositoryIdentifier::Local(path),
|
||||
} = event
|
||||
{
|
||||
if *path == shared_repo_for_event {
|
||||
if let Some(tx) = tx_cell.borrow_mut().take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
RepoMetadataModel::handle(&app).update(&mut app, |model: &mut RepoMetadataModel, ctx| {
|
||||
model.index_directory(repo_handle, ctx).unwrap();
|
||||
});
|
||||
indexed_rx.await.expect("shared repo should be indexed");
|
||||
|
||||
let terminal_view = add_window_with_terminal(&mut app, None);
|
||||
let driver_handle = app.add_model(|ctx| {
|
||||
let terminal_driver =
|
||||
super::terminal::TerminalDriver::create_from_existing_view(terminal_view, ctx);
|
||||
AgentDriver::new_for_test(working_dir.clone(), terminal_driver, ctx)
|
||||
});
|
||||
|
||||
// The same repo is listed in both env repos and global repos.
|
||||
// The global spec targets only "deploy".
|
||||
let (done_tx, done_rx) = futures::channel::oneshot::channel::<()>();
|
||||
let env_repos = vec![SourceRepo::new(
|
||||
CodeForge::GitHub,
|
||||
"org".to_string(),
|
||||
"shared-repo".to_string(),
|
||||
)];
|
||||
let global_repos = vec![GithubRepo::new(
|
||||
"org".to_string(),
|
||||
"shared-repo".to_string(),
|
||||
)];
|
||||
let global_specs: Vec<SkillSpec> = ["org/shared-repo:deploy".to_string()]
|
||||
.iter()
|
||||
.map(|s| s.parse().unwrap())
|
||||
.collect();
|
||||
driver_handle.update(&mut app, |_, ctx| {
|
||||
let spawner = ctx.spawner();
|
||||
ctx.spawn(
|
||||
async move {
|
||||
AgentDriver::load_environment_skills(&spawner, env_repos).await;
|
||||
AgentDriver::load_global_skills(&spawner, global_specs, global_repos).await;
|
||||
let _ = done_tx.send(());
|
||||
},
|
||||
|_, _, _| {},
|
||||
);
|
||||
});
|
||||
done_rx.await.expect("loading task should complete");
|
||||
|
||||
let skill_names = SkillManager::handle(&app).read(&app, |manager: &SkillManager, ctx| {
|
||||
manager
|
||||
.get_skills_for_working_directory(None, ctx)
|
||||
.into_iter()
|
||||
.map(|s| s.name.clone())
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
// All three skills from the repo are present (env loading wins).
|
||||
assert!(
|
||||
skill_names.contains(&"deploy".to_string()),
|
||||
"'deploy' should be loaded; got: {skill_names:?}"
|
||||
);
|
||||
assert!(
|
||||
skill_names.contains(&"lint".to_string()),
|
||||
"'lint' should be loaded; got: {skill_names:?}"
|
||||
);
|
||||
assert!(
|
||||
skill_names.contains(&"test-cmd".to_string()),
|
||||
"'test-cmd' should be loaded; got: {skill_names:?}"
|
||||
);
|
||||
|
||||
// No skill is duplicated.
|
||||
let deploy_count = skill_names.iter().filter(|n| *n == "deploy").count();
|
||||
assert_eq!(
|
||||
deploy_count, 1,
|
||||
"'deploy' should appear exactly once; got: {skill_names:?}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Write a minimal SKILL.md at `{repo}/.agents/skills/{name}/SKILL.md`.
|
||||
/// The name is derived from the parent directory name, so no frontmatter is required.
|
||||
fn write_skill_file(repo: &Path, name: &str) {
|
||||
let skill_dir = repo.join(".agents").join("skills").join(name);
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
fs::write(skill_dir.join("SKILL.md"), format!("Skill: {name}.")).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn openai_api_key_exports_only_api_key_not_base_url() {
|
||||
// The OpenAI typed secret should only export OPENAI_API_KEY as an env var.
|
||||
// base_url is piped through the structured secret to the harness instead.
|
||||
std::env::remove_var("OPENAI_API_KEY");
|
||||
std::env::remove_var("OPENAI_BASE_URL");
|
||||
let secrets = HashMap::from([(
|
||||
"openai-key".to_string(),
|
||||
ManagedSecretValue::openai_api_key(
|
||||
"sk-test-key",
|
||||
Some("https://us.api.openai.com/v1".to_string()),
|
||||
),
|
||||
)]);
|
||||
let env_vars = build_secret_env_vars(&secrets);
|
||||
assert_eq!(
|
||||
env_vars.get(&OsString::from("OPENAI_API_KEY")),
|
||||
Some(&OsString::from("sk-test-key")),
|
||||
"OPENAI_API_KEY should be exported from the typed secret"
|
||||
);
|
||||
assert!(
|
||||
!env_vars.contains_key(&OsString::from("OPENAI_BASE_URL")),
|
||||
"OPENAI_BASE_URL should NOT be exported as an env var"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use comfy_table::Cell;
|
||||
use galaxy_cli::{
|
||||
agent::OutputFormat,
|
||||
environment::{EnvironmentCommand, ImageCommand},
|
||||
scope::ObjectScope,
|
||||
GlobalOptions,
|
||||
use cynic::QueryBuilder;
|
||||
use inquire::error::InquireError;
|
||||
use inquire::{Confirm, Select};
|
||||
use serde::Serialize;
|
||||
use galaxy_cli::agent::OutputFormat;
|
||||
use galaxy_cli::environment::{EnvironmentCommand, ImageCommand};
|
||||
use galaxy_cli::scope::ObjectScope;
|
||||
use galaxy_cli::GlobalOptions;
|
||||
use galaxy_graphql::queries::get_oauth_connect_tx_status::OauthConnectTxStatus;
|
||||
use galaxy_graphql::queries::list_warp_dev_images::{
|
||||
ListWarpDevImages, ListWarpDevImagesResult, ListWarpDevImagesVariables,
|
||||
};
|
||||
use galaxy_graphql::queries::user_repo_auth_status::UserRepoAuthStatusEnum;
|
||||
use galaxyui::r#async::FutureExt;
|
||||
use galaxyui::{AppContext, ModelContext, SingletonEntity};
|
||||
use inquire::{error::InquireError, Confirm, Select};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::ai::agent_sdk::output::{self, TableFormat};
|
||||
|
||||
use crate::ai::agent_sdk::driver::WARP_DRIVE_SYNC_TIMEOUT;
|
||||
use crate::ai::agent_sdk::oauth_flow::poll_oauth_until_terminal;
|
||||
use crate::ai::agent_sdk::output::{self, TableFormat};
|
||||
use crate::ai::cloud_environments::{
|
||||
AmbientAgentEnvironment, BaseImage, CloudAmbientAgentEnvironment,
|
||||
CloudAmbientAgentEnvironmentModel, GithubRepo,
|
||||
};
|
||||
use crate::auth::UserUid;
|
||||
use crate::cloud_object::model::generic_string_model::GenericStringObjectId;
|
||||
use crate::cloud_object::CloudObject;
|
||||
use crate::cloud_object::{CloudObject, CloudObjectLookup as _};
|
||||
use crate::server::cloud_objects::update_manager::{
|
||||
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
|
||||
};
|
||||
@@ -31,12 +35,6 @@ use crate::server::server_api::ServerApiProvider;
|
||||
use crate::util::time_format::format_approx_duration_from_now_utc;
|
||||
use crate::workspaces::user_profiles::UserProfiles;
|
||||
use crate::CloudObjectTypeAndId;
|
||||
use cynic::QueryBuilder;
|
||||
use galaxy_graphql::queries::get_oauth_connect_tx_status::OauthConnectTxStatus;
|
||||
use galaxy_graphql::queries::list_warp_dev_images::{
|
||||
ListWarpDevImages, ListWarpDevImagesResult, ListWarpDevImagesVariables,
|
||||
};
|
||||
use galaxy_graphql::queries::user_repo_auth_status::UserRepoAuthStatusEnum;
|
||||
|
||||
const WARP_DEV_ENVIRONMENTS_REPO: &str = "https://github.com/warpdotdev/warp-dev-environments";
|
||||
|
||||
@@ -756,7 +754,7 @@ impl EnvironmentCommandRunner {
|
||||
// We should subscribe to the UpdateManager here because we want to wait
|
||||
// for our environment to be assigned a ServerId. Environments are not
|
||||
// usable without first being synced.
|
||||
ctx.subscribe_to_model(&UpdateManager::handle(ctx), move |_, event, ctx| {
|
||||
ctx.subscribe_to_model(&UpdateManager::handle(ctx), move |_, _, event, ctx| {
|
||||
if let UpdateManagerEvent::ObjectOperationComplete { result } = event {
|
||||
if matches!(result.operation, ObjectOperation::Create { .. })
|
||||
&& matches!(result.success_type, OperationSuccessType::Success)
|
||||
@@ -1001,7 +999,7 @@ impl EnvironmentCommandRunner {
|
||||
});
|
||||
|
||||
// Subscribe to UpdateManager to wait for the update to complete
|
||||
ctx.subscribe_to_model(&UpdateManager::handle(ctx), move |_, event, ctx| {
|
||||
ctx.subscribe_to_model(&UpdateManager::handle(ctx), move |_, _, event, ctx| {
|
||||
if let UpdateManagerEvent::ObjectOperationComplete { result } = event {
|
||||
if matches!(result.operation, ObjectOperation::Update)
|
||||
&& result.server_id == Some(server_id)
|
||||
@@ -1087,7 +1085,7 @@ impl EnvironmentCommandRunner {
|
||||
});
|
||||
|
||||
// Listen to the UpdateManager for a completed object deletion
|
||||
ctx.subscribe_to_model(&UpdateManager::handle(ctx), move |_, event, ctx| {
|
||||
ctx.subscribe_to_model(&UpdateManager::handle(ctx), move |_, _, event, ctx| {
|
||||
if let UpdateManagerEvent::ObjectOperationComplete { result } = event {
|
||||
if matches!(result.operation, ObjectOperation::Delete { .. }) {
|
||||
match result.success_type {
|
||||
|
||||
@@ -7,6 +7,12 @@ use galaxy_core::{features::FeatureFlag, report_error};
|
||||
use galaxy_managed_secrets::ManagedSecretManager;
|
||||
use galaxyui::{platform::TerminationMode, AppContext, SingletonEntity as _};
|
||||
use serde_json::json;
|
||||
use galaxy_cli::agent::OutputFormat;
|
||||
use galaxy_cli::GlobalOptions;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::report_error;
|
||||
use galaxyui::platform::TerminationMode;
|
||||
use galaxyui::{AppContext, SingletonEntity as _};
|
||||
|
||||
use super::common::set_ambient_task_context_from_run_id;
|
||||
|
||||
@@ -30,7 +36,7 @@ fn issue_token(
|
||||
args: IssueTokenArgs,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
// Set the task ID so the ambient workload token header is sent.
|
||||
// Set the task ID so the ambient agent workload token header is sent.
|
||||
set_ambient_task_context_from_run_id(ctx, &args.run_id)?;
|
||||
|
||||
let duration: std::time::Duration = args.duration.into();
|
||||
@@ -85,7 +91,7 @@ fn issue_token(
|
||||
Ok(())
|
||||
}
|
||||
fn issue_gcp_token(ctx: &mut AppContext, args: IssueGcpTokenArgs) -> Result<()> {
|
||||
// Set the task ID so the ambient workload token header is sent.
|
||||
// Set the task ID so the ambient agent workload token header is sent.
|
||||
set_ambient_task_context_from_run_id(ctx, &args.run_id)?;
|
||||
|
||||
let duration: std::time::Duration = args.duration.into();
|
||||
|
||||
@@ -7,11 +7,12 @@ use anyhow::Result;
|
||||
use galaxy_cli::agent::OutputFormat;
|
||||
use galaxy_cli::harness_support::{
|
||||
FinishTaskArgs, HarnessSupportArgs, HarnessSupportCommand, NotifyUserArgs, ReportArtifactArgs,
|
||||
ReportArtifactCommand, TaskStatus,
|
||||
ReportArtifactCommand, ReportShutdownArgs, TaskStatus,
|
||||
};
|
||||
use galaxy_cli::GlobalOptions;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::{platform::TerminationMode, AppContext, ModelHandle, SingletonEntity};
|
||||
use galaxyui::platform::TerminationMode;
|
||||
use galaxyui::{AppContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use super::common::set_ambient_task_context_from_run_id;
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
@@ -43,6 +44,9 @@ pub fn run(
|
||||
HarnessSupportCommand::FinishTask(finish_args) => {
|
||||
finish_task(ctx, runner, finish_args, global_options.output_format)
|
||||
}
|
||||
HarnessSupportCommand::ReportShutdown(shutdown_args) => {
|
||||
report_shutdown(ctx, runner, shutdown_args, global_options.output_format)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +207,53 @@ fn finish_task(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Report that the agent process is shutting down.
|
||||
///
|
||||
/// Routes to `report_clean_shutdown` or `report_error_shutdown` on the API client
|
||||
/// depending on whether error arguments were provided.
|
||||
fn report_shutdown(
|
||||
ctx: &mut AppContext,
|
||||
runner: ModelHandle<HarnessSupportRunner>,
|
||||
args: ReportShutdownArgs,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
runner.update(ctx, |_, ctx| {
|
||||
let client = ServerApiProvider::as_ref(ctx).get_harness_support_client();
|
||||
|
||||
ctx.spawn(
|
||||
async move {
|
||||
match (args.error_category, args.error_message) {
|
||||
(Some(category), Some(message)) => {
|
||||
client.report_error_shutdown(category, message).await
|
||||
}
|
||||
(None, None) => client.report_clean_shutdown().await,
|
||||
_ => anyhow::bail!(
|
||||
"--error-category and --error-message must be provided together"
|
||||
),
|
||||
}
|
||||
},
|
||||
move |_, result, ctx| match result {
|
||||
Ok(()) => {
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Ndjson => {
|
||||
println!("{{}}");
|
||||
}
|
||||
OutputFormat::Pretty | OutputFormat::Text => {
|
||||
println!("Shutdown reported.");
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use futures::future;
|
||||
use galaxy_cli::{
|
||||
integration::{CreateIntegrationArgs, IntegrationCommand, UpdateIntegrationArgs},
|
||||
provider::ProviderType,
|
||||
GlobalOptions,
|
||||
};
|
||||
use galaxy_cli::integration::{CreateIntegrationArgs, IntegrationCommand, UpdateIntegrationArgs};
|
||||
use galaxy_cli::provider::ProviderType;
|
||||
use galaxy_cli::GlobalOptions;
|
||||
use galaxy_graphql::mutations::create_simple_integration::CreateSimpleIntegrationOutput;
|
||||
use galaxy_graphql::queries::get_oauth_connect_tx_status::OauthConnectTxStatus;
|
||||
use galaxy_graphql::queries::get_simple_integrations::SimpleIntegrationsOutput;
|
||||
use galaxyui::{platform::TerminationMode, AppContext, ModelContext, SingletonEntity};
|
||||
use galaxyui::platform::TerminationMode;
|
||||
use galaxyui::{AppContext, ModelContext, SingletonEntity};
|
||||
|
||||
use super::common::{EnvironmentChoice, ResolveConfigurationError};
|
||||
use super::integration_output;
|
||||
use super::oauth_flow::poll_oauth_until_terminal;
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
|
||||
pub fn run(
|
||||
ctx: &mut AppContext,
|
||||
@@ -104,6 +103,8 @@ impl IntegrationCommandRunner {
|
||||
crate::ai::ambient_agents::AgentConfigSnapshot {
|
||||
name: None,
|
||||
environment_id: args.environment.environment.clone(),
|
||||
// TODO(REMOTE-1936): support a runner for integrations.
|
||||
runner_id: None,
|
||||
model_id: args.model.model.clone(),
|
||||
base_prompt: args.prompt.clone(),
|
||||
mcp_servers: cli_mcp_servers,
|
||||
@@ -416,6 +417,8 @@ impl IntegrationCommandRunner {
|
||||
crate::ai::ambient_agents::AgentConfigSnapshot {
|
||||
name: None,
|
||||
environment_id: args.environment.environment.clone(),
|
||||
// TODO(REMOTE-1936): support a runner for integrations.
|
||||
runner_id: None,
|
||||
model_id: args.model.model.clone(),
|
||||
base_prompt: args.prompt.clone(),
|
||||
mcp_servers: cli_mcp_servers,
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use comfy_table::{presets::UTF8_FULL, Cell, Table};
|
||||
use galaxy_cli::agent::OutputFormat;
|
||||
use comfy_table::presets::UTF8_FULL;
|
||||
use comfy_table::{Cell, Table};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::ai::agent_sdk::output::{self, TableFormat};
|
||||
use crate::util::time_format::format_approx_duration_from_now_utc;
|
||||
use galaxy_cli::agent::OutputFormat;
|
||||
use galaxy_graphql::queries::get_simple_integrations::{
|
||||
ListedSimpleIntegrationConfig, SimpleIntegration, SimpleIntegrationConnectionStatus,
|
||||
SimpleIntegrationsOutput,
|
||||
};
|
||||
|
||||
use crate::ai::agent_sdk::output::{self, TableFormat};
|
||||
use crate::util::time_format::format_approx_duration_from_now_utc;
|
||||
|
||||
const MAX_LINE_WIDTH: usize = 90;
|
||||
|
||||
/// Print simple integrations.
|
||||
|
||||
@@ -2,6 +2,8 @@ use comfy_table::Cell;
|
||||
use galaxy_cli::{mcp::MCPCommand, GlobalOptions};
|
||||
use galaxyui::{AppContext, ModelContext, SingletonEntity};
|
||||
use serde::Serialize;
|
||||
use galaxy_cli::mcp::MCPCommand;
|
||||
use galaxy_cli::GlobalOptions;
|
||||
|
||||
use crate::ai::agent_sdk::output::{self, TableFormat};
|
||||
use crate::ai::mcp::TemplatableMCPServerManager;
|
||||
|
||||
@@ -260,6 +260,7 @@ fn validation_rejects_invalid_entries() {
|
||||
|
||||
#[test]
|
||||
fn serializes_mcp_servers_as_object_not_string() {
|
||||
use crate::ai::agent::UserQueryMode;
|
||||
use crate::ai::ambient_agents::AgentConfigSnapshot;
|
||||
use crate::server::server_api::ai::SpawnAgentRequest;
|
||||
|
||||
@@ -269,19 +270,25 @@ fn serializes_mcp_servers_as_object_not_string() {
|
||||
.unwrap();
|
||||
|
||||
let request = SpawnAgentRequest {
|
||||
prompt: "hello".to_string(),
|
||||
prompt: Some("hello".to_string()),
|
||||
mode: UserQueryMode::Normal,
|
||||
config: Some(AgentConfigSnapshot {
|
||||
mcp_servers: Some(mcp_servers),
|
||||
..Default::default()
|
||||
}),
|
||||
title: None,
|
||||
team: None,
|
||||
agent_identity_uid: None,
|
||||
skill: None,
|
||||
attachments: vec![],
|
||||
interactive: None,
|
||||
parent_run_id: None,
|
||||
runtime_skills: vec![],
|
||||
referenced_attachments: vec![],
|
||||
conversation_id: None,
|
||||
initial_snapshot_token: None,
|
||||
snapshot_disabled: None,
|
||||
orchestration_handoff: None,
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&request).unwrap();
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
use anyhow::Result;
|
||||
use comfy_table::Cell;
|
||||
use serde::Serialize;
|
||||
use warp_cli::agent::OutputFormat;
|
||||
use warp_cli::memory_store::{
|
||||
CreateMemoryArgs, DeleteMemoryArgs, GetStoreArgs, ListMemoriesArgs, ListStoreAgentsArgs,
|
||||
ListVersionsArgs, MemoryCommand, MemoryStoreCommand, UpdateMemoryArgs, UpdateStoreArgs,
|
||||
};
|
||||
use warp_cli::GlobalOptions;
|
||||
use warpui::platform::TerminationMode;
|
||||
use warpui::{AppContext, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::ai::agent_sdk::output::{self, TableFormat};
|
||||
use crate::server::server_api::ai::{
|
||||
AIClient, AgentAttachmentItem, CreateMemoryRequest, CreateMemoryResponse, MemoryItem,
|
||||
MemorySource, MemoryStoreItem, MemoryVersionItem, UpdateMemoryRequest, UpdateMemoryResponse,
|
||||
UpdateMemoryStoreRequest,
|
||||
};
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::util::time_format::format_approx_duration_from_now_utc;
|
||||
|
||||
/// Run memory-store related commands.
|
||||
pub fn run(
|
||||
ctx: &mut AppContext,
|
||||
global_options: GlobalOptions,
|
||||
command: MemoryStoreCommand,
|
||||
) -> Result<()> {
|
||||
let runner = ctx.add_singleton_model(|_ctx| MemoryStoreCommandRunner);
|
||||
match command {
|
||||
MemoryStoreCommand::List => {
|
||||
runner.update(ctx, |runner, ctx| {
|
||||
runner.list_stores(global_options.output_format, ctx)
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
MemoryStoreCommand::Get(args) => {
|
||||
runner.update(ctx, |runner, ctx| {
|
||||
runner.get_store(global_options.output_format, args, ctx)
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
MemoryStoreCommand::Update(args) => {
|
||||
runner.update(ctx, |runner, ctx| {
|
||||
runner.update_store(global_options.output_format, args, ctx)
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
MemoryStoreCommand::ListStoreAgents(args) => {
|
||||
runner.update(ctx, |runner, ctx| {
|
||||
runner.list_store_agents(global_options.output_format, args, ctx)
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run memory related commands.
|
||||
pub fn run_memory(
|
||||
ctx: &mut AppContext,
|
||||
global_options: GlobalOptions,
|
||||
command: MemoryCommand,
|
||||
) -> Result<()> {
|
||||
let runner = ctx.add_singleton_model(|_ctx| MemoryStoreCommandRunner);
|
||||
match command {
|
||||
MemoryCommand::List(args) => {
|
||||
runner.update(ctx, |runner, ctx| {
|
||||
runner.list_memories(global_options.output_format, args, ctx)
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
MemoryCommand::Create(args) => {
|
||||
runner.update(ctx, |runner, ctx| {
|
||||
runner.create_memory(global_options.output_format, args, ctx)
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
MemoryCommand::Update(args) => {
|
||||
runner.update(ctx, |runner, ctx| {
|
||||
runner.update_memory(global_options.output_format, args, ctx)
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
MemoryCommand::Delete(args) => {
|
||||
runner.update(ctx, |runner, ctx| {
|
||||
runner.delete_memory(global_options.output_format, args, ctx)
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
MemoryCommand::Versions(args) => {
|
||||
runner.update(ctx, |runner, ctx| {
|
||||
runner.list_versions(global_options.output_format, args, ctx)
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MemoryStoreCommandRunner;
|
||||
|
||||
impl MemoryStoreCommandRunner {
|
||||
fn list_stores(&self, output_format: OutputFormat, ctx: &mut ModelContext<Self>) {
|
||||
let server_api = ServerApiProvider::as_ref(ctx).get();
|
||||
|
||||
ctx.spawn(
|
||||
async move {
|
||||
let stores = server_api.list_memory_stores().await?;
|
||||
print_memory_stores(stores, output_format);
|
||||
Ok(())
|
||||
},
|
||||
|_, result: Result<()>, ctx| finish_command(result, ctx),
|
||||
);
|
||||
}
|
||||
|
||||
fn list_memories(
|
||||
&self,
|
||||
output_format: OutputFormat,
|
||||
args: ListMemoriesArgs,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let server_api = ServerApiProvider::as_ref(ctx).get();
|
||||
|
||||
ctx.spawn(
|
||||
async move {
|
||||
let memories = server_api
|
||||
.list_memory_store_memories(&args.store_uid)
|
||||
.await?;
|
||||
print_memories(memories, output_format);
|
||||
Ok(())
|
||||
},
|
||||
|_, result: Result<()>, ctx| finish_command(result, ctx),
|
||||
);
|
||||
}
|
||||
|
||||
fn create_memory(
|
||||
&self,
|
||||
output_format: OutputFormat,
|
||||
args: CreateMemoryArgs,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let server_api = ServerApiProvider::as_ref(ctx).get();
|
||||
|
||||
ctx.spawn(
|
||||
async move {
|
||||
let request = CreateMemoryRequest {
|
||||
content: args.content,
|
||||
version: args.version,
|
||||
source: MemorySource::Manual,
|
||||
source_id: None,
|
||||
reason: args.reason,
|
||||
};
|
||||
let response = server_api
|
||||
.create_memory_store_memory(&args.store_uid, request)
|
||||
.await?;
|
||||
print_create_memory_response(response, output_format)?;
|
||||
Ok(())
|
||||
},
|
||||
|_, result: Result<()>, ctx| finish_command(result, ctx),
|
||||
);
|
||||
}
|
||||
|
||||
fn get_store(
|
||||
&self,
|
||||
output_format: OutputFormat,
|
||||
args: GetStoreArgs,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let server_api = ServerApiProvider::as_ref(ctx).get();
|
||||
ctx.spawn(
|
||||
async move {
|
||||
let store = server_api.get_memory_store(&args.store_uid).await?;
|
||||
match output_format {
|
||||
OutputFormat::Json => output::write_json(&store, std::io::stdout())?,
|
||||
OutputFormat::Ndjson => output::write_json_line(&store, std::io::stdout())?,
|
||||
OutputFormat::Pretty | OutputFormat::Text => {
|
||||
output::print_list([store], output_format);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
|_, result: Result<()>, ctx| finish_command(result, ctx),
|
||||
);
|
||||
}
|
||||
|
||||
fn update_store(
|
||||
&self,
|
||||
output_format: OutputFormat,
|
||||
args: UpdateStoreArgs,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let server_api = ServerApiProvider::as_ref(ctx).get();
|
||||
ctx.spawn(
|
||||
async move {
|
||||
let request = UpdateMemoryStoreRequest {
|
||||
description: args.description,
|
||||
};
|
||||
let store = server_api
|
||||
.update_memory_store(&args.store_uid, request)
|
||||
.await?;
|
||||
match output_format {
|
||||
OutputFormat::Json => output::write_json(&store, std::io::stdout())?,
|
||||
OutputFormat::Ndjson => output::write_json_line(&store, std::io::stdout())?,
|
||||
OutputFormat::Pretty | OutputFormat::Text => {
|
||||
println!("Updated store {}.", store.uid);
|
||||
output::print_list([store], output_format);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
|_, result: Result<()>, ctx| finish_command(result, ctx),
|
||||
);
|
||||
}
|
||||
|
||||
fn list_store_agents(
|
||||
&self,
|
||||
output_format: OutputFormat,
|
||||
args: ListStoreAgentsArgs,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let server_api = ServerApiProvider::as_ref(ctx).get();
|
||||
ctx.spawn(
|
||||
async move {
|
||||
let agents = server_api.list_memory_store_agents(&args.store_uid).await?;
|
||||
if agents.is_empty()
|
||||
&& matches!(output_format, OutputFormat::Pretty | OutputFormat::Text)
|
||||
{
|
||||
println!("No agents attached to this store.");
|
||||
return Ok(());
|
||||
}
|
||||
output::print_list(agents, output_format);
|
||||
Ok(())
|
||||
},
|
||||
|_, result: Result<()>, ctx| finish_command(result, ctx),
|
||||
);
|
||||
}
|
||||
|
||||
fn list_versions(
|
||||
&self,
|
||||
output_format: OutputFormat,
|
||||
args: ListVersionsArgs,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let server_api = ServerApiProvider::as_ref(ctx).get();
|
||||
ctx.spawn(
|
||||
async move {
|
||||
let versions = server_api
|
||||
.list_memory_versions(&args.store_uid, &args.memory_uid)
|
||||
.await?;
|
||||
if versions.is_empty()
|
||||
&& matches!(output_format, OutputFormat::Pretty | OutputFormat::Text)
|
||||
{
|
||||
println!("No versions found.");
|
||||
return Ok(());
|
||||
}
|
||||
output::print_list(versions, output_format);
|
||||
Ok(())
|
||||
},
|
||||
|_, result: Result<()>, ctx| finish_command(result, ctx),
|
||||
);
|
||||
}
|
||||
|
||||
fn update_memory(
|
||||
&self,
|
||||
output_format: OutputFormat,
|
||||
args: UpdateMemoryArgs,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let server_api = ServerApiProvider::as_ref(ctx).get();
|
||||
|
||||
ctx.spawn(
|
||||
async move {
|
||||
let request = UpdateMemoryRequest {
|
||||
content: args.content,
|
||||
version: args.version,
|
||||
reason: args.reason,
|
||||
};
|
||||
let response = server_api
|
||||
.update_memory_store_memory(&args.store_uid, &args.memory_uid, request)
|
||||
.await?;
|
||||
print_update_memory_response(response, output_format)?;
|
||||
Ok(())
|
||||
},
|
||||
|_, result: Result<()>, ctx| finish_command(result, ctx),
|
||||
);
|
||||
}
|
||||
|
||||
fn delete_memory(
|
||||
&self,
|
||||
output_format: OutputFormat,
|
||||
args: DeleteMemoryArgs,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let server_api = ServerApiProvider::as_ref(ctx).get();
|
||||
|
||||
ctx.spawn(
|
||||
async move {
|
||||
server_api
|
||||
.delete_memory_store_memory(&args.store_uid, &args.memory_uid)
|
||||
.await?;
|
||||
let output = DeleteMemoryOutput {
|
||||
memory_uid: args.memory_uid.clone(),
|
||||
};
|
||||
match output_format {
|
||||
OutputFormat::Json => output::write_json(&output, std::io::stdout())?,
|
||||
OutputFormat::Ndjson => output::write_json_line(&output, std::io::stdout())?,
|
||||
OutputFormat::Pretty | OutputFormat::Text => {
|
||||
println!("Deleted memory {}.", args.memory_uid);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
|_, result: Result<()>, ctx| finish_command(result, ctx),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl warpui::Entity for MemoryStoreCommandRunner {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for MemoryStoreCommandRunner {}
|
||||
|
||||
impl TableFormat for MemoryStoreItem {
|
||||
fn header() -> Vec<Cell> {
|
||||
vec![
|
||||
Cell::new("UID"),
|
||||
Cell::new("Owner Type"),
|
||||
Cell::new("Owner UID"),
|
||||
Cell::new("Description"),
|
||||
Cell::new("Created"),
|
||||
Cell::new("Updated"),
|
||||
]
|
||||
}
|
||||
|
||||
fn row(&self) -> Vec<Cell> {
|
||||
vec![
|
||||
Cell::new(&self.uid),
|
||||
Cell::new(&self.owner_type),
|
||||
Cell::new(&self.owner_uid),
|
||||
Cell::new(self.description.as_deref().unwrap_or("")),
|
||||
Cell::new(format_approx_duration_from_now_utc(self.created_at)),
|
||||
Cell::new(format_approx_duration_from_now_utc(self.updated_at)),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
impl TableFormat for MemoryVersionItem {
|
||||
fn header() -> Vec<Cell> {
|
||||
vec![
|
||||
Cell::new("UID"),
|
||||
Cell::new("Version"),
|
||||
Cell::new("Content"),
|
||||
Cell::new("Reason"),
|
||||
Cell::new("Created"),
|
||||
]
|
||||
}
|
||||
|
||||
fn row(&self) -> Vec<Cell> {
|
||||
vec![
|
||||
Cell::new(&self.uid),
|
||||
Cell::new(&self.version),
|
||||
Cell::new(&self.content),
|
||||
Cell::new(self.reason.as_deref().unwrap_or("")),
|
||||
Cell::new(format_approx_duration_from_now_utc(self.created_at)),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
impl TableFormat for AgentAttachmentItem {
|
||||
fn header() -> Vec<Cell> {
|
||||
vec![
|
||||
Cell::new("UID"),
|
||||
Cell::new("Name"),
|
||||
Cell::new("Access"),
|
||||
Cell::new("Instructions"),
|
||||
]
|
||||
}
|
||||
|
||||
fn row(&self) -> Vec<Cell> {
|
||||
vec![
|
||||
Cell::new(&self.uid),
|
||||
Cell::new(&self.name),
|
||||
Cell::new(&self.access),
|
||||
Cell::new(&self.instructions),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
impl TableFormat for MemoryItem {
|
||||
fn header() -> Vec<Cell> {
|
||||
vec![
|
||||
Cell::new("UID"),
|
||||
Cell::new("Version"),
|
||||
Cell::new("Source"),
|
||||
Cell::new("Content"),
|
||||
Cell::new("Created"),
|
||||
Cell::new("Updated"),
|
||||
]
|
||||
}
|
||||
|
||||
fn row(&self) -> Vec<Cell> {
|
||||
vec![
|
||||
Cell::new(&self.uid),
|
||||
Cell::new(&self.version_id),
|
||||
Cell::new(&self.source),
|
||||
Cell::new(&self.content),
|
||||
Cell::new(format_approx_duration_from_now_utc(self.created_at)),
|
||||
Cell::new(format_approx_duration_from_now_utc(self.updated_at)),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DeleteMemoryOutput {
|
||||
memory_uid: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CreateMemoryOutput {
|
||||
memory_id: String,
|
||||
version_id: String,
|
||||
}
|
||||
|
||||
impl From<CreateMemoryResponse> for CreateMemoryOutput {
|
||||
fn from(response: CreateMemoryResponse) -> Self {
|
||||
Self {
|
||||
memory_id: response.memory_id,
|
||||
version_id: response.version_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TableFormat for CreateMemoryOutput {
|
||||
fn header() -> Vec<Cell> {
|
||||
vec![Cell::new("Memory ID"), Cell::new("Version ID")]
|
||||
}
|
||||
|
||||
fn row(&self) -> Vec<Cell> {
|
||||
vec![Cell::new(&self.memory_id), Cell::new(&self.version_id)]
|
||||
}
|
||||
}
|
||||
|
||||
fn print_memory_stores(stores: Vec<MemoryStoreItem>, output_format: OutputFormat) {
|
||||
if stores.is_empty() && matches!(output_format, OutputFormat::Pretty | OutputFormat::Text) {
|
||||
println!("No memory stores found.");
|
||||
return;
|
||||
}
|
||||
output::print_list(stores, output_format);
|
||||
}
|
||||
|
||||
fn print_memories(memories: Vec<MemoryItem>, output_format: OutputFormat) {
|
||||
if memories.is_empty() && matches!(output_format, OutputFormat::Pretty | OutputFormat::Text) {
|
||||
println!("No memories found.");
|
||||
return;
|
||||
}
|
||||
output::print_list(memories, output_format);
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct UpdateMemoryOutput {
|
||||
memory_id: String,
|
||||
version_id: String,
|
||||
}
|
||||
|
||||
impl From<UpdateMemoryResponse> for UpdateMemoryOutput {
|
||||
fn from(response: UpdateMemoryResponse) -> Self {
|
||||
Self {
|
||||
memory_id: response.memory_id,
|
||||
version_id: response.version_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TableFormat for UpdateMemoryOutput {
|
||||
fn header() -> Vec<Cell> {
|
||||
vec![Cell::new("Memory ID"), Cell::new("Version ID")]
|
||||
}
|
||||
|
||||
fn row(&self) -> Vec<Cell> {
|
||||
vec![Cell::new(&self.memory_id), Cell::new(&self.version_id)]
|
||||
}
|
||||
}
|
||||
|
||||
fn print_update_memory_response(
|
||||
response: UpdateMemoryResponse,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let output = UpdateMemoryOutput::from(response);
|
||||
match output_format {
|
||||
OutputFormat::Json => output::write_json(&output, std::io::stdout())?,
|
||||
OutputFormat::Ndjson => output::write_json_line(&output, std::io::stdout())?,
|
||||
OutputFormat::Pretty | OutputFormat::Text => {
|
||||
println!("Updated memory {}.", output.memory_id);
|
||||
output::print_list([output], output_format);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_create_memory_response(
|
||||
response: CreateMemoryResponse,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let output = CreateMemoryOutput::from(response);
|
||||
match output_format {
|
||||
OutputFormat::Json => output::write_json(&output, std::io::stdout())?,
|
||||
OutputFormat::Ndjson => output::write_json_line(&output, std::io::stdout())?,
|
||||
OutputFormat::Pretty | OutputFormat::Text => {
|
||||
println!("Created memory {}.", output.memory_id);
|
||||
output::print_list([output], output_format);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish_command(result: Result<()>, ctx: &mut ModelContext<MemoryStoreCommandRunner>) {
|
||||
match result {
|
||||
Ok(()) => ctx.terminate_app(TerminationMode::ForceTerminate, None),
|
||||
Err(err) => super::report_fatal_error(err, ctx),
|
||||
}
|
||||
}
|
||||
+466
-176
@@ -7,6 +7,40 @@ use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use ai::api_keys::{ApiKeyManager, AwsCredentialsRefreshStrategy};
|
||||
use anyhow::Context;
|
||||
pub(crate) use driver::harness::{task_env_vars, validate_cli_installed, ClaudeHarness};
|
||||
pub use driver::AgentDriver;
|
||||
use driver::AgentDriverError;
|
||||
use telemetry::CliTelemetryEvent;
|
||||
use tracing::Instrument as _;
|
||||
use warp_cli::agent::{
|
||||
AgentCommand, AgentProfileCommand, Harness, OutputFormat, Prompt, RunAgentArgs,
|
||||
};
|
||||
use warp_cli::api_key::ApiKeyCommand;
|
||||
use warp_cli::artifact::ArtifactCommand;
|
||||
use warp_cli::environment::{EnvironmentCommand, ImageCommand};
|
||||
use warp_cli::federate::FederateCommand;
|
||||
use warp_cli::harness_support::{HarnessSupportCommand, ReportArtifactCommand, TaskStatus};
|
||||
use warp_cli::integration::IntegrationCommand;
|
||||
use warp_cli::mcp::MCPCommand;
|
||||
use warp_cli::memory_store::{MemoryCommand, MemoryStoreCommand};
|
||||
use warp_cli::model::ModelCommand;
|
||||
use warp_cli::provider::ProviderCommand;
|
||||
use warp_cli::schedule::ScheduleSubcommand;
|
||||
use warp_cli::secret::SecretCommand;
|
||||
use warp_cli::share::ShareRequest;
|
||||
use warp_cli::task::{MessageCommand, TaskCommand};
|
||||
use warp_cli::{CliCommand, GlobalOptions, OZ_HARNESS_ENV};
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use warp_graphql::object_permissions::OwnerType;
|
||||
use warp_isolation_platform::IsolationPlatformError;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use warp_logging::log_file_path;
|
||||
use warp_managed_secrets::ManagedSecretManager;
|
||||
use warpui::platform::TerminationMode;
|
||||
use warpui::{AppContext, ModelSpawner, SingletonEntity};
|
||||
|
||||
use crate::ai::agent::api::convert_conversation::{
|
||||
convert_conversation_data_to_ai_conversation, RestorationMode,
|
||||
};
|
||||
@@ -15,69 +49,35 @@ use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent_sdk::driver::harness::{harness_kind, HarnessKind};
|
||||
use crate::ai::agent_sdk::driver::{AgentDriverOptions, AgentRunPrompt, Task};
|
||||
use crate::ai::agent_sdk::mcp_config::build_mcp_servers_from_specs;
|
||||
use crate::ai::agent_sdk::setup_observability::{
|
||||
OzRunTimelineEvent, SetupClientEventReporter, SetupStep,
|
||||
};
|
||||
use crate::ai::ambient_agents::task::HarnessConfig;
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::ai::attachment_utils::attachments_download_dir;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::aws_credentials::refresh_aws_credentials;
|
||||
use crate::ai::cloud_environments::CloudAmbientAgentEnvironment;
|
||||
use crate::ai::llms::LLMId;
|
||||
use crate::auth::auth_manager::{AuthManager, AuthManagerEvent};
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::server::server_api::ai::AIClient;
|
||||
use crate::workflows::workflow::Workflow;
|
||||
use ai::api_keys::{ApiKeyManager, AwsCredentialsRefreshStrategy};
|
||||
use anyhow::Context;
|
||||
use galaxy_cli::{
|
||||
agent::{AgentCommand, AgentProfileCommand, OutputFormat},
|
||||
artifact::ArtifactCommand,
|
||||
environment::{EnvironmentCommand, ImageCommand},
|
||||
federate::FederateCommand,
|
||||
harness_support::{HarnessSupportCommand, ReportArtifactCommand, TaskStatus},
|
||||
integration::IntegrationCommand,
|
||||
mcp::MCPCommand,
|
||||
model::ModelCommand,
|
||||
provider::ProviderCommand,
|
||||
schedule::ScheduleSubcommand,
|
||||
secret::SecretCommand,
|
||||
share::ShareRequest,
|
||||
task::{MessageCommand, TaskCommand},
|
||||
CliCommand, GlobalOptions,
|
||||
};
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_isolation_platform::IsolationPlatformError;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use galaxy_logging::log_file_path;
|
||||
use galaxy_managed_secrets::ManagedSecretManager;
|
||||
use galaxyui::ModelSpawner;
|
||||
use galaxyui::{platform::TerminationMode, AppContext, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
ai::ambient_agents::{task::HarnessConfig, AmbientAgentTaskId},
|
||||
ai::cloud_environments::CloudAmbientAgentEnvironment,
|
||||
auth::AuthStateProvider,
|
||||
send_telemetry_sync_from_app_ctx,
|
||||
server::{
|
||||
ids::{ServerId, SyncId},
|
||||
server_api::{ai::AgentConfigSnapshot, ServerApiProvider},
|
||||
},
|
||||
terminal::view::ConversationRestorationInNewPaneType,
|
||||
};
|
||||
use driver::AgentDriverError;
|
||||
use galaxy_graphql::object_permissions::OwnerType;
|
||||
|
||||
use crate::ai::attachment_utils::attachments_download_dir;
|
||||
use crate::ai::skills::{
|
||||
clone_repo_for_skill, resolve_skill_spec, ResolveSkillError, ResolvedSkill,
|
||||
};
|
||||
|
||||
pub(crate) use driver::harness::{
|
||||
task_env_vars, validate_cli_installed, ClaudeHarness, ThirdPartyHarness,
|
||||
};
|
||||
pub use driver::AgentDriver;
|
||||
use galaxy_cli::agent::{Harness, Prompt, RunAgentArgs};
|
||||
use galaxy_cli::OZ_HARNESS_ENV;
|
||||
use telemetry::CliTelemetryEvent;
|
||||
use crate::auth::auth_manager::{AuthManager, AuthManagerEvent};
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::CloudObjectLookup as _;
|
||||
use crate::send_telemetry_sync_from_app_ctx;
|
||||
use crate::server::ids::{ServerId, SyncId};
|
||||
use crate::server::server_api::ai::{AIClient, AgentConfigSnapshot, GitCredential};
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::terminal::view::ConversationRestorationInNewPaneType;
|
||||
use crate::workflows::workflow::Workflow;
|
||||
|
||||
mod admin;
|
||||
mod agent_config;
|
||||
mod agent_management;
|
||||
mod ambient;
|
||||
mod api_key;
|
||||
mod artifact;
|
||||
pub(crate) mod artifact_upload;
|
||||
mod common;
|
||||
@@ -92,6 +92,7 @@ mod integration;
|
||||
mod integration_output;
|
||||
mod mcp;
|
||||
mod mcp_config;
|
||||
mod memory_store;
|
||||
mod model;
|
||||
mod oauth_flow;
|
||||
pub mod output;
|
||||
@@ -100,6 +101,7 @@ mod provider;
|
||||
pub(crate) mod retry;
|
||||
mod schedule;
|
||||
mod secret;
|
||||
pub(crate) mod setup_observability;
|
||||
mod telemetry;
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
@@ -120,6 +122,7 @@ fn maybe_warn_team_api_key(ctx: &AppContext) {
|
||||
}
|
||||
|
||||
/// Run a Warp CLI command.
|
||||
#[tracing::instrument(name = "agent_sdk::run", skip_all, err, fields(tags.cloud_agent = true))]
|
||||
pub fn run(
|
||||
ctx: &mut AppContext,
|
||||
command: CliCommand,
|
||||
@@ -148,6 +151,10 @@ fn dispatch_command(
|
||||
CliCommand::MCP(mcp_cmd) => mcp::run(ctx, global_options, mcp_cmd),
|
||||
CliCommand::Run(task_cmd) => run_task(ctx, global_options, task_cmd),
|
||||
CliCommand::Model(model_cmd) => model::run(ctx, global_options, model_cmd),
|
||||
CliCommand::MemoryStore(memory_store_cmd) => {
|
||||
memory_store::run(ctx, global_options, memory_store_cmd)
|
||||
}
|
||||
CliCommand::Memory(memory_cmd) => memory_store::run_memory(ctx, global_options, memory_cmd),
|
||||
CliCommand::Login => admin::login(ctx),
|
||||
CliCommand::Logout => admin::logout(ctx),
|
||||
CliCommand::Whoami => admin::whoami(ctx, global_options.output_format),
|
||||
@@ -198,6 +205,12 @@ fn dispatch_command(
|
||||
}
|
||||
artifact::run(ctx, global_options, artifact_cmd)
|
||||
}
|
||||
CliCommand::ApiKey(api_key_cmd) => {
|
||||
if !FeatureFlag::APIKeyManagement.is_enabled() {
|
||||
return Err(anyhow::anyhow!("invalid value 'api-key'"));
|
||||
}
|
||||
api_key::run(ctx, global_options, api_key_cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,10 +318,28 @@ fn run_agent(
|
||||
"--claude-auth-secret is only valid with --harness claude."
|
||||
));
|
||||
}
|
||||
if args.runner.is_some() && !FeatureFlag::CloudRunners.is_enabled() {
|
||||
return Err(anyhow::anyhow!("unexpected argument '--runner' found"));
|
||||
}
|
||||
ambient::run_ambient_agent(ctx, args)
|
||||
}
|
||||
AgentCommand::Profile(sub) => profiles::run(ctx, global_options, sub),
|
||||
AgentCommand::List(args) => agent_config::list_agents(ctx, args),
|
||||
AgentCommand::List(args) => {
|
||||
agent_management::list_agents(ctx, global_options.output_format, args)
|
||||
}
|
||||
AgentCommand::Get(args) => {
|
||||
agent_management::get_agent(ctx, global_options.output_format, args)
|
||||
}
|
||||
AgentCommand::Create(args) => {
|
||||
agent_management::create_agent(ctx, global_options.output_format, args)
|
||||
}
|
||||
AgentCommand::Update(args) => {
|
||||
agent_management::update_agent(ctx, global_options.output_format, args)
|
||||
}
|
||||
AgentCommand::Delete(args) => {
|
||||
agent_management::delete_agent(ctx, global_options.output_format, args)
|
||||
}
|
||||
AgentCommand::Skills(args) => agent_config::list_skills(ctx, args),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,21 +368,44 @@ fn build_merged_config_and_task(
|
||||
// Merge precedence: file < CLI < skill
|
||||
let file_merged = config_file::merge_with_precedence(loaded_file.as_ref(), Default::default());
|
||||
|
||||
// Runner support is gated. The `run` command has no `--runner` flag, but a
|
||||
// config file can still set `runner_id`, so reject it when the flag is off.
|
||||
if file_merged.runner_id.is_some() && !FeatureFlag::CloudRunners.is_enabled() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"`runner_id` is set in the config file but runner support is not enabled"
|
||||
));
|
||||
}
|
||||
|
||||
// Skill provides base_prompt and optionally name
|
||||
let (skill_name, runtime_base_prompt) = match resolved_skill {
|
||||
Some(skill) => (Some(skill.name.clone()), Some(skill.instructions.clone())),
|
||||
None => (None, None),
|
||||
};
|
||||
|
||||
// When a non-Oz harness is active, --model targets the harness rather than the Oz model.
|
||||
let harness_model_id = if args.harness != Harness::Oz {
|
||||
args.model.model.clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let harness_override = (args.harness != Harness::Oz).then_some(HarnessConfig {
|
||||
harness_type: args.harness,
|
||||
model_id: harness_model_id,
|
||||
reasoning_level: None,
|
||||
});
|
||||
|
||||
let oz_model = if args.harness == Harness::Oz {
|
||||
args.model.model.clone().or(file_merged.model_id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut merged_config = AgentConfigSnapshot {
|
||||
// CLI name > skill name > file name
|
||||
name: args.name.clone().or(skill_name).or(file_merged.name),
|
||||
environment_id: args.environment.clone().or(file_merged.environment_id),
|
||||
model_id: args.model.model.clone().or(file_merged.model_id),
|
||||
runner_id: file_merged.runner_id,
|
||||
model_id: oz_model,
|
||||
// Skill base_prompt takes precedence over file base_prompt
|
||||
base_prompt: runtime_base_prompt.clone().or(file_merged.base_prompt),
|
||||
mcp_servers: config_file::merge_mcp_servers(file_merged.mcp_servers, cli_mcp_servers),
|
||||
@@ -374,6 +428,7 @@ fn build_merged_config_and_task(
|
||||
let model_override: Option<LLMId> = merged_config
|
||||
.model_id
|
||||
.as_deref()
|
||||
.filter(|_| args.harness == Harness::Oz)
|
||||
.map(|model_id| common::validate_agent_mode_base_model_id(model_id, ctx))
|
||||
.transpose()?;
|
||||
|
||||
@@ -420,15 +475,25 @@ fn build_server_side_task(
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
let model_override: Option<LLMId> = args
|
||||
.model
|
||||
.model
|
||||
.as_deref()
|
||||
.map(|model_id| common::validate_agent_mode_base_model_id(model_id, ctx))
|
||||
.transpose()?;
|
||||
let harness_model_id = if args.harness != Harness::Oz {
|
||||
args.model.model.clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let model_override: Option<LLMId> = if args.harness == Harness::Oz {
|
||||
args.model
|
||||
.model
|
||||
.as_deref()
|
||||
.map(|model_id| common::validate_agent_mode_base_model_id(model_id, ctx))
|
||||
.transpose()?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let harness_override = (args.harness != Harness::Oz).then_some(HarnessConfig {
|
||||
harness_type: args.harness,
|
||||
model_id: harness_model_id,
|
||||
reasoning_level: None,
|
||||
});
|
||||
|
||||
let skill_name = resolved_skill.as_ref().map(|s| s.name.clone());
|
||||
@@ -439,6 +504,7 @@ fn build_server_side_task(
|
||||
let config = AgentConfigSnapshot {
|
||||
name: args.name.clone().or(skill_name),
|
||||
environment_id: environment.clone(),
|
||||
runner_id: None,
|
||||
model_id: model_id_string,
|
||||
base_prompt: None,
|
||||
mcp_servers: cli_mcp_servers,
|
||||
@@ -466,6 +532,24 @@ fn build_server_side_task(
|
||||
Ok((config, task))
|
||||
}
|
||||
|
||||
fn reconcile_task_harness(
|
||||
task_id: &str,
|
||||
selected_harness: &mut Harness,
|
||||
task_harness: Harness,
|
||||
) -> Result<HarnessKind, AgentDriverError> {
|
||||
if *selected_harness == Harness::Oz {
|
||||
*selected_harness = task_harness;
|
||||
} else if task_harness != *selected_harness {
|
||||
return Err(AgentDriverError::TaskHarnessMismatch {
|
||||
task_id: task_id.to_string(),
|
||||
expected: task_harness.to_string(),
|
||||
got: selected_harness.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
harness_kind(*selected_harness)
|
||||
}
|
||||
|
||||
/// Resolve a `Prompt` to a plain string.
|
||||
fn resolve_prompt(prompt: &Prompt, ctx: &AppContext) -> Result<String, AgentDriverError> {
|
||||
match prompt {
|
||||
@@ -515,14 +599,7 @@ fn run_task(
|
||||
}
|
||||
}
|
||||
}
|
||||
TaskCommand::Message(message_cmd) => {
|
||||
if !FeatureFlag::OrchestrationV2.is_enabled() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"The 'message' subcommand is not available in this build"
|
||||
));
|
||||
}
|
||||
ambient::run_message(ctx, global_options, message_cmd)
|
||||
}
|
||||
TaskCommand::Message(message_cmd) => ambient::run_message(ctx, global_options, message_cmd),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,53 +615,80 @@ impl galaxyui::Entity for AgentDriverRunner {
|
||||
impl galaxyui::SingletonEntity for AgentDriverRunner {}
|
||||
|
||||
impl AgentDriverRunner {
|
||||
#[tracing::instrument(skip_all, err, fields(
|
||||
tags.cloud_agent = true,
|
||||
args.sandboxed = args.sandboxed,
|
||||
args.computer_use = args.computer_use.computer_use,
|
||||
args.no_computer_use = args.computer_use.no_computer_use
|
||||
))]
|
||||
async fn setup_and_run_driver(
|
||||
foreground: ModelSpawner<Self>,
|
||||
args: RunAgentArgs,
|
||||
server_api: Arc<dyn AIClient>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<(), AgentDriverError> {
|
||||
// Extract the task ID as early as possible for best-effort setup observability.
|
||||
// Local CLI-created runs may not have a task yet, so those setup events explicitly no-op.
|
||||
let mut task_id: Option<AmbientAgentTaskId> =
|
||||
args.task_id.as_deref().and_then(|s| s.parse().ok());
|
||||
Self::set_ambient_agent_task_id(&foreground, task_id).await?;
|
||||
let background = foreground.spawn(|_, ctx| ctx.background_executor()).await?;
|
||||
let setup_events = match task_id {
|
||||
Some(task_id) => SetupClientEventReporter::new(task_id, server_api.clone(), background),
|
||||
None => SetupClientEventReporter::noop(server_api.clone(), background),
|
||||
};
|
||||
setup_events
|
||||
.post_timeline_event(OzRunTimelineEvent::WorkerContainerReady)
|
||||
.await;
|
||||
|
||||
// Ensure we've synced team state before starting the driver.
|
||||
Self::refresh_team_metadata(&foreground).await?;
|
||||
setup_events
|
||||
.record_result(
|
||||
SetupStep::TeamMetadataRefresh,
|
||||
Self::refresh_team_metadata(&foreground),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Wait for Galaxy Drive to sync before building the task config, since
|
||||
// prompt resolution (SavedPrompt -> workflow lookup) and environment
|
||||
// resolution (CloudAmbientAgentEnvironment lookup) depend on it.
|
||||
if foreground
|
||||
.spawn(|_, ctx| common::refresh_warp_drive(ctx))
|
||||
.await?
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(AgentDriverError::WarpDriveSyncFailed);
|
||||
}
|
||||
|
||||
// Extract the task ID if available, so that if there are setup errors and we have
|
||||
// a server-provided task ID, we can report them. If we create a task for a local CLI
|
||||
// run, its ID will be stored in the inner future.
|
||||
let mut task_id: Option<AmbientAgentTaskId> =
|
||||
args.task_id.as_deref().and_then(|s| s.parse().ok());
|
||||
setup_events
|
||||
.record_result(SetupStep::WarpDriveSync, async {
|
||||
if foreground
|
||||
.spawn(|_, ctx| common::refresh_warp_drive(ctx))
|
||||
.await?
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(AgentDriverError::WarpDriveSyncFailed);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Set up and run the driver, reporting any errors back to the server.
|
||||
let result: Result<(), AgentDriverError> = async {
|
||||
// Pull relevant variables out of args before moving it into the closure.
|
||||
let share_requests = args.share.share.clone();
|
||||
let bedrock_inference_role = args.bedrock_inference_role.clone();
|
||||
let bedrock_role_region = args.bedrock_role_region.clone();
|
||||
let has_task_id = args.task_id.is_some();
|
||||
let args_harness = args.harness;
|
||||
|
||||
// `--conversation` path (user-invoked local resume): validate before any task side
|
||||
// effects so mismatches fail fast. The `--task-id` path derives its conversation id
|
||||
// from the server-side task metadata inside `build_driver_options_and_task`. Both
|
||||
// can currently be passed together (the worker server-side appends `--conversation`
|
||||
// alongside `--task-id` for Slack/Linear followups); when both are set, the explicit
|
||||
// `--conversation` value wins via the merge below.
|
||||
if let Some(conversation_id) = args.conversation.as_deref() {
|
||||
common::fetch_and_validate_conversation_harness(
|
||||
server_api.clone(),
|
||||
conversation_id,
|
||||
args_harness,
|
||||
)
|
||||
.await?;
|
||||
if !has_task_id {
|
||||
if let Some(conversation_id) = args.conversation.as_deref() {
|
||||
common::fetch_and_validate_conversation_harness(
|
||||
server_api.clone(),
|
||||
conversation_id,
|
||||
args_harness,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
let resume_conversation_id = args.conversation.clone();
|
||||
|
||||
@@ -593,7 +697,8 @@ impl AgentDriverRunner {
|
||||
// the fetched `AmbientAgentTask` (set by the server when linking the task to an
|
||||
// existing conversation, e.g. via `run-cloud --conversation`).
|
||||
let (mut driver_options, task, task_conversation_id) =
|
||||
Self::build_driver_options_and_task(&foreground, args, &server_api).await?;
|
||||
Self::build_driver_options_and_task(&foreground, args, &server_api, &setup_events)
|
||||
.await?;
|
||||
|
||||
// Update the effective task ID so errors are reported correctly.
|
||||
// This only matters if we created a task ID locally.
|
||||
@@ -609,6 +714,16 @@ impl AgentDriverRunner {
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
if let Some(role_arn) = bedrock_inference_role {
|
||||
// clap's `requires` constraint enforces this at parse time, so a missing
|
||||
// region here means a caller is constructing `RunAgentArgs` directly
|
||||
// without the flag. Fail loudly so callers don't silently fall back to a
|
||||
// hard-coded STS region.
|
||||
let role_region = bedrock_role_region.ok_or_else(|| {
|
||||
AgentDriverError::AwsBedrockCredentialsFailed(
|
||||
"--bedrock-role-region is required when --bedrock-inference-role is set"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
// Set the OIDC strategy on the UI thread and kick off the refresh; the
|
||||
// returned future resolves when credentials are committed to the model.
|
||||
let refresh_future = foreground
|
||||
@@ -619,6 +734,7 @@ impl AgentDriverRunner {
|
||||
AwsCredentialsRefreshStrategy::OidcManaged {
|
||||
task_id: bedrock_task_id,
|
||||
role_arn,
|
||||
region: role_region,
|
||||
},
|
||||
);
|
||||
refresh_aws_credentials(manager, ctx)
|
||||
@@ -654,12 +770,16 @@ impl AgentDriverRunner {
|
||||
|
||||
// Pull conversation information, if we have it
|
||||
if let Some(conversation_id) = resume_conversation_id {
|
||||
driver_options.resume = Self::load_conversation_information(
|
||||
&foreground,
|
||||
conversation_id,
|
||||
&task.harness,
|
||||
)
|
||||
.await?;
|
||||
driver_options.resume = setup_events
|
||||
.record_result(
|
||||
SetupStep::ConversationResumeLoading,
|
||||
Self::load_conversation_information(
|
||||
&foreground,
|
||||
conversation_id,
|
||||
&task.harness,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Run the driver
|
||||
@@ -701,6 +821,114 @@ impl AgentDriverRunner {
|
||||
.map_err(|_| AgentDriverError::TeamMetadataRefreshTimeout)
|
||||
}
|
||||
|
||||
async fn set_ambient_agent_task_id(
|
||||
foreground: &ModelSpawner<Self>,
|
||||
task_id: Option<AmbientAgentTaskId>,
|
||||
) -> Result<(), AgentDriverError> {
|
||||
foreground
|
||||
.spawn(move |_, ctx| {
|
||||
ServerApiProvider::handle(ctx)
|
||||
.as_ref(ctx)
|
||||
.get()
|
||||
.set_ambient_agent_task_id(task_id);
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_task_git_credentials(
|
||||
task_id_str: String,
|
||||
ai_client: Arc<dyn AIClient>,
|
||||
) -> anyhow::Result<Vec<GitCredential>> {
|
||||
let workload_token = warp_isolation_platform::issue_workload_token(Some(
|
||||
std::time::Duration::from_secs(5 * 60),
|
||||
))
|
||||
.await?
|
||||
.token;
|
||||
ai_client
|
||||
.get_task_git_credentials(task_id_str, workload_token)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn bootstrap_git_credentials_for_task(
|
||||
foreground: &ModelSpawner<Self>,
|
||||
task_id_str: &str,
|
||||
args: &RunAgentArgs,
|
||||
) -> Result<(), AgentDriverError> {
|
||||
if warp_isolation_platform::detect().is_none() && args.configure_git_credentials_with_github
|
||||
{
|
||||
foreground
|
||||
.spawn(|_, _| {
|
||||
command::blocking::Command::new("gh")
|
||||
.args(["auth", "setup-git"])
|
||||
.spawn()
|
||||
.map_err(|err| {
|
||||
AgentDriverError::ConfigBuildFailed(anyhow::anyhow!(
|
||||
"gh auth setup-git failed: {err:?}"
|
||||
))
|
||||
})
|
||||
})
|
||||
.await?
|
||||
.map(|_| ())?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !FeatureFlag::GitCredentialRefresh.is_enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if task_id_str.parse::<AmbientAgentTaskId>().is_err() {
|
||||
log::debug!(
|
||||
"Skipping git credentials bootstrap: could not parse task ID '{task_id_str}'"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (ai_client, task_id_str) = foreground
|
||||
.spawn({
|
||||
let task_id_str = task_id_str.to_string();
|
||||
move |_, ctx| {
|
||||
let ai_client = ServerApiProvider::handle(ctx)
|
||||
.as_ref(ctx)
|
||||
.get_ai_client()
|
||||
.clone();
|
||||
(ai_client, task_id_str)
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
let credentials = match Self::fetch_task_git_credentials(task_id_str, ai_client).await {
|
||||
Ok(credentials) => credentials,
|
||||
Err(err)
|
||||
if err
|
||||
.downcast_ref::<IsolationPlatformError>()
|
||||
.is_some_and(|err| {
|
||||
matches!(err, IsolationPlatformError::NoIsolationPlatformDetected)
|
||||
}) =>
|
||||
{
|
||||
log::debug!("Skipping git credentials bootstrap: {err}");
|
||||
return Ok(());
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(AgentDriverError::SkillResolutionFailed(format!(
|
||||
"Failed to fetch git credentials before skill resolution: {err:#}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
if credentials.is_empty() {
|
||||
log::debug!("No git credentials returned before skill resolution");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
driver::git_credentials::configure_git_credentials(&credentials).map_err(|err| {
|
||||
AgentDriverError::SkillResolutionFailed(format!(
|
||||
"Failed to write git credentials before skill resolution: {err:#}"
|
||||
))
|
||||
})?;
|
||||
log::info!("Git credentials configured before task setup");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the skill spec from args, if one was provided.
|
||||
///
|
||||
/// In sandboxed mode with a fully-qualified spec (org + repo), the repo is
|
||||
@@ -710,6 +938,7 @@ impl AgentDriverRunner {
|
||||
foreground: &ModelSpawner<Self>,
|
||||
args: &RunAgentArgs,
|
||||
working_dir: &Path,
|
||||
setup_events: &SetupClientEventReporter,
|
||||
) -> Result<Option<ResolvedSkill>, AgentDriverError> {
|
||||
if !FeatureFlag::OzPlatformSkills.is_enabled() {
|
||||
return Ok(None);
|
||||
@@ -724,11 +953,17 @@ impl AgentDriverRunner {
|
||||
let org = skill_spec.org.as_ref().expect("org checked above");
|
||||
let repo_name = skill_spec.repo.as_ref().expect("repo checked above");
|
||||
log::info!("Cloning {org}/{repo_name} for skill resolution in sandboxed mode");
|
||||
clone_repo_for_skill(org, repo_name, working_dir)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
AgentDriverError::SkillResolutionFailed(format_skill_resolution_error(err))
|
||||
})?;
|
||||
setup_events
|
||||
.record_result(SetupStep::SkillRepoClone, async {
|
||||
clone_repo_for_skill(org, repo_name, working_dir)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
AgentDriverError::SkillResolutionFailed(format_skill_resolution_error(
|
||||
err,
|
||||
))
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
let working_dir_buf = working_dir.to_path_buf();
|
||||
@@ -756,6 +991,7 @@ impl AgentDriverRunner {
|
||||
foreground: &ModelSpawner<Self>,
|
||||
args: RunAgentArgs,
|
||||
server_api: &Arc<dyn AIClient>,
|
||||
setup_events: &SetupClientEventReporter,
|
||||
) -> Result<(AgentDriverOptions, Task, Option<String>), AgentDriverError> {
|
||||
// Get the working directory
|
||||
let working_dir = match args.cwd.as_ref() {
|
||||
@@ -765,8 +1001,12 @@ impl AgentDriverRunner {
|
||||
}
|
||||
.map_err(AgentDriverError::ConfigBuildFailed)?;
|
||||
|
||||
if let Some(task_id_str) = args.task_id.as_ref() {
|
||||
Self::bootstrap_git_credentials_for_task(foreground, task_id_str, &args).await?;
|
||||
}
|
||||
// Resolve the skill, if we have one
|
||||
let resolved_skill = Self::resolve_skill(foreground, &args, &working_dir).await?;
|
||||
let resolved_skill =
|
||||
Self::resolve_skill(foreground, &args, &working_dir, setup_events).await?;
|
||||
|
||||
// Extract variables we want to use later before moving args into the closure
|
||||
let task_id_str = args.task_id.clone();
|
||||
@@ -784,6 +1024,10 @@ impl AgentDriverRunner {
|
||||
let should_share = (args.share.is_shared() || args.task_id.is_some())
|
||||
&& FeatureFlag::AgentSharedSessions.is_enabled();
|
||||
|
||||
let third_party_harness_model_config = merged_config
|
||||
.harness
|
||||
.as_ref()
|
||||
.and_then(|h| h.model_config());
|
||||
let driver_options = driver::AgentDriverOptions {
|
||||
working_dir: working_dir.clone(),
|
||||
task_id,
|
||||
@@ -795,6 +1039,7 @@ impl AgentDriverRunner {
|
||||
cloud_providers: Vec::new(),
|
||||
environment: None,
|
||||
selected_harness: args.harness,
|
||||
third_party_harness_model_config,
|
||||
snapshot_disabled: args.snapshot.no_snapshot.then_some(true),
|
||||
snapshot_upload_timeout: args
|
||||
.snapshot
|
||||
@@ -804,6 +1049,9 @@ impl AgentDriverRunner {
|
||||
.snapshot
|
||||
.snapshot_script_timeout
|
||||
.map(|duration| duration.into()),
|
||||
skip_initial_turn: args.skip_initial_turn,
|
||||
strict_mcp_startup: args.strict_mcp_startup,
|
||||
mcp_startup_timeout: args.mcp_startup_timeout.map(|duration| duration.into()),
|
||||
};
|
||||
|
||||
Ok((merged_config, task, driver_options))
|
||||
@@ -817,13 +1065,17 @@ impl AgentDriverRunner {
|
||||
// The existing-task branch also surfaces the task's `conversation_id` (if any) so
|
||||
// the caller can wire up resume without a separate `--conversation` arg.
|
||||
let task_conversation_id = if let Some(task_id_str) = task_id_str {
|
||||
Self::fetch_secrets_and_attachments(
|
||||
foreground,
|
||||
task_id_str,
|
||||
&mut driver_options,
|
||||
&mut task,
|
||||
)
|
||||
.await?
|
||||
setup_events
|
||||
.record_result(
|
||||
SetupStep::TaskDataFetch,
|
||||
Self::fetch_secrets_and_attachments(
|
||||
foreground,
|
||||
task_id_str,
|
||||
&mut driver_options,
|
||||
&mut task,
|
||||
),
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
// Extract the prompt text that we'll pass up to the server when we create the task.
|
||||
let prompt_for_task_creation = match &prompt {
|
||||
@@ -847,9 +1099,13 @@ impl AgentDriverRunner {
|
||||
.await?;
|
||||
None
|
||||
};
|
||||
|
||||
// Resolve environment and cloud providers.
|
||||
Self::resolve_environment(foreground, environment_id, &mut driver_options).await?;
|
||||
setup_events
|
||||
.record_result(
|
||||
SetupStep::EnvironmentResolution,
|
||||
Self::resolve_environment(foreground, environment_id, &mut driver_options),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((driver_options, task, task_conversation_id))
|
||||
}
|
||||
@@ -889,15 +1145,8 @@ impl AgentDriverRunner {
|
||||
}
|
||||
};
|
||||
|
||||
foreground
|
||||
.spawn(move |_, ctx| {
|
||||
// Set the task ID on the ServerApi so it's sent with all subsequent requests.
|
||||
ServerApiProvider::handle(ctx)
|
||||
.as_ref(ctx)
|
||||
.get()
|
||||
.set_ambient_agent_task_id(task_id);
|
||||
})
|
||||
.await?;
|
||||
// Set the task ID on the ServerApi so it's sent with all subsequent requests.
|
||||
Self::set_ambient_agent_task_id(foreground, task_id).await?;
|
||||
driver_options.task_id = task_id;
|
||||
|
||||
Ok(())
|
||||
@@ -939,6 +1188,9 @@ impl AgentDriverRunner {
|
||||
None
|
||||
}
|
||||
};
|
||||
// Set the task ID on the ServerApi before any task-scoped server calls below, so failures
|
||||
// during setup can still be reported with cloud-agent context.
|
||||
Self::set_ambient_agent_task_id(foreground, parsed_task_id).await?;
|
||||
|
||||
// Fetch secrets, task metadata, regular attachments, and handoff snapshot
|
||||
// attachments in parallel. The handoff snapshot fetch is independent of the
|
||||
@@ -976,19 +1228,18 @@ impl AgentDriverRunner {
|
||||
)
|
||||
.await
|
||||
};
|
||||
let (secrets_result, attachments_result, task_metadata_result, handoff_snapshot_result) =
|
||||
futures::future::join4(
|
||||
task_secrets,
|
||||
driver::attachments::fetch_and_download_attachments(
|
||||
ai_client.clone(),
|
||||
server_api.clone(),
|
||||
task_id_str.clone(),
|
||||
attachments_download_dir.clone(),
|
||||
),
|
||||
task_metadata,
|
||||
handoff_snapshot,
|
||||
)
|
||||
.await;
|
||||
|
||||
let (secrets_result, attachments_result, task_metadata_result, handoff_snapshot_result) = futures::join!(
|
||||
task_secrets,
|
||||
driver::attachments::fetch_and_download_attachments(
|
||||
ai_client.clone(),
|
||||
server_api.clone(),
|
||||
task_id_str.clone(),
|
||||
attachments_download_dir.clone(),
|
||||
),
|
||||
task_metadata,
|
||||
handoff_snapshot,
|
||||
);
|
||||
|
||||
// Extract attachments_dir from successful result, log errors
|
||||
let mut attachments_dir = match attachments_result {
|
||||
@@ -1010,6 +1261,7 @@ impl AgentDriverRunner {
|
||||
log::warn!("Failed to fetch handoff snapshot attachments: {e:#}");
|
||||
}
|
||||
}
|
||||
|
||||
let secrets = match secrets_result {
|
||||
Ok(secrets) => secrets,
|
||||
Err(err) => {
|
||||
@@ -1028,55 +1280,52 @@ impl AgentDriverRunner {
|
||||
}
|
||||
}
|
||||
};
|
||||
let (parent_run_id, task_conversation_id, task_harness) = match task_metadata_result {
|
||||
Ok(Some(task_metadata)) => {
|
||||
// The task's harness is stored on the snapshot; if absent, it's the default Oz.
|
||||
let task_harness = task_metadata
|
||||
.agent_config_snapshot
|
||||
.as_ref()
|
||||
.and_then(|c| c.harness.as_ref())
|
||||
.map(|h| h.harness_type)
|
||||
.unwrap_or(Harness::Oz);
|
||||
(
|
||||
task_metadata.parent_run_id,
|
||||
task_metadata.conversation_id,
|
||||
Some(task_harness),
|
||||
)
|
||||
}
|
||||
Ok(None) => (None, None, None),
|
||||
Err(err) => {
|
||||
log::warn!("Failed to fetch task metadata: {err:#}");
|
||||
(None, None, None)
|
||||
}
|
||||
};
|
||||
let (parent_run_id, task_conversation_id, task_harness, task_harness_model_config) =
|
||||
match task_metadata_result {
|
||||
Ok(Some(task_metadata)) => {
|
||||
// The task's harness is stored on the snapshot; if absent, it's the default Oz.
|
||||
let task_harness_config = task_metadata
|
||||
.agent_config_snapshot
|
||||
.as_ref()
|
||||
.and_then(|c| c.harness.as_ref());
|
||||
let task_harness = task_harness_config
|
||||
.map(|h| h.harness_type)
|
||||
.unwrap_or(Harness::Oz);
|
||||
let task_harness_model_config =
|
||||
task_harness_config.and_then(|h| h.model_config());
|
||||
(
|
||||
task_metadata.parent_run_id,
|
||||
task_metadata.conversation_id,
|
||||
Some(task_harness),
|
||||
task_harness_model_config,
|
||||
)
|
||||
}
|
||||
Ok(None) => (None, None, None, None),
|
||||
Err(err) => {
|
||||
log::warn!("Failed to fetch task metadata: {err:#}");
|
||||
(None, None, None, None)
|
||||
}
|
||||
};
|
||||
|
||||
// Validate the requested `--harness` against the task's harness setting. This avoids the
|
||||
// extra conversation-metadata roundtrip that would otherwise be needed downstream when the
|
||||
// task is linked to an existing conversation, since task harness and conversation harness
|
||||
// always match (the task spawned the conversation).
|
||||
if let Some(task_harness) = task_harness {
|
||||
if task_harness != driver_options.selected_harness {
|
||||
return Err(AgentDriverError::TaskHarnessMismatch {
|
||||
task_id: task_id_str,
|
||||
expected: task_harness.to_string(),
|
||||
got: driver_options.selected_harness.to_string(),
|
||||
});
|
||||
}
|
||||
task.harness = reconcile_task_harness(
|
||||
&task_id_str,
|
||||
&mut driver_options.selected_harness,
|
||||
task_harness,
|
||||
)?;
|
||||
}
|
||||
|
||||
// Set the task ID on the ServerApi so it's sent with all subsequent requests.
|
||||
foreground
|
||||
.spawn(move |_, ctx| {
|
||||
ServerApiProvider::handle(ctx)
|
||||
.as_ref(ctx)
|
||||
.get()
|
||||
.set_ambient_agent_task_id(parsed_task_id);
|
||||
})
|
||||
.await?;
|
||||
|
||||
driver_options.task_id = parsed_task_id;
|
||||
driver_options.parent_run_id = parent_run_id;
|
||||
driver_options.secrets = secrets;
|
||||
// CLI flags continue to take precedence so users can still override per-invocation.
|
||||
if driver_options.third_party_harness_model_config.is_none() {
|
||||
driver_options.third_party_harness_model_config = task_harness_model_config;
|
||||
}
|
||||
|
||||
// Update the task prompt to include the downloaded attachments dir
|
||||
if let AgentRunPrompt::ServerSide {
|
||||
@@ -1102,6 +1351,7 @@ impl AgentDriverRunner {
|
||||
/// wraps the returned payload (if any) in [`driver::ResumeOptions::ThirdParty`]; each harness
|
||||
/// owns its server call and error mapping. Returns `None` if a third-party harness has no
|
||||
/// resume payload to surface.
|
||||
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true, conversation_id = conversation_id))]
|
||||
async fn load_conversation_information(
|
||||
foreground: &ModelSpawner<Self>,
|
||||
conversation_id: String,
|
||||
@@ -1133,7 +1383,6 @@ impl AgentDriverRunner {
|
||||
"Failed to convert conversation data to AIConversation".into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Some(driver::ResumeOptions::Oz(Box::new(
|
||||
ConversationRestorationInNewPaneType::Historical {
|
||||
conversation,
|
||||
@@ -1164,6 +1413,7 @@ impl AgentDriverRunner {
|
||||
}
|
||||
|
||||
/// Resolve the environment and store into `driver_options`.
|
||||
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true, ?environment_id))]
|
||||
async fn resolve_environment(
|
||||
foreground: &ModelSpawner<Self>,
|
||||
environment_id: Option<String>,
|
||||
@@ -1207,6 +1457,7 @@ impl AgentDriverRunner {
|
||||
}
|
||||
|
||||
/// Create the AgentDriver and start running the task.
|
||||
#[tracing::instrument(skip_all, fields(tags.cloud_agent = true))]
|
||||
fn create_and_run_driver(
|
||||
ctx: &mut AppContext,
|
||||
driver_options: driver::AgentDriverOptions,
|
||||
@@ -1227,7 +1478,9 @@ impl AgentDriverRunner {
|
||||
if let Some(share_requests) = share_requests {
|
||||
driver.add_share_requests(share_requests, ctx);
|
||||
}
|
||||
let agent_future = driver.run(task, ctx);
|
||||
let span =
|
||||
tracing::info_span!("AgentDriver::run", tags.cloud_agent = true, ?task.model, ?task.harness);
|
||||
let agent_future = driver.run(task, ctx).instrument(span);
|
||||
|
||||
ctx.spawn(agent_future, |_, result, ctx| match result {
|
||||
Ok(()) => {
|
||||
@@ -1251,6 +1504,11 @@ fn command_requires_auth(command: &CliCommand) -> bool {
|
||||
AgentProfileCommand::List => true,
|
||||
},
|
||||
AgentCommand::List(_) => true,
|
||||
AgentCommand::Get(_) => true,
|
||||
AgentCommand::Create(_) => true,
|
||||
AgentCommand::Update(_) => true,
|
||||
AgentCommand::Delete(_) => true,
|
||||
AgentCommand::Skills(_) => true,
|
||||
},
|
||||
CliCommand::Environment(environment_cmd) => match environment_cmd {
|
||||
EnvironmentCommand::List => true,
|
||||
@@ -1272,6 +1530,8 @@ fn command_requires_auth(command: &CliCommand) -> bool {
|
||||
CliCommand::Model(model_cmd) => match model_cmd {
|
||||
ModelCommand::List => true,
|
||||
},
|
||||
CliCommand::MemoryStore(_) => true,
|
||||
CliCommand::Memory(_) => true,
|
||||
CliCommand::Login => false,
|
||||
CliCommand::Logout => false,
|
||||
CliCommand::Whoami => true,
|
||||
@@ -1282,6 +1542,7 @@ fn command_requires_auth(command: &CliCommand) -> bool {
|
||||
CliCommand::Federate(_) => true,
|
||||
CliCommand::HarnessSupport(_) => true,
|
||||
CliCommand::Artifact(_) => true,
|
||||
CliCommand::ApiKey(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1365,6 +1626,8 @@ fn report_fatal_error(err: anyhow::Error, ctx: &mut AppContext) {
|
||||
let _ = write!(&mut message, "\n=> {cause}");
|
||||
}
|
||||
|
||||
tracing::event!(tracing::Level::ERROR, tags.cloud_agent = true, message);
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
{
|
||||
if let Ok(path) = log_file_path() {
|
||||
@@ -1389,6 +1652,7 @@ fn resolve_orchestration_harness_label() -> &'static str {
|
||||
Some(Harness::Claude) => "claude",
|
||||
Some(Harness::OpenCode) => "opencode",
|
||||
Some(Harness::Gemini) => "gemini",
|
||||
Some(Harness::Codex) => "codex",
|
||||
Some(Harness::Unknown) | None => "unknown",
|
||||
}
|
||||
}
|
||||
@@ -1408,6 +1672,11 @@ fn command_to_telemetry_event(command: &CliCommand) -> CliTelemetryEvent {
|
||||
AgentProfileCommand::List => CliTelemetryEvent::AgentProfileList,
|
||||
},
|
||||
CliCommand::Agent(AgentCommand::List(_)) => CliTelemetryEvent::AgentList,
|
||||
CliCommand::Agent(AgentCommand::Get(_)) => CliTelemetryEvent::AgentGet,
|
||||
CliCommand::Agent(AgentCommand::Create(_)) => CliTelemetryEvent::AgentCreate,
|
||||
CliCommand::Agent(AgentCommand::Update(_)) => CliTelemetryEvent::AgentUpdate,
|
||||
CliCommand::Agent(AgentCommand::Delete(_)) => CliTelemetryEvent::AgentDelete,
|
||||
CliCommand::Agent(AgentCommand::Skills(_)) => CliTelemetryEvent::AgentSkills,
|
||||
CliCommand::Environment(EnvironmentCommand::List) => CliTelemetryEvent::EnvironmentList,
|
||||
CliCommand::Environment(EnvironmentCommand::Create { .. }) => {
|
||||
CliTelemetryEvent::EnvironmentCreate
|
||||
@@ -1452,6 +1721,19 @@ fn command_to_telemetry_event(command: &CliCommand) -> CliTelemetryEvent {
|
||||
},
|
||||
},
|
||||
CliCommand::Model(ModelCommand::List) => CliTelemetryEvent::ModelList,
|
||||
CliCommand::MemoryStore(memory_store_cmd) => match memory_store_cmd {
|
||||
MemoryStoreCommand::List => CliTelemetryEvent::MemoryStoreList,
|
||||
MemoryStoreCommand::Get(_) => CliTelemetryEvent::MemoryStoreGetStore,
|
||||
MemoryStoreCommand::Update(_) => CliTelemetryEvent::MemoryStoreUpdateStore,
|
||||
MemoryStoreCommand::ListStoreAgents(_) => CliTelemetryEvent::MemoryStoreListStoreAgents,
|
||||
},
|
||||
CliCommand::Memory(memory_cmd) => match memory_cmd {
|
||||
MemoryCommand::List(_) => CliTelemetryEvent::MemoryStoreListMemories,
|
||||
MemoryCommand::Create(_) => CliTelemetryEvent::MemoryStoreCreateMemory,
|
||||
MemoryCommand::Update(_) => CliTelemetryEvent::MemoryStoreUpdateMemory,
|
||||
MemoryCommand::Delete(_) => CliTelemetryEvent::MemoryStoreDeleteMemory,
|
||||
MemoryCommand::Versions(_) => CliTelemetryEvent::MemoryStoreListVersions,
|
||||
},
|
||||
CliCommand::Login => CliTelemetryEvent::Login,
|
||||
CliCommand::Logout => CliTelemetryEvent::Logout,
|
||||
CliCommand::Whoami => CliTelemetryEvent::Whoami,
|
||||
@@ -1496,12 +1778,20 @@ fn command_to_telemetry_event(command: &CliCommand) -> CliTelemetryEvent {
|
||||
success: finish_args.status == TaskStatus::Success,
|
||||
}
|
||||
}
|
||||
HarnessSupportCommand::ReportShutdown(_) => {
|
||||
CliTelemetryEvent::HarnessSupportReportShutdown
|
||||
}
|
||||
},
|
||||
CliCommand::Artifact(artifact_cmd) => match artifact_cmd {
|
||||
ArtifactCommand::Upload(_) => CliTelemetryEvent::ArtifactUpload,
|
||||
ArtifactCommand::Get(_) => CliTelemetryEvent::ArtifactGet,
|
||||
ArtifactCommand::Download(_) => CliTelemetryEvent::ArtifactDownload,
|
||||
},
|
||||
CliCommand::ApiKey(api_key_cmd) => match api_key_cmd {
|
||||
ApiKeyCommand::List(_) => CliTelemetryEvent::ApiKeyList,
|
||||
ApiKeyCommand::Create(_) => CliTelemetryEvent::ApiKeyCreate,
|
||||
ApiKeyCommand::Expire(_) => CliTelemetryEvent::ApiKeyExpire,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
use galaxy_cli::{
|
||||
artifact::{ArtifactCommand, DownloadArtifactArgs, GetArtifactArgs, UploadArtifactArgs},
|
||||
task::{MessageCommand, MessageSendArgs, MessageWatchArgs, TaskCommand},
|
||||
CliCommand,
|
||||
};
|
||||
use galaxy_core::telemetry::TelemetryEvent;
|
||||
use serde_json::json;
|
||||
use galaxy_cli::agent::Harness;
|
||||
use galaxy_cli::artifact::{
|
||||
ArtifactCommand, DownloadArtifactArgs, GetArtifactArgs, UploadArtifactArgs,
|
||||
};
|
||||
use galaxy_cli::task::{MessageCommand, MessageSendArgs, MessageWatchArgs, TaskCommand};
|
||||
use galaxy_cli::CliCommand;
|
||||
use galaxy_core::telemetry::TelemetryEvent;
|
||||
|
||||
use super::{command_requires_auth, command_to_telemetry_event};
|
||||
use super::{command_requires_auth, command_to_telemetry_event, reconcile_task_harness};
|
||||
|
||||
const TASK_ID: &str = "00000000-0000-0000-0000-000000000001";
|
||||
|
||||
#[test]
|
||||
fn logout_does_not_require_auth() {
|
||||
@@ -128,6 +131,38 @@ fn run_message_send_telemetry_defaults_to_unknown_harness() {
|
||||
assert_eq!(event.payload(), Some(json!({ "harness": "unknown" })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_task_harness_adopts_task_harness_when_cli_uses_default() {
|
||||
let mut selected_harness = Harness::Oz;
|
||||
let harness = reconcile_task_harness(TASK_ID, &mut selected_harness, Harness::Claude)
|
||||
.expect("default harness should adopt task harness");
|
||||
|
||||
assert_eq!(selected_harness, Harness::Claude);
|
||||
assert_eq!(harness.harness(), Harness::Claude);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_task_harness_allows_matching_explicit_harness() {
|
||||
let mut selected_harness = Harness::Claude;
|
||||
let harness = reconcile_task_harness(TASK_ID, &mut selected_harness, Harness::Claude)
|
||||
.expect("matching harness should succeed");
|
||||
|
||||
assert_eq!(selected_harness, Harness::Claude);
|
||||
assert_eq!(harness.harness(), Harness::Claude);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_task_harness_rejects_explicit_mismatch() {
|
||||
let mut selected_harness = Harness::Gemini;
|
||||
let err = reconcile_task_harness(TASK_ID, &mut selected_harness, Harness::Claude)
|
||||
.expect_err("mismatched harness should fail");
|
||||
|
||||
assert_eq!(selected_harness, Harness::Gemini);
|
||||
assert!(err.to_string().contains("Task"));
|
||||
assert!(err.to_string().contains("--harness gemini"));
|
||||
assert!(err.to_string().contains("claude"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn run_message_watch_telemetry_defaults_to_unknown_harness() {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use comfy_table::Cell;
|
||||
use serde::Serialize;
|
||||
use warp_cli::model::ModelCommand;
|
||||
use warp_cli::GlobalOptions;
|
||||
use warpui::platform::TerminationMode;
|
||||
use warpui::{AppContext, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::ai::agent_sdk::output::{self, TableFormat};
|
||||
use crate::ai::llms::LLMPreferences;
|
||||
use comfy_table::Cell;
|
||||
use galaxy_cli::{model::ModelCommand, GlobalOptions};
|
||||
use galaxyui::{platform::TerminationMode, AppContext, ModelContext, SingletonEntity};
|
||||
use serde::Serialize;
|
||||
|
||||
/// Handle model-related CLI commands.
|
||||
pub fn run(
|
||||
@@ -43,7 +46,7 @@ impl ModelCommandRunner {
|
||||
|
||||
let llm_prefs = LLMPreferences::as_ref(ctx);
|
||||
let mut ids = BTreeSet::new();
|
||||
for info in llm_prefs.get_base_llm_choices_for_agent_mode() {
|
||||
for info in llm_prefs.get_base_llm_choices_for_agent_mode(ctx) {
|
||||
ids.insert(info.id.to_string());
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use galaxy_graphql::queries::get_oauth_connect_tx_status::OauthConnectTxStatus;
|
||||
use galaxyui::r#async::Timer;
|
||||
|
||||
use crate::server::server_api::integrations::IntegrationsClient;
|
||||
use galaxy_graphql::queries::get_oauth_connect_tx_status::OauthConnectTxStatus;
|
||||
|
||||
/// Shared helpers for OAuth-based connect flows (txId + polling).
|
||||
///
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
use super::{
|
||||
run_jq_filter, write_filter_output, write_json, write_json_line, write_list, TableFormat,
|
||||
};
|
||||
use comfy_table::Cell;
|
||||
use galaxy_cli::agent::OutputFormat;
|
||||
use galaxy_cli::json_filter::parse_jq_filter;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
run_jq_filter, write_filter_output, write_json, write_json_line, write_list, TableFormat,
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TestItem {
|
||||
id: &'static str,
|
||||
|
||||
@@ -2,6 +2,8 @@ use comfy_table::Cell;
|
||||
use galaxy_cli::{agent::AgentProfileCommand, GlobalOptions};
|
||||
use galaxyui::{AppContext, ModelContext, SingletonEntity};
|
||||
use serde::Serialize;
|
||||
use galaxy_cli::agent::AgentProfileCommand;
|
||||
use galaxy_cli::GlobalOptions;
|
||||
|
||||
use crate::ai::agent_sdk::output::{self, TableFormat};
|
||||
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
//! Provider command for linking third-party services.
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use comfy_table::Cell;
|
||||
use galaxy_cli::{
|
||||
provider::{ProviderCommand, ProviderType},
|
||||
GlobalOptions,
|
||||
};
|
||||
use galaxy_core::channel::ChannelState;
|
||||
use galaxyui::{platform::TerminationMode, AppContext, ModelContext, SingletonEntity};
|
||||
use serde::Serialize;
|
||||
use galaxy_cli::provider::{ProviderCommand, ProviderType};
|
||||
use galaxy_cli::GlobalOptions;
|
||||
use galaxy_core::channel::ChannelState;
|
||||
use galaxyui::platform::TerminationMode;
|
||||
use galaxyui::{AppContext, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::ai::agent_sdk::output::{self, TableFormat};
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
|
||||
/// Handle provider-related CLI commands.
|
||||
pub fn run(
|
||||
|
||||
@@ -1,62 +1,16 @@
|
||||
//! 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.
|
||||
//! The canonical implementation of `with_bounded_retry` and `is_transient_http_error`
|
||||
//! lives in [`crate::server::retry_strategies`] (available on all targets, including WASM).
|
||||
//! This module re-exports those symbols so existing agent-SDK call sites keep compiling
|
||||
//! without a path change.
|
||||
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use galaxyui::duration_with_jitter;
|
||||
use galaxyui::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})"
|
||||
))
|
||||
}
|
||||
pub(crate) use crate::server::retry_strategies::with_bounded_retry;
|
||||
// Re-export for tests only; the canonical definitions live in retry_strategies.
|
||||
#[cfg(test)]
|
||||
pub(crate) use crate::server::retry_strategies::{
|
||||
is_transient_graphql_or_http_error, is_transient_http_error, MAX_ATTEMPTS,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "retry_tests.rs"]
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use anyhow::{anyhow, Result};
|
||||
use futures::executor::block_on;
|
||||
use http::StatusCode;
|
||||
|
||||
use super::*;
|
||||
use crate::server::graphql::GraphQLError;
|
||||
use crate::server::server_api::presigned_upload::HttpStatusError;
|
||||
|
||||
fn http_err(status: u16) -> anyhow::Error {
|
||||
@@ -15,6 +17,14 @@ fn http_err(status: u16) -> anyhow::Error {
|
||||
.into()
|
||||
}
|
||||
|
||||
fn graphql_http_err(status: StatusCode) -> anyhow::Error {
|
||||
GraphQLError::HttpError {
|
||||
status,
|
||||
body: format!("status {status} body"),
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_5xx_status_codes_are_retryable() {
|
||||
assert!(is_transient_http_error(&http_err(503)));
|
||||
@@ -45,6 +55,40 @@ fn errors_without_http_status_are_treated_as_transient() {
|
||||
assert!(is_transient_http_error(&err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphql_status_classifier_retries_transient_statuses() {
|
||||
assert!(is_transient_graphql_or_http_error(&graphql_http_err(
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
)));
|
||||
assert!(is_transient_graphql_or_http_error(&graphql_http_err(
|
||||
StatusCode::REQUEST_TIMEOUT
|
||||
)));
|
||||
assert!(is_transient_graphql_or_http_error(&graphql_http_err(
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphql_status_classifier_fails_fast_on_permanent_statuses() {
|
||||
assert!(!is_transient_graphql_or_http_error(&graphql_http_err(
|
||||
StatusCode::UNAUTHORIZED
|
||||
)));
|
||||
assert!(!is_transient_graphql_or_http_error(&graphql_http_err(
|
||||
StatusCode::FORBIDDEN
|
||||
)));
|
||||
assert!(!is_transient_graphql_or_http_error(&graphql_http_err(
|
||||
StatusCode::NOT_FOUND
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphql_status_classifier_fails_fast_without_typed_transport_error() {
|
||||
// GraphQL user-facing errors are converted to plain anyhow errors at the operation
|
||||
// layer, so GraphQL retry call sites should not treat unknown errors as transient.
|
||||
let err = anyhow!("user-facing GraphQL error");
|
||||
assert!(!is_transient_graphql_or_http_error(&err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_loop_succeeds_on_first_attempt() {
|
||||
let attempts = Rc::new(Cell::new(0));
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use comfy_table::Cell;
|
||||
use futures::future;
|
||||
use serde::Serialize;
|
||||
use galaxy_cli::agent::OutputFormat;
|
||||
use galaxy_cli::schedule::{
|
||||
CreateScheduleArgs, DeleteScheduleArgs, GetScheduleArgs, PauseScheduleArgs, ScheduleCommand,
|
||||
ScheduleSubcommand, UnpauseScheduleArgs, UpdateScheduleArgs,
|
||||
};
|
||||
use galaxy_cli::{agent::OutputFormat, GlobalOptions};
|
||||
use galaxy_cli::GlobalOptions;
|
||||
use galaxy_graphql::queries::get_scheduled_agent_history::ScheduledAgentHistory;
|
||||
use galaxyui::platform::TerminationMode;
|
||||
use galaxyui::{AppContext, SingletonEntity};
|
||||
use serde::Serialize;
|
||||
|
||||
use super::common::{EnvironmentChoice, ResolveConfigurationError};
|
||||
use super::output::{self, TableFormat};
|
||||
use crate::ai::ambient_agents::scheduled::{
|
||||
CloudScheduledAmbientAgent, ScheduledAgentManager, ScheduledAmbientAgent, UpdateScheduleParams,
|
||||
};
|
||||
use crate::ai::ambient_agents::AgentConfigSnapshot;
|
||||
use crate::cloud_object::CloudObject;
|
||||
use crate::cloud_object::{CloudObject, CloudObjectLookup as _};
|
||||
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,
|
||||
@@ -113,6 +113,8 @@ fn create(ctx: &mut AppContext, args: CreateScheduleArgs) -> anyhow::Result<()>
|
||||
crate::ai::ambient_agents::AgentConfigSnapshot {
|
||||
name: None,
|
||||
environment_id,
|
||||
// TODO(REMOTE-1936): support --runner for scheduled agents.
|
||||
runner_id: None,
|
||||
model_id: args.model.model.clone(),
|
||||
base_prompt: None,
|
||||
mcp_servers: cli_mcp_servers,
|
||||
|
||||
+105
-24
@@ -1,36 +1,31 @@
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, IsTerminal as _, Read},
|
||||
};
|
||||
use std::fs;
|
||||
use std::io::{self, IsTerminal as _, Read};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use comfy_table::Cell;
|
||||
use galaxy_cli::{
|
||||
agent::OutputFormat,
|
||||
scope::ObjectScope,
|
||||
secret::{
|
||||
AnthropicMethod, CreateProvider, CreateSecretArgs, DeleteSecretArgs, ListSecretsArgs,
|
||||
SecretCommand, SecretType, UpdateSecretArgs, ValueArgs,
|
||||
},
|
||||
GlobalOptions,
|
||||
};
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_graphql::{
|
||||
managed_secrets::{ManagedSecret, ManagedSecretType},
|
||||
object::SpaceType,
|
||||
};
|
||||
use galaxy_managed_secrets::{client::SecretOwner, ManagedSecretManager, ManagedSecretValue};
|
||||
use galaxyui::{platform::TerminationMode, AppContext, SingletonEntity as _};
|
||||
use inquire::{Confirm, InquireError, Password};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
auth::UserUid, cloud_object::Owner, server::ids::ServerId,
|
||||
util::time_format::format_approx_duration_from_now_utc,
|
||||
use galaxy_cli::agent::OutputFormat;
|
||||
use galaxy_cli::scope::ObjectScope;
|
||||
use galaxy_cli::secret::{
|
||||
AnthropicMethod, CodexMethod, CreateProvider, CreateSecretArgs, DeleteSecretArgs,
|
||||
ListSecretsArgs, SecretCommand, SecretType, UpdateSecretArgs, ValueArgs,
|
||||
};
|
||||
use galaxy_cli::GlobalOptions;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_graphql::managed_secrets::{ManagedSecret, ManagedSecretType};
|
||||
use galaxy_graphql::object::SpaceType;
|
||||
use galaxy_managed_secrets::client::SecretOwner;
|
||||
use galaxy_managed_secrets::{ManagedSecretManager, ManagedSecretValue};
|
||||
use galaxyui::platform::TerminationMode;
|
||||
use galaxyui::{AppContext, SingletonEntity as _};
|
||||
|
||||
use super::output::{self, TableFormat};
|
||||
use crate::auth::UserUid;
|
||||
use crate::cloud_object::Owner;
|
||||
use crate::server::ids::ServerId;
|
||||
use crate::util::time_format::format_approx_duration_from_now_utc;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SecretInfo {
|
||||
@@ -105,6 +100,11 @@ enum SecretInput {
|
||||
session_token: Option<String>,
|
||||
region: Option<String>,
|
||||
},
|
||||
/// OpenAI API key secret with optional base URL.
|
||||
OpenaiApiKey {
|
||||
value_args: ValueArgs,
|
||||
base_url: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl SecretInput {
|
||||
@@ -137,6 +137,10 @@ impl SecretInput {
|
||||
session_token,
|
||||
region,
|
||||
),
|
||||
SecretInput::OpenaiApiKey {
|
||||
value_args,
|
||||
base_url,
|
||||
} => read_openai_api_key_secret_value(&value_args, base_url),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,6 +180,17 @@ fn create_secret(ctx: &mut AppContext, args: CreateSecretArgs) -> Result<()> {
|
||||
a.common.scope,
|
||||
),
|
||||
},
|
||||
Some(CreateProvider::Codex(codex)) => match codex.method {
|
||||
CodexMethod::ApiKey(a) => (
|
||||
a.common.name,
|
||||
SecretInput::OpenaiApiKey {
|
||||
value_args: a.value,
|
||||
base_url: a.base_url,
|
||||
},
|
||||
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>")
|
||||
@@ -556,6 +571,11 @@ fn make_simple_secret_value(secret_type: SecretType, raw: &str) -> ManagedSecret
|
||||
// Bedrock secrets are multi-field and handled via SecretInput::Bedrock.
|
||||
unreachable!("Bedrock secrets should not go through make_simple_secret_value")
|
||||
}
|
||||
SecretType::OpenaiApiKey => {
|
||||
// OpenAI API key secrets are handled via SecretInput::OpenaiApiKey so the optional
|
||||
// base URL can be plumbed through alongside the API key.
|
||||
unreachable!("OpenAI API key secrets should not go through make_simple_secret_value")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,9 +603,69 @@ fn make_secret_value_from_gql_type(
|
||||
"Bedrock API key secrets cannot be updated via `--value`; re-create the secret instead"
|
||||
))
|
||||
}
|
||||
ManagedSecretType::OpenaiApiKey => Ok(ManagedSecretValue::openai_api_key(raw, None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an OpenAI API key secret from CLI flags or interactive prompts.
|
||||
///
|
||||
/// The API key value is read from `--value-file`, stdin, or an interactive password prompt (in
|
||||
/// that order), matching the behavior of other simple secret types.
|
||||
///
|
||||
/// `base_url` is optional. When `--base-url` is provided we use it verbatim (an empty value is
|
||||
/// treated as "no base URL"). When it is not provided and we are running interactively, we
|
||||
/// prompt for it; pressing Enter at the prompt skips the base URL. In non-interactive mode we
|
||||
/// silently default to no base URL, since the vast majority of users use the provider's default
|
||||
/// endpoint.
|
||||
fn read_openai_api_key_secret_value(
|
||||
value_args: &ValueArgs,
|
||||
base_url: Option<String>,
|
||||
) -> Result<Option<ManagedSecretValue>> {
|
||||
let api_key = match read_simple_secret_value(value_args)? {
|
||||
Some(v) => v,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
// The base URL is optional. An empty `--base-url` flag, empty interactive submission, or
|
||||
// omission in non-interactive mode all map to `None` so we never persist a blank string.
|
||||
let base_url: Option<String> = match base_url {
|
||||
Some(s) => {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_owned())
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if !io::stdin().is_terminal() {
|
||||
// Non-interactive: leave the base URL unset rather than prompting or failing.
|
||||
None
|
||||
} else {
|
||||
match inquire::Text::new("OpenAI base URL (optional, press Enter to skip):")
|
||||
.with_help_message("e.g. https://us.api.openai.com/v1 for a regional endpoint")
|
||||
.prompt()
|
||||
{
|
||||
Ok(value) => {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_owned())
|
||||
}
|
||||
}
|
||||
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
|
||||
return Ok(None);
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(ManagedSecretValue::openai_api_key(api_key, base_url)))
|
||||
}
|
||||
|
||||
/// Read a Bedrock API key secret from dedicated CLI flags or interactive prompts.
|
||||
fn read_bedrock_secret_value(
|
||||
bedrock_api_key: Option<String>,
|
||||
@@ -772,5 +852,6 @@ fn format_secret_type(type_: &ManagedSecretType) -> String {
|
||||
ManagedSecretType::AnthropicApiKey => "Anthropic API Key".to_string(),
|
||||
ManagedSecretType::AnthropicBedrockAccessKey => "Anthropic Bedrock Access Key".to_string(),
|
||||
ManagedSecretType::AnthropicBedrockApiKey => "Anthropic Bedrock API Key".to_string(),
|
||||
ManagedSecretType::OpenaiApiKey => "OpenAI API Key".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::FutureExt as _;
|
||||
use tracing::Instrument as _;
|
||||
use warpui::r#async::executor::Background;
|
||||
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::server::server_api::ai::{AIClient, AgentRunClientEventRequest};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct SetupClientEventReporter {
|
||||
run_id: Option<AmbientAgentTaskId>,
|
||||
ai_client: Arc<dyn AIClient>,
|
||||
background: Arc<Background>,
|
||||
}
|
||||
|
||||
impl SetupClientEventReporter {
|
||||
/// Constructs a reporter for setup events associated with an existing Oz run.
|
||||
pub(crate) fn new(
|
||||
run_id: AmbientAgentTaskId,
|
||||
ai_client: Arc<dyn AIClient>,
|
||||
background: Arc<Background>,
|
||||
) -> Self {
|
||||
Self {
|
||||
run_id: Some(run_id),
|
||||
ai_client,
|
||||
background,
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs a reporter for setup paths that are intentionally not backed by an Oz run.
|
||||
pub(crate) fn noop(ai_client: Arc<dyn AIClient>, background: Arc<Background>) -> Self {
|
||||
Self {
|
||||
run_id: None,
|
||||
ai_client,
|
||||
background,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn record_result<T, E: std::error::Error>(
|
||||
&self,
|
||||
step: SetupStep,
|
||||
future: impl Future<Output = Result<T, E>>,
|
||||
) -> Result<T, E> {
|
||||
let (step_name, span) = step.to_event_name_and_span();
|
||||
|
||||
let start_timestamp = Utc::now();
|
||||
let result = future
|
||||
.map(|result| {
|
||||
result.inspect_err(|err| {
|
||||
tracing::error!(error = %err);
|
||||
})
|
||||
})
|
||||
.instrument(span)
|
||||
.await;
|
||||
let finish_timestamp = Utc::now();
|
||||
|
||||
self.post_setup_metric_event_best_effort(
|
||||
step_name,
|
||||
start_timestamp,
|
||||
finish_timestamp,
|
||||
result.is_err(),
|
||||
);
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) async fn record_value<T>(
|
||||
&self,
|
||||
step: SetupStep,
|
||||
future: impl Future<Output = T>,
|
||||
) -> T {
|
||||
let (step_name, span) = step.to_event_name_and_span();
|
||||
|
||||
let start_timestamp = Utc::now();
|
||||
let value = future.instrument(span).await;
|
||||
let finish_timestamp = Utc::now();
|
||||
|
||||
self.post_setup_metric_event_best_effort(
|
||||
step_name,
|
||||
start_timestamp,
|
||||
finish_timestamp,
|
||||
false,
|
||||
);
|
||||
value
|
||||
}
|
||||
pub(crate) fn record_value_detached<T>(
|
||||
&self,
|
||||
step: SetupStep,
|
||||
future: impl Future<Output = T> + Send + 'static,
|
||||
) where
|
||||
T: Send + 'static,
|
||||
{
|
||||
let (step_name, span) = step.to_event_name_and_span();
|
||||
|
||||
let reporter = self.clone();
|
||||
self.background
|
||||
.spawn(async move {
|
||||
let start_timestamp = Utc::now();
|
||||
future.instrument(span).await;
|
||||
let finish_timestamp = Utc::now();
|
||||
reporter.post_setup_metric_event_best_effort(
|
||||
step_name,
|
||||
start_timestamp,
|
||||
finish_timestamp,
|
||||
false,
|
||||
);
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) async fn post_timeline_event(&self, event: OzRunTimelineEvent) {
|
||||
let Some(run_id) = self.run_id else {
|
||||
return;
|
||||
};
|
||||
let timestamp = Utc::now();
|
||||
let event_name = event.as_event_name();
|
||||
let request = AgentRunClientEventRequest::timeline_event(event_name, timestamp);
|
||||
Self::post_client_event(run_id, self.ai_client.clone(), event_name, request).await;
|
||||
}
|
||||
|
||||
fn post_setup_metric_event_best_effort(
|
||||
&self,
|
||||
event_name: &'static str,
|
||||
start_timestamp: DateTime<Utc>,
|
||||
finish_timestamp: DateTime<Utc>,
|
||||
is_error: bool,
|
||||
) {
|
||||
let Some(run_id) = self.run_id else {
|
||||
return;
|
||||
};
|
||||
|
||||
let ai_client = self.ai_client.clone();
|
||||
self.background
|
||||
.spawn(async move {
|
||||
let request = AgentRunClientEventRequest::setup_metric_event(
|
||||
event_name,
|
||||
start_timestamp,
|
||||
finish_timestamp,
|
||||
is_error,
|
||||
);
|
||||
Self::post_client_event(run_id, ai_client, event_name, request).await;
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
async fn post_client_event(
|
||||
run_id: AmbientAgentTaskId,
|
||||
ai_client: Arc<dyn AIClient>,
|
||||
event_name: &'static str,
|
||||
request: AgentRunClientEventRequest,
|
||||
) {
|
||||
tracing::info!(event_name, tags.cloud_agent = true);
|
||||
|
||||
if let Err(err) = ai_client
|
||||
.post_agent_run_client_event(&run_id, request)
|
||||
.await
|
||||
{
|
||||
log::warn!("Failed to post setup client event {event_name} for run {run_id}: {err:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum OzRunTimelineEvent {
|
||||
AgentStarted,
|
||||
WorkerContainerReady,
|
||||
}
|
||||
|
||||
impl OzRunTimelineEvent {
|
||||
fn as_event_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::AgentStarted => "agent_started",
|
||||
Self::WorkerContainerReady => "worker_container_ready",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum SetupStep {
|
||||
TeamMetadataRefresh,
|
||||
WarpDriveSync,
|
||||
TaskDataFetch,
|
||||
EnvironmentResolution,
|
||||
SkillRepoClone,
|
||||
TerminalBootstrap,
|
||||
CloudProviderSetup,
|
||||
McpServerStartup,
|
||||
AgentProfileConfiguration,
|
||||
ProfileMcpServerStartup,
|
||||
SharedSessionEstablishment,
|
||||
GlobalSkillResolution,
|
||||
GlobalSkillRepoClone,
|
||||
EnvironmentRepoClone,
|
||||
EnvironmentSetupCommands,
|
||||
EnvironmentCodebaseIndexing,
|
||||
FileBasedMcpDiscovery,
|
||||
FileBasedMcpReadiness,
|
||||
EnvironmentSkillLoading,
|
||||
GlobalSkillLoading,
|
||||
ConversationResumeLoading,
|
||||
ThirdPartyHarnessPreparation,
|
||||
ThirdPartyHarnessExternalConversation,
|
||||
/// Sub-steps of [`SetupStep::ThirdPartyHarnessPreparation`] that track plugin
|
||||
/// install/update latency and reliability individually.
|
||||
ThirdPartyHarnessPreparationNotificationPluginInstall,
|
||||
ThirdPartyHarnessPreparationNotificationPluginUpdate,
|
||||
ThirdPartyHarnessPreparationPlatformPluginInstall,
|
||||
ThirdPartyHarnessPreparationPlatformPluginUpdate,
|
||||
}
|
||||
|
||||
macro_rules! span_and_name {
|
||||
($name:literal) => {
|
||||
($name, tracing::info_span!($name, tags.cloud_agent = true))
|
||||
};
|
||||
}
|
||||
|
||||
impl SetupStep {
|
||||
fn to_event_name_and_span(self) -> (&'static str, tracing::Span) {
|
||||
match self {
|
||||
Self::TeamMetadataRefresh => {
|
||||
span_and_name!("setup_team_metadata_refresh")
|
||||
}
|
||||
Self::WarpDriveSync => {
|
||||
span_and_name!("setup_warp_drive_sync")
|
||||
}
|
||||
Self::TaskDataFetch => {
|
||||
span_and_name!("setup_task_metadata_secrets_attachments_git_credentials_fetch")
|
||||
}
|
||||
Self::EnvironmentResolution => {
|
||||
span_and_name!("setup_environment_resolution")
|
||||
}
|
||||
Self::SkillRepoClone => {
|
||||
span_and_name!("setup_skill_repo_clone")
|
||||
}
|
||||
Self::TerminalBootstrap => {
|
||||
span_and_name!("setup_terminal_bootstrap")
|
||||
}
|
||||
Self::CloudProviderSetup => {
|
||||
span_and_name!("setup_cloud_provider_setup")
|
||||
}
|
||||
Self::McpServerStartup => {
|
||||
span_and_name!("setup_mcp_server_startup")
|
||||
}
|
||||
Self::AgentProfileConfiguration => {
|
||||
span_and_name!("setup_agent_profile_configuration")
|
||||
}
|
||||
Self::ProfileMcpServerStartup => {
|
||||
span_and_name!("setup_profile_mcp_server_startup")
|
||||
}
|
||||
Self::SharedSessionEstablishment => {
|
||||
span_and_name!("setup_shared_session_establishment")
|
||||
}
|
||||
Self::GlobalSkillResolution => {
|
||||
span_and_name!("setup_global_skill_resolution")
|
||||
}
|
||||
Self::GlobalSkillRepoClone => {
|
||||
span_and_name!("setup_global_skill_repo_clone")
|
||||
}
|
||||
Self::EnvironmentRepoClone => {
|
||||
span_and_name!("setup_environment_repo_clone")
|
||||
}
|
||||
Self::EnvironmentSetupCommands => {
|
||||
span_and_name!("setup_environment_setup_commands")
|
||||
}
|
||||
Self::EnvironmentCodebaseIndexing => {
|
||||
span_and_name!("setup_environment_codebase_indexing")
|
||||
}
|
||||
Self::FileBasedMcpDiscovery => {
|
||||
span_and_name!("setup_file_based_mcp_discovery")
|
||||
}
|
||||
Self::FileBasedMcpReadiness => {
|
||||
span_and_name!("setup_file_based_mcp_readiness")
|
||||
}
|
||||
Self::EnvironmentSkillLoading => {
|
||||
span_and_name!("setup_environment_skill_loading")
|
||||
}
|
||||
Self::GlobalSkillLoading => {
|
||||
span_and_name!("setup_global_skill_loading")
|
||||
}
|
||||
Self::ConversationResumeLoading => {
|
||||
span_and_name!("setup_conversation_resume_loading")
|
||||
}
|
||||
Self::ThirdPartyHarnessPreparation => {
|
||||
span_and_name!("setup_third_party_harness_preparation")
|
||||
}
|
||||
Self::ThirdPartyHarnessExternalConversation => {
|
||||
span_and_name!("setup_third_party_harness_external_conversation")
|
||||
}
|
||||
Self::ThirdPartyHarnessPreparationNotificationPluginInstall => {
|
||||
span_and_name!("setup_third_party_harness_preparation_notification_plugin_install")
|
||||
}
|
||||
Self::ThirdPartyHarnessPreparationNotificationPluginUpdate => {
|
||||
span_and_name!("setup_third_party_harness_preparation_notification_plugin_update")
|
||||
}
|
||||
Self::ThirdPartyHarnessPreparationPlatformPluginInstall => {
|
||||
span_and_name!("setup_third_party_harness_preparation_platform_plugin_install")
|
||||
}
|
||||
Self::ThirdPartyHarnessPreparationPlatformPluginUpdate => {
|
||||
span_and_name!("setup_third_party_harness_preparation_platform_plugin_update")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::features::FeatureFlag;
|
||||
use galaxy_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
|
||||
use serde_json::{json, Value};
|
||||
use strum_macros::{EnumDiscriminants, EnumIter};
|
||||
|
||||
use crate::features::FeatureFlag;
|
||||
|
||||
#[derive(Debug, EnumDiscriminants)]
|
||||
#[strum_discriminants(derive(EnumIter))]
|
||||
pub(super) enum CliTelemetryEvent {
|
||||
@@ -22,6 +22,16 @@ pub(super) enum CliTelemetryEvent {
|
||||
AgentProfileList,
|
||||
/// Executing `warp agent list`
|
||||
AgentList,
|
||||
/// Executing `warp agent get`
|
||||
AgentGet,
|
||||
/// Executing `warp agent create`
|
||||
AgentCreate,
|
||||
/// Executing `warp agent update`
|
||||
AgentUpdate,
|
||||
/// Executing `warp agent delete`
|
||||
AgentDelete,
|
||||
/// Executing `warp agent skills`
|
||||
AgentSkills,
|
||||
/// Executing `warp environment list`
|
||||
EnvironmentList,
|
||||
/// Executing `warp environment create`
|
||||
@@ -38,6 +48,24 @@ pub(super) enum CliTelemetryEvent {
|
||||
MCPList,
|
||||
/// Executing `warp model list`
|
||||
ModelList,
|
||||
/// Executing `warp memory-store list`
|
||||
MemoryStoreList,
|
||||
/// Executing `warp memory list`
|
||||
MemoryStoreListMemories,
|
||||
/// Executing `warp memory create`
|
||||
MemoryStoreCreateMemory,
|
||||
/// Executing `warp memory update`
|
||||
MemoryStoreUpdateMemory,
|
||||
/// Executing `warp memory delete`
|
||||
MemoryStoreDeleteMemory,
|
||||
/// Executing `warp memory-store get`
|
||||
MemoryStoreGetStore,
|
||||
/// Executing `warp memory-store update`
|
||||
MemoryStoreUpdateStore,
|
||||
/// Executing `warp memory-store list-store-agents`
|
||||
MemoryStoreListStoreAgents,
|
||||
/// Executing `warp memory versions`
|
||||
MemoryStoreListVersions,
|
||||
/// Executing `warp task list`
|
||||
TaskList,
|
||||
/// Executing `warp task get`
|
||||
@@ -78,6 +106,12 @@ pub(super) enum CliTelemetryEvent {
|
||||
ArtifactGet,
|
||||
/// Executing `warp artifact download`
|
||||
ArtifactDownload,
|
||||
/// Executing `warp api-key list`
|
||||
ApiKeyList,
|
||||
/// Executing `warp api-key create`
|
||||
ApiKeyCreate,
|
||||
/// Executing `warp api-key expire`
|
||||
ApiKeyExpire,
|
||||
/// Executing `warp schedule create`
|
||||
ScheduleCreate,
|
||||
/// Executing `warp schedule list`
|
||||
@@ -112,6 +146,8 @@ pub(super) enum CliTelemetryEvent {
|
||||
HarnessSupportNotifyUser,
|
||||
/// Executing `warp harness-support finish-task`
|
||||
HarnessSupportFinishTask { success: bool },
|
||||
/// Executing `warp harness-support report-shutdown`
|
||||
HarnessSupportReportShutdown,
|
||||
}
|
||||
|
||||
impl TelemetryEvent for CliTelemetryEvent {
|
||||
@@ -137,6 +173,11 @@ impl TelemetryEvent for CliTelemetryEvent {
|
||||
CliTelemetryEvent::AgentRunAmbient => None,
|
||||
CliTelemetryEvent::AgentProfileList => None,
|
||||
CliTelemetryEvent::AgentList => None,
|
||||
CliTelemetryEvent::AgentGet => None,
|
||||
CliTelemetryEvent::AgentCreate => None,
|
||||
CliTelemetryEvent::AgentUpdate => None,
|
||||
CliTelemetryEvent::AgentDelete => None,
|
||||
CliTelemetryEvent::AgentSkills => None,
|
||||
CliTelemetryEvent::EnvironmentList => None,
|
||||
CliTelemetryEvent::EnvironmentCreate => None,
|
||||
CliTelemetryEvent::EnvironmentDelete => None,
|
||||
@@ -145,6 +186,15 @@ impl TelemetryEvent for CliTelemetryEvent {
|
||||
CliTelemetryEvent::EnvironmentImageList => None,
|
||||
CliTelemetryEvent::MCPList => None,
|
||||
CliTelemetryEvent::ModelList => None,
|
||||
CliTelemetryEvent::MemoryStoreList => None,
|
||||
CliTelemetryEvent::MemoryStoreListMemories => None,
|
||||
CliTelemetryEvent::MemoryStoreCreateMemory => None,
|
||||
CliTelemetryEvent::MemoryStoreUpdateMemory => None,
|
||||
CliTelemetryEvent::MemoryStoreDeleteMemory => None,
|
||||
CliTelemetryEvent::MemoryStoreGetStore => None,
|
||||
CliTelemetryEvent::MemoryStoreUpdateStore => None,
|
||||
CliTelemetryEvent::MemoryStoreListStoreAgents => None,
|
||||
CliTelemetryEvent::MemoryStoreListVersions => None,
|
||||
CliTelemetryEvent::TaskList => None,
|
||||
CliTelemetryEvent::TaskGet => None,
|
||||
CliTelemetryEvent::ConversationGet => None,
|
||||
@@ -167,6 +217,9 @@ impl TelemetryEvent for CliTelemetryEvent {
|
||||
CliTelemetryEvent::ArtifactUpload => None,
|
||||
CliTelemetryEvent::ArtifactGet => None,
|
||||
CliTelemetryEvent::ArtifactDownload => None,
|
||||
CliTelemetryEvent::ApiKeyList => None,
|
||||
CliTelemetryEvent::ApiKeyCreate => None,
|
||||
CliTelemetryEvent::ApiKeyExpire => None,
|
||||
CliTelemetryEvent::ScheduleCreate => None,
|
||||
CliTelemetryEvent::ScheduleList => None,
|
||||
CliTelemetryEvent::ScheduleGet => None,
|
||||
@@ -188,6 +241,7 @@ impl TelemetryEvent for CliTelemetryEvent {
|
||||
CliTelemetryEvent::HarnessSupportFinishTask { success } => {
|
||||
Some(json!({ "success": success }))
|
||||
}
|
||||
CliTelemetryEvent::HarnessSupportReportShutdown => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +269,11 @@ impl TelemetryEventDesc for CliTelemetryEventDiscriminants {
|
||||
CliTelemetryEventDiscriminants::AgentRunAmbient => "CLI.Execute.Agent.RunAmbient",
|
||||
CliTelemetryEventDiscriminants::AgentProfileList => "CLI.Execute.Agent.Profile.List",
|
||||
CliTelemetryEventDiscriminants::AgentList => "CLI.Execute.Agent.List",
|
||||
CliTelemetryEventDiscriminants::AgentGet => "CLI.Execute.Agent.Get",
|
||||
CliTelemetryEventDiscriminants::AgentCreate => "CLI.Execute.Agent.Create",
|
||||
CliTelemetryEventDiscriminants::AgentUpdate => "CLI.Execute.Agent.Update",
|
||||
CliTelemetryEventDiscriminants::AgentDelete => "CLI.Execute.Agent.Delete",
|
||||
CliTelemetryEventDiscriminants::AgentSkills => "CLI.Execute.Agent.Skills",
|
||||
CliTelemetryEventDiscriminants::EnvironmentList => "CLI.Execute.Environment.List",
|
||||
CliTelemetryEventDiscriminants::EnvironmentCreate => "CLI.Execute.Environment.Create",
|
||||
CliTelemetryEventDiscriminants::EnvironmentDelete => "CLI.Execute.Environment.Delete",
|
||||
@@ -225,6 +284,31 @@ impl TelemetryEventDesc for CliTelemetryEventDiscriminants {
|
||||
}
|
||||
CliTelemetryEventDiscriminants::MCPList => "CLI.Execute.MCP.List",
|
||||
CliTelemetryEventDiscriminants::ModelList => "CLI.Execute.Model.List",
|
||||
CliTelemetryEventDiscriminants::MemoryStoreList => "CLI.Execute.MemoryStore.List",
|
||||
CliTelemetryEventDiscriminants::MemoryStoreListMemories => {
|
||||
"CLI.Execute.MemoryStore.ListMemories"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::MemoryStoreCreateMemory => {
|
||||
"CLI.Execute.MemoryStore.CreateMemory"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::MemoryStoreUpdateMemory => {
|
||||
"CLI.Execute.MemoryStore.UpdateMemory"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::MemoryStoreDeleteMemory => {
|
||||
"CLI.Execute.MemoryStore.DeleteMemory"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::MemoryStoreGetStore => {
|
||||
"CLI.Execute.MemoryStore.GetStore"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::MemoryStoreUpdateStore => {
|
||||
"CLI.Execute.MemoryStore.UpdateStore"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::MemoryStoreListStoreAgents => {
|
||||
"CLI.Execute.MemoryStore.ListStoreAgents"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::MemoryStoreListVersions => {
|
||||
"CLI.Execute.MemoryStore.ListVersions"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::TaskList => "CLI.Execute.Task.List",
|
||||
CliTelemetryEventDiscriminants::TaskGet => "CLI.Execute.Task.Get",
|
||||
CliTelemetryEventDiscriminants::ConversationGet => "CLI.Execute.Conversation.Get",
|
||||
@@ -249,6 +333,9 @@ impl TelemetryEventDesc for CliTelemetryEventDiscriminants {
|
||||
CliTelemetryEventDiscriminants::ArtifactUpload => "CLI.Execute.Artifact.Upload",
|
||||
CliTelemetryEventDiscriminants::ArtifactGet => "CLI.Execute.Artifact.Get",
|
||||
CliTelemetryEventDiscriminants::ArtifactDownload => "CLI.Execute.Artifact.Download",
|
||||
CliTelemetryEventDiscriminants::ApiKeyList => "CLI.Execute.ApiKey.List",
|
||||
CliTelemetryEventDiscriminants::ApiKeyCreate => "CLI.Execute.ApiKey.Create",
|
||||
CliTelemetryEventDiscriminants::ApiKeyExpire => "CLI.Execute.ApiKey.Expire",
|
||||
CliTelemetryEventDiscriminants::ScheduleCreate => "CLI.Execute.Schedule.Create",
|
||||
CliTelemetryEventDiscriminants::ScheduleList => "CLI.Execute.Schedule.List",
|
||||
CliTelemetryEventDiscriminants::ScheduleGet => "CLI.Execute.Schedule.Get",
|
||||
@@ -274,6 +361,9 @@ impl TelemetryEventDesc for CliTelemetryEventDiscriminants {
|
||||
CliTelemetryEventDiscriminants::HarnessSupportFinishTask => {
|
||||
"CLI.Execute.HarnessSupport.FinishTask"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::HarnessSupportReportShutdown => {
|
||||
"CLI.Execute.HarnessSupport.ReportShutdown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,6 +377,11 @@ impl TelemetryEventDesc for CliTelemetryEventDiscriminants {
|
||||
"Listed agent profiles from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::AgentList => "Listed agents from the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::AgentGet => "Got agent details from the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::AgentCreate => "Created an agent from the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::AgentUpdate => "Updated an agent from the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::AgentDelete => "Deleted an agent from the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::AgentSkills => "Listed agent skills from the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::EnvironmentList => {
|
||||
"Listed cloud environments from the Warp CLI"
|
||||
}
|
||||
@@ -307,6 +402,33 @@ impl TelemetryEventDesc for CliTelemetryEventDiscriminants {
|
||||
}
|
||||
CliTelemetryEventDiscriminants::MCPList => "Listed MCP servers from the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::ModelList => "Listed models from the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::MemoryStoreList => {
|
||||
"Listed memory stores from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::MemoryStoreListMemories => {
|
||||
"Listed memories in a memory store from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::MemoryStoreCreateMemory => {
|
||||
"Created a manual memory in a memory store from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::MemoryStoreUpdateMemory => {
|
||||
"Updated a memory in a memory store from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::MemoryStoreDeleteMemory => {
|
||||
"Deleted a memory from a memory store from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::MemoryStoreGetStore => {
|
||||
"Got a memory store from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::MemoryStoreUpdateStore => {
|
||||
"Updated a memory store from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::MemoryStoreListStoreAgents => {
|
||||
"Listed agents attached to a memory store from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::MemoryStoreListVersions => {
|
||||
"Listed version history of a memory from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::TaskList => "Listed tasks from the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::TaskGet => "Got status of task from the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::ConversationGet => {
|
||||
@@ -353,6 +475,9 @@ impl TelemetryEventDesc for CliTelemetryEventDiscriminants {
|
||||
CliTelemetryEventDiscriminants::ArtifactDownload => {
|
||||
"Downloaded an artifact from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::ApiKeyList => "Listed API keys from the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::ApiKeyCreate => "Created an API key from the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::ApiKeyExpire => "Expired an API key from the Warp CLI",
|
||||
CliTelemetryEventDiscriminants::ScheduleCreate => {
|
||||
"Created a scheduled agent from the Warp CLI"
|
||||
}
|
||||
@@ -396,6 +521,9 @@ impl TelemetryEventDesc for CliTelemetryEventDiscriminants {
|
||||
CliTelemetryEventDiscriminants::HarnessSupportFinishTask => {
|
||||
"Reported task completion via harness-support from the Warp CLI"
|
||||
}
|
||||
CliTelemetryEventDiscriminants::HarnessSupportReportShutdown => {
|
||||
"Reported agent shutdown via harness-support from the Warp CLI"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,11 +539,14 @@ impl TelemetryEventDesc for CliTelemetryEventDiscriminants {
|
||||
Self::ArtifactUpload | Self::ArtifactGet | Self::ArtifactDownload => {
|
||||
EnablementState::Flag(FeatureFlag::ArtifactCommand)
|
||||
}
|
||||
Self::ApiKeyList | Self::ApiKeyCreate | Self::ApiKeyExpire => {
|
||||
EnablementState::Flag(FeatureFlag::APIKeyManagement)
|
||||
}
|
||||
Self::RunMessageWatch
|
||||
| Self::RunMessageSend
|
||||
| Self::RunMessageList
|
||||
| Self::RunMessageRead
|
||||
| Self::RunMessageMarkDelivered => EnablementState::Flag(FeatureFlag::OrchestrationV2),
|
||||
| Self::RunMessageMarkDelivered => EnablementState::Always,
|
||||
_ => EnablementState::Always,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
/// 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)
|
||||
.tls_certs_only([])
|
||||
.no_proxy()
|
||||
.pool_max_idle_per_host(0);
|
||||
http_client::Client::from_client_builder(builder)
|
||||
|
||||
Reference in New Issue
Block a user