Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
@@ -0,0 +1,46 @@
//! Notifier for GitHub authentication state changes.
//!
//! This singleton emits events when GitHub OAuth completes, allowing
//! any component in the app (e.g., `UpdateEnvironmentForm`) to react to
//! auth state changes without relying on window activation timing.
use warpui::{Entity, ModelContext, SingletonEntity};
/// Events emitted by the GitHub auth notifier.
#[derive(Debug, Clone)]
pub enum GitHubAuthEvent {
/// GitHub authentication completed successfully.
/// Components should refetch GitHub data when this fires.
AuthCompleted,
}
/// Singleton notifier for GitHub authentication state.
///
/// This serves as a coordination point for GitHub OAuth flows.
/// When auth completes (detected via URI callback), this notifier emits
/// an `AuthCompleted` event that any subscribed component can react to.
pub struct GitHubAuthNotifier;
impl GitHubAuthNotifier {
pub fn new() -> Self {
Self
}
/// Notify subscribers that GitHub auth has completed.
/// Call this from within an update closure on the notifier.
pub fn notify_auth_completed(&self, ctx: &mut ModelContext<Self>) {
ctx.emit(GitHubAuthEvent::AuthCompleted);
}
}
impl Default for GitHubAuthNotifier {
fn default() -> Self {
Self::new()
}
}
impl Entity for GitHubAuthNotifier {
type Event = GitHubAuthEvent;
}
impl SingletonEntity for GitHubAuthNotifier {}
+97
View File
@@ -0,0 +1,97 @@
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 scheduled;
pub mod spawn;
pub mod task;
pub mod telemetry;
pub use task::{
cancel_task_with_toast, AgentConfigSnapshot, AgentSource, AmbientAgentTask,
AmbientAgentTaskState, TaskStatusMessage,
};
pub const OUT_OF_CREDITS_TASK_FAILURE_MESSAGE: &str =
"Out of credits. Upgrade your Warp plan to continue running cloud agents.";
pub const SERVER_OVERLOADED_TASK_FAILURE_MESSAGE: &str =
"Warp is temporarily overloaded. Please try again shortly.";
#[derive(Debug, thiserror::Error)]
#[error("Invalid task ID: {0}")]
pub struct ParseAmbientAgentTaskIdError(#[from] uuid::Error);
/// A globally unique ID for an ambient agent task.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct AmbientAgentTaskId(NonNilUuid);
impl Display for AmbientAgentTaskId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl FromStr for AmbientAgentTaskId {
type Err = ParseAmbientAgentTaskIdError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let uuid = Uuid::try_parse(s)?;
Ok(Self(NonNilUuid::try_from(uuid)?))
}
}
impl From<AmbientAgentTaskId> for cynic::Id {
fn from(id: AmbientAgentTaskId) -> Self {
Self::new(id.to_string())
}
}
/// High-level outcome of an ambient agent conversation.
#[derive(Clone, Debug)]
pub enum AmbientConversationStatus {
Success,
Error {
error: RenderableAIError,
},
#[allow(dead_code)]
Cancelled {
reason: CancellationReason,
},
#[allow(dead_code)]
Blocked {
blocked_action: String,
},
}
/// Derive an [`AmbientConversationStatus`] from the given conversation, if it has
/// reached a terminal state that we care about for ambient agents.
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(),
});
}
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 }
}
FinishedAIAgentOutput::Error { output: _, error } => AmbientConversationStatus::Error {
error: error.clone(),
},
FinishedAIAgentOutput::Success { output: _ } => AmbientConversationStatus::Success,
};
return Some(status);
}
None
}
+470
View File
@@ -0,0 +1,470 @@
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,
},
};
use futures::channel::oneshot;
use futures::FutureExt;
use warp_graphql::queries::get_scheduled_agent_history::ScheduledAgentHistory;
use warpui::{AppContext, Entity, ModelContext, 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(),
}
}
}
impl StringModel for ScheduledAmbientAgent {
type CloudObjectType = CloudScheduledAmbientAgent;
fn model_type_name(&self) -> &'static str {
"Scheduled ambient agent"
}
fn should_enforce_revisions() -> bool {
true
}
fn model_format() -> GenericStringObjectFormat {
GenericStringObjectFormat::Json(JsonObjectType::ScheduledAmbientAgent)
}
fn display_name(&self) -> String {
self.name.clone()
}
fn update_object_queue_item(
&self,
revision_ts: Option<Revision>,
object: &CloudScheduledAmbientAgent,
) -> QueueItem {
QueueItem::UpdateScheduledAmbientAgent {
model: object.model().clone().into(),
id: object.id,
revision: revision_ts.or_else(|| object.metadata.revision.clone()),
}
}
fn uniqueness_key(&self) -> Option<GenericStringObjectUniqueKey> {
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
}
fn warn_if_unsaved_at_quit() -> bool {
true
}
}
impl JsonModel for ScheduledAmbientAgent {
fn json_object_type() -> JsonObjectType {
JsonObjectType::ScheduledAmbientAgent
}
}
/// Parameters for updating a scheduled ambient agent.
pub struct UpdateScheduleParams {
/// The new name of the scheduled agent. If not provided, the name will not be updated.
pub name: Option<String>,
/// The new cron schedule of the scheduled agent. If not provided, the cron schedule will not be updated.
pub cron: Option<String>,
/// The new model ID of the scheduled agent. If not provided, the model ID will not be updated.
pub model_id: Option<String>,
/// The new environment ID of the scheduled agent.
/// If this is:
/// * `Some(Some(id))`, the environment ID will be updated to the given ID.
/// * `Some(None)`, the environment will be removed.
/// * `None`, the environment will not be updated.
pub environment_id: Option<Option<String>>,
/// The new base prompt to use for the scheduled agent's configuration.
/// If not provided, the base prompt will not be updated.
pub base_prompt: Option<String>,
/// The new prompt of the scheduled agent. If not provided, the prompt will not be updated.
pub prompt: Option<String>,
/// MCP servers to upsert into this schedule's agent config.
///
/// Entries are merged by key, overwriting existing keys.
pub mcp_servers_upsert: Option<Map<String, Value>>,
/// MCP server names (keys) to remove from this schedule's agent config.
pub remove_mcp_server_names: Vec<String>,
/// The new skill spec for the scheduled agent.
/// If this is:
/// * `Some(Some(spec))`, the skill spec will be updated to the given value.
/// * `Some(None)`, the skill will be removed.
/// * `None`, the skill spec will not be updated.
pub skill_spec: Option<Option<String>>,
/// The new worker host for the scheduled agent.
/// If not provided, the worker host will not be updated.
/// Setting to "warp" or empty string reverts to Warp-hosted.
pub worker_host: Option<String>,
}
pub struct ScheduledAgentManager {
/// Mapping of channels for schedules that are pending deletion, so that we can
/// report when they complete from the CLI.
pending_deletes: HashMap<SyncId, oneshot::Sender<anyhow::Result<()>>>,
}
#[cfg_attr(target_family = "wasm", allow(dead_code))]
impl ScheduledAgentManager {
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
ctx.subscribe_to_model(
&UpdateManager::handle(ctx),
Self::handle_update_manager_event,
);
Self {
pending_deletes: Default::default(),
}
}
/// List all scheduled ambient agents currently present in the local cloud object store.
pub fn list_schedules(&self, app: &AppContext) -> Vec<CloudScheduledAmbientAgent> {
CloudScheduledAmbientAgent::get_all(app)
}
/// Get the execution history for a scheduled ambient agent.
pub fn fetch_schedule_history(
&self,
schedule_id: SyncId,
app: &AppContext,
) -> impl warpui::r#async::Spawnable<Output = anyhow::Result<Option<ScheduledAgentHistory>>>
{
let ai_client = ServerApiProvider::as_ref(app).get_ai_client();
async move {
let SyncId::ServerId(server_id) = schedule_id else {
return Ok(None);
};
let schedule_id = server_id.to_string();
let history = ai_client.get_scheduled_agent_history(&schedule_id).await?;
Ok(Some(history))
}
}
fn handle_update_manager_event(
&mut self,
event: &UpdateManagerEvent,
_ctx: &mut ModelContext<Self>,
) {
if let UpdateManagerEvent::ObjectOperationComplete { result } = event {
if let ObjectOperation::Delete { .. } = result.operation {
if let Some(server_id) = result.server_id {
let sync_id = SyncId::ServerId(server_id);
if let Some(tx) = self.pending_deletes.remove(&sync_id) {
match result.success_type {
OperationSuccessType::Success => {
let _ = tx.send(Ok(()));
}
OperationSuccessType::Failure => {
let _ = tx.send(Err(anyhow::anyhow!(
"Failed to delete scheduled ambient agent"
)));
}
OperationSuccessType::Denied(ref message) => {
let _ =
tx.send(Err(anyhow::anyhow!("Deletion denied: {}", message)));
}
OperationSuccessType::Rejection => {
let _ =
tx.send(Err(anyhow::anyhow!("Deletion rejected by server")));
}
OperationSuccessType::FeatureNotAvailable => {
let _ = tx.send(Err(anyhow::anyhow!(
"Scheduled ambient agents not available"
)));
}
}
}
}
}
}
}
/// Create a new scheduled ambient agent.
pub fn create_schedule(
&mut self,
config: ScheduledAmbientAgent,
owner: Owner,
ctx: &mut ModelContext<Self>,
) -> impl Future<Output = anyhow::Result<SyncId>> + Send + 'static {
let client_id = ClientId::default();
let create_future = UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
update_manager.create_scheduled_ambient_agent_online(config, client_id, owner, ctx)
});
async move { create_future.await.map(SyncId::ServerId) }
}
/// Helper method to fetch a schedule, modify its model, update it, and wait for completion.
fn modify_schedule<F>(
&mut self,
schedule_id: SyncId,
error_message: &'static str,
modifier: F,
ctx: &mut ModelContext<Self>,
) -> impl Future<Output = anyhow::Result<()>> + Send + 'static
where
F: FnOnce(&mut ScheduledAmbientAgent) + Send + 'static,
{
let schedule_object = CloudScheduledAmbientAgent::get_by_id(&schedule_id, ctx);
match schedule_object {
Some(schedule_obj) => {
let mut updated_config = schedule_obj.model().string_model.clone();
modifier(&mut updated_config);
let revision = schedule_obj.metadata.revision.clone();
let update_future =
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
update_manager.update_scheduled_ambient_agent_online(
updated_config,
schedule_id,
revision,
ctx,
)
});
async move {
update_future
.await
.map_err(|e| anyhow::anyhow!("{}: {}", error_message, e))
}
.boxed()
}
None => async move { Err(anyhow::anyhow!("Schedule not found")) }.boxed(),
}
}
/// Pause a scheduled ambient agent.
pub fn pause_schedule(
&mut self,
schedule_id: SyncId,
ctx: &mut ModelContext<Self>,
) -> impl Future<Output = anyhow::Result<()>> + Send + 'static {
self.modify_schedule(
schedule_id,
"Failed to pause schedule",
|config| config.enabled = false,
ctx,
)
}
/// Unpause a scheduled ambient agent.
pub fn unpause_schedule(
&mut self,
schedule_id: SyncId,
ctx: &mut ModelContext<Self>,
) -> impl Future<Output = anyhow::Result<()>> + Send + 'static {
self.modify_schedule(
schedule_id,
"Failed to unpause schedule",
|config| config.enabled = true,
ctx,
)
}
/// Update a scheduled ambient agent.
pub fn update_schedule(
&mut self,
schedule_id: SyncId,
params: UpdateScheduleParams,
ctx: &mut ModelContext<Self>,
) -> impl Future<Output = anyhow::Result<()>> + Send + 'static {
self.modify_schedule(
schedule_id,
"Failed to update schedule",
move |config| {
if let Some(new_name) = params.name {
config.name = new_name;
}
if let Some(new_cron) = params.cron {
config.cron_schedule = new_cron;
}
if let Some(model_id) = params.model_id {
config.agent_config.model_id = Some(model_id);
}
if let Some(environment_id) = params.environment_id {
config.agent_config.environment_id = environment_id;
}
if let Some(base_prompt) = params.base_prompt {
config.agent_config.base_prompt = Some(base_prompt);
}
if let Some(prompt) = params.prompt {
config.prompt = prompt;
}
if let Some(skill_spec) = params.skill_spec {
config.agent_config.skill_spec = skill_spec;
}
if let Some(worker_host) = params.worker_host {
config.agent_config.worker_host = Some(worker_host);
}
for server_name in params.remove_mcp_server_names {
let server_name = server_name.trim();
if server_name.is_empty() {
continue;
}
if let Some(mcp_servers) = config.agent_config.mcp_servers.as_mut() {
mcp_servers.remove(server_name);
}
}
if let Some(upsert) = params.mcp_servers_upsert {
let mcp_servers = config.agent_config.mcp_servers.get_or_insert_with(Map::new);
for (name, config_value) in upsert {
mcp_servers.insert(name, config_value);
}
}
if config
.agent_config
.mcp_servers
.as_ref()
.is_some_and(|mcp_servers| mcp_servers.is_empty())
{
config.agent_config.mcp_servers = None;
}
},
ctx,
)
}
/// Delete a scheduled ambient agent.
pub fn delete_schedule(
&mut self,
schedule_id: SyncId,
ctx: &mut ModelContext<Self>,
) -> impl Future<Output = anyhow::Result<()>> + Send + 'static {
let id_and_type = CloudObjectTypeAndId::GenericStringObject {
object_type: GenericStringObjectFormat::Json(JsonObjectType::ScheduledAmbientAgent),
id: schedule_id,
};
let (tx, rx) = oneshot::channel();
match CloudModel::as_ref(ctx).get_by_uid(&schedule_id.uid()) {
None => {
let _ = tx.send(Err(anyhow::anyhow!("Schedule {schedule_id} not found")));
}
Some(schedule) => {
if schedule.metadata().has_pending_online_only_change()
|| schedule.metadata().pending_changes_statuses.pending_delete
{
let _ = tx.send(Err(anyhow::anyhow!(
"Cannot delete schedule with pending changes"
)));
} else {
self.pending_deletes.insert(schedule_id, tx);
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
update_manager.delete_object_by_user(id_and_type, ctx);
});
}
}
}
async move {
rx.await
.map_err(|e| anyhow::anyhow!("Failed to delete schedule: {}", e))?
}
}
}
impl Entity for ScheduledAgentManager {
type Event = ();
}
impl SingletonEntity for ScheduledAgentManager {}
+176
View File
@@ -0,0 +1,176 @@
//! 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 futures::{select, FutureExt, Stream};
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,
};
/// 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);
#[cfg(test)]
const TASK_STATUS_POLL_INTERVAL: Duration = Duration::from_millis(1);
/// Information about a session join link for an ambient agent task.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SessionJoinInfo {
pub session_id: Option<SessionId>,
pub session_link: String,
}
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(),
});
}
// 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
}
}
/// Lifecycle events during ambient agent startup.
#[derive(Debug)]
pub enum AmbientAgentEvent {
/// The task was successfully spawned with the given task ID and run ID.
TaskSpawned {
task_id: AmbientAgentTaskId,
run_id: String,
},
/// The task state changed.
StateChanged {
state: AmbientAgentTaskState,
status_message: Option<TaskStatusMessage>,
},
/// Session started and join information became available.
SessionStarted { session_join_info: SessionJoinInfo },
/// Timed out waiting for the agent session to be ready.
TimedOut,
/// Cloud agent capacity limit has been reached. This does not block
/// the task from eventually starting.
AtCapacity,
}
/// Spawns an ambient agent task and monitors its state.
///
/// The stream completes when:
/// - The task completes (either successfully or with a failure)
/// - The task's shared session is ready to join
/// - The timeout expires (if provided)
/// - An error occurs
///
/// If `timeout` is `None`, there is no timeout.
pub fn spawn_task(
request: SpawnAgentRequest,
ai_client: Arc<dyn AIClient>,
timeout: Option<Duration>,
) -> impl Stream<Item = Result<AmbientAgentEvent, anyhow::Error>> {
// We can't use try_stream! because of the select! macro invocation.
// See https://github.com/tokio-rs/async-stream/issues/63.
async_stream::stream! {
// First, spawn the ambient agent task.
let (task_id, run_id, at_capacity) = match ai_client.spawn_agent(request).await {
Ok(response) => (response.task_id, response.run_id, response.at_capacity),
Err(err) => {
yield Err(err);
return;
},
};
yield Ok(AmbientAgentEvent::TaskSpawned { task_id, run_id });
// Emit AtCapacity event if the server indicates capacity limit reached.
if at_capacity {
yield Ok(AmbientAgentEvent::AtCapacity);
}
// 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 {
Some(d) => warpui::r#async::Timer::after(d),
None => warpui::r#async::Timer::never(),
}.fuse();
let mut last_state = None;
loop {
let mut poll_timer = warpui::r#async::Timer::after(TASK_STATUS_POLL_INTERVAL).fuse();
select! {
_ = timeout_timer => {
yield Ok(AmbientAgentEvent::TimedOut);
return;
}
_ = poll_timer => {
match ai_client.get_ambient_agent_task(&task_id).await {
Ok(task) => {
// Only emit a state-change event if the state has changed.
if last_state.as_ref() != Some(&task.state) {
last_state = Some(task.state.clone());
yield Ok(AmbientAgentEvent::StateChanged {
state: task.state.clone(),
status_message: task.status_message.clone(),
});
}
// Check if the task has completed or started sharing its session.
if task.state.is_terminal() {
// Task completed, stream ends.
return;
}
if task.state == AmbientAgentTaskState::InProgress {
if let Some(session_join_info) = SessionJoinInfo::from_task(&task) {
yield Ok(AmbientAgentEvent::SessionStarted {
session_join_info,
});
return;
}
// Continue polling.
} else {
log::info!("Agent {task_id} state: {:?}", task.state);
// Continue polling.
}
}
Err(err) => {
yield Err(err);
return;
},
}
}
}
}
}
}
#[cfg(test)]
#[path = "spawn_tests.rs"]
mod tests;
+190
View File
@@ -0,0 +1,190 @@
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
use chrono::Utc;
use crate::ai::ambient_agents::{AmbientAgentTask, AmbientAgentTaskState};
use crate::server::server_api::ai::{MockAIClient, SpawnAgentResponse};
use super::{spawn_task, AmbientAgentEvent, SessionJoinInfo};
fn task_with(
state: AmbientAgentTaskState,
session_id: Option<String>,
session_link: Option<String>,
) -> AmbientAgentTask {
AmbientAgentTask {
task_id: "550e8400-e29b-41d4-a716-446655440000".parse().unwrap(),
parent_run_id: None,
title: "title".to_string(),
state,
prompt: "prompt".to_string(),
created_at: Utc::now(),
started_at: Some(Utc::now()),
updated_at: Utc::now(),
status_message: None,
source: None,
session_id,
session_link,
creator: None,
conversation_id: None,
request_usage: None,
agent_config_snapshot: None,
artifacts: vec![],
is_sandbox_running: true,
}
}
#[tokio::test]
async fn poll_stops_on_terminal_failure_like_state() {
use futures::StreamExt;
let mut mock = MockAIClient::new();
mock.expect_spawn_agent().returning(|_| {
Ok(SpawnAgentResponse {
task_id: "550e8400-e29b-41d4-a716-446655440000".parse().unwrap(),
run_id: "550e8400-e29b-41d4-a716-446655440000".to_string(),
at_capacity: false,
})
});
mock.expect_get_ambient_agent_task()
.times(1)
.returning(|_task_id| Ok(task_with(AmbientAgentTaskState::Error, None, None)));
let ai_client = Arc::new(mock);
let request = crate::server::server_api::ai::SpawnAgentRequest {
prompt: "test".to_string(),
config: None,
title: None,
team: None,
skill: None,
attachments: vec![],
interactive: None,
parent_run_id: None,
runtime_skills: vec![],
referenced_attachments: vec![],
};
let mut stream = Box::pin(spawn_task(request, ai_client, None));
let event = stream
.next()
.await
.expect("expected event")
.expect("expected ok");
assert!(matches!(event, AmbientAgentEvent::TaskSpawned { .. }));
let event = stream
.next()
.await
.expect("expected event")
.expect("expected ok");
assert!(matches!(
event,
AmbientAgentEvent::StateChanged {
state: AmbientAgentTaskState::Error,
..
}
));
assert!(stream.next().await.is_none());
}
#[test]
fn session_join_info_prefers_session_link_and_tolerates_missing_session_id() {
let task = task_with(
AmbientAgentTaskState::InProgress,
None,
Some("https://example.com/session/abc".to_string()),
);
let join_info = SessionJoinInfo::from_task(&task).expect("expected join info");
assert_eq!(join_info.session_link, "https://example.com/session/abc");
assert!(join_info.session_id.is_none());
}
#[tokio::test]
async fn poll_for_session_join_info_waits_until_link_is_available() {
use futures::StreamExt;
let mut mock = MockAIClient::new();
let call_count = Arc::new(AtomicUsize::new(0));
mock.expect_spawn_agent().returning(|_| {
Ok(SpawnAgentResponse {
task_id: "550e8400-e29b-41d4-a716-446655440000".parse().unwrap(),
run_id: "550e8400-e29b-41d4-a716-446655440000".to_string(),
at_capacity: false,
})
});
mock.expect_get_ambient_agent_task().returning({
let call_count = call_count.clone();
move |_task_id| {
let idx = call_count.fetch_add(1, Ordering::SeqCst);
let task = if idx == 0 {
task_with(AmbientAgentTaskState::InProgress, None, None)
} else {
task_with(
AmbientAgentTaskState::InProgress,
None,
Some("https://example.com/session/ready".to_string()),
)
};
Ok(task)
}
});
let ai_client = Arc::new(mock);
let request = crate::server::server_api::ai::SpawnAgentRequest {
prompt: "test".to_string(),
config: None,
title: None,
team: None,
skill: None,
attachments: vec![],
interactive: None,
parent_run_id: None,
runtime_skills: vec![],
referenced_attachments: vec![],
};
let mut stream = Box::pin(spawn_task(request, ai_client, None));
// First event should be TaskSpawned
let event = stream
.next()
.await
.expect("expected event")
.expect("expected ok");
assert!(matches!(event, AmbientAgentEvent::TaskSpawned { .. }));
// Second event should be StateChanged
let event = stream
.next()
.await
.expect("expected event")
.expect("expected ok");
assert!(matches!(event, AmbientAgentEvent::StateChanged { .. }));
// Third event should be SessionStarted
let event = stream
.next()
.await
.expect("expected event")
.expect("expected ok");
if let AmbientAgentEvent::SessionStarted { session_join_info } = event {
assert_eq!(
session_join_info.session_link,
"https://example.com/session/ready"
);
} else {
panic!("Expected SessionStarted event");
}
}
+470
View File
@@ -0,0 +1,470 @@
//! Ambient agent task types and utilities.
use anyhow::anyhow;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use warp_cli::agent::Harness;
use warp_core::report_error;
use warp_core::ui::theme::WarpTheme;
use warpui::color::ColorU;
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 warpui::{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>,
}
/// 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,
}
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,
AgentWebhook,
Slack,
Cli,
ScheduledAgent,
Interactive,
WebApp,
GitHubAction,
CloudMode,
}
impl AgentSource {
pub fn as_str(&self) -> &str {
match self {
AgentSource::Linear => "LINEAR",
AgentSource::AgentWebhook => "API",
AgentSource::Slack => "SLACK",
AgentSource::Cli => "CLI",
AgentSource::ScheduledAgent => "SCHEDULED_AGENT",
// The public API's run source for local interactive tasks is named
// `LOCAL`.
AgentSource::Interactive => "LOCAL",
AgentSource::WebApp => "WEB_APP",
AgentSource::GitHubAction => "GITHUB_ACTION",
AgentSource::CloudMode => "CLOUD_MODE",
}
}
pub fn display_name(&self) -> &str {
match self {
AgentSource::Linear => "Linear",
AgentSource::AgentWebhook => "API",
AgentSource::Slack => "Slack",
AgentSource::Cli => "CLI",
AgentSource::ScheduledAgent => "Scheduled",
AgentSource::Interactive => "Warp (local agent)",
AgentSource::WebApp => "Oz Web",
AgentSource::GitHubAction => "GitHub Action",
AgentSource::CloudMode => "Warp (cloud agent)",
}
}
/// Returns true if this source represents a user-initiated conversation
/// (as opposed to automated/programmatic sources like CLI or scheduled runs).
pub fn is_user_initiated(&self) -> bool {
match self {
AgentSource::Linear
| AgentSource::Slack
| AgentSource::Interactive
| AgentSource::WebApp
| AgentSource::CloudMode => true,
AgentSource::Cli
| AgentSource::ScheduledAgent
| AgentSource::AgentWebhook
| AgentSource::GitHubAction => false,
}
}
}
fn deserialize_ambient_agent_source<'de, D>(
deserializer: D,
) -> Result<Option<AgentSource>, D::Error>
where
D: serde::Deserializer<'de>,
{
let s: Option<String> = serde::Deserialize::deserialize(deserializer)?;
Ok(match s {
Some(s) => match s.as_str() {
"LINEAR" => Some(AgentSource::Linear),
"AGENT_WEBHOOK" | "API" => Some(AgentSource::AgentWebhook),
"SLACK" => Some(AgentSource::Slack),
"LOCAL" => Some(AgentSource::Interactive),
"CLI" => Some(AgentSource::Cli),
"SCHEDULED_AGENT" => Some(AgentSource::ScheduledAgent),
"WEB_APP" => Some(AgentSource::WebApp),
"GITHUB_ACTION" => Some(AgentSource::GitHubAction),
"CLOUD_MODE" => Some(AgentSource::CloudMode),
_ => {
report_error!(anyhow!("Unknown AmbientAgentSource: {}", s));
None
}
},
None => None,
})
}
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
pub struct AmbientAgentTask {
pub task_id: AmbientAgentTaskId,
#[serde(default)]
pub parent_run_id: Option<String>,
pub title: String,
pub state: AmbientAgentTaskState,
pub prompt: String,
pub created_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub updated_at: DateTime<Utc>,
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 conversation_id: Option<String>,
pub request_usage: Option<RequestUsage>,
pub is_sandbox_running: bool,
/// Snapshot of the agent config used to create the task.
#[serde(default, alias = "agent_config")]
pub agent_config_snapshot: Option<AgentConfigSnapshot>,
#[serde(default, deserialize_with = "deserialize_artifacts")]
pub artifacts: Vec<Artifact>,
}
/// Represents a single attachment input from the client (e.g., file upload)
#[derive(Clone, Debug, Serialize)]
pub struct AttachmentInput {
pub file_name: String,
pub mime_type: String,
pub data: String, // base64-encoded data
}
/// Information about a task attachment retrieved from the server
#[derive(Clone, Debug)]
pub struct TaskAttachment {
pub file_id: String,
pub filename: String,
pub download_url: String,
pub mime_type: 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)
}
/// 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)
}
/// Creator's display name, if available.
pub fn creator_display_name(&self) -> Option<String> {
self.creator.as_ref().and_then(|c| c.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()
}
}
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
#[serde(rename_all = "UPPERCASE")]
pub enum AmbientAgentTaskState {
Queued,
Pending,
Claimed,
#[serde(alias = "IN_PROGRESS")]
InProgress,
Succeeded,
Failed,
Error,
Blocked,
Cancelled,
#[serde(other)]
Unknown,
}
impl AmbientAgentTaskState {
/// Returns the query param value for the server API.
pub fn as_query_param(&self) -> Option<&str> {
match self {
AmbientAgentTaskState::Queued => Some("QUEUED"),
AmbientAgentTaskState::Pending => Some("PENDING"),
AmbientAgentTaskState::Claimed => Some("CLAIMED"),
AmbientAgentTaskState::InProgress => Some("INPROGRESS"),
AmbientAgentTaskState::Succeeded => Some("SUCCEEDED"),
AmbientAgentTaskState::Failed => Some("FAILED"),
AmbientAgentTaskState::Error => Some("ERROR"),
AmbientAgentTaskState::Blocked => Some("BLOCKED"),
AmbientAgentTaskState::Cancelled => Some("CANCELLED"),
// Unknown states are only for resilient deserialization and should not be
// sent back as filter values.
AmbientAgentTaskState::Unknown => None,
}
}
pub fn is_working(&self) -> bool {
match self {
AmbientAgentTaskState::Queued
| AmbientAgentTaskState::Pending
| AmbientAgentTaskState::Claimed
| AmbientAgentTaskState::InProgress => true,
AmbientAgentTaskState::Succeeded
| AmbientAgentTaskState::Failed
| AmbientAgentTaskState::Error
| AmbientAgentTaskState::Blocked
| AmbientAgentTaskState::Cancelled
| AmbientAgentTaskState::Unknown => false,
}
}
pub fn is_cancellable(&self) -> bool {
self.is_working()
}
pub fn is_failure_like(&self) -> bool {
match self {
AmbientAgentTaskState::Failed
| AmbientAgentTaskState::Error
| AmbientAgentTaskState::Blocked
| AmbientAgentTaskState::Unknown => true,
AmbientAgentTaskState::Queued
| AmbientAgentTaskState::Pending
| AmbientAgentTaskState::Claimed
| AmbientAgentTaskState::InProgress
| AmbientAgentTaskState::Succeeded
| AmbientAgentTaskState::Cancelled => false,
}
}
pub fn is_terminal(&self) -> bool {
match self {
AmbientAgentTaskState::Succeeded
| AmbientAgentTaskState::Failed
| AmbientAgentTaskState::Error
| AmbientAgentTaskState::Blocked
| AmbientAgentTaskState::Cancelled
| AmbientAgentTaskState::Unknown => true,
AmbientAgentTaskState::Queued
| AmbientAgentTaskState::Pending
| AmbientAgentTaskState::Claimed
| AmbientAgentTaskState::InProgress => false,
}
}
pub fn status_icon_and_color(&self, theme: &WarpTheme) -> (Icon, ColorU) {
match self {
AmbientAgentTaskState::Queued
| AmbientAgentTaskState::Pending
| AmbientAgentTaskState::Claimed
| AmbientAgentTaskState::InProgress => (Icon::ClockLoader, theme.ansi_fg_magenta()),
AmbientAgentTaskState::Succeeded => (Icon::Check, theme.ansi_fg_green()),
AmbientAgentTaskState::Failed
| AmbientAgentTaskState::Error
| AmbientAgentTaskState::Unknown => (Icon::Triangle, theme.ansi_fg_red()),
AmbientAgentTaskState::Blocked => (Icon::StopFilled, theme.ansi_fg_yellow()),
AmbientAgentTaskState::Cancelled => (
Icon::Cancelled,
theme.disabled_text_color(theme.background()).into_solid(),
),
}
}
}
impl std::fmt::Display for AmbientAgentTaskState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AmbientAgentTaskState::Queued => write!(f, "Queued"),
AmbientAgentTaskState::Pending => write!(f, "Pending"),
AmbientAgentTaskState::Claimed => write!(f, "Claimed"),
AmbientAgentTaskState::InProgress => write!(f, "In progress"),
AmbientAgentTaskState::Succeeded => write!(f, "Done"),
AmbientAgentTaskState::Failed => write!(f, "Failed"),
AmbientAgentTaskState::Error => write!(f, "Error"),
AmbientAgentTaskState::Blocked => write!(f, "Blocked"),
AmbientAgentTaskState::Cancelled => write!(f, "Cancelled"),
AmbientAgentTaskState::Unknown => write!(f, "Failed"),
}
}
}
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
pub struct TaskCreatorInfo {
#[serde(rename = "type")]
pub creator_type: String,
pub uid: String,
pub display_name: Option<String>,
}
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
pub struct TaskStatusMessage {
pub message: String,
}
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
pub struct RequestUsage {
pub inference_cost: Option<f64>,
pub compute_cost: Option<f64>,
}
/// Cancel an ambient agent task and show a toast with the result.
pub fn cancel_task_with_toast<V: View>(task_id: AmbientAgentTaskId, ctx: &mut ViewContext<V>) {
let ai_client = ServerApiProvider::handle(ctx).as_ref(ctx).get_ai_client();
let window_id = ctx.window_id();
ctx.spawn(
async move { ai_client.cancel_ambient_agent_task(&task_id).await },
move |_view, result, ctx| {
let message = match result {
Ok(()) => "Task cancelled".to_string(),
Err(e) => {
log::error!("Failed to cancel task: {e}");
format!("Failed to cancel task: {e}")
}
};
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast = DismissibleToast::default(message);
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
},
);
}
+181
View File
@@ -0,0 +1,181 @@
use crate::server::ids::ServerId;
use serde::Serialize;
use serde_json::{json, Value};
use strum_macros::{EnumDiscriminants, EnumIter};
use warp_core::features::FeatureFlag;
use warp_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
/// The entry point through which Cloud Mode was entered.
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CloudModeEntryPoint {
/// User clicked "New Cloud Agent Tab" or similar action to create a dedicated Cloud Mode tab.
NewTab,
/// User entered Cloud Mode from an existing local terminal session (e.g., via keyboard shortcut or command).
LocalSession,
/// User entered Cloud Mode through the Oz launch modal.
OzLaunchModal,
/// User re-entered Cloud Mode by clicking on an ambient agent entry block.
EntryBlock,
}
/// Telemetry events for client interactions with cloud agents.
#[derive(Debug, EnumDiscriminants)]
#[strum_discriminants(derive(EnumIter))]
pub enum CloudAgentTelemetryEvent {
/// User entered Cloud Mode.
EnteredCloudMode { entry_point: CloudModeEntryPoint },
/// User opened the environment selector menu.
EnvironmentSelectorOpened,
/// User selected an environment from the environment selector.
EnvironmentSelected {
/// The ID of the selected environment, if available.
environment_id: Option<ServerId>,
},
/// User opened the environment management pane from the environment selector.
OpenedEnvironmentManagementPane,
/// User created a new environment.
EnvironmentCreated,
/// User updated an existing environment.
EnvironmentUpdated {
/// The server ID of the updated environment, if available.
environment_id: Option<ServerId>,
},
/// User deleted an environment.
EnvironmentDeleted {
/// The server ID of the deleted environment, if available.
environment_id: Option<ServerId>,
},
/// Docker image was successfully suggested for an environment.
#[cfg_attr(target_family = "wasm", allow(dead_code))]
ImageSuggested {
/// The suggested Docker image string.
image: String,
/// Whether the user needs to create a custom image.
needs_custom_image: bool,
},
/// Docker image suggestion failed.
#[cfg_attr(target_family = "wasm", allow(dead_code))]
ImageSuggestionFailed {
/// Error message describing why the suggestion failed.
error: String,
},
/// User launched an environment setup agent from the environment form.
LaunchedAgentFromEnvironmentForm,
/// User started GitHub authentication from the environment form.
GitHubAuthFromEnvironmentForm,
/// Ambient agent failed to dispatch or encountered an error during subscription.
DispatchFailed {
/// Error message describing the failure.
error: String,
},
}
impl TelemetryEvent for CloudAgentTelemetryEvent {
fn name(&self) -> &'static str {
CloudAgentTelemetryEventDiscriminants::from(self).name()
}
fn payload(&self) -> Option<Value> {
match self {
CloudAgentTelemetryEvent::EnteredCloudMode { entry_point } => Some(json!({
"entry_point": entry_point,
})),
CloudAgentTelemetryEvent::EnvironmentSelectorOpened => None,
CloudAgentTelemetryEvent::EnvironmentSelected { environment_id } => Some(json!({
"environment_id": environment_id.map(|id| id.to_string()),
})),
CloudAgentTelemetryEvent::OpenedEnvironmentManagementPane => None,
CloudAgentTelemetryEvent::EnvironmentCreated => None,
CloudAgentTelemetryEvent::EnvironmentUpdated { environment_id } => Some(json!({
"environment_id": environment_id.map(|id| id.to_string()),
})),
CloudAgentTelemetryEvent::EnvironmentDeleted { environment_id } => Some(json!({
"environment_id": environment_id.map(|id| id.to_string()),
})),
CloudAgentTelemetryEvent::ImageSuggested {
image,
needs_custom_image,
} => Some(json!({
"image": image,
"needs_custom_image": needs_custom_image,
})),
CloudAgentTelemetryEvent::ImageSuggestionFailed { error } => Some(json!({
"error": error,
})),
CloudAgentTelemetryEvent::LaunchedAgentFromEnvironmentForm => None,
CloudAgentTelemetryEvent::GitHubAuthFromEnvironmentForm => None,
CloudAgentTelemetryEvent::DispatchFailed { error } => Some(json!({
"error": error,
})),
}
}
fn description(&self) -> &'static str {
CloudAgentTelemetryEventDiscriminants::from(self).description()
}
fn enablement_state(&self) -> EnablementState {
CloudAgentTelemetryEventDiscriminants::from(self).enablement_state()
}
fn contains_ugc(&self) -> bool {
false
}
fn event_descs() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>> {
warp_core::telemetry::enum_events::<Self>()
}
}
impl TelemetryEventDesc for CloudAgentTelemetryEventDiscriminants {
fn name(&self) -> &'static str {
match self {
Self::EnteredCloudMode => "AmbientAgent.CloudMode.Entered",
Self::EnvironmentSelectorOpened => "AmbientAgent.CloudMode.EnvironmentSelector.Opened",
Self::EnvironmentSelected => "AmbientAgent.CloudMode.EnvironmentSelector.Selected",
Self::OpenedEnvironmentManagementPane => "AmbientAgent.EnvironmentSettings.Opened",
Self::EnvironmentCreated => "AmbientAgent.EnvironmentSettings.CreatedEnvironment",
Self::EnvironmentUpdated => "AmbientAgent.EnvironmentSettings.UpdatedEnvironment",
Self::EnvironmentDeleted => "AmbientAgent.EnvironmentSettings.DeletedEnvironment",
Self::ImageSuggested => "AmbientAgent.EnvironmentSettings.Image.Suggested",
Self::ImageSuggestionFailed => {
"AmbientAgent.EnvironmentSettings.Image.SuggestionFailed"
}
Self::LaunchedAgentFromEnvironmentForm => {
"AmbientAgent.CloudMode.EnvironmentSettings.LaunchedAgent"
}
Self::GitHubAuthFromEnvironmentForm => {
"AmbientAgent.CloudMode.EnvironmentSettings.GitHubAuth"
}
Self::DispatchFailed => "AmbientAgent.DispatchFailed",
}
}
fn description(&self) -> &'static str {
match self {
Self::EnteredCloudMode => "User entered cloud agent view",
Self::EnvironmentSelectorOpened => "User opened the environment selector menu",
Self::EnvironmentSelected => "User selected an environment from the selector",
Self::OpenedEnvironmentManagementPane => "User opened the environment management pane",
Self::EnvironmentCreated => "User created a new environment",
Self::EnvironmentUpdated => "User updated an existing environment",
Self::EnvironmentDeleted => "User deleted an environment",
Self::ImageSuggested => "Docker image was suggested for an environment",
Self::ImageSuggestionFailed => "Docker image suggestion failed",
Self::LaunchedAgentFromEnvironmentForm => {
"User launched an environment setup agent from the environment form"
}
Self::GitHubAuthFromEnvironmentForm => {
"User started GitHub authentication from the environment form"
}
Self::DispatchFailed => "Ambient agent failed to dispatch or encountered an error",
}
}
fn enablement_state(&self) -> EnablementState {
EnablementState::Flag(FeatureFlag::CloudMode)
}
}
warp_core::register_telemetry_event!(CloudAgentTelemetryEvent);