first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
use url::Url;
|
||||
|
||||
use crate::ChannelState;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GithubAuthRedirectTarget {
|
||||
SettingsEnvironments,
|
||||
FocusCloudMode,
|
||||
}
|
||||
|
||||
impl GithubAuthRedirectTarget {
|
||||
fn next_path(self) -> &'static str {
|
||||
match self {
|
||||
Self::SettingsEnvironments => "settings/environments",
|
||||
Self::FocusCloudMode => "action/focus_cloud_mode",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Indicates where the GitHub authorization flow was initiated from.
|
||||
/// This affects the redirect URL used after auth completes.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum AuthSource {
|
||||
/// Auth initiated from the settings page (default behavior: redirect to settings)
|
||||
#[default]
|
||||
Settings,
|
||||
/// Auth initiated from cloud agent setup (skip redirect, just refresh in place)
|
||||
CloudSetup,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum OAuthNextPlatform {
|
||||
Native,
|
||||
Web,
|
||||
}
|
||||
|
||||
pub fn auth_url_with_next(
|
||||
base_auth_url: &str,
|
||||
target: GithubAuthRedirectTarget,
|
||||
auth_source: AuthSource,
|
||||
) -> String {
|
||||
let scheme = oauth_next_scheme();
|
||||
build_auth_url_with_next(base_auth_url, target, &scheme, auth_source)
|
||||
}
|
||||
pub fn settings_environments_auth_url_with_next(base_auth_url: &str) -> String {
|
||||
auth_url_with_next(
|
||||
base_auth_url,
|
||||
GithubAuthRedirectTarget::SettingsEnvironments,
|
||||
AuthSource::Settings,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn cloud_setup_auth_url_with_next(base_auth_url: &str) -> String {
|
||||
auth_url_with_next(
|
||||
base_auth_url,
|
||||
GithubAuthRedirectTarget::FocusCloudMode,
|
||||
AuthSource::CloudSetup,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_auth_url_with_next(
|
||||
base_auth_url: &str,
|
||||
target: GithubAuthRedirectTarget,
|
||||
scheme: &str,
|
||||
auth_source: AuthSource,
|
||||
) -> String {
|
||||
let Ok(mut url) = Url::parse(base_auth_url) else {
|
||||
return base_auth_url.to_string();
|
||||
};
|
||||
|
||||
let scheme_for_next = std::env::var("WARP_OAUTH_NEXT_SCHEME")
|
||||
.ok()
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| {
|
||||
url.query_pairs()
|
||||
.find(|(key, _)| key == "scheme")
|
||||
.map(|(_, value)| value.into_owned())
|
||||
})
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| scheme.to_string());
|
||||
|
||||
let platform = if cfg!(target_family = "wasm") {
|
||||
OAuthNextPlatform::Web
|
||||
} else {
|
||||
OAuthNextPlatform::Native
|
||||
};
|
||||
|
||||
let next_url = build_next_url(target, &scheme_for_next, auth_source, platform)
|
||||
.unwrap_or_else(|| format!("{scheme_for_next}://{}", target.next_path()));
|
||||
|
||||
let existing_pairs = url
|
||||
.query_pairs()
|
||||
.filter(|(key, _)| key != "next")
|
||||
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
{
|
||||
let mut query_pairs = url.query_pairs_mut();
|
||||
query_pairs.clear();
|
||||
for (key, value) in existing_pairs {
|
||||
query_pairs.append_pair(&key, &value);
|
||||
}
|
||||
query_pairs.append_pair("next", &next_url);
|
||||
}
|
||||
|
||||
url.to_string()
|
||||
}
|
||||
|
||||
fn build_next_url(
|
||||
target: GithubAuthRedirectTarget,
|
||||
scheme_for_next: &str,
|
||||
auth_source: AuthSource,
|
||||
platform: OAuthNextPlatform,
|
||||
) -> Option<String> {
|
||||
match platform {
|
||||
OAuthNextPlatform::Native => {
|
||||
let base = format!("{scheme_for_next}://{}", target.next_path());
|
||||
let mut url = Url::parse(&base).ok()?;
|
||||
|
||||
if matches!(auth_source, AuthSource::CloudSetup) {
|
||||
url.query_pairs_mut()
|
||||
.append_pair("source", crate::uri::CLOUD_SETUP_SOURCE);
|
||||
}
|
||||
|
||||
Some(url.to_string())
|
||||
}
|
||||
OAuthNextPlatform::Web => {
|
||||
let mut url = Url::parse(&ChannelState::server_root_url()).ok()?;
|
||||
url.set_query(None);
|
||||
|
||||
match target {
|
||||
GithubAuthRedirectTarget::SettingsEnvironments => {
|
||||
url.set_path("/settings/environments");
|
||||
{
|
||||
let mut pairs = url.query_pairs_mut();
|
||||
pairs.append_pair("oauth", "github");
|
||||
if matches!(auth_source, AuthSource::CloudSetup) {
|
||||
pairs.append_pair("source", crate::uri::CLOUD_SETUP_SOURCE);
|
||||
}
|
||||
}
|
||||
}
|
||||
GithubAuthRedirectTarget::FocusCloudMode => {
|
||||
url.set_path("/action/focus_cloud_mode");
|
||||
}
|
||||
}
|
||||
|
||||
Some(url.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn oauth_next_scheme() -> String {
|
||||
if let Ok(override_value) = std::env::var("WARP_OAUTH_NEXT_SCHEME") {
|
||||
if !override_value.is_empty() {
|
||||
return override_value;
|
||||
}
|
||||
}
|
||||
ChannelState::url_scheme().to_string()
|
||||
}
|
||||
@@ -1,21 +1,24 @@
|
||||
use std::fmt::Display;
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::{NonNilUuid, Uuid};
|
||||
|
||||
use crate::ai::agent::conversation::{AIConversation, ConversationStatus};
|
||||
use crate::ai::agent::{
|
||||
AIAgentOutputStatus, CancellationReason, FinishedAIAgentOutput, RenderableAIError,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::Display;
|
||||
use std::str::FromStr;
|
||||
use uuid::{NonNilUuid, Uuid};
|
||||
|
||||
pub mod github_auth_notifier;
|
||||
pub mod github_auth_url;
|
||||
pub mod scheduled;
|
||||
pub mod spawn;
|
||||
pub mod task;
|
||||
pub mod telemetry;
|
||||
|
||||
pub use task::{
|
||||
cancel_task_with_toast, AgentConfigSnapshot, AgentSource, AmbientAgentTask,
|
||||
AmbientAgentTaskState, TaskStatusMessage,
|
||||
cancel_task_silently, cancel_task_with_toast, AgentConfigSnapshot, AgentSource,
|
||||
AmbientAgentLiveSessionState, AmbientAgentTask, AmbientAgentTaskState, TaskStatusMessage,
|
||||
};
|
||||
pub const OUT_OF_CREDITS_TASK_FAILURE_MESSAGE: &str =
|
||||
"Out of credits. Upgrade your Warp plan to continue running cloud agents.";
|
||||
@@ -73,25 +76,69 @@ pub enum AmbientConversationStatus {
|
||||
pub fn conversation_output_status_from_conversation(
|
||||
conversation: &AIConversation,
|
||||
) -> Option<AmbientConversationStatus> {
|
||||
if let ConversationStatus::Blocked { blocked_action } = conversation.status() {
|
||||
return Some(AmbientConversationStatus::Blocked {
|
||||
blocked_action: blocked_action.clone(),
|
||||
});
|
||||
}
|
||||
match conversation.status() {
|
||||
// A pending recovery is not a terminal outcome.
|
||||
ConversationStatus::TransientError => None,
|
||||
|
||||
let last_exchange = conversation.root_task_exchanges().last()?;
|
||||
if let AIAgentOutputStatus::Finished { finished_output } = &last_exchange.output_status {
|
||||
let status = match finished_output {
|
||||
FinishedAIAgentOutput::Cancelled { output: _, reason } => {
|
||||
AmbientConversationStatus::Cancelled { reason: *reason }
|
||||
ConversationStatus::Blocked { blocked_action } => {
|
||||
Some(AmbientConversationStatus::Blocked {
|
||||
blocked_action: blocked_action.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
ConversationStatus::Error => {
|
||||
// Prefer the structured error on the last exchange: it carries the precise
|
||||
// error variant and rendering hints. Fall back to the conversation-level
|
||||
// `status_error` for out-of-band failures (e.g. shell exit) recorded
|
||||
// without an exchange. Both drive FAILED-vs-ERROR classification downstream.
|
||||
if let Some(AIAgentOutputStatus::Finished {
|
||||
finished_output: FinishedAIAgentOutput::Error { error, .. },
|
||||
}) = conversation
|
||||
.root_task_exchanges()
|
||||
.last()
|
||||
.map(|exchange| &exchange.output_status)
|
||||
{
|
||||
return Some(AmbientConversationStatus::Error {
|
||||
error: error.clone(),
|
||||
});
|
||||
}
|
||||
FinishedAIAgentOutput::Error { output: _, error } => AmbientConversationStatus::Error {
|
||||
error: error.clone(),
|
||||
},
|
||||
FinishedAIAgentOutput::Success { output: _ } => AmbientConversationStatus::Success,
|
||||
};
|
||||
return Some(status);
|
||||
}
|
||||
if let Some(error) = conversation.status_error() {
|
||||
return Some(AmbientConversationStatus::Error {
|
||||
error: error.clone(),
|
||||
});
|
||||
}
|
||||
// No structured error anywhere; fall back to whatever terminal outcome
|
||||
// the last exchange carries.
|
||||
terminal_status_from_last_exchange(conversation)
|
||||
}
|
||||
|
||||
None
|
||||
// `InProgress` and `WaitingForEvents` are not terminal, but we preserve the
|
||||
// existing behavior of reporting a terminal outcome whenever the last exchange
|
||||
// has already finished.
|
||||
ConversationStatus::InProgress
|
||||
| ConversationStatus::Success
|
||||
| ConversationStatus::Cancelled
|
||||
| ConversationStatus::WaitingForEvents => terminal_status_from_last_exchange(conversation),
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a terminal [`AmbientConversationStatus`] from the conversation's last
|
||||
/// exchange, if that exchange has finished.
|
||||
fn terminal_status_from_last_exchange(
|
||||
conversation: &AIConversation,
|
||||
) -> Option<AmbientConversationStatus> {
|
||||
let AIAgentOutputStatus::Finished { finished_output } =
|
||||
&conversation.root_task_exchanges().last()?.output_status
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
Some(match finished_output {
|
||||
FinishedAIAgentOutput::Cancelled { reason, .. } => {
|
||||
AmbientConversationStatus::Cancelled { reason: *reason }
|
||||
}
|
||||
FinishedAIAgentOutput::Error { error, .. } => AmbientConversationStatus::Error {
|
||||
error: error.clone(),
|
||||
},
|
||||
FinishedAIAgentOutput::Success { .. } => AmbientConversationStatus::Success,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,92 +1,29 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
|
||||
use super::AgentConfigSnapshot;
|
||||
|
||||
use crate::{
|
||||
cloud_object::{
|
||||
model::{
|
||||
generic_string_model::{GenericStringModel, GenericStringObjectId, StringModel},
|
||||
json_model::{JsonModel, JsonSerializer},
|
||||
persistence::CloudModel,
|
||||
},
|
||||
GenericCloudObject, GenericStringObjectFormat, GenericStringObjectUniqueKey,
|
||||
JsonObjectType, Owner, Revision, ServerCloudObject,
|
||||
},
|
||||
drive::CloudObjectTypeAndId,
|
||||
server::{
|
||||
cloud_objects::update_manager::{
|
||||
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
|
||||
},
|
||||
ids::{ClientId, SyncId},
|
||||
server_api::ServerApiProvider,
|
||||
sync_queue::QueueItem,
|
||||
},
|
||||
pub use cloud_object_models::{
|
||||
CloudScheduledAmbientAgent, CloudScheduledAmbientAgentModel, ScheduledAmbientAgent,
|
||||
};
|
||||
use futures::channel::oneshot;
|
||||
use futures::FutureExt;
|
||||
use serde_json::{Map, Value};
|
||||
use galaxy_graphql::queries::get_scheduled_agent_history::ScheduledAgentHistory;
|
||||
use galaxyui::{AppContext, Entity, ModelContext, SingletonEntity};
|
||||
use galaxyui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
/// A ScheduledAmbientAgent represents configuration for ambient agents that run on a cron schedule.
|
||||
pub struct ScheduledAmbientAgent {
|
||||
/// Agent name
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
/// Cron schedule expression
|
||||
#[serde(default)]
|
||||
pub cron_schedule: String,
|
||||
/// Whether the scheduled agent is enabled
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// The prompt to use for the scheduled agent
|
||||
#[serde(default)]
|
||||
pub prompt: String,
|
||||
/// The latest failure to execute this scheduled agent.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub last_spawn_error: Option<String>,
|
||||
/// Configuration for how the ambient agent should run.
|
||||
#[serde(default, skip_serializing_if = "AgentConfigSnapshot::is_empty")]
|
||||
pub agent_config: AgentConfigSnapshot,
|
||||
}
|
||||
|
||||
pub type CloudScheduledAmbientAgent =
|
||||
GenericCloudObject<GenericStringObjectId, CloudScheduledAmbientAgentModel>;
|
||||
pub type CloudScheduledAmbientAgentModel =
|
||||
GenericStringModel<ScheduledAmbientAgent, JsonSerializer>;
|
||||
|
||||
impl CloudScheduledAmbientAgent {
|
||||
pub fn get_all(app: &AppContext) -> Vec<CloudScheduledAmbientAgent> {
|
||||
CloudModel::as_ref(app)
|
||||
.get_all_objects_of_type::<GenericStringObjectId, CloudScheduledAmbientAgentModel>()
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn get_by_id<'a>(
|
||||
sync_id: &'a SyncId,
|
||||
app: &'a AppContext,
|
||||
) -> Option<&'a CloudScheduledAmbientAgent> {
|
||||
CloudModel::as_ref(app)
|
||||
.get_object_of_type::<GenericStringObjectId, CloudScheduledAmbientAgentModel>(sync_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl ScheduledAmbientAgent {
|
||||
pub fn new(name: String, cron_schedule: String, enabled: bool, prompt: String) -> Self {
|
||||
Self {
|
||||
name,
|
||||
cron_schedule,
|
||||
enabled,
|
||||
prompt,
|
||||
last_spawn_error: None,
|
||||
agent_config: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
use crate::cloud_object::model::generic_string_model::StringModel;
|
||||
use crate::cloud_object::model::json_model::JsonModel;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::{
|
||||
CloudObjectLookup as _, GenericStringObjectFormat, GenericStringObjectUniqueKey,
|
||||
JsonObjectType, Owner, Revision,
|
||||
};
|
||||
use crate::drive::CloudObjectTypeAndId;
|
||||
use crate::server::cloud_objects::update_manager::{
|
||||
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
|
||||
};
|
||||
use crate::server::ids::{ClientId, SyncId};
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::server::sync_queue::QueueItem;
|
||||
|
||||
impl StringModel for ScheduledAmbientAgent {
|
||||
type CloudObjectType = CloudScheduledAmbientAgent;
|
||||
@@ -123,15 +60,6 @@ impl StringModel for ScheduledAmbientAgent {
|
||||
None
|
||||
}
|
||||
|
||||
fn new_from_server_update(&self, server_cloud_object: &ServerCloudObject) -> Option<Self> {
|
||||
if let ServerCloudObject::ScheduledAmbientAgent(server_scheduled_agent) =
|
||||
server_cloud_object
|
||||
{
|
||||
return Some(server_scheduled_agent.model.clone().string_model);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn should_show_activity_toasts() -> bool {
|
||||
false
|
||||
}
|
||||
@@ -230,6 +158,7 @@ impl ScheduledAgentManager {
|
||||
|
||||
fn handle_update_manager_event(
|
||||
&mut self,
|
||||
_: ModelHandle<UpdateManager>,
|
||||
event: &UpdateManagerEvent,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
|
||||
@@ -1,27 +1,38 @@
|
||||
//! Stream-based API for spawning and monitoring ambient agents.
|
||||
#![cfg_attr(target_family = "wasm", expect(dead_code))]
|
||||
|
||||
use std::{str::FromStr, sync::Arc, time::Duration};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::{select, FutureExt, Stream};
|
||||
use anyhow::anyhow;
|
||||
use futures::{select, FutureExt, Stream, StreamExt};
|
||||
use session_sharing_protocol::common::SessionId;
|
||||
|
||||
use super::AmbientAgentTaskId;
|
||||
use super::{AmbientAgentTask, AmbientAgentTaskState};
|
||||
use crate::{
|
||||
server::server_api::ai::{AIClient, SpawnAgentRequest, TaskStatusMessage},
|
||||
terminal::shared_session,
|
||||
use super::{AmbientAgentTask, AmbientAgentTaskId, AmbientAgentTaskState};
|
||||
use crate::server::retry_strategies::with_bounded_retry;
|
||||
use crate::server::server_api::ai::{
|
||||
AIClient, RunFollowupRequest, SpawnAgentRequest, TaskStatusMessage,
|
||||
};
|
||||
use crate::terminal::shared_session;
|
||||
|
||||
/// How long to poll for the agent to be ready.
|
||||
/// This should be long enough that the shared session will be joinable.
|
||||
pub const TASK_STATUS_POLLING_DURATION: Duration = Duration::from_secs(80);
|
||||
|
||||
#[cfg(not(test))]
|
||||
const TASK_STATUS_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||
const TASK_STATUS_POLL_INTERVAL: Duration = Duration::from_secs(3);
|
||||
#[cfg(test)]
|
||||
const TASK_STATUS_POLL_INTERVAL: Duration = Duration::from_millis(1);
|
||||
|
||||
/// Maximum number of consecutive polls that may observe a non-working, non-cancelled task
|
||||
/// state for a follow-up before we give up and surface a failure. Bounds the worst-case
|
||||
/// wait when the server is wedged and never transitions the task off its prior terminal
|
||||
/// state. At the production `TASK_STATUS_POLL_INTERVAL` of 3s, 10 skipped observations
|
||||
/// is ~30s — comfortably longer than the dispatcher's `ProcessingInterval` plus typical
|
||||
/// worker claim latency.
|
||||
const MAX_STALE_POLLS_BEFORE_FAILURE: usize = 10;
|
||||
|
||||
/// Information about a session join link for an ambient agent task.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SessionJoinInfo {
|
||||
@@ -31,30 +42,22 @@ pub struct SessionJoinInfo {
|
||||
|
||||
impl SessionJoinInfo {
|
||||
pub fn from_task(task: &AmbientAgentTask) -> Option<Self> {
|
||||
// Prefer the server-provided session_link when available; it is a better signal
|
||||
// that a session-sharing link is ready to be shown to the user.
|
||||
if let Some(link) = task.session_link.as_ref().filter(|l| !l.is_empty()) {
|
||||
let session_id = task
|
||||
.session_id
|
||||
.as_deref()
|
||||
.and_then(|s| SessionId::from_str(s).ok());
|
||||
return Some(Self {
|
||||
session_id,
|
||||
session_link: link.to_string(),
|
||||
});
|
||||
}
|
||||
let run_execution = task.active_run_execution();
|
||||
// The cloud-mode pane joins on `session_id`; a standalone `session_link` isn't
|
||||
// actionable without it.
|
||||
let session_id_str = run_execution.session_id?;
|
||||
let session_id = SessionId::from_str(session_id_str).ok()?;
|
||||
|
||||
// Fallback to constructing a link from the session_id.
|
||||
if let Some(session_id_str) = task.session_id.as_deref() {
|
||||
if let Ok(session_id) = SessionId::from_str(session_id_str) {
|
||||
return Some(Self {
|
||||
session_id: Some(session_id),
|
||||
session_link: shared_session::join_link(&session_id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
// Prefer the server-provided `session_link`; fall back to constructing one from
|
||||
// `session_id`. `active_run_execution()` already filters out empty links.
|
||||
let session_link = run_execution
|
||||
.session_link
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| shared_session::join_link(&session_id));
|
||||
Some(Self {
|
||||
session_id: Some(session_id),
|
||||
session_link,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +83,13 @@ pub enum AmbientAgentEvent {
|
||||
AtCapacity,
|
||||
}
|
||||
|
||||
enum RunPollMode {
|
||||
InitialRun,
|
||||
Followup {
|
||||
previous_session_id: Option<SessionId>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Spawns an ambient agent task and monitors its state.
|
||||
///
|
||||
/// The stream completes when:
|
||||
@@ -113,16 +123,76 @@ pub fn spawn_task(
|
||||
yield Ok(AmbientAgentEvent::AtCapacity);
|
||||
}
|
||||
|
||||
let mut stream = Box::pin(poll_run_until_joinable_session(
|
||||
task_id,
|
||||
ai_client,
|
||||
RunPollMode::InitialRun,
|
||||
timeout,
|
||||
));
|
||||
while let Some(event) = stream.next().await {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn submit_run_followup(
|
||||
message: String,
|
||||
run_id: AmbientAgentTaskId,
|
||||
previous_session_id: Option<SessionId>,
|
||||
ai_client: Arc<dyn AIClient>,
|
||||
timeout: Option<Duration>,
|
||||
) -> impl Stream<Item = Result<AmbientAgentEvent, anyhow::Error>> {
|
||||
async_stream::stream! {
|
||||
let request = RunFollowupRequest { message };
|
||||
if let Err(err) = ai_client.submit_run_followup(&run_id, request).await {
|
||||
yield Err(err);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut stream = Box::pin(poll_run_until_joinable_session(
|
||||
run_id,
|
||||
ai_client,
|
||||
RunPollMode::Followup {
|
||||
previous_session_id,
|
||||
},
|
||||
timeout,
|
||||
));
|
||||
while let Some(event) = stream.next().await {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_run_until_joinable_session(
|
||||
run_id: AmbientAgentTaskId,
|
||||
ai_client: Arc<dyn AIClient>,
|
||||
mode: RunPollMode,
|
||||
timeout: Option<Duration>,
|
||||
) -> impl Stream<Item = Result<AmbientAgentEvent, anyhow::Error>> {
|
||||
async_stream::stream! {
|
||||
// Poll for the task until it completes OR has session join info.
|
||||
// We use a timeout to ensure we don't wait indefinitely for session info.
|
||||
// If no timeout is provided, we use a future that never completes.
|
||||
let mut timeout_timer = match timeout {
|
||||
let mut timeout_timer = FutureExt::fuse(match timeout {
|
||||
Some(d) => galaxyui::r#async::Timer::after(d),
|
||||
None => galaxyui::r#async::Timer::never(),
|
||||
}.fuse();
|
||||
});
|
||||
let mut last_state = None;
|
||||
// For follow-ups, the server does NOT synchronously transition the task off its
|
||||
// prior terminal state when `submit_run_followup` returns — the transition happens
|
||||
// later, asynchronously, when the dispatcher loop picks up the newly enqueued
|
||||
// execution and the worker claims it. If we treated the first observed state as
|
||||
// the follow-up's outcome we'd misreport the prior run's `status_message` as a
|
||||
// failure and end the stream, leaving the model permanently stuck in `Failed`
|
||||
// even though the new run is actually about to start.
|
||||
//
|
||||
// To avoid that, gate event emission for follow-ups on having observed at least
|
||||
// one working state. Initial spawns don't need this — they start from a fresh
|
||||
// task whose first observation reflects the spawn itself.
|
||||
let mut seen_working_state = matches!(&mode, RunPollMode::InitialRun);
|
||||
let mut skipped_stale_polls: usize = 0;
|
||||
loop {
|
||||
let mut poll_timer = galaxyui::r#async::Timer::after(TASK_STATUS_POLL_INTERVAL).fuse();
|
||||
let mut poll_timer = FutureExt::fuse(galaxyui::r#async::Timer::after(TASK_STATUS_POLL_INTERVAL));
|
||||
|
||||
select! {
|
||||
_ = timeout_timer => {
|
||||
@@ -130,9 +200,50 @@ pub fn spawn_task(
|
||||
return;
|
||||
}
|
||||
_ = poll_timer => {
|
||||
match ai_client.get_ambient_agent_task(&task_id).await {
|
||||
// Wrap the status poll in with_bounded_retry so transient
|
||||
// HTTP errors (429, 5xx) are retried with exponential
|
||||
// backoff instead of immediately killing the CLI.
|
||||
let poll_result = {
|
||||
let client = ai_client.clone();
|
||||
with_bounded_retry(
|
||||
&format!("poll agent {run_id}"),
|
||||
|| {
|
||||
let client = client.clone();
|
||||
async move { client.get_ambient_agent_task(&run_id).await }
|
||||
},
|
||||
)
|
||||
.await
|
||||
};
|
||||
match poll_result {
|
||||
Ok(task) => {
|
||||
// Only emit a state-change event if the state has changed.
|
||||
// Log every non-InProgress observation BEFORE the skip check
|
||||
// so we retain visibility into stalls where the server is
|
||||
// wedged on the prior terminal state.
|
||||
if task.state != AmbientAgentTaskState::InProgress {
|
||||
log::info!("Agent {run_id} state: {:?}", task.state);
|
||||
}
|
||||
|
||||
if task.state.is_working() {
|
||||
seen_working_state = true;
|
||||
} else if !seen_working_state
|
||||
&& task.state != AmbientAgentTaskState::Cancelled
|
||||
&& skipped_stale_polls < MAX_STALE_POLLS_BEFORE_FAILURE
|
||||
{
|
||||
// Likely the prior run's residual terminal state; the
|
||||
// server hasn't transitioned the task yet. Skip without
|
||||
// emitting events or ending the stream.
|
||||
//
|
||||
// Carve-outs:
|
||||
// - `Cancelled` always falls through: it's only reached
|
||||
// via explicit user/admin/server cancellation and the
|
||||
// server will never transition out of it on its own.
|
||||
// - After `MAX_STALE_POLLS_BEFORE_FAILURE` skipped polls
|
||||
// we give up so a wedged server can't keep the stream
|
||||
// alive indefinitely.
|
||||
skipped_stale_polls += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if last_state.as_ref() != Some(&task.state) {
|
||||
last_state = Some(task.state.clone());
|
||||
yield Ok(AmbientAgentEvent::StateChanged {
|
||||
@@ -141,23 +252,50 @@ pub fn spawn_task(
|
||||
});
|
||||
}
|
||||
|
||||
// Check if the task has completed or started sharing its session.
|
||||
if task.state.is_terminal() {
|
||||
// Task completed, stream ends.
|
||||
if matches!(&mode, RunPollMode::Followup { .. }) {
|
||||
let exhausted_stale_skips = !seen_working_state
|
||||
&& skipped_stale_polls >= MAX_STALE_POLLS_BEFORE_FAILURE;
|
||||
let message = if exhausted_stale_skips {
|
||||
"Cloud follow-up did not start in time".to_string()
|
||||
} else {
|
||||
task.status_message
|
||||
.as_ref()
|
||||
.map(|msg| msg.message.clone())
|
||||
.unwrap_or_else(|| {
|
||||
if task.state.is_failure_like() {
|
||||
"Cloud agent failed".to_string()
|
||||
} else {
|
||||
"Cloud follow-up finished before a new session became available".to_string()
|
||||
}
|
||||
})
|
||||
};
|
||||
yield Err(anyhow!(message));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if task.state == AmbientAgentTaskState::InProgress {
|
||||
if let Some(session_join_info) = SessionJoinInfo::from_task(&task) {
|
||||
yield Ok(AmbientAgentEvent::SessionStarted {
|
||||
session_join_info,
|
||||
});
|
||||
return;
|
||||
let has_new_session = match &mode {
|
||||
RunPollMode::InitialRun
|
||||
| RunPollMode::Followup {
|
||||
previous_session_id: None,
|
||||
} => true,
|
||||
RunPollMode::Followup {
|
||||
previous_session_id: Some(previous_session_id),
|
||||
} => session_join_info
|
||||
.session_id
|
||||
.as_ref()
|
||||
.is_some_and(|session_id| session_id != previous_session_id),
|
||||
};
|
||||
if has_new_session {
|
||||
yield Ok(AmbientAgentEvent::SessionStarted {
|
||||
session_join_info,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Continue polling.
|
||||
} else {
|
||||
log::info!("Agent {task_id} state: {:?}", task.state);
|
||||
// Continue polling.
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+230
-150
@@ -1,153 +1,42 @@
|
||||
//! Ambient agent task types and utilities.
|
||||
|
||||
use anyhow::anyhow;
|
||||
use chrono::{DateTime, Utc};
|
||||
use galaxy_cli::agent::Harness;
|
||||
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub use cloud_object_models::HarnessModelConfig;
|
||||
pub use cloud_object_models::{AgentConfigSnapshot, HarnessAuthSecretsConfig, HarnessConfig};
|
||||
use iso8601_duration::Duration as Iso8601Duration;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use session_sharing_protocol::common::SessionId;
|
||||
use url::Url;
|
||||
use galaxy_core::report_error;
|
||||
use galaxy_core::ui::theme::GalaxyTheme;
|
||||
use galaxy_core::ui::theme::WarpTheme;
|
||||
use galaxyui::color::ColorU;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use galaxyui::{SingletonEntity, View, ViewContext};
|
||||
|
||||
use super::AmbientAgentTaskId;
|
||||
use crate::ai::artifacts::{deserialize_artifacts, Artifact};
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::view_components::DismissibleToast;
|
||||
use crate::workspace::ToastStack;
|
||||
use galaxyui::{SingletonEntity, View, ViewContext};
|
||||
|
||||
use super::AmbientAgentTaskId;
|
||||
|
||||
/// Runtime configuration snapshot for agent execution.
|
||||
///
|
||||
/// This is the merged/resolved config used when spawning or running an agent.
|
||||
/// It combines settings from config files and CLI args.
|
||||
/// Unlike `AgentConfig` (the cloud model), field names here use the runtime format
|
||||
/// (e.g. `model_id` instead of `base_model_id`).
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct AgentConfigSnapshot {
|
||||
/// Config name for searchability/traceability.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub environment_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub base_prompt: Option<String>,
|
||||
/// MCP server configuration map (unwrapped; no `mcpServers` wrapper).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mcp_servers: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
/// Profile ID for local agent runs. This configures the terminal session
|
||||
/// with the specified execution profile. Only used for local runs, not cloud runs.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile_id: Option<String>,
|
||||
/// Self-hosted worker ID that should execute this task.
|
||||
/// If None or Some("warp"), the task will be dispatched to Warp-hosted (Namespace) workers.
|
||||
/// Otherwise, the task will only be assigned to a connected self-hosted worker with matching ID.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub worker_host: Option<String>,
|
||||
/// Skill spec to use as the base prompt for the agent.
|
||||
/// Format: "skill_name", "repo:skill_name", or "org/repo:skill_name".
|
||||
/// The skill is resolved at runtime in the agent environment.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub skill_spec: Option<String>,
|
||||
/// Whether computer use is enabled for this agent run.
|
||||
/// If None, the default behavior is used.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub computer_use_enabled: Option<bool>,
|
||||
/// Execution harness for the agent run.
|
||||
/// If None, we use Warp's default ("oz").
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub harness: Option<HarnessConfig>,
|
||||
/// Authentication secrets for third-party harnesses.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub harness_auth_secrets: Option<HarnessAuthSecretsConfig>,
|
||||
fn parse_session_id_from_link(session_link: &str) -> Option<SessionId> {
|
||||
Url::parse(session_link).ok().and_then(|url| {
|
||||
url.path_segments()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.and_then(|segment| segment.parse().ok())
|
||||
})
|
||||
}
|
||||
|
||||
/// Configuration for a third-party execution harness.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct HarnessConfig {
|
||||
/// The harness type, e.g. [`Harness::Claude`].
|
||||
#[serde(
|
||||
rename = "type",
|
||||
serialize_with = "serialize_harness",
|
||||
deserialize_with = "deserialize_harness"
|
||||
)]
|
||||
pub harness_type: Harness,
|
||||
fn parse_execution_session_id(execution: RunExecution<'_>) -> Option<SessionId> {
|
||||
execution
|
||||
.session_id
|
||||
.and_then(|id| id.parse().ok())
|
||||
.or_else(|| execution.session_link.and_then(parse_session_id_from_link))
|
||||
}
|
||||
|
||||
impl HarnessConfig {
|
||||
/// Builds a harness config from just the harness type.
|
||||
pub fn from_harness_type(harness_type: Harness) -> Self {
|
||||
Self { harness_type }
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a harness type name (e.g. `"claude"`) into a [`Harness`] variant.
|
||||
/// Unknown values fall back to [`Harness::Unknown`] so we don't
|
||||
/// misrepresent a future-server harness as Oz; UI surfaces should treat
|
||||
/// `Unknown` as a non-Oz, non-runnable harness.
|
||||
pub(crate) fn harness_from_name(name: &str) -> Harness {
|
||||
match name {
|
||||
"claude" => Harness::Claude,
|
||||
"opencode" => Harness::OpenCode,
|
||||
"gemini" => Harness::Gemini,
|
||||
"oz" => Harness::Oz,
|
||||
other => {
|
||||
log::warn!("Unknown harness config name: {other:?}; treating as Unknown");
|
||||
Harness::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_harness<S: Serializer>(harness: &Harness, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&harness.to_string())
|
||||
}
|
||||
|
||||
fn deserialize_harness<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Harness, D::Error> {
|
||||
let name = String::deserialize(deserializer)?;
|
||||
Ok(harness_from_name(&name))
|
||||
}
|
||||
|
||||
/// Authentication secrets for third-party harnesses.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct HarnessAuthSecretsConfig {
|
||||
/// Name of a managed secret for Claude Code harness authentication.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub claude_auth_secret_name: Option<String>,
|
||||
}
|
||||
|
||||
impl AgentConfigSnapshot {
|
||||
/// Returns true if this config is empty (no options are set).
|
||||
pub fn is_empty(&self) -> bool {
|
||||
let Self {
|
||||
name,
|
||||
environment_id,
|
||||
model_id,
|
||||
base_prompt,
|
||||
mcp_servers,
|
||||
profile_id,
|
||||
worker_host,
|
||||
skill_spec,
|
||||
computer_use_enabled,
|
||||
harness,
|
||||
harness_auth_secrets,
|
||||
} = self;
|
||||
|
||||
name.is_none()
|
||||
&& environment_id.is_none()
|
||||
&& model_id.is_none()
|
||||
&& base_prompt.is_none()
|
||||
&& mcp_servers.is_none()
|
||||
&& profile_id.is_none()
|
||||
&& worker_host.is_none()
|
||||
&& skill_spec.is_none()
|
||||
&& computer_use_enabled.is_none()
|
||||
&& harness.is_none()
|
||||
&& harness_auth_secrets.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AgentSource {
|
||||
Linear,
|
||||
@@ -185,10 +74,24 @@ impl AgentSource {
|
||||
AgentSource::Slack => "Slack",
|
||||
AgentSource::Cli => "CLI",
|
||||
AgentSource::ScheduledAgent => "Scheduled",
|
||||
AgentSource::Interactive => "Warp (local agent)",
|
||||
AgentSource::Interactive | AgentSource::CloudMode => "Warp App",
|
||||
AgentSource::WebApp => "Oz Web",
|
||||
AgentSource::GitHubAction => "GitHub Action",
|
||||
AgentSource::CloudMode => "Warp (cloud agent)",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when tasks from this source must not accept user-triggered cloud follow-ups.
|
||||
pub fn blocks_cloud_followups(&self) -> bool {
|
||||
match self {
|
||||
AgentSource::GitHubAction => true,
|
||||
AgentSource::Linear
|
||||
| AgentSource::AgentWebhook
|
||||
| AgentSource::Slack
|
||||
| AgentSource::Cli
|
||||
| AgentSource::ScheduledAgent
|
||||
| AgentSource::Interactive
|
||||
| AgentSource::WebApp
|
||||
| AgentSource::CloudMode => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,12 +150,16 @@ pub struct AmbientAgentTask {
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
#[serde(default)]
|
||||
pub run_time: Option<Iso8601Duration>,
|
||||
pub status_message: Option<TaskStatusMessage>,
|
||||
#[serde(default, deserialize_with = "deserialize_ambient_agent_source")]
|
||||
pub source: Option<AgentSource>,
|
||||
pub session_id: Option<String>,
|
||||
pub session_link: Option<String>,
|
||||
pub creator: Option<TaskCreatorInfo>,
|
||||
pub creator: Option<TaskPrincipalInfo>,
|
||||
#[serde(default)]
|
||||
pub executor: Option<TaskPrincipalInfo>,
|
||||
pub conversation_id: Option<String>,
|
||||
pub request_usage: Option<RequestUsage>,
|
||||
pub is_sandbox_running: bool,
|
||||
@@ -278,6 +185,35 @@ pub struct AmbientAgentTask {
|
||||
pub children: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct RunExecution<'a> {
|
||||
pub session_id: Option<&'a str>,
|
||||
pub session_link: Option<&'a str>,
|
||||
pub request_usage: Option<&'a RequestUsage>,
|
||||
pub is_sandbox_running: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AmbientAgentLiveSessionState {
|
||||
/// The task does not currently have a running execution with a joinable session signal.
|
||||
Inactive,
|
||||
/// The task has a running execution, but this client does not have a parsed
|
||||
/// shared-session id it can attach to.
|
||||
ActiveUnattachable,
|
||||
/// The task has a running execution and this client can attach to its shared session.
|
||||
Attachable { session_id: SessionId },
|
||||
}
|
||||
|
||||
impl RunExecution<'_> {
|
||||
pub fn has_joinable_session(&self) -> bool {
|
||||
self.session_id.is_some() || self.session_link.is_some()
|
||||
}
|
||||
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.is_sandbox_running && self.has_joinable_session()
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a single attachment input from the client (e.g., file upload)
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct AttachmentInput {
|
||||
@@ -295,19 +231,115 @@ pub struct TaskAttachment {
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
/// Returns the trimmed orchestrator agent name, or `None` when empty / whitespace-only.
|
||||
pub fn normalize_orchestrator_agent_name(raw: &str) -> Option<String> {
|
||||
let trimmed = raw.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
}
|
||||
|
||||
impl AmbientAgentTask {
|
||||
/// Total credits used (inference + compute).
|
||||
pub fn credits_used(&self) -> Option<f32> {
|
||||
self.request_usage
|
||||
.as_ref()
|
||||
.map(|u| (u.inference_cost.unwrap_or(0.0) + u.compute_cost.unwrap_or(0.0)) as f32)
|
||||
pub fn run_id(&self) -> AmbientAgentTaskId {
|
||||
self.task_id
|
||||
}
|
||||
|
||||
/// Duration from started_at to updated_at.
|
||||
pub fn run_time(&self) -> Option<chrono::Duration> {
|
||||
let started = self.started_at?;
|
||||
let duration = self.updated_at.signed_duration_since(started);
|
||||
(duration.num_seconds() >= 0).then_some(duration)
|
||||
/// Returns the short label for this task: trimmed `agent_config_snapshot.name`,
|
||||
/// trimmed `title`, or `"Agent"`.
|
||||
pub fn display_name(&self) -> &str {
|
||||
if let Some(name) = self
|
||||
.agent_config_snapshot
|
||||
.as_ref()
|
||||
.and_then(|c| c.name.as_deref())
|
||||
{
|
||||
let trimmed = name.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
let trimmed_title = self.title.trim();
|
||||
if !trimmed_title.is_empty() {
|
||||
return trimmed_title;
|
||||
}
|
||||
"Agent"
|
||||
}
|
||||
|
||||
pub fn conversation_id(&self) -> Option<&str> {
|
||||
self.conversation_id.as_deref()
|
||||
}
|
||||
|
||||
/// Returns true when this task's source must not accept user-triggered cloud follow-ups.
|
||||
pub fn blocks_cloud_followups(&self) -> bool {
|
||||
self.source
|
||||
.as_ref()
|
||||
.is_some_and(AgentSource::blocks_cloud_followups)
|
||||
}
|
||||
|
||||
pub fn active_run_execution(&self) -> RunExecution<'_> {
|
||||
RunExecution {
|
||||
session_id: self.session_id.as_deref(),
|
||||
session_link: self.session_link.as_deref().filter(|link| !link.is_empty()),
|
||||
request_usage: self.request_usage.as_ref(),
|
||||
is_sandbox_running: self.is_sandbox_running,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_execution_session_id(&self) -> Option<&str> {
|
||||
let execution = self.active_run_execution();
|
||||
if self.state == AmbientAgentTaskState::InProgress && execution.is_active() {
|
||||
execution.session_id
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the canonical live-session state for this task from the client's perspective.
|
||||
///
|
||||
/// This separates task liveness from attachability: an in-progress task can have an active
|
||||
/// execution without a usable shared-session id, and callers should not treat that as a
|
||||
/// completed transcript/follow-up state.
|
||||
pub fn active_live_session_state(&self) -> AmbientAgentLiveSessionState {
|
||||
let execution = self.active_run_execution();
|
||||
if self.state != AmbientAgentTaskState::InProgress || !execution.is_active() {
|
||||
return AmbientAgentLiveSessionState::Inactive;
|
||||
}
|
||||
|
||||
match parse_execution_session_id(execution) {
|
||||
Some(session_id) => AmbientAgentLiveSessionState::Attachable { session_id },
|
||||
None => AmbientAgentLiveSessionState::ActiveUnattachable,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_execution_conversation_id(&self) -> Option<&str> {
|
||||
if self.has_active_execution() {
|
||||
self.conversation_id()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_active_execution(&self) -> bool {
|
||||
self.state == AmbientAgentTaskState::InProgress && self.active_run_execution().is_active()
|
||||
}
|
||||
|
||||
pub fn is_terminal_run_state(&self) -> bool {
|
||||
self.state.is_terminal()
|
||||
}
|
||||
|
||||
pub fn can_submit_cloud_followup(&self) -> bool {
|
||||
self.is_terminal_run_state() && !self.has_active_execution()
|
||||
}
|
||||
|
||||
/// Total credits used (inference + compute + platform).
|
||||
pub fn credits_used(&self) -> Option<f32> {
|
||||
self.active_run_execution().request_usage.map(|u| {
|
||||
(u.inference_cost.unwrap_or(0.0)
|
||||
+ u.compute_cost.unwrap_or(0.0)
|
||||
+ u.platform_cost.unwrap_or(0.0)) as f32
|
||||
})
|
||||
}
|
||||
|
||||
/// Server-reported run duration.
|
||||
pub fn run_time(&self) -> Option<ChronoDuration> {
|
||||
self.run_time.and_then(|run_time| run_time.to_chrono())
|
||||
}
|
||||
|
||||
/// Creator's display name, if available.
|
||||
@@ -315,9 +347,14 @@ impl AmbientAgentTask {
|
||||
self.creator.as_ref().and_then(|c| c.display_name.clone())
|
||||
}
|
||||
|
||||
/// Principal the run executed as, formatted for user-facing surfaces.
|
||||
pub fn executor_display_name(&self) -> Option<String> {
|
||||
self.executor.as_ref().and_then(|e| e.display_name.clone())
|
||||
}
|
||||
|
||||
/// Returns true if the underlying session for the ambient agent is no longer running.
|
||||
pub fn is_no_longer_running(&self) -> bool {
|
||||
!self.is_sandbox_running && !self.state.is_working()
|
||||
!self.active_run_execution().is_sandbox_running && !self.state.is_working()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,7 +480,7 @@ impl std::fmt::Display for AmbientAgentTaskState {
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub struct TaskCreatorInfo {
|
||||
pub struct TaskPrincipalInfo {
|
||||
#[serde(rename = "type")]
|
||||
pub creator_type: String,
|
||||
pub uid: String,
|
||||
@@ -453,12 +490,38 @@ pub struct TaskCreatorInfo {
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub struct TaskStatusMessage {
|
||||
pub message: String,
|
||||
#[serde(default, alias = "errorCode")]
|
||||
pub error_code: Option<TaskStatusErrorCode>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaskStatusErrorCode {
|
||||
#[serde(alias = "ENVIRONMENT_SETUP_FAILED")]
|
||||
EnvironmentSetupFailed,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl TaskStatusErrorCode {
|
||||
pub fn is_environment_setup_failure(&self) -> bool {
|
||||
matches!(self, TaskStatusErrorCode::EnvironmentSetupFailed)
|
||||
}
|
||||
}
|
||||
|
||||
impl TaskStatusMessage {
|
||||
pub fn is_environment_setup_failure(&self) -> bool {
|
||||
self.error_code
|
||||
.as_ref()
|
||||
.is_some_and(TaskStatusErrorCode::is_environment_setup_failure)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub struct RequestUsage {
|
||||
pub inference_cost: Option<f64>,
|
||||
pub compute_cost: Option<f64>,
|
||||
pub platform_cost: Option<f64>,
|
||||
}
|
||||
|
||||
/// Cancel an ambient agent task and show a toast with the result.
|
||||
@@ -482,3 +545,20 @@ pub fn cancel_task_with_toast<V: View>(task_id: AmbientAgentTaskId, ctx: &mut Vi
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancel an ambient agent task without surfacing a toast to the user.
|
||||
pub fn cancel_task_silently<V: View>(task_id: AmbientAgentTaskId, ctx: &mut ViewContext<V>) {
|
||||
let ai_client = ServerApiProvider::handle(ctx).as_ref(ctx).get_ai_client();
|
||||
ctx.spawn(
|
||||
async move { ai_client.cancel_ambient_agent_task(&task_id).await },
|
||||
move |_view, result, _| {
|
||||
if let Err(e) = result {
|
||||
log::error!("Failed to cancel task: {e}");
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "task_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
use chrono::{Duration, Utc};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::{
|
||||
AgentConfigSnapshot, AmbientAgentTask, AmbientAgentTaskState, TaskStatusErrorCode,
|
||||
TaskStatusMessage,
|
||||
};
|
||||
|
||||
fn make_task(snapshot_name: Option<&str>, title: &str) -> AmbientAgentTask {
|
||||
let now = Utc::now();
|
||||
let agent_config_snapshot = snapshot_name.map(|name| AgentConfigSnapshot {
|
||||
name: Some(name.to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
AmbientAgentTask {
|
||||
task_id: "11111111-1111-1111-1111-111111111111".parse().unwrap(),
|
||||
parent_run_id: None,
|
||||
title: title.to_string(),
|
||||
state: AmbientAgentTaskState::InProgress,
|
||||
prompt: String::new(),
|
||||
created_at: now,
|
||||
started_at: Some(now),
|
||||
updated_at: now,
|
||||
run_time: Some("PT1S".parse().unwrap()),
|
||||
status_message: None,
|
||||
source: None,
|
||||
session_id: None,
|
||||
session_link: None,
|
||||
creator: None,
|
||||
executor: None,
|
||||
conversation_id: None,
|
||||
request_usage: None,
|
||||
is_sandbox_running: false,
|
||||
agent_config_snapshot,
|
||||
artifacts: vec![],
|
||||
last_event_sequence: None,
|
||||
children: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn task_json_with_run_time(run_time_key: &str, run_time: Value) -> Value {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let mut task = json!({
|
||||
"task_id": "11111111-1111-1111-1111-111111111111",
|
||||
"title": "Task",
|
||||
"state": "SUCCEEDED",
|
||||
"prompt": "test",
|
||||
"created_at": now,
|
||||
"started_at": now,
|
||||
"updated_at": now,
|
||||
"status_message": null,
|
||||
"session_id": null,
|
||||
"session_link": null,
|
||||
"creator": null,
|
||||
"conversation_id": null,
|
||||
"request_usage": null,
|
||||
"is_sandbox_running": false
|
||||
});
|
||||
task[run_time_key] = run_time;
|
||||
task
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_name_prefers_agent_config_snapshot_name_over_title() {
|
||||
let task = make_task(Some("frontend-tests"), "Long descriptive task title");
|
||||
assert_eq!(task.display_name(), "frontend-tests");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_name_falls_back_to_title_when_snapshot_name_is_missing() {
|
||||
let task = make_task(None, "Long descriptive task title");
|
||||
assert_eq!(task.display_name(), "Long descriptive task title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_name_falls_back_to_title_when_snapshot_name_is_whitespace() {
|
||||
let task = make_task(Some(" "), "Long descriptive task title");
|
||||
assert_eq!(task.display_name(), "Long descriptive task title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_name_returns_literal_agent_when_both_sources_are_empty() {
|
||||
let task = make_task(None, "");
|
||||
assert_eq!(task.display_name(), "Agent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_name_returns_literal_agent_for_whitespace_only_title() {
|
||||
let task = make_task(None, " \t\n ");
|
||||
assert_eq!(task.display_name(), "Agent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_name_trims_whitespace_at_each_layer() {
|
||||
let task = make_task(Some(" frontend-tests "), " Long descriptive title ");
|
||||
assert_eq!(task.display_name(), "frontend-tests");
|
||||
|
||||
let task = make_task(None, " Long descriptive title ");
|
||||
assert_eq!(task.display_name(), "Long descriptive title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_status_error_code_deserializes_public_api_casing() {
|
||||
let message: TaskStatusMessage = serde_json::from_str(
|
||||
"{\"message\":\"setup failed\",\"error_code\":\"environment_setup_failed\"}",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
message.error_code,
|
||||
Some(TaskStatusErrorCode::EnvironmentSetupFailed)
|
||||
);
|
||||
assert!(message.is_environment_setup_failure());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_status_error_code_deserializes_graphql_casing() {
|
||||
let message: TaskStatusMessage = serde_json::from_str(
|
||||
"{\"message\":\"setup failed\",\"errorCode\":\"ENVIRONMENT_SETUP_FAILED\"}",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
message.error_code,
|
||||
Some(TaskStatusErrorCode::EnvironmentSetupFailed)
|
||||
);
|
||||
assert!(message.is_environment_setup_failure());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_status_error_code_deserializes_unknown_codes() {
|
||||
let message: TaskStatusMessage =
|
||||
serde_json::from_str("{\"message\":\"failed\",\"error_code\":\"new_error\"}").unwrap();
|
||||
|
||||
assert_eq!(message.error_code, Some(TaskStatusErrorCode::Unknown));
|
||||
assert!(!message.is_environment_setup_failure());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_agent_task_deserializes_run_time_iso8601() {
|
||||
let task: AmbientAgentTask =
|
||||
serde_json::from_value(task_json_with_run_time("run_time", json!("PT2M30S"))).unwrap();
|
||||
|
||||
assert_eq!(task.run_time(), Some(Duration::seconds(150)));
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
use crate::server::ids::ServerId;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use strum_macros::{EnumDiscriminants, EnumIter};
|
||||
|
||||
use crate::server::ids::ServerId;
|
||||
|
||||
/// The entry point through which Cloud Mode was entered.
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -19,6 +18,41 @@ pub enum CloudModeEntryPoint {
|
||||
EntryBlock,
|
||||
}
|
||||
|
||||
/// The entry point through which a local-to-cloud handoff was initiated.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HandoffEntryPoint {
|
||||
/// User typed `&` in the input to enter handoff compose mode.
|
||||
#[default]
|
||||
Ampersand,
|
||||
/// User used the `/handoff` slash command.
|
||||
SlashCommand,
|
||||
/// User clicked the "Hand off to cloud" chip in the footer toolbar.
|
||||
FooterChip,
|
||||
/// The client automatically initiated handoff for an eligible local agent.
|
||||
Automatic,
|
||||
}
|
||||
|
||||
/// Describes which synthetic-input path drives an empty-prompt handoff.
|
||||
/// Captured at handoff initiation so telemetry reflects the intended path
|
||||
/// regardless of whether the snapshot derivation later produces content.
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
#[derive(Clone, Copy, Debug, Default, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HandoffInjectionPath {
|
||||
/// The handoff carried a non-empty user prompt; no client-side injection.
|
||||
#[default]
|
||||
None,
|
||||
/// Empty prompt + in-progress source. The client substituted `"Continue"`
|
||||
/// on the wire so the cloud agent picks up where the local agent left off.
|
||||
Continue,
|
||||
/// Empty prompt + idle source. The client substituted
|
||||
/// `"Apply the workspace changes from my previous session."` on the wire
|
||||
/// alongside the snapshot token; the cloud agent's first user-role turn
|
||||
/// carries an intent for the rehydrated workspace state.
|
||||
SnapshotRehydration,
|
||||
}
|
||||
|
||||
/// Telemetry events for client interactions with cloud agents.
|
||||
#[derive(Debug, EnumDiscriminants)]
|
||||
#[strum_discriminants(derive(EnumIter))]
|
||||
@@ -69,6 +103,41 @@ pub enum CloudAgentTelemetryEvent {
|
||||
/// Error message describing the failure.
|
||||
error: String,
|
||||
},
|
||||
/// User initiated a local-to-cloud handoff.
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
HandoffInitiated {
|
||||
/// How the handoff was triggered.
|
||||
entry_point: HandoffEntryPoint,
|
||||
/// Whether the handoff forked an existing conversation.
|
||||
forked_existing_conversation: bool,
|
||||
/// Whether the user submitted with an empty prompt buffer.
|
||||
empty_prompt: bool,
|
||||
/// Which synthetic-input path drives this submission (relevant only
|
||||
/// when `empty_prompt` is true; always `None` otherwise). Captured at
|
||||
/// handoff initiation, before snapshot derivation has settled — the
|
||||
/// `HandoffSnapshotPrepared` event reports the actual snapshot result.
|
||||
injection_path: HandoffInjectionPath,
|
||||
},
|
||||
/// The async snapshot-upload pipeline that backs a handoff has settled.
|
||||
/// Fires once per handoff after `derive_touched_workspace` completes.
|
||||
/// Pair with `HandoffInitiated` on the same run to learn whether the
|
||||
/// `SnapshotRehydration` injection path actually carried snapshot content.
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
HandoffSnapshotPrepared {
|
||||
/// True when the derived `TouchedWorkspace` had at least one repo or
|
||||
/// orphan file. Reports what the snapshot pipeline produced; the upload
|
||||
/// itself may still fail downstream, so the wire prompt is not implied.
|
||||
derived_workspace_had_content: bool,
|
||||
},
|
||||
/// The auto-handoff sleep discoverability prompt was surfaced on wake.
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
SleepPromptShown,
|
||||
/// User clicked "Enable" on the auto-handoff sleep prompt.
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
SleepPromptEnabled,
|
||||
/// User clicked "Dismiss" on the auto-handoff sleep prompt.
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
SleepPromptDismissed,
|
||||
}
|
||||
|
||||
impl TelemetryEvent for CloudAgentTelemetryEvent {
|
||||
@@ -108,6 +177,25 @@ impl TelemetryEvent for CloudAgentTelemetryEvent {
|
||||
CloudAgentTelemetryEvent::DispatchFailed { error } => Some(json!({
|
||||
"error": error,
|
||||
})),
|
||||
CloudAgentTelemetryEvent::HandoffInitiated {
|
||||
entry_point,
|
||||
forked_existing_conversation,
|
||||
empty_prompt,
|
||||
injection_path,
|
||||
} => Some(json!({
|
||||
"entry_point": entry_point,
|
||||
"forked_existing_conversation": forked_existing_conversation,
|
||||
"empty_prompt": empty_prompt,
|
||||
"injection_path": injection_path,
|
||||
})),
|
||||
CloudAgentTelemetryEvent::HandoffSnapshotPrepared {
|
||||
derived_workspace_had_content,
|
||||
} => Some(json!({
|
||||
"derived_workspace_had_content": derived_workspace_had_content,
|
||||
})),
|
||||
CloudAgentTelemetryEvent::SleepPromptShown
|
||||
| CloudAgentTelemetryEvent::SleepPromptEnabled
|
||||
| CloudAgentTelemetryEvent::SleepPromptDismissed => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +237,11 @@ impl TelemetryEventDesc for CloudAgentTelemetryEventDiscriminants {
|
||||
"AmbientAgent.CloudMode.EnvironmentSettings.GitHubAuth"
|
||||
}
|
||||
Self::DispatchFailed => "AmbientAgent.DispatchFailed",
|
||||
Self::HandoffInitiated => "AmbientAgent.Handoff.Initiated",
|
||||
Self::HandoffSnapshotPrepared => "AmbientAgent.Handoff.SnapshotPrepared",
|
||||
Self::SleepPromptShown => "AmbientAgent.Handoff.SleepPrompt.Shown",
|
||||
Self::SleepPromptEnabled => "AmbientAgent.Handoff.SleepPrompt.Enabled",
|
||||
Self::SleepPromptDismissed => "AmbientAgent.Handoff.SleepPrompt.Dismissed",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +263,19 @@ impl TelemetryEventDesc for CloudAgentTelemetryEventDiscriminants {
|
||||
"User started GitHub authentication from the environment form"
|
||||
}
|
||||
Self::DispatchFailed => "Ambient agent failed to dispatch or encountered an error",
|
||||
Self::HandoffInitiated => "User initiated a local-to-cloud handoff",
|
||||
Self::HandoffSnapshotPrepared => {
|
||||
"Handoff snapshot upload settled; reports whether it carried content"
|
||||
}
|
||||
Self::SleepPromptShown => {
|
||||
"The auto-handoff sleep discoverability prompt was shown on wake"
|
||||
}
|
||||
Self::SleepPromptEnabled => {
|
||||
"User enabled auto-handoff on sleep from the discoverability prompt"
|
||||
}
|
||||
Self::SleepPromptDismissed => {
|
||||
"User dismissed the auto-handoff sleep discoverability prompt"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user