Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -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 {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user