From 1f6aff68a0cf89162a7582c7a77946d1b2419885 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:09:40 +0000 Subject: [PATCH 1/7] [REV-2383] Scope environment selection by team Co-Authored-By: Warp Agent --- app/src/ai/agent_sdk/ambient.rs | 21 ++-- app/src/ai/agent_sdk/common.rs | 23 +++-- app/src/ai/agent_sdk/common_tests.rs | 137 ++++++++++++++++++++++++++- app/src/ai/agent_sdk/environment.rs | 41 +++++--- app/src/ai/agent_sdk/integration.rs | 49 ++++++---- app/src/ai/agent_sdk/mod.rs | 6 +- app/src/ai/agent_sdk/schedule.rs | 40 ++++---- crates/warp_cli/src/environment.rs | 9 +- crates/warp_cli/src/lib_tests.rs | 37 ++++++++ 9 files changed, 289 insertions(+), 74 deletions(-) diff --git a/app/src/ai/agent_sdk/ambient.rs b/app/src/ai/agent_sdk/ambient.rs index 44b48a8ce26..05eade6231b 100644 --- a/app/src/ai/agent_sdk/ambient.rs +++ b/app/src/ai/agent_sdk/ambient.rs @@ -386,12 +386,14 @@ impl AmbientAgentRunner { vec![] }; - if let Err(err) = - super::common::validate_team_scope(&args.scope.team_selection, ctx) - { - super::report_fatal_error(err, ctx); - return; - } + let team_scope = + match super::common::resolve_team_scope(&args.scope.team_selection, ctx) { + Ok(team_scope) => team_scope, + Err(err) => { + super::report_fatal_error(err, ctx); + return; + } + }; let mut environment_args = args.environment; if environment_args.environment.is_none() && !environment_args.no_environment @@ -402,8 +404,11 @@ impl AmbientAgentRunner { environment_args.environment = Some(environment_id); } - let environment_id = match EnvironmentChoice::resolve_for_create(environment_args, ctx) - { + let environment_id = match EnvironmentChoice::resolve_for_create( + environment_args, + &team_scope, + ctx, + ) { Ok(EnvironmentChoice::None) => { eprintln!("Agent will run without an environment."); None diff --git a/app/src/ai/agent_sdk/common.rs b/app/src/ai/agent_sdk/common.rs index 227d231fb92..4273ac042bc 100644 --- a/app/src/ai/agent_sdk/common.rs +++ b/app/src/ai/agent_sdk/common.rs @@ -29,7 +29,7 @@ use crate::workspaces::update_manager::TeamUpdateManager; use crate::workspaces::user_workspaces::team_workspace_settings::{ NotATeamMemberError, TeamScopeForCli, TeamScopeForCliError, }; -use crate::workspaces::user_workspaces::{SoleTeamError, TeamScope as _, UserWorkspaces}; +use crate::workspaces::user_workspaces::{SoleTeamError, TeamScope, UserWorkspaces}; /// How long to wait for workspace metadata to refresh. pub const WORKSPACE_METADATA_REFRESH_TIMEOUT: Duration = Duration::from_secs(10); @@ -147,8 +147,8 @@ fn describe_team_resolution_error(error: TeamScopeForCliError, ctx: &AppContext) } } -/// The team a CLI command's policy reads are scoped to. -fn resolve_team_scope( +/// The team a CLI command acts within. +pub(super) fn resolve_team_scope( team_selection: &TeamSelection, ctx: &AppContext, ) -> anyhow::Result { @@ -213,13 +213,14 @@ pub fn resolve_owner(scope: &ObjectScope, ctx: &AppContext) -> anyhow::Result anyhow::Result<()> { - if !team_selection.is_team() { - return Ok(()); +pub(super) fn environment_is_visible_to_scope( + environment: &CloudAmbientAgentEnvironment, + team_scope: &(impl TeamScope + ?Sized), +) -> bool { + match environment.permissions().owner { + Owner::User { .. } => true, + Owner::Team { team_uid } => team_scope.team_uid() == Some(team_uid), } - resolve_team_scope(team_selection, ctx).map(|_| ()) } /// Refresh workspace metadata before executing an operation. @@ -324,10 +325,11 @@ pub enum EnvironmentChoice { } impl EnvironmentChoice { - /// Resolve the environment to use when creating an agent integration. + /// Resolve the environment to use when creating an agent operation. /// Warp Drive *must* have been synced first. pub fn resolve_for_create( args: EnvironmentCreateArgs, + team_scope: &(impl TeamScope + ?Sized), ctx: &AppContext, ) -> Result { if args.no_environment { @@ -339,6 +341,7 @@ impl EnvironmentChoice { let mut synced_environments: Vec<(ServerId, &CloudAmbientAgentEnvironment)> = all_environments .iter() + .filter(|env| environment_is_visible_to_scope(env, team_scope)) .filter_map(|env| { if let SyncId::ServerId(server_id) = env.sync_id() { Some((server_id, env)) diff --git a/app/src/ai/agent_sdk/common_tests.rs b/app/src/ai/agent_sdk/common_tests.rs index 8c04db7db08..d0a6bd7e0af 100644 --- a/app/src/ai/agent_sdk/common_tests.rs +++ b/app/src/ai/agent_sdk/common_tests.rs @@ -1,11 +1,16 @@ use std::collections::HashMap; +use warp_cli::environment::EnvironmentCreateArgs; use warpui::App; use super::{ - classify_agent_mode_base_model_id, parse_ambient_task_id, validate_agent_mode_base_model_id, + EnvironmentChoice, classify_agent_mode_base_model_id, environment_is_visible_to_scope, + parse_ambient_task_id, validate_agent_mode_base_model_id, }; use crate::LaunchMode; +use crate::ai::cloud_environments::{ + AmbientAgentEnvironment, CloudAmbientAgentEnvironment, CloudAmbientAgentEnvironmentModel, +}; use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; use crate::ai::llms::{ AvailableLLMs, LLMContextWindow, LLMId, LLMInfo, LLMPreferences, LLMProvider, LLMUsageMetadata, @@ -15,13 +20,141 @@ use crate::ai::mcp::TemplatableMCPServerManager; use crate::auth::AuthStateProvider; use crate::auth::auth_manager::AuthManager; use crate::cloud_object::model::persistence::CloudModel; +use crate::cloud_object::{CloudObjectMetadata, CloudObjectPermissions, Owner}; use crate::network::NetworkStatus; use crate::server::cloud_objects::update_manager::UpdateManager; +use crate::server::ids::{ServerId, SyncId}; use crate::server::server_api::ServerApiProvider; use crate::server::sync_queue::SyncQueue; use crate::test_util::settings::initialize_settings_for_tests; use crate::workspaces::team_tester::TeamTesterStatus; -use crate::workspaces::user_workspaces::{TeamlessScopeForTest, UserWorkspaces}; +use crate::workspaces::user_workspaces::{ + TeamContextForOperation, TeamlessScopeForTest, UserWorkspaces, +}; + +fn environment_with_owner( + sync_id: SyncId, + name: &str, + owner: Owner, +) -> CloudAmbientAgentEnvironment { + let environment = AmbientAgentEnvironment::new( + name.to_string(), + None, + Vec::new(), + "ubuntu:latest".to_string(), + Vec::new(), + ); + let mut permissions = CloudObjectPermissions::mock_personal(); + permissions.owner = owner; + CloudAmbientAgentEnvironment::new( + sync_id, + CloudAmbientAgentEnvironmentModel::new(environment), + CloudObjectMetadata::mock(), + permissions, + ) +} + +#[test] +fn environment_scope_includes_personal_and_matching_team_environments() { + let selected_team_uid = ServerId::from(123); + let other_team_uid = ServerId::from(456); + let selected_scope = TeamContextForOperation::new_for_test(selected_team_uid); + let personal_environment = environment_with_owner( + SyncId::ServerId(ServerId::from(1)), + "Personal", + Owner::mock_current_user(), + ); + let selected_team_environment = environment_with_owner( + SyncId::ServerId(ServerId::from(2)), + "Selected team", + Owner::Team { + team_uid: selected_team_uid, + }, + ); + let other_team_environment = environment_with_owner( + SyncId::ServerId(ServerId::from(3)), + "Other team", + Owner::Team { + team_uid: other_team_uid, + }, + ); + + assert!(environment_is_visible_to_scope( + &personal_environment, + &selected_scope + )); + assert!(environment_is_visible_to_scope( + &selected_team_environment, + &selected_scope + )); + assert!(!environment_is_visible_to_scope( + &other_team_environment, + &selected_scope + )); +} + +#[test] +fn teamless_environment_scope_includes_only_personal_environments() { + let personal_environment = environment_with_owner( + SyncId::ServerId(ServerId::from(1)), + "Personal", + Owner::mock_current_user(), + ); + let team_environment = environment_with_owner( + SyncId::ServerId(ServerId::from(2)), + "Team", + Owner::Team { + team_uid: ServerId::from(123), + }, + ); + + assert!(environment_is_visible_to_scope( + &personal_environment, + &TeamlessScopeForTest + )); + assert!(!environment_is_visible_to_scope( + &team_environment, + &TeamlessScopeForTest + )); +} + +#[test] +fn explicit_environment_id_remains_resource_authoritative() { + App::test((), |mut app| async move { + let cloud_model = app.add_singleton_model(CloudModel::mock); + let server_id = ServerId::from(123); + let sync_id = SyncId::ServerId(server_id); + let team_environment = environment_with_owner( + sync_id, + "Other team", + Owner::Team { + team_uid: ServerId::from(456), + }, + ); + cloud_model.update(&mut app, |model, ctx| { + model.create_object(sync_id, team_environment, ctx); + }); + + let choice = app.update(|ctx| { + EnvironmentChoice::resolve_for_create( + EnvironmentCreateArgs { + environment: Some(server_id.to_string()), + no_environment: false, + }, + &TeamlessScopeForTest, + ctx, + ) + }); + + assert_eq!( + choice.unwrap(), + EnvironmentChoice::Environment { + id: server_id.to_string(), + name: "Other team".to_string(), + } + ); + }); +} #[test] fn parse_ambient_task_id_accepts_valid_ids() { diff --git a/app/src/ai/agent_sdk/environment.rs b/app/src/ai/agent_sdk/environment.rs index 2af6f353483..677cb511251 100644 --- a/app/src/ai/agent_sdk/environment.rs +++ b/app/src/ai/agent_sdk/environment.rs @@ -2,13 +2,14 @@ use std::collections::HashSet; use comfy_table::Cell; use cynic::QueryBuilder; +use futures::future; use inquire::error::InquireError; use inquire::{Confirm, Select}; use serde::Serialize; use warp_cli::GlobalOptions; use warp_cli::agent::OutputFormat; use warp_cli::environment::{EnvironmentCommand, ImageCommand}; -use warp_cli::scope::ObjectScope; +use warp_cli::scope::{ObjectScope, TeamSelection}; use warp_graphql::queries::get_oauth_connect_tx_status::OauthConnectTxStatus; use warp_graphql::queries::list_warp_dev_images::{ ListWarpDevImages, ListWarpDevImagesResult, ListWarpDevImagesVariables, @@ -63,8 +64,10 @@ pub fn run( ) -> anyhow::Result<()> { let runner = ctx.add_singleton_model(|_ctx| EnvironmentCommandRunner); match command { - EnvironmentCommand::List => { - runner.update(ctx, |runner, ctx| runner.list(global_options, ctx)); + EnvironmentCommand::List { team_selection } => { + runner.update(ctx, |runner, ctx| { + runner.list(global_options, team_selection, ctx) + }); Ok(()) } EnvironmentCommand::Create { @@ -184,24 +187,36 @@ impl EnvironmentCommandRunner { }); } - fn list(&self, global_options: GlobalOptions, ctx: &mut ModelContext) { - let initial_sync = UpdateManager::as_ref(ctx) - .initial_load_complete() - .with_timeout(WARP_DRIVE_SYNC_TIMEOUT); + fn list( + &self, + global_options: GlobalOptions, + team_selection: TeamSelection, + ctx: &mut ModelContext, + ) { + let refresh_future = super::common::refresh_workspace_metadata(ctx); + let warp_drive_sync_future = super::common::refresh_warp_drive(ctx); + let setup_future = future::try_join(refresh_future, warp_drive_sync_future); - ctx.spawn(initial_sync, move |_, result, ctx| { - if result.is_err() { - super::report_fatal_error( - anyhow::anyhow!("Timed out waiting for Warp Drive to sync"), - ctx, - ); + ctx.spawn(setup_future, move |_, result, ctx| { + if let Err(err) = result { + super::report_fatal_error(err, ctx); return; } + let team_scope = match super::common::resolve_team_scope(&team_selection, ctx) { + Ok(team_scope) => team_scope, + Err(err) => { + super::report_fatal_error(err, ctx); + return; + } + }; let environments = CloudAmbientAgentEnvironment::get_all(ctx); let environment_infos: Vec<_> = environments .iter() + .filter(|environment| { + super::common::environment_is_visible_to_scope(environment, &team_scope) + }) .map(|environment| { let name = environment.model().string_model.name.clone(); let description = environment.model().string_model.description.clone(); diff --git a/app/src/ai/agent_sdk/integration.rs b/app/src/ai/agent_sdk/integration.rs index 681527869c5..c0d077c5def 100644 --- a/app/src/ai/agent_sdk/integration.rs +++ b/app/src/ai/agent_sdk/integration.rs @@ -2,6 +2,7 @@ use futures::future; use warp_cli::GlobalOptions; use warp_cli::integration::{CreateIntegrationArgs, IntegrationCommand, UpdateIntegrationArgs}; use warp_cli::provider::ProviderType; +use warp_cli::scope::TeamSelection; use warp_graphql::mutations::create_simple_integration::CreateSimpleIntegrationOutput; use warp_graphql::queries::get_oauth_connect_tx_status::OauthConnectTxStatus; use warp_graphql::queries::get_simple_integrations::SimpleIntegrationsOutput; @@ -73,6 +74,14 @@ impl IntegrationCommandRunner { ctx.terminate_app(TerminationMode::ForceTerminate, Some(Err(err))); return; } + let team_scope = + match super::common::resolve_team_scope(&TeamSelection { team: None }, ctx) { + Ok(team_scope) => team_scope, + Err(err) => { + ctx.terminate_app(TerminationMode::ForceTerminate, Some(Err(err))); + return; + } + }; let loaded_file = match args.config_file.file.as_deref() { Some(path) => match super::config_file::load_config_file(path) { @@ -154,26 +163,26 @@ impl IntegrationCommandRunner { environment_args.environment = merged_config.environment_id.take(); } - let environment_uid = match EnvironmentChoice::resolve_for_create(environment_args, ctx) - { - Ok(EnvironmentChoice::None) => { - eprintln!("Creating integration without an environment."); - None - } - Ok(EnvironmentChoice::Environment { id, .. }) => { - eprintln!("Creating integration with environment {id}."); - Some(id) - } - Err(ResolveConfigurationError::Canceled) => { - eprintln!("Integration creation canceled."); - ctx.terminate_app(TerminationMode::ForceTerminate, None); - return; - } - Err(err) => { - super::report_fatal_error(anyhow::anyhow!(err), ctx); - return; - } - }; + let environment_uid = + match EnvironmentChoice::resolve_for_create(environment_args, &team_scope, ctx) { + Ok(EnvironmentChoice::None) => { + eprintln!("Creating integration without an environment."); + None + } + Ok(EnvironmentChoice::Environment { id, .. }) => { + eprintln!("Creating integration with environment {id}."); + Some(id) + } + Err(ResolveConfigurationError::Canceled) => { + eprintln!("Integration creation canceled."); + ctx.terminate_app(TerminationMode::ForceTerminate, None); + return; + } + Err(err) => { + super::report_fatal_error(anyhow::anyhow!(err), ctx); + return; + } + }; runner.start_create_or_update_flow( ctx, diff --git a/app/src/ai/agent_sdk/mod.rs b/app/src/ai/agent_sdk/mod.rs index eeadd8dff33..fd6b905e86c 100644 --- a/app/src/ai/agent_sdk/mod.rs +++ b/app/src/ai/agent_sdk/mod.rs @@ -1583,7 +1583,7 @@ fn command_requires_auth(command: &CliCommand) -> bool { AgentCommand::Skills(_) => true, }, CliCommand::Environment(environment_cmd) => match environment_cmd { - EnvironmentCommand::List => true, + EnvironmentCommand::List { .. } => true, EnvironmentCommand::Create { .. } => true, EnvironmentCommand::Delete { .. } => true, EnvironmentCommand::Update { .. } => true, @@ -1809,7 +1809,9 @@ fn command_to_telemetry_event(command: &CliCommand) -> CliTelemetryEvent { CliCommand::Agent(AgentCommand::Update(_)) => CliTelemetryEvent::AgentUpdate, CliCommand::Agent(AgentCommand::Delete(_)) => CliTelemetryEvent::AgentDelete, CliCommand::Agent(AgentCommand::Skills(_)) => CliTelemetryEvent::AgentSkills, - CliCommand::Environment(EnvironmentCommand::List) => CliTelemetryEvent::EnvironmentList, + CliCommand::Environment(EnvironmentCommand::List { .. }) => { + CliTelemetryEvent::EnvironmentList + } CliCommand::Environment(EnvironmentCommand::Create { .. }) => { CliTelemetryEvent::EnvironmentCreate } diff --git a/app/src/ai/agent_sdk/schedule.rs b/app/src/ai/agent_sdk/schedule.rs index fec13daa9e7..fecd9b5e0bd 100644 --- a/app/src/ai/agent_sdk/schedule.rs +++ b/app/src/ai/agent_sdk/schedule.rs @@ -51,6 +51,14 @@ fn create(ctx: &mut AppContext, args: CreateScheduleArgs) -> anyhow::Result<()> super::report_fatal_error(err, ctx); return; } + let team_scope = + match super::common::resolve_team_scope(&args.scope.team_selection, ctx) { + Ok(team_scope) => team_scope, + Err(err) => { + super::report_fatal_error(err, ctx); + return; + } + }; let loaded_file = match args.config_file.file.as_deref() { Some(path) => match super::config_file::load_config_file(path) { @@ -73,22 +81,22 @@ fn create(ctx: &mut AppContext, args: CreateScheduleArgs) -> anyhow::Result<()> environment_args.environment = Some(environment_id); } - let environment_id = match EnvironmentChoice::resolve_for_create(environment_args, ctx) - { - Ok(EnvironmentChoice::None) => { - eprintln!("Scheduling agent to run without an environment."); - None - } - Ok(EnvironmentChoice::Environment { id, .. }) => Some(id), - Err(ResolveConfigurationError::Canceled) => { - ctx.terminate_app(TerminationMode::ForceTerminate, None); - return; - } - Err(err) => { - super::report_fatal_error(anyhow::anyhow!(err), ctx); - return; - } - }; + let environment_id = + match EnvironmentChoice::resolve_for_create(environment_args, &team_scope, ctx) { + Ok(EnvironmentChoice::None) => { + eprintln!("Scheduling agent to run without an environment."); + None + } + Ok(EnvironmentChoice::Environment { id, .. }) => Some(id), + Err(ResolveConfigurationError::Canceled) => { + ctx.terminate_app(TerminationMode::ForceTerminate, None); + return; + } + Err(err) => { + super::report_fatal_error(anyhow::anyhow!(err), ctx); + return; + } + }; let owner = match super::common::resolve_owner(&args.scope, ctx) { Ok(owner) => owner, diff --git a/crates/warp_cli/src/environment.rs b/crates/warp_cli/src/environment.rs index 31d8b67e9dd..ea70c83613f 100644 --- a/crates/warp_cli/src/environment.rs +++ b/crates/warp_cli/src/environment.rs @@ -1,6 +1,6 @@ use clap::{ArgAction, ArgGroup, Args, Subcommand}; -use crate::scope::ObjectScope; +use crate::scope::{ObjectScope, TeamSelection}; /// Maximum length for environment descriptions. const MAX_DESCRIPTION_LENGTH: usize = 240; @@ -24,7 +24,10 @@ fn validate_description(s: &str) -> Result { #[command(visible_alias = "e")] pub enum EnvironmentCommand { /// List cloud environments. - List, + List { + #[command(flatten)] + team_selection: TeamSelection, + }, /// Manage base images for cloud environments. #[command(subcommand)] Image(ImageCommand), @@ -104,7 +107,7 @@ pub enum EnvironmentCommand { impl EnvironmentCommand { pub(crate) fn as_str_for_tracing(&self) -> &'static str { match self { - EnvironmentCommand::List => "environment list", + EnvironmentCommand::List { .. } => "environment list", EnvironmentCommand::Image(_) => "environment image", EnvironmentCommand::Create { .. } => "environment create", EnvironmentCommand::Delete { .. } => "environment delete", diff --git a/crates/warp_cli/src/lib_tests.rs b/crates/warp_cli/src/lib_tests.rs index c6547c08a90..b249413f56d 100644 --- a/crates/warp_cli/src/lib_tests.rs +++ b/crates/warp_cli/src/lib_tests.rs @@ -2262,6 +2262,43 @@ fn environment_image_list_parses() { assert!(matches!(image_cmd, ImageCommand::List)); } +fn parse_environment_list(args: &[&str]) -> crate::scope::TeamSelection { + let full_args = std::iter::once("warp") + .chain(["environment", "list"]) + .chain(args.iter().copied()); + let args = Args::try_parse_from(full_args).expect("environment list args should parse"); + + let Some(Command::CommandLine(boxed_cmd)) = args.command else { + panic!("Expected `warp environment list` command"); + }; + let CliCommand::Environment(EnvironmentCommand::List { team_selection }) = boxed_cmd.as_ref() + else { + panic!("Expected `warp environment list` command"); + }; + + team_selection.clone() +} + +#[test] +fn environment_list_defaults_to_implicit_team_selection() { + let team_selection = parse_environment_list(&[]); + + assert_eq!(team_selection.team, None); +} + +#[test] +fn environment_list_accepts_bare_team_selection() { + let team_selection = parse_environment_list(&["--team"]); + + assert_eq!(team_selection.team, Some(None)); +} + +#[test] +fn environment_list_accepts_explicit_team_selection() { + let team_selection = parse_environment_list(&["--team=123"]); + + assert_eq!(team_selection.team, Some(Some("123".to_string()))); +} #[test] fn environment_create_accepts_description() { From 6f2563c9b0d3484614bdd04d154409a238c7eec0 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:31:21 +0000 Subject: [PATCH 2/7] fix(cli): honor personal environment scope Co-Authored-By: Warp Agent --- app/src/ai/agent_sdk/ambient.rs | 15 ++-- app/src/ai/agent_sdk/common.rs | 10 +++ app/src/ai/agent_sdk/common_tests.rs | 87 ++++++++++++++----- app/src/ai/agent_sdk/schedule.rs | 15 ++-- .../team_workspace_settings.rs | 7 ++ 5 files changed, 95 insertions(+), 39 deletions(-) diff --git a/app/src/ai/agent_sdk/ambient.rs b/app/src/ai/agent_sdk/ambient.rs index 05eade6231b..4ac5ed52227 100644 --- a/app/src/ai/agent_sdk/ambient.rs +++ b/app/src/ai/agent_sdk/ambient.rs @@ -386,14 +386,13 @@ impl AmbientAgentRunner { vec![] }; - let team_scope = - match super::common::resolve_team_scope(&args.scope.team_selection, ctx) { - Ok(team_scope) => team_scope, - Err(err) => { - super::report_fatal_error(err, ctx); - return; - } - }; + let team_scope = match super::common::resolve_environment_team_scope(&args.scope, ctx) { + Ok(team_scope) => team_scope, + Err(err) => { + super::report_fatal_error(err, ctx); + return; + } + }; let mut environment_args = args.environment; if environment_args.environment.is_none() && !environment_args.no_environment diff --git a/app/src/ai/agent_sdk/common.rs b/app/src/ai/agent_sdk/common.rs index 4273ac042bc..4389bbd36e8 100644 --- a/app/src/ai/agent_sdk/common.rs +++ b/app/src/ai/agent_sdk/common.rs @@ -156,6 +156,16 @@ pub(super) fn resolve_team_scope( .team_scope_for_cli(team_selection) .map_err(|err| describe_team_resolution_error(err, ctx)) } +pub(super) fn resolve_environment_team_scope( + scope: &ObjectScope, + ctx: &AppContext, +) -> anyhow::Result { + if scope.personal { + Ok(TeamScopeForCli::personal()) + } else { + resolve_team_scope(&scope.team_selection, ctx) + } +} /// [`validate_agent_mode_base_model_id`], also rejecting a model `scope`'s team does not let this /// member use. diff --git a/app/src/ai/agent_sdk/common_tests.rs b/app/src/ai/agent_sdk/common_tests.rs index d0a6bd7e0af..0838ecb6188 100644 --- a/app/src/ai/agent_sdk/common_tests.rs +++ b/app/src/ai/agent_sdk/common_tests.rs @@ -1,11 +1,12 @@ use std::collections::HashMap; use warp_cli::environment::EnvironmentCreateArgs; +use warp_cli::scope::{ObjectScope, TeamSelection}; use warpui::App; use super::{ EnvironmentChoice, classify_agent_mode_base_model_id, environment_is_visible_to_scope, - parse_ambient_task_id, validate_agent_mode_base_model_id, + parse_ambient_task_id, resolve_environment_team_scope, validate_agent_mode_base_model_id, }; use crate::LaunchMode; use crate::ai::cloud_environments::{ @@ -26,10 +27,11 @@ use crate::server::cloud_objects::update_manager::UpdateManager; use crate::server::ids::{ServerId, SyncId}; use crate::server::server_api::ServerApiProvider; use crate::server::sync_queue::SyncQueue; +use crate::settings::PrivacySettings; use crate::test_util::settings::initialize_settings_for_tests; use crate::workspaces::team_tester::TeamTesterStatus; use crate::workspaces::user_workspaces::{ - TeamContextForOperation, TeamlessScopeForTest, UserWorkspaces, + TeamContextForOperation, TeamScope, TeamlessScopeForTest, UserWorkspaces, }; fn environment_with_owner( @@ -94,28 +96,67 @@ fn environment_scope_includes_personal_and_matching_team_environments() { } #[test] -fn teamless_environment_scope_includes_only_personal_environments() { - let personal_environment = environment_with_owner( - SyncId::ServerId(ServerId::from(1)), - "Personal", - Owner::mock_current_user(), - ); - let team_environment = environment_with_owner( - SyncId::ServerId(ServerId::from(2)), - "Team", - Owner::Team { - team_uid: ServerId::from(123), - }, - ); +fn multi_team_personal_scope_includes_only_personal_environments() { + App::test((), |mut app| async move { + initialize_settings_for_tests(&mut app); + app.add_singleton_model(PrivacySettings::mock); + let user_workspaces = app.add_singleton_model(UserWorkspaces::default_mock); + user_workspaces.update(&mut app, |user_workspaces, ctx| { + user_workspaces.setup_test_workspace(ctx); + user_workspaces.update_current_workspace( + |workspace| { + let mut second_team = workspace.teams[0].clone(); + second_team.uid = ServerId::from(456); + second_team.name = "Second team".to_string(); + workspace.teams.push(second_team); + }, + ctx, + ); + }); + let implicit_scope = app.read(|ctx| { + resolve_environment_team_scope( + &ObjectScope { + team_selection: TeamSelection { team: None }, + personal: false, + }, + ctx, + ) + }); + let personal_scope = app + .read(|ctx| { + resolve_environment_team_scope( + &ObjectScope { + team_selection: TeamSelection { team: None }, + personal: true, + }, + ctx, + ) + }) + .expect("explicit personal scope should not require a sole team"); + let personal_environment = environment_with_owner( + SyncId::ServerId(ServerId::from(1)), + "Personal", + Owner::mock_current_user(), + ); + let team_environment = environment_with_owner( + SyncId::ServerId(ServerId::from(2)), + "Team", + Owner::Team { + team_uid: ServerId::from(123), + }, + ); - assert!(environment_is_visible_to_scope( - &personal_environment, - &TeamlessScopeForTest - )); - assert!(!environment_is_visible_to_scope( - &team_environment, - &TeamlessScopeForTest - )); + assert!(implicit_scope.is_err()); + assert_eq!(personal_scope.team_uid(), None); + assert!(environment_is_visible_to_scope( + &personal_environment, + &personal_scope + )); + assert!(!environment_is_visible_to_scope( + &team_environment, + &personal_scope + )); + }); } #[test] diff --git a/app/src/ai/agent_sdk/schedule.rs b/app/src/ai/agent_sdk/schedule.rs index fecd9b5e0bd..62f0fcd2a1a 100644 --- a/app/src/ai/agent_sdk/schedule.rs +++ b/app/src/ai/agent_sdk/schedule.rs @@ -51,14 +51,13 @@ fn create(ctx: &mut AppContext, args: CreateScheduleArgs) -> anyhow::Result<()> super::report_fatal_error(err, ctx); return; } - let team_scope = - match super::common::resolve_team_scope(&args.scope.team_selection, ctx) { - Ok(team_scope) => team_scope, - Err(err) => { - super::report_fatal_error(err, ctx); - return; - } - }; + let team_scope = match super::common::resolve_environment_team_scope(&args.scope, ctx) { + Ok(team_scope) => team_scope, + Err(err) => { + super::report_fatal_error(err, ctx); + return; + } + }; let loaded_file = match args.config_file.file.as_deref() { Some(path) => match super::config_file::load_config_file(path) { diff --git a/app/src/workspaces/user_workspaces/team_workspace_settings.rs b/app/src/workspaces/user_workspaces/team_workspace_settings.rs index 92ebfdfd361..acb98aa29a0 100644 --- a/app/src/workspaces/user_workspaces/team_workspace_settings.rs +++ b/app/src/workspaces/user_workspaces/team_workspace_settings.rs @@ -101,6 +101,13 @@ impl TeamScope for TeamContext<'_> { /// memberships instead of from a window. #[cfg(not(target_family = "wasm"))] pub struct TeamScopeForCli(Option); +#[cfg(not(target_family = "wasm"))] +impl TeamScopeForCli { + /// A teamless scope selected explicitly with a personal CLI flag. + pub(crate) fn personal() -> Self { + Self(None) + } +} #[cfg(not(target_family = "wasm"))] impl sealed::Sealed for TeamScopeForCli {} From 401b4a03e6369591eb0fed755231de668eb6107b Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:05:14 +0000 Subject: [PATCH 3/7] [REV-2383] Scope schedule listing to selected team Co-Authored-By: Warp Agent --- app/src/ai/agent_sdk/mod.rs | 2 +- app/src/ai/agent_sdk/schedule.rs | 43 +++++++++++++-- app/src/ai/agent_sdk/schedule_tests.rs | 75 ++++++++++++++++++++++++++ crates/warp_cli/src/lib_tests.rs | 71 ++++++++++++++++++++++++ crates/warp_cli/src/schedule.rs | 9 ++-- 5 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 app/src/ai/agent_sdk/schedule_tests.rs diff --git a/app/src/ai/agent_sdk/mod.rs b/app/src/ai/agent_sdk/mod.rs index fd6b905e86c..8485dfa8c8e 100644 --- a/app/src/ai/agent_sdk/mod.rs +++ b/app/src/ai/agent_sdk/mod.rs @@ -1880,7 +1880,7 @@ fn command_to_telemetry_event(command: &CliCommand) -> CliTelemetryEvent { }, CliCommand::Schedule(c) => match c.subcommand() { None | Some(ScheduleSubcommand::Create(_)) => CliTelemetryEvent::ScheduleCreate, - Some(ScheduleSubcommand::List) => CliTelemetryEvent::ScheduleList, + Some(ScheduleSubcommand::List { .. }) => CliTelemetryEvent::ScheduleList, Some(ScheduleSubcommand::Get(_)) => CliTelemetryEvent::ScheduleGet, Some(ScheduleSubcommand::Pause(_)) => CliTelemetryEvent::SchedulePause, Some(ScheduleSubcommand::Unpause(_)) => CliTelemetryEvent::ScheduleUnpause, diff --git a/app/src/ai/agent_sdk/schedule.rs b/app/src/ai/agent_sdk/schedule.rs index 62f0fcd2a1a..94aef5a5d5c 100644 --- a/app/src/ai/agent_sdk/schedule.rs +++ b/app/src/ai/agent_sdk/schedule.rs @@ -8,6 +8,7 @@ use warp_cli::schedule::{ CreateScheduleArgs, DeleteScheduleArgs, GetScheduleArgs, PauseScheduleArgs, ScheduleCommand, ScheduleSubcommand, UnpauseScheduleArgs, UpdateScheduleArgs, }; +use warp_cli::scope::TeamSelection; use warp_graphql::queries::get_scheduled_agent_history::ScheduledAgentHistory; use warpui::platform::TerminationMode; use warpui::{AppContext, SingletonEntity}; @@ -21,6 +22,7 @@ use crate::ai::ambient_agents::scheduled::{ use crate::cloud_object::{CloudObject, CloudObjectLookup as _}; use crate::server::ids::{ServerId, SyncId}; use crate::util::time_format::format_approx_duration_from_now_utc; +use crate::workspaces::user_workspaces::TeamScope; /// Run a scheduled agent command. pub fn run( @@ -31,7 +33,7 @@ pub fn run( let output_format = global_options.output_format; match command.into_subcommand() { ScheduleSubcommand::Create(args) => create(ctx, args), - ScheduleSubcommand::List => list(ctx, output_format), + ScheduleSubcommand::List { team_selection } => list(ctx, output_format, team_selection), ScheduleSubcommand::Get(args) => get(ctx, output_format, args), ScheduleSubcommand::Pause(args) => pause(ctx, args), ScheduleSubcommand::Unpause(args) => unpause(ctx, args), @@ -547,17 +549,44 @@ fn update(ctx: &mut AppContext, args: UpdateScheduleArgs) -> anyhow::Result<()> Ok(()) } +fn schedule_is_visible_to_scope( + schedule: &CloudScheduledAmbientAgent, + team_scope: &(impl TeamScope + ?Sized), +) -> bool { + match schedule.permissions().owner { + crate::cloud_object::Owner::User { .. } => true, + crate::cloud_object::Owner::Team { team_uid } => team_scope.team_uid() == Some(team_uid), + } +} + /// List all scheduled agents available to the current user. -fn list(ctx: &mut AppContext, output_format: OutputFormat) -> anyhow::Result<()> { +fn list( + ctx: &mut AppContext, + output_format: OutputFormat, + team_selection: TeamSelection, +) -> anyhow::Result<()> { ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| { + let refresh_future = super::common::refresh_workspace_metadata(ctx); let warp_drive_sync_future = super::common::refresh_warp_drive(ctx); - ctx.spawn(warp_drive_sync_future, move |manager, result, ctx| { - if let Err(err) = result { + let setup_future = future::try_join(refresh_future, warp_drive_sync_future); + ctx.spawn(setup_future, move |manager, setup_result, ctx| { + if let Err(err) = setup_result { super::report_fatal_error(err, ctx); return; } + let team_scope = match super::common::resolve_team_scope(&team_selection, ctx) { + Ok(team_scope) => team_scope, + Err(err) => { + super::report_fatal_error(err, ctx); + return; + } + }; - let mut schedules = manager.list_schedules(ctx); + let mut schedules: Vec<_> = manager + .list_schedules(ctx) + .into_iter() + .filter(|schedule| schedule_is_visible_to_scope(schedule, &team_scope)) + .collect(); schedules.sort_by_key(|schedule| schedule.model().string_model.name.clone()); let futures = schedules.into_iter().map(|schedule| { @@ -682,3 +711,7 @@ fn delete(ctx: &mut AppContext, args: DeleteScheduleArgs) -> anyhow::Result<()> Ok(()) } + +#[cfg(test)] +#[path = "schedule_tests.rs"] +mod tests; diff --git a/app/src/ai/agent_sdk/schedule_tests.rs b/app/src/ai/agent_sdk/schedule_tests.rs new file mode 100644 index 00000000000..6b37b03c6ac --- /dev/null +++ b/app/src/ai/agent_sdk/schedule_tests.rs @@ -0,0 +1,75 @@ +use super::schedule_is_visible_to_scope; +use crate::ai::ambient_agents::scheduled::{ + CloudScheduledAmbientAgent, CloudScheduledAmbientAgentModel, ScheduledAmbientAgent, +}; +use crate::cloud_object::{CloudObjectMetadata, CloudObjectPermissions, Owner}; +use crate::server::ids::{ServerId, SyncId}; +use crate::workspaces::user_workspaces::{TeamContextForOperation, TeamlessScopeForTest}; + +fn schedule_with_owner(id: i64, owner: Owner) -> CloudScheduledAmbientAgent { + let mut permissions = CloudObjectPermissions::mock_personal(); + permissions.owner = owner; + CloudScheduledAmbientAgent::new( + SyncId::ServerId(ServerId::from(id)), + CloudScheduledAmbientAgentModel::new(ScheduledAmbientAgent::new( + "Schedule".to_string(), + "0 9 * * 1".to_string(), + true, + "Prompt".to_string(), + )), + CloudObjectMetadata::mock(), + permissions, + ) +} + +#[test] +fn schedule_scope_includes_personal_and_matching_team_schedules() { + let selected_team_uid = ServerId::from(123); + let selected_scope = TeamContextForOperation::new_for_test(selected_team_uid); + let personal_schedule = schedule_with_owner(1, Owner::mock_current_user()); + let selected_team_schedule = schedule_with_owner( + 2, + Owner::Team { + team_uid: selected_team_uid, + }, + ); + let other_team_schedule = schedule_with_owner( + 3, + Owner::Team { + team_uid: ServerId::from(456), + }, + ); + + assert!(schedule_is_visible_to_scope( + &personal_schedule, + &selected_scope + )); + assert!(schedule_is_visible_to_scope( + &selected_team_schedule, + &selected_scope + )); + assert!(!schedule_is_visible_to_scope( + &other_team_schedule, + &selected_scope + )); +} + +#[test] +fn teamless_schedule_scope_includes_only_personal_schedules() { + let personal_schedule = schedule_with_owner(1, Owner::mock_current_user()); + let team_schedule = schedule_with_owner( + 2, + Owner::Team { + team_uid: ServerId::from(123), + }, + ); + + assert!(schedule_is_visible_to_scope( + &personal_schedule, + &TeamlessScopeForTest + )); + assert!(!schedule_is_visible_to_scope( + &team_schedule, + &TeamlessScopeForTest + )); +} diff --git a/crates/warp_cli/src/lib_tests.rs b/crates/warp_cli/src/lib_tests.rs index b249413f56d..88d91529674 100644 --- a/crates/warp_cli/src/lib_tests.rs +++ b/crates/warp_cli/src/lib_tests.rs @@ -2161,6 +2161,34 @@ fn schedule_create_accepts_personal_scope() { assert!(!create_args.scope.is_team()); assert!(create_args.scope.personal); } +#[test] +fn schedule_create_defaults_to_implicit_team_selection() { + let args = Args::try_parse_from([ + "warp", + "schedule", + "create", + "--name", + "test", + "--cron", + "0 9 * * 1", + "--prompt", + "hello", + ]) + .unwrap(); + + let Some(Command::CommandLine(boxed_cmd)) = args.command else { + panic!("Expected `warp schedule create` command"); + }; + let CliCommand::Schedule(schedule_cmd) = boxed_cmd.as_ref() else { + panic!("Expected `warp schedule create` command"); + }; + let Some(ScheduleSubcommand::Create(create_args)) = schedule_cmd.subcommand() else { + panic!("Expected `warp schedule create` subcommand"); + }; + + assert_eq!(create_args.scope.team_selection.team, None); + assert!(!create_args.scope.personal); +} #[test] fn schedule_create_rejects_multiple_scopes() { @@ -2182,6 +2210,49 @@ fn schedule_create_rejects_multiple_scopes() { ); } +fn parse_schedule_list(args: &[&str]) -> crate::scope::TeamSelection { + let full_args = std::iter::once("warp") + .chain(["schedule", "list"]) + .chain(args.iter().copied()); + let args = Args::try_parse_from(full_args).expect("schedule list args should parse"); + + let Some(Command::CommandLine(boxed_cmd)) = args.command else { + panic!("Expected `warp schedule list` command"); + }; + let CliCommand::Schedule(schedule_cmd) = boxed_cmd.as_ref() else { + panic!("Expected `warp schedule list` command"); + }; + let Some(ScheduleSubcommand::List { team_selection }) = schedule_cmd.subcommand() else { + panic!("Expected `warp schedule list` subcommand"); + }; + + team_selection.clone() +} + +#[test] +fn schedule_list_defaults_to_implicit_team_selection() { + let team_selection = parse_schedule_list(&[]); + + assert_eq!(team_selection.team, None); +} + +#[test] +fn schedule_list_accepts_bare_team_selection() { + let team_selection = parse_schedule_list(&["--team"]); + + assert_eq!(team_selection.team, Some(None)); +} + +#[test] +fn schedule_list_accepts_explicit_team_selection() { + let team_selection = parse_schedule_list(&["--team=team_uid00000000000123"]); + + assert_eq!( + team_selection.team, + Some(Some("team_uid00000000000123".to_string())) + ); +} + #[test] fn schedule_update_accepts_file() { let args = Args::try_parse_from([ diff --git a/crates/warp_cli/src/schedule.rs b/crates/warp_cli/src/schedule.rs index 4385376f74f..396585579f9 100644 --- a/crates/warp_cli/src/schedule.rs +++ b/crates/warp_cli/src/schedule.rs @@ -4,7 +4,7 @@ use crate::config_file::ConfigFileArgs; use crate::environment::{EnvironmentCreateArgs, EnvironmentUpdateArgs}; use crate::mcp::MCPSpec; use crate::model::ModelArgs; -use crate::scope::ObjectScope; +use crate::scope::{ObjectScope, TeamSelection}; use crate::skill::SkillSpec; /// `ScheduleCommand` has a slightly unusual definition because we allow `oz schedule` as @@ -23,7 +23,7 @@ impl ScheduleCommand { pub(crate) fn as_str_for_tracing(&self) -> &'static str { match self.subcommand() { Some(ScheduleSubcommand::Create(_)) | None => "schedule create", - Some(ScheduleSubcommand::List) => "schedule list", + Some(ScheduleSubcommand::List { .. }) => "schedule list", Some(ScheduleSubcommand::Get(_)) => "schedule get", Some(ScheduleSubcommand::Update(_)) => "schedule update", Some(ScheduleSubcommand::Pause(_)) => "schedule pause", @@ -54,7 +54,10 @@ pub enum ScheduleSubcommand { /// Create a scheduled Oz agent. Create(CreateScheduleArgs), /// List scheduled Oz agents. - List, + List { + #[command(flatten)] + team_selection: TeamSelection, + }, /// Get a scheduled Oz agent's configuration. Get(GetScheduleArgs), /// Update a scheduled Oz agent. From e3c53ea6417fcd7bf217076d4c23258b8fec3245 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:27:06 +0000 Subject: [PATCH 4/7] fix(cli): scope schedule Drive refreshes --- app/src/ai/agent_sdk/common.rs | 20 +- app/src/ai/agent_sdk/schedule.rs | 329 +++++++------ .../server/cloud_objects/update_manager.rs | 8 + app/src/server/server_api.rs | 47 ++ app/src/server/server_api/object.rs | 451 ++++++++++-------- app/src/server/server_api_tests.rs | 75 +++ .../warp_server_client/src/graphql_helpers.rs | 3 +- 7 files changed, 571 insertions(+), 362 deletions(-) diff --git a/app/src/ai/agent_sdk/common.rs b/app/src/ai/agent_sdk/common.rs index 4389bbd36e8..5ed1c6438d9 100644 --- a/app/src/ai/agent_sdk/common.rs +++ b/app/src/ai/agent_sdk/common.rs @@ -20,11 +20,13 @@ use crate::ai::cloud_environments::CloudAmbientAgentEnvironment; use crate::ai::llms::{LLMId, LLMPreferences, is_model_allowed_for_scope}; use crate::auth::UserUid; use crate::auth::auth_state::AuthStateProvider; +use crate::cloud_object::model::persistence::CloudModel; use crate::cloud_object::{CloudObject, CloudObjectLookup as _, Owner}; -use crate::server::cloud_objects::update_manager::UpdateManager; +use crate::server::cloud_objects::update_manager::{InitialLoadResponse, UpdateManager}; use crate::server::ids::{ServerId, SyncId}; use crate::server::server_api::ServerApiProvider; use crate::server::server_api::ai::AIClient; +use crate::server::team_scope::RequestTeamScope; use crate::workspaces::update_manager::TeamUpdateManager; use crate::workspaces::user_workspaces::team_workspace_settings::{ NotATeamMemberError, TeamScopeForCli, TeamScopeForCliError, @@ -267,6 +269,22 @@ pub fn refresh_warp_drive( .map_err(|_| anyhow::anyhow!("Timed out waiting for Warp Drive to sync")) } +pub fn refresh_warp_drive_for_scope( + team_scope: RequestTeamScope, + ctx: &AppContext, +) -> impl Future> + Send + 'static + use<> { + let server_api = ServerApiProvider::as_ref(ctx).get(); + let objects_to_update = CloudModel::as_ref(ctx).get_versions_for_all_objects(ctx); + async move { + server_api + .fetch_changed_objects_for_scope(objects_to_update, false, team_scope) + .await + } + .with_timeout(WARP_DRIVE_SYNC_TIMEOUT) + .map_err(|_| anyhow::anyhow!("Timed out refreshing Warp Drive")) + .and_then(|result| async move { result }) +} + /// Fetch the conversation's server metadata and validate that its harness matches the caller's /// `--harness` choice. Returns the metadata on success so the caller can reuse it (e.g. for the /// server conversation token). diff --git a/app/src/ai/agent_sdk/schedule.rs b/app/src/ai/agent_sdk/schedule.rs index 94aef5a5d5c..ddf628137e1 100644 --- a/app/src/ai/agent_sdk/schedule.rs +++ b/app/src/ai/agent_sdk/schedule.rs @@ -44,12 +44,9 @@ pub fn run( fn create(ctx: &mut AppContext, args: CreateScheduleArgs) -> anyhow::Result<()> { ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| { - let refresh_future = super::common::refresh_workspace_metadata(ctx); - let warp_drive_sync_future = super::common::refresh_warp_drive(ctx); - let setup_future = future::try_join(refresh_future, warp_drive_sync_future); - - ctx.spawn(setup_future, move |manager, setup_result, ctx| { - if let Err(err) = setup_result { + let metadata_refresh = super::common::refresh_workspace_metadata(ctx); + ctx.spawn(metadata_refresh, move |_, result, ctx| { + if let Err(err) = result { super::report_fatal_error(err, ctx); return; } @@ -60,122 +57,140 @@ fn create(ctx: &mut AppContext, args: CreateScheduleArgs) -> anyhow::Result<()> return; } }; - - let loaded_file = match args.config_file.file.as_deref() { - Some(path) => match super::config_file::load_config_file(path) { - Ok(file) => Some(file), + let request_team_scope = + crate::server::team_scope::RequestTeamScope::from_scope(&team_scope); + let drive_refresh = + super::common::refresh_warp_drive_for_scope(request_team_scope, ctx); + ctx.spawn(drive_refresh, move |manager, result, ctx| { + let response = match result { + Ok(response) => response, Err(err) => { super::report_fatal_error(err, ctx); return; } - }, - None => None, - }; + }; + crate::server::cloud_objects::update_manager::UpdateManager::handle(ctx) + .update(ctx, |update_manager, ctx| { + update_manager.apply_scoped_refresh(response, ctx) + }); + + let loaded_file = match args.config_file.file.as_deref() { + Some(path) => match super::config_file::load_config_file(path) { + Ok(file) => Some(file), + Err(err) => { + super::report_fatal_error(err, ctx); + return; + } + }, + None => None, + }; - let mut environment_args = args.environment; - if environment_args.environment.is_none() - && !environment_args.no_environment - && let Some(environment_id) = loaded_file - .as_ref() - .and_then(|f| f.file.environment_id.clone()) - { - environment_args.environment = Some(environment_id); - } + let mut environment_args = args.environment; + if environment_args.environment.is_none() + && !environment_args.no_environment + && let Some(environment_id) = loaded_file + .as_ref() + .and_then(|f| f.file.environment_id.clone()) + { + environment_args.environment = Some(environment_id); + } - let environment_id = - match EnvironmentChoice::resolve_for_create(environment_args, &team_scope, ctx) { - Ok(EnvironmentChoice::None) => { - eprintln!("Scheduling agent to run without an environment."); - None - } - Ok(EnvironmentChoice::Environment { id, .. }) => Some(id), - Err(ResolveConfigurationError::Canceled) => { - ctx.terminate_app(TerminationMode::ForceTerminate, None); - return; - } + let environment_id = + match EnvironmentChoice::resolve_for_create(environment_args, &team_scope, ctx) + { + Ok(EnvironmentChoice::None) => { + eprintln!("Scheduling agent to run without an environment."); + None + } + Ok(EnvironmentChoice::Environment { id, .. }) => Some(id), + Err(ResolveConfigurationError::Canceled) => { + ctx.terminate_app(TerminationMode::ForceTerminate, None); + return; + } + Err(err) => { + super::report_fatal_error(anyhow::anyhow!(err), ctx); + return; + } + }; + + let owner = match super::common::resolve_owner(&args.scope, ctx) { + Ok(owner) => owner, Err(err) => { - super::report_fatal_error(anyhow::anyhow!(err), ctx); + super::report_fatal_error(err, ctx); return; } }; - let owner = match super::common::resolve_owner(&args.scope, ctx) { - Ok(owner) => owner, - Err(err) => { - super::report_fatal_error(err, ctx); - return; - } - }; + let cli_mcp_servers = + match super::mcp_config::build_mcp_servers_from_specs(&args.mcp_specs) { + Ok(mcp_servers) => mcp_servers, + Err(err) => { + super::report_fatal_error(err, ctx); + return; + } + }; - let cli_mcp_servers = - match super::mcp_config::build_mcp_servers_from_specs(&args.mcp_specs) { - Ok(mcp_servers) => mcp_servers, + let merged_config = super::config_file::merge_with_precedence( + loaded_file.as_ref(), + crate::ai::ambient_agents::AgentConfigSnapshot { + name: None, + environment_id, + // TODO(REMOTE-1936): support --runner for scheduled agents. + runner_id: None, + model_id: args.model.model.clone(), + base_prompt: None, + mcp_servers: cli_mcp_servers, + profile_id: None, + worker_host: args.worker_host, + skill_spec: args.skill.map(|s| s.to_string()), + // TODO(QUALITY-294): Support computer use flag in scheduled agents. + computer_use_enabled: None, + // TODO(REMOTE-1134): Support harness flag for scheduled agents. + harness: None, + harness_auth_secrets: None, + additional_source_repos: None, + }, + ); + + // We must wait until after workspace metadata is refreshed to check available LLMs. + let model_id = match merged_config + .model_id + .as_deref() + .map(|model_id| { + super::common::validate_agent_mode_base_model_id_for_scope( + model_id, + &args.scope.team_selection, + ctx, + ) + }) + .transpose() + { + Ok(id) => id.map(|id| id.to_string()), Err(err) => { - super::report_fatal_error(err, ctx); + super::report_fatal_error(anyhow::anyhow!(err), ctx); return; } }; - let merged_config = super::config_file::merge_with_precedence( - loaded_file.as_ref(), - crate::ai::ambient_agents::AgentConfigSnapshot { - name: None, - environment_id, - // TODO(REMOTE-1936): support --runner for scheduled agents. - runner_id: None, - model_id: args.model.model.clone(), - base_prompt: None, - mcp_servers: cli_mcp_servers, - profile_id: None, - worker_host: args.worker_host, - skill_spec: args.skill.map(|s| s.to_string()), - // TODO(QUALITY-294): Support computer use flag in scheduled agents. - computer_use_enabled: None, - // TODO(REMOTE-1134): Support harness flag for scheduled agents. - harness: None, - harness_auth_secrets: None, - additional_source_repos: None, - }, - ); + let mut agent_config = merged_config; + agent_config.model_id = model_id; - // We must wait until after workspace metadata is refreshed to check available LLMs. - let model_id = match merged_config - .model_id - .as_deref() - .map(|model_id| { - super::common::validate_agent_mode_base_model_id_for_scope( - model_id, - &args.scope.team_selection, - ctx, - ) - }) - .transpose() - { - Ok(id) => id.map(|id| id.to_string()), - Err(err) => { - super::report_fatal_error(anyhow::anyhow!(err), ctx); - return; - } - }; + let prompt = args.prompt.unwrap_or_default(); + let mut config = ScheduledAmbientAgent::new(args.name, args.cron, true, prompt); + config.agent_config = agent_config; - let mut agent_config = merged_config; - agent_config.model_id = model_id; - - let prompt = args.prompt.unwrap_or_default(); - let mut config = ScheduledAmbientAgent::new(args.name, args.cron, true, prompt); - config.agent_config = agent_config; - - // Print something here because scheduling an agent can take a while. - println!("Scheduling agent {}...", config.name); - let create_future = manager.create_schedule(config, owner, ctx); - ctx.spawn(create_future, |_manager, result, ctx| match result { - Ok(sync_id) => { - println!("Scheduled agent: {sync_id}"); - ctx.terminate_app(TerminationMode::ForceTerminate, None); - } - Err(err) => { - super::report_fatal_error(err, ctx); - } + // Print something here because scheduling an agent can take a while. + println!("Scheduling agent {}...", config.name); + let create_future = manager.create_schedule(config, owner, ctx); + ctx.spawn(create_future, |_manager, result, ctx| match result { + Ok(sync_id) => { + println!("Scheduled agent: {sync_id}"); + ctx.terminate_app(TerminationMode::ForceTerminate, None); + } + Err(err) => { + super::report_fatal_error(err, ctx); + } + }); }); }); }); @@ -566,11 +581,9 @@ fn list( team_selection: TeamSelection, ) -> anyhow::Result<()> { ScheduledAgentManager::handle(ctx).update(ctx, move |_manager, ctx| { - let refresh_future = super::common::refresh_workspace_metadata(ctx); - let warp_drive_sync_future = super::common::refresh_warp_drive(ctx); - let setup_future = future::try_join(refresh_future, warp_drive_sync_future); - ctx.spawn(setup_future, move |manager, setup_result, ctx| { - if let Err(err) = setup_result { + let metadata_refresh = super::common::refresh_workspace_metadata(ctx); + ctx.spawn(metadata_refresh, move |_, result, ctx| { + if let Err(err) = result { super::report_fatal_error(err, ctx); return; } @@ -581,50 +594,68 @@ fn list( return; } }; + let request_team_scope = + crate::server::team_scope::RequestTeamScope::from_scope(&team_scope); + let drive_refresh = + super::common::refresh_warp_drive_for_scope(request_team_scope, ctx); + ctx.spawn(drive_refresh, move |manager, result, ctx| { + let response = match result { + Ok(response) => response, + Err(err) => { + super::report_fatal_error(err, ctx); + return; + } + }; + crate::server::cloud_objects::update_manager::UpdateManager::handle(ctx) + .update(ctx, |update_manager, ctx| { + update_manager.apply_scoped_refresh(response, ctx) + }); + + let mut schedules: Vec<_> = manager + .list_schedules(ctx) + .into_iter() + .filter(|schedule| schedule_is_visible_to_scope(schedule, &team_scope)) + .collect(); + schedules.sort_by_key(|schedule| schedule.model().string_model.name.clone()); + + let futures = schedules.into_iter().map(|schedule| { + let config = schedule.model().string_model.clone(); + let sync_id = schedule.sync_id(); + let scope = + super::common::format_owner(&schedule.permissions().owner).to_string(); + + // TODO(ben): Consider a bulk lookup API for scheduled agent history. + let history_future = manager.fetch_schedule_history(sync_id, ctx); + + async move { + // Try to fetch the scheduled agent history, but still show output if this fails. + let history = match history_future.await { + Ok(v) => v, + Err(err) => { + log::warn!("Failed to fetch scheduled agent history: {err:#}"); + None + } + }; + + let id = match sync_id { + SyncId::ServerId(server_id) => server_id.to_string(), + SyncId::ClientId(_) => "Unsynced".to_string(), + }; + + ScheduleInfo::new(id, scope, config, history.as_ref()) + } + }); - let mut schedules: Vec<_> = manager - .list_schedules(ctx) - .into_iter() - .filter(|schedule| schedule_is_visible_to_scope(schedule, &team_scope)) - .collect(); - schedules.sort_by_key(|schedule| schedule.model().string_model.name.clone()); - - let futures = schedules.into_iter().map(|schedule| { - let config = schedule.model().string_model.clone(); - let sync_id = schedule.sync_id(); - let scope = super::common::format_owner(&schedule.permissions().owner).to_string(); - - // TODO(ben): Consider a bulk lookup API for scheduled agent history. - let history_future = manager.fetch_schedule_history(sync_id, ctx); - - async move { - // Try to fetch the scheduled agent history, but still show output if this fails. - let history = match history_future.await { - Ok(v) => v, - Err(err) => { - log::warn!("Failed to fetch scheduled agent history: {err:#}"); - None - } - }; - - let id = match sync_id { - SyncId::ServerId(server_id) => server_id.to_string(), - SyncId::ClientId(_) => "Unsynced".to_string(), - }; + let output_format = output_format; + ctx.spawn( + futures::future::join_all(futures), + move |_manager, infos, ctx| { + output::print_list(infos, output_format); - ScheduleInfo::new(id, scope, config, history.as_ref()) - } + ctx.terminate_app(TerminationMode::ForceTerminate, None); + }, + ); }); - - let output_format = output_format; - ctx.spawn( - futures::future::join_all(futures), - move |_manager, infos, ctx| { - output::print_list(infos, output_format); - - ctx.terminate_app(TerminationMode::ForceTerminate, None); - }, - ); }); }); diff --git a/app/src/server/cloud_objects/update_manager.rs b/app/src/server/cloud_objects/update_manager.rs index 9dce190decb..2cbb6fe88e1 100644 --- a/app/src/server/cloud_objects/update_manager.rs +++ b/app/src/server/cloud_objects/update_manager.rs @@ -1099,6 +1099,14 @@ impl UpdateManager { } } + pub(crate) fn apply_scoped_refresh( + &mut self, + response: InitialLoadResponse, + ctx: &mut ModelContext, + ) { + self.on_changed_objects_fetched(response, false, ctx); + } + fn handle_team_memberships_changed(&mut self, ctx: &mut ModelContext) { // Immediately check for updates in workspace metadata TeamUpdateManager::handle(ctx).update(ctx, |manager, ctx| { diff --git a/app/src/server/server_api.rs b/app/src/server/server_api.rs index 5d5821597d2..260a934bc54 100644 --- a/app/src/server/server_api.rs +++ b/app/src/server/server_api.rs @@ -567,6 +567,53 @@ impl ServerApi { ) } + fn send_graphql_request_with_team_scope< + 'a, + QF, + O: warp_graphql::client::Operation + Send + 'a, + >( + &'a self, + operation: O, + timeout: Option, + team_scope: RequestTeamScope, + ) -> BoxFuture<'a, Result> + where + QF: 'a, + { + Box::pin(async move { + let options = self.graphql_request_options(timeout).await?; + self.send_graphql_request_with_options_and_team_scope(operation, options, team_scope) + .await + }) + } + + fn send_graphql_request_with_options_and_team_scope< + 'a, + QF, + O: warp_graphql::client::Operation + Send + 'a, + >( + &'a self, + operation: O, + mut options: warp_graphql::client::RequestOptions, + team_scope: RequestTeamScope, + ) -> BoxFuture<'a, Result> + where + QF: 'a, + { + if let Some(team_uid) = team_scope.team_uid() { + options + .headers + .insert(TEAM_UID_HEADER.to_string(), team_uid.uid().to_string()); + } + Box::pin( + warp_server_client::graphql_helpers::send_graphql_request_with_options( + &self.base_client, + operation, + options, + ), + ) + } + /// Opens an SSE stream to the agent event-push endpoint. /// /// The returned `EventSourceStream` yields `reqwest_eventsource::Event` diff --git a/app/src/server/server_api/object.rs b/app/src/server/server_api/object.rs index d6d529e0a88..91c5b068f76 100644 --- a/app/src/server/server_api/object.rs +++ b/app/src/server/server_api/object.rs @@ -152,12 +152,250 @@ use crate::server::graphql::{get_request_context, get_user_facing_error_message} use crate::server::ids::{ClientId, HashableId, ServerId, ServerIdAndType, SyncId, ToServerId}; use crate::server::server_api::ServerApi; use crate::server::sync_queue::SerializedModel; +use crate::server::team_scope::RequestTeamScope; use crate::settings::Preference; use crate::workflows::WorkflowId; use crate::workflows::workflow_enum::WorkflowEnum; use crate::workspaces::gql_convert::object_update_message_from_gql; use crate::workspaces::user_profiles::UserProfileWithUID; +impl ServerApi { + pub(crate) async fn fetch_changed_objects_for_scope( + &self, + objects_to_update: ObjectsToUpdate, + force_refresh: bool, + team_scope: RequestTeamScope, + ) -> Result { + self.fetch_changed_objects_with_scope(objects_to_update, force_refresh, Some(team_scope)) + .await + } + + async fn fetch_changed_objects_with_scope( + &self, + objects_to_update: ObjectsToUpdate, + force_refresh: bool, + team_scope: Option, + ) -> Result { + log::info!("fetching updated cloud objects"); + if force_refresh { + log::info!("forcing sync of all objects") + } + + let variables = GetUpdatedCloudObjectsVariables { + input: UpdatedCloudObjectsInput { + folders: Some(objects_to_update.folders), + force_refresh, + generic_string_objects: Some(objects_to_update.generic_string_objects), + notebooks: Some(objects_to_update.notebooks), + workflows: Some(objects_to_update.workflows), + }, + request_context: get_request_context(), + }; + + let operation = GetUpdatedCloudObjects::build(variables); + let response_data = match team_scope { + Some(team_scope) => { + self.send_graphql_request_with_team_scope(operation, None, team_scope) + .await? + } + None => self.send_graphql_request(operation, None).await?, + }; + + match response_data.updated_cloud_objects { + UpdatedCloudObjectsResult::UpdatedCloudObjectsOutput(output) => { + let updated_notebooks = output + .notebooks + .map(|notebooks| { + notebooks + .into_iter() + .filter_map(|notebook| ServerNotebook::try_from_gql(notebook).ok()) + .collect() + }) + .unwrap_or_default(); + + let updated_workflows = output + .workflows + .map(|workflows| { + workflows + .into_iter() + .filter_map(|workflow| ServerWorkflow::try_from_gql(workflow).ok()) + .collect() + }) + .unwrap_or_default(); + + let updated_folders = output + .folders + .map(|folders| { + folders + .into_iter() + .filter_map(|folder| ServerFolder::try_from_gql(folder).ok()) + .collect() + }) + .unwrap_or_default(); + + let mut updated_generic_string_objects = HashMap::new(); + if let Some(objects) = output.generic_string_objects { + for gso in objects { + match gso.format { + warp_graphql::generic_string_object::GenericStringObjectFormat::JsonEnvVarCollection => { + parse_server_gso::( + &mut updated_generic_string_objects, + GenericStringObjectFormat::Json(JsonObjectType::EnvVarCollection), + gso, + ); + } + warp_graphql::generic_string_object::GenericStringObjectFormat::JsonPreference => { + parse_server_gso::( + &mut updated_generic_string_objects, + GenericStringObjectFormat::Json(JsonObjectType::Preference), + gso, + ); + } + warp_graphql::generic_string_object::GenericStringObjectFormat::JsonWorkflowEnum => { + parse_server_gso::( + &mut updated_generic_string_objects, + GenericStringObjectFormat::Json(JsonObjectType::WorkflowEnum), + gso, + ); + } + warp_graphql::generic_string_object::GenericStringObjectFormat::JsonAIFact => { + parse_server_gso::( + &mut updated_generic_string_objects, + GenericStringObjectFormat::Json(JsonObjectType::AIFact), + gso, + ); + } + warp_graphql::generic_string_object::GenericStringObjectFormat::JsonMCPServer => { + parse_server_gso::( + &mut updated_generic_string_objects, + GenericStringObjectFormat::Json(JsonObjectType::MCPServer), + gso, + ); + } + warp_graphql::generic_string_object::GenericStringObjectFormat::JsonAIExecutionProfile => { + parse_server_gso::( + &mut updated_generic_string_objects, + GenericStringObjectFormat::Json(JsonObjectType::AIExecutionProfile), + gso, + ); + } + warp_graphql::generic_string_object::GenericStringObjectFormat::JsonTemplatableMCPServer => { + parse_server_gso::( + &mut updated_generic_string_objects, + GenericStringObjectFormat::Json(JsonObjectType::TemplatableMCPServer), + gso, + ); + } + warp_graphql::generic_string_object::GenericStringObjectFormat::JsonCloudEnvironment => { + parse_server_gso::( + &mut updated_generic_string_objects, + GenericStringObjectFormat::Json(JsonObjectType::CloudEnvironment), + gso, + ); + } + warp_graphql::generic_string_object::GenericStringObjectFormat::JsonScheduledAmbientAgent => { + parse_server_gso::( + &mut updated_generic_string_objects, + GenericStringObjectFormat::Json(JsonObjectType::ScheduledAmbientAgent), + gso, + ); + } + // GSO formats unknown to this client build (e.g. the + // server-only `JsonRunner`) are skipped so syncing of + // known objects still succeeds instead of failing to + // decode the whole response. + warp_graphql::generic_string_object::GenericStringObjectFormat::Unknown => {} + } + } + } + + let deleted_notebooks: Vec = output + .deleted_object_uids + .notebook_uids + .map(|uids| { + uids.into_iter() + .map(|uid| uid.into_inner().into()) + .collect() + }) + .unwrap_or_default(); + + let deleted_workflows: Vec = output + .deleted_object_uids + .workflow_uids + .map(|uids| { + uids.into_iter() + .map(|uid| uid.into_inner().into()) + .collect() + }) + .unwrap_or_default(); + + let deleted_folders: Vec = output + .deleted_object_uids + .folder_uids + .map(|uids| { + uids.into_iter() + .map(|uid| uid.into_inner().into()) + .collect() + }) + .unwrap_or_default(); + + let deleted_generic_string_objects: Vec = output + .deleted_object_uids + .generic_string_object_uids + .map(|uids| { + uids.into_iter() + .map(|uid| uid.into_inner().into()) + .collect() + }) + .unwrap_or_default(); + + let user_profiles: Vec = output + .user_profiles + .map(|user_profiles| { + user_profiles + .into_iter() + .map(|profile| profile.into()) + .collect() + }) + .unwrap_or_default(); + + let action_histories: Vec = output + .action_histories + .map(|histories| { + histories + .into_iter() + .filter_map(|history| object_action_history_from_gql(history).ok()) + .collect() + }) + .unwrap_or_default(); + + let mcp_gallery = output.mcp_gallery.unwrap_or_default(); + + let response = InitialLoadResponse { + updated_notebooks, + deleted_notebooks, + updated_workflows, + deleted_workflows, + updated_folders, + deleted_folders, + updated_generic_string_objects, + deleted_generic_string_objects, + user_profiles, + action_histories, + mcp_gallery, + }; + Ok(response) + } + UpdatedCloudObjectsResult::UserFacingError(e) => { + Err(anyhow!(get_user_facing_error_message(e))) + } + UpdatedCloudObjectsResult::Unknown => Err(anyhow!( + "Failed to get updated cloud objects due to unknown variant" + )), + } + } +} + #[cfg_attr(not(target_family = "wasm"), async_trait)] #[cfg_attr(target_family = "wasm", async_trait(?Send))] impl ObjectClient for ServerApi { @@ -714,217 +952,8 @@ impl ObjectClient for ServerApi { objects_to_update: ObjectsToUpdate, force_refresh: bool, ) -> Result { - log::info!("fetching updated cloud objects"); - if force_refresh { - log::info!("forcing sync of all objects") - } - - let variables = GetUpdatedCloudObjectsVariables { - input: UpdatedCloudObjectsInput { - folders: Some(objects_to_update.folders), - force_refresh, - generic_string_objects: Some(objects_to_update.generic_string_objects), - notebooks: Some(objects_to_update.notebooks), - workflows: Some(objects_to_update.workflows), - }, - request_context: get_request_context(), - }; - - let operation = GetUpdatedCloudObjects::build(variables); - let response_data = self.send_graphql_request(operation, None).await?; - - match response_data.updated_cloud_objects { - UpdatedCloudObjectsResult::UpdatedCloudObjectsOutput(output) => { - let updated_notebooks = output - .notebooks - .map(|notebooks| { - notebooks - .into_iter() - .filter_map(|notebook| ServerNotebook::try_from_gql(notebook).ok()) - .collect() - }) - .unwrap_or_default(); - - let updated_workflows = output - .workflows - .map(|workflows| { - workflows - .into_iter() - .filter_map(|workflow| ServerWorkflow::try_from_gql(workflow).ok()) - .collect() - }) - .unwrap_or_default(); - - let updated_folders = output - .folders - .map(|folders| { - folders - .into_iter() - .filter_map(|folder| ServerFolder::try_from_gql(folder).ok()) - .collect() - }) - .unwrap_or_default(); - - let mut updated_generic_string_objects = HashMap::new(); - if let Some(objects) = output.generic_string_objects { - for gso in objects { - match gso.format { - warp_graphql::generic_string_object::GenericStringObjectFormat::JsonEnvVarCollection => { - parse_server_gso::( - &mut updated_generic_string_objects, - GenericStringObjectFormat::Json(JsonObjectType::EnvVarCollection), - gso, - ); - } - warp_graphql::generic_string_object::GenericStringObjectFormat::JsonPreference => { - parse_server_gso::( - &mut updated_generic_string_objects, - GenericStringObjectFormat::Json(JsonObjectType::Preference), - gso, - ); - } - warp_graphql::generic_string_object::GenericStringObjectFormat::JsonWorkflowEnum => { - parse_server_gso::( - &mut updated_generic_string_objects, - GenericStringObjectFormat::Json(JsonObjectType::WorkflowEnum), - gso, - ); - } - warp_graphql::generic_string_object::GenericStringObjectFormat::JsonAIFact => { - parse_server_gso::( - &mut updated_generic_string_objects, - GenericStringObjectFormat::Json(JsonObjectType::AIFact), - gso, - ); - } - warp_graphql::generic_string_object::GenericStringObjectFormat::JsonMCPServer => { - parse_server_gso::( - &mut updated_generic_string_objects, - GenericStringObjectFormat::Json(JsonObjectType::MCPServer), - gso, - ); - } - warp_graphql::generic_string_object::GenericStringObjectFormat::JsonAIExecutionProfile => { - parse_server_gso::( - &mut updated_generic_string_objects, - GenericStringObjectFormat::Json(JsonObjectType::AIExecutionProfile), - gso, - ); - } - warp_graphql::generic_string_object::GenericStringObjectFormat::JsonTemplatableMCPServer => { - parse_server_gso::( - &mut updated_generic_string_objects, - GenericStringObjectFormat::Json(JsonObjectType::TemplatableMCPServer), - gso, - ); - } - warp_graphql::generic_string_object::GenericStringObjectFormat::JsonCloudEnvironment => { - parse_server_gso::( - &mut updated_generic_string_objects, - GenericStringObjectFormat::Json(JsonObjectType::CloudEnvironment), - gso, - ); - } - warp_graphql::generic_string_object::GenericStringObjectFormat::JsonScheduledAmbientAgent => { - parse_server_gso::( - &mut updated_generic_string_objects, - GenericStringObjectFormat::Json(JsonObjectType::ScheduledAmbientAgent), - gso, - ); - } - // GSO formats unknown to this client build (e.g. the - // server-only `JsonRunner`) are skipped so syncing of - // known objects still succeeds instead of failing to - // decode the whole response. - warp_graphql::generic_string_object::GenericStringObjectFormat::Unknown => {} - } - } - } - - let deleted_notebooks: Vec = output - .deleted_object_uids - .notebook_uids - .map(|uids| { - uids.into_iter() - .map(|uid| uid.into_inner().into()) - .collect() - }) - .unwrap_or_default(); - - let deleted_workflows: Vec = output - .deleted_object_uids - .workflow_uids - .map(|uids| { - uids.into_iter() - .map(|uid| uid.into_inner().into()) - .collect() - }) - .unwrap_or_default(); - - let deleted_folders: Vec = output - .deleted_object_uids - .folder_uids - .map(|uids| { - uids.into_iter() - .map(|uid| uid.into_inner().into()) - .collect() - }) - .unwrap_or_default(); - - let deleted_generic_string_objects: Vec = output - .deleted_object_uids - .generic_string_object_uids - .map(|uids| { - uids.into_iter() - .map(|uid| uid.into_inner().into()) - .collect() - }) - .unwrap_or_default(); - - let user_profiles: Vec = output - .user_profiles - .map(|user_profiles| { - user_profiles - .into_iter() - .map(|profile| profile.into()) - .collect() - }) - .unwrap_or_default(); - - let action_histories: Vec = output - .action_histories - .map(|histories| { - histories - .into_iter() - .filter_map(|history| object_action_history_from_gql(history).ok()) - .collect() - }) - .unwrap_or_default(); - - let mcp_gallery = output.mcp_gallery.unwrap_or_default(); - - let response = InitialLoadResponse { - updated_notebooks, - deleted_notebooks, - updated_workflows, - deleted_workflows, - updated_folders, - deleted_folders, - updated_generic_string_objects, - deleted_generic_string_objects, - user_profiles, - action_histories, - mcp_gallery, - }; - Ok(response) - } - UpdatedCloudObjectsResult::UserFacingError(e) => { - Err(anyhow!(get_user_facing_error_message(e))) - } - UpdatedCloudObjectsResult::Unknown => Err(anyhow!( - "Failed to get updated cloud objects due to unknown variant" - )), - } + self.fetch_changed_objects_with_scope(objects_to_update, force_refresh, None) + .await } async fn fetch_single_cloud_object(&self, id: ServerId) -> Result { diff --git a/app/src/server/server_api_tests.rs b/app/src/server/server_api_tests.rs index 44afb5de291..f5026b2e509 100644 --- a/app/src/server/server_api_tests.rs +++ b/app/src/server/server_api_tests.rs @@ -1,8 +1,83 @@ +use std::borrow::Cow; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use cynic::GraphQlResponse; use futures::executor::block_on; use mockito::Server; +use warp_graphql::client::{GraphQLError, Operation, RequestOptions}; +use warp_server_client::base_client::TEAM_UID_HEADER; use super::*; +use crate::server::ids::ServerId; use crate::server::retry_strategies::is_transient_http_error; +use crate::server::team_scope::RequestTeamScope; +use crate::workspaces::user_workspaces::{TeamContextForOperation, TeamlessScopeForTest}; + +struct HeaderCapturingOperation { + expected_team_uid: Option, +} + +impl Operation<()> for HeaderCapturingOperation { + fn operation_name(&self) -> Option> { + Some(Cow::Borrowed("HeaderCapturingOperation")) + } + + fn send_request( + self, + _client: Arc, + options: RequestOptions, + ) -> Pin< + Box< + dyn Future, GraphQLError>> + + Send + + 'static, + >, + > { + Box::pin(async move { + assert_eq!( + options.headers.get(TEAM_UID_HEADER), + self.expected_team_uid.as_ref() + ); + Ok(GraphQlResponse { + data: Some(()), + errors: None, + }) + }) + } +} + +#[test] +fn team_scoped_graphql_request_sends_selected_team_header() { + let team_uid = ServerId::from(123); + let team_scope = RequestTeamScope::from_scope(&TeamContextForOperation::new_for_test(team_uid)); + let server_api = ServerApi::new_for_test(); + + block_on(server_api.send_graphql_request_with_options_and_team_scope( + HeaderCapturingOperation { + expected_team_uid: Some(team_uid.uid().to_string()), + }, + RequestOptions::default(), + team_scope, + )) + .unwrap(); +} + +#[test] +fn team_scoped_graphql_request_omits_header_for_teamless_scope() { + let team_scope = RequestTeamScope::from_scope(&TeamlessScopeForTest); + let server_api = ServerApi::new_for_test(); + + block_on(server_api.send_graphql_request_with_options_and_team_scope( + HeaderCapturingOperation { + expected_team_uid: None, + }, + RequestOptions::default(), + team_scope, + )) + .unwrap(); +} /// Sends a GET request to a mock endpoint returning `status`/`headers`/`body`, then feeds the /// resulting response through [`ServerApi::error_from_response`]. diff --git a/crates/warp_server_client/src/graphql_helpers.rs b/crates/warp_server_client/src/graphql_helpers.rs index a5f0e09d026..9738e30d4c7 100644 --- a/crates/warp_server_client/src/graphql_helpers.rs +++ b/crates/warp_server_client/src/graphql_helpers.rs @@ -28,7 +28,8 @@ where }) } -async fn send_graphql_request_with_options( +/// Sends a GraphQL operation with fully constructed request options. +pub async fn send_graphql_request_with_options( base_client: &BaseClient, operation: O, options: RequestOptions, From 0518ee025fb5980c0e0cb7a2539a709cda9af980 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:38:59 +0000 Subject: [PATCH 5/7] fix schedule team-scoped cache refresh --- app/src/ai/agent_sdk/common.rs | 2 +- app/src/ai/agent_sdk/schedule.rs | 20 +++-- app/src/cloud_object/model/model_tests.rs | 61 ++++++++++++- app/src/cloud_object/model/persistence.rs | 25 ++++++ .../server/cloud_objects/update_manager.rs | 29 ++++++- .../cloud_objects/update_manager_tests.rs | 85 +++++++++++++++++++ app/src/server/team_scope.rs | 15 ++++ 7 files changed, 226 insertions(+), 11 deletions(-) diff --git a/app/src/ai/agent_sdk/common.rs b/app/src/ai/agent_sdk/common.rs index 5ed1c6438d9..d92d0786188 100644 --- a/app/src/ai/agent_sdk/common.rs +++ b/app/src/ai/agent_sdk/common.rs @@ -274,7 +274,7 @@ pub fn refresh_warp_drive_for_scope( ctx: &AppContext, ) -> impl Future> + Send + 'static + use<> { let server_api = ServerApiProvider::as_ref(ctx).get(); - let objects_to_update = CloudModel::as_ref(ctx).get_versions_for_all_objects(ctx); + let objects_to_update = CloudModel::as_ref(ctx).get_versions_for_request_scope(team_scope, ctx); async move { server_api .fetch_changed_objects_for_scope(objects_to_update, false, team_scope) diff --git a/app/src/ai/agent_sdk/schedule.rs b/app/src/ai/agent_sdk/schedule.rs index ddf628137e1..8a520796207 100644 --- a/app/src/ai/agent_sdk/schedule.rs +++ b/app/src/ai/agent_sdk/schedule.rs @@ -69,10 +69,12 @@ fn create(ctx: &mut AppContext, args: CreateScheduleArgs) -> anyhow::Result<()> return; } }; - crate::server::cloud_objects::update_manager::UpdateManager::handle(ctx) - .update(ctx, |update_manager, ctx| { - update_manager.apply_scoped_refresh(response, ctx) - }); + crate::server::cloud_objects::update_manager::UpdateManager::handle(ctx).update( + ctx, + |update_manager, ctx| { + update_manager.apply_scoped_refresh(response, request_team_scope, ctx) + }, + ); let loaded_file = match args.config_file.file.as_deref() { Some(path) => match super::config_file::load_config_file(path) { @@ -606,10 +608,12 @@ fn list( return; } }; - crate::server::cloud_objects::update_manager::UpdateManager::handle(ctx) - .update(ctx, |update_manager, ctx| { - update_manager.apply_scoped_refresh(response, ctx) - }); + crate::server::cloud_objects::update_manager::UpdateManager::handle(ctx).update( + ctx, + |update_manager, ctx| { + update_manager.apply_scoped_refresh(response, request_team_scope, ctx) + }, + ); let mut schedules: Vec<_> = manager .list_schedules(ctx) diff --git a/app/src/cloud_object/model/model_tests.rs b/app/src/cloud_object/model/model_tests.rs index c97c9eb9ea6..67ef069976c 100644 --- a/app/src/cloud_object/model/model_tests.rs +++ b/app/src/cloud_object/model/model_tests.rs @@ -34,6 +34,7 @@ use crate::server::server_api::object::ObjectClient; use crate::server::server_api::team::MockTeamClient; use crate::server::server_api::workspace::MockWorkspaceClient; use crate::server::sync_queue::SyncQueue; +use crate::server::team_scope::RequestTeamScope; use crate::server::telemetry::context_provider::AppTelemetryContextProvider; use crate::settings::{Preference, init_and_register_user_preferences}; use crate::system::SystemStats; @@ -41,7 +42,9 @@ use crate::workflows::CloudWorkflowModel; use crate::workspaces::team::Team; use crate::workspaces::team_tester::TeamTesterStatus; use crate::workspaces::user_profiles::UserProfiles; -use crate::workspaces::user_workspaces::UserWorkspaces; +use crate::workspaces::user_workspaces::{ + TeamContextForOperation, TeamlessScopeForTest, UserWorkspaces, +}; use crate::workspaces::workspace::{Workspace, WorkspaceUid}; use crate::{NetworkStatus, UpdateManager}; @@ -1254,6 +1257,62 @@ fn test_breadcrumbs() { }); } +#[test] +fn request_scope_versions_include_only_personal_and_selected_team_objects() { + App::test((), |mut app| async move { + let selected_team_uid = ServerId::from(7); + let objects = mock_server_workflows(1, Owner::mock_current_user(), 1) + .into_iter() + .chain(mock_server_workflows( + 2, + Owner::Team { + team_uid: selected_team_uid, + }, + 1, + )) + .chain(mock_server_workflows( + 3, + Owner::Team { + team_uid: ServerId::from(8), + }, + 1, + )) + .map(|workflow| { + Box::new(CloudWorkflow::new_from_server(workflow)) as Box + }) + .collect(); + app.add_singleton_model(|_| ObjectActions::new(Vec::new())); + let cloud_model = create_cloud_model(&mut app, objects); + + let selected_scope = + RequestTeamScope::from_scope(&TeamContextForOperation::new_for_test(selected_team_uid)); + let mut selected_uids = cloud_model.read(&app, |model, ctx| { + model + .get_versions_for_request_scope(selected_scope, ctx) + .workflows + .into_iter() + .map(|workflow| workflow.uid.into_inner()) + .collect::>() + }); + selected_uids.sort(); + assert_eq!( + selected_uids, + vec![ServerId::from(1).uid(), ServerId::from(2).uid()] + ); + + let personal_scope = RequestTeamScope::from_scope(&TeamlessScopeForTest); + let personal_uids = cloud_model.read(&app, |model, ctx| { + model + .get_versions_for_request_scope(personal_scope, ctx) + .workflows + .into_iter() + .map(|workflow| workflow.uid.into_inner()) + .collect::>() + }); + assert_eq!(personal_uids, vec![ServerId::from(1).uid()]); + }); +} + /// Asserts that the object with the given ID has the expected sorting timestamp. #[track_caller] fn assert_sorting_timestamp(id: ServerId, expected_ts: impl Into, app: &App) { diff --git a/app/src/cloud_object/model/persistence.rs b/app/src/cloud_object/model/persistence.rs index c9aff9af416..b625fcb887c 100644 --- a/app/src/cloud_object/model/persistence.rs +++ b/app/src/cloud_object/model/persistence.rs @@ -27,6 +27,7 @@ use crate::env_vars::{CloudEnvVarCollection, CloudEnvVarCollectionModel, EnvVarC use crate::notebooks::CloudNotebook; use crate::persistence::ModelEvent; use crate::server::ids::{ClientId, HashableId, ObjectUid, ServerId, SyncId, ToServerId}; +use crate::server::team_scope::RequestTeamScope; use crate::settings::cloud_preferences::{CloudPreference, CloudPreferenceModel}; use crate::workflows::workflow::Workflow; use crate::workflows::workflow_enum::{CloudWorkflowEnum, CloudWorkflowEnumModel, WorkflowEnum}; @@ -1373,6 +1374,30 @@ impl CloudModel { objects_to_update } + pub(crate) fn get_versions_for_request_scope( + &self, + request_scope: RequestTeamScope, + app: &AppContext, + ) -> ObjectsToUpdate { + let mut objects_to_update = ObjectsToUpdate::default(); + for (versions, object_type) in self + .objects_by_id + .values() + .filter(|object| request_scope.includes_owner_for_sync(object.permissions().owner)) + .filter_map(|object| object.versions(app).zip(Some(object.object_type()))) + { + match object_type { + ObjectType::Notebook => objects_to_update.notebooks.push(versions), + ObjectType::Workflow => objects_to_update.workflows.push(versions), + ObjectType::Folder => objects_to_update.folders.push(versions), + ObjectType::GenericStringObject(_) => { + objects_to_update.generic_string_objects.push(versions) + } + } + } + objects_to_update + } + pub fn get_notebook(&self, notebook_id: &SyncId) -> Option<&CloudNotebook> { self.objects_by_id .get(¬ebook_id.uid()) diff --git a/app/src/server/cloud_objects/update_manager.rs b/app/src/server/cloud_objects/update_manager.rs index 2cbb6fe88e1..27aa9195081 100644 --- a/app/src/server/cloud_objects/update_manager.rs +++ b/app/src/server/cloud_objects/update_manager.rs @@ -82,6 +82,7 @@ use crate::server::server_api::object::{GuestIdentifier, ObjectClient}; use crate::server::sync_queue::{ CreationFailureReason, GenericStringObjectToCreate, QueueItem, SyncQueue, SyncQueueEvent, }; +use crate::server::team_scope::RequestTeamScope; use crate::settings::cloud_preferences::Preference; use crate::workflows::workflow::Workflow; use crate::workflows::workflow_enum::{CloudWorkflowEnum, CloudWorkflowEnumModel, WorkflowEnum}; @@ -1101,12 +1102,38 @@ impl UpdateManager { pub(crate) fn apply_scoped_refresh( &mut self, - response: InitialLoadResponse, + mut response: InitialLoadResponse, + request_scope: RequestTeamScope, ctx: &mut ModelContext, ) { + let cloud_model = CloudModel::as_ref(ctx); + Self::retain_scoped_deletions(&mut response.deleted_notebooks, request_scope, cloud_model); + Self::retain_scoped_deletions(&mut response.deleted_workflows, request_scope, cloud_model); + Self::retain_scoped_deletions(&mut response.deleted_folders, request_scope, cloud_model); + Self::retain_scoped_deletions( + &mut response.deleted_generic_string_objects, + request_scope, + cloud_model, + ); self.on_changed_objects_fetched(response, false, ctx); } + fn retain_scoped_deletions( + deleted_objects: &mut Vec, + request_scope: RequestTeamScope, + cloud_model: &CloudModel, + ) where + K: ToServerId, + { + deleted_objects.retain(|id| { + cloud_model + .get_by_uid(&id.to_server_id().uid()) + .is_some_and(|object| { + request_scope.allows_scoped_deletion(object.permissions().owner) + }) + }); + } + fn handle_team_memberships_changed(&mut self, ctx: &mut ModelContext) { // Immediately check for updates in workspace metadata TeamUpdateManager::handle(ctx).update(ctx, |manager, ctx| { diff --git a/app/src/server/cloud_objects/update_manager_tests.rs b/app/src/server/cloud_objects/update_manager_tests.rs index 4a394312825..9eaf4229516 100644 --- a/app/src/server/cloud_objects/update_manager_tests.rs +++ b/app/src/server/cloud_objects/update_manager_tests.rs @@ -50,6 +50,7 @@ use crate::server::ids::{ ClientId, HashableId, ObjectUid, ServerId, ServerIdAndType, SyncId, ToServerId, }; use crate::server::sync_queue::SyncQueue; +use crate::server::team_scope::RequestTeamScope; use crate::settings::{CloudPreferenceModel, Preference}; use crate::workflows::workflow::{Argument, ArgumentType, Workflow}; use crate::workflows::workflow_enum::{ @@ -57,6 +58,7 @@ use crate::workflows::workflow_enum::{ }; use crate::workflows::{CloudWorkflow, CloudWorkflowModel, WorkflowId}; use crate::workspaces::user_profiles::{UserProfileWithUID, UserProfiles}; +use crate::workspaces::user_workspaces::TeamContextForOperation; fn create_object( app: &mut App, @@ -5786,6 +5788,89 @@ fn test_move_object_personal_to_team_failure() { }); } +#[test] +fn scoped_refresh_deletes_only_selected_team_objects() { + App::test(ASSETS, |mut app| async move { + initialize_app(&mut app); + let update_manager_struct = + create_update_manager_struct(&mut app, Arc::new(mock_server_api())); + let selected_team_uid = ServerId::from(7); + let personal_id: WorkflowId = 1.into(); + let selected_team_id: WorkflowId = 2.into(); + let other_team_id: WorkflowId = 3.into(); + + CloudModel::handle(&app).update(&mut app, |cloud_model, _| { + for (id, owner) in [ + (personal_id, Owner::mock_current_user()), + ( + selected_team_id, + Owner::Team { + team_uid: selected_team_uid, + }, + ), + ( + other_team_id, + Owner::Team { + team_uid: ServerId::from(8), + }, + ), + ] { + cloud_model.add_object( + SyncId::from_object_id(id), + CloudWorkflow::new_from_server(mock_server_workflow( + id, + owner, + ServerMetadata { + uid: id.into(), + revision: Revision::now(), + metadata_last_updated_ts: Utc::now().into(), + trashed_ts: None, + folder_id: None, + is_welcome_object: false, + creator_uid: None, + last_editor_uid: None, + current_editor_uid: None, + }, + )), + ); + } + }); + + let request_scope = + RequestTeamScope::from_scope(&TeamContextForOperation::new_for_test(selected_team_uid)); + update_manager_struct + .update_manager + .update(&mut app, |update_manager, ctx| { + update_manager.apply_scoped_refresh( + InitialLoadResponse { + deleted_workflows: vec![personal_id, selected_team_id, other_team_id], + ..Default::default() + }, + request_scope, + ctx, + ); + }); + + CloudModel::handle(&app).read(&app, |cloud_model, _| { + assert!( + cloud_model + .get_workflow(&SyncId::from_object_id(personal_id)) + .is_some() + ); + assert!( + cloud_model + .get_workflow(&SyncId::from_object_id(selected_team_id)) + .is_none() + ); + assert!( + cloud_model + .get_workflow(&SyncId::from_object_id(other_team_id)) + .is_some() + ); + }); + }); +} + /// Test successfully moving a Cloud Environment (generic string object) from a user's personal /// space to a team drive. #[test] diff --git a/app/src/server/team_scope.rs b/app/src/server/team_scope.rs index b8179f7d7fe..da1bf844846 100644 --- a/app/src/server/team_scope.rs +++ b/app/src/server/team_scope.rs @@ -1,3 +1,4 @@ +use crate::cloud_object::Owner; use crate::server::ids::ServerId; use crate::workspaces::user_workspaces::TeamScope; @@ -25,4 +26,18 @@ impl RequestTeamScope { pub(crate) fn team_uid(self) -> Option { self.0 } + + pub(crate) fn includes_owner_for_sync(self, owner: Owner) -> bool { + match owner { + Owner::User { .. } => true, + Owner::Team { team_uid } => self.0 == Some(team_uid), + } + } + + pub(crate) fn allows_scoped_deletion(self, owner: Owner) -> bool { + match owner { + Owner::User { .. } => false, + Owner::Team { team_uid } => self.0 == Some(team_uid), + } + } } From 1f6998199188a8361f5390796f62eb4ba7ff2730 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:48:36 +0000 Subject: [PATCH 6/7] docs clarify request team scope contract --- app/src/server/team_scope.rs | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/app/src/server/team_scope.rs b/app/src/server/team_scope.rs index da1bf844846..e35c48e3ae5 100644 --- a/app/src/server/team_scope.rs +++ b/app/src/server/team_scope.rs @@ -2,18 +2,7 @@ use crate::cloud_object::Owner; use crate::server::ids::ServerId; use crate::workspaces::user_workspaces::TeamScope; -/// The team an outbound request is scoped to, as sent in `X-Warp-Team-Uid`. -/// -/// A [`TeamScope`] is the only way to name one; there is no constructor from a bare `ServerId`, -/// because a loose uid cannot say which team resolved it, and "no team" is a scope's answer to -/// give rather than a value to pass. The field is private to this module and this module holds -/// nothing else, so [`Self::from_scope`] is provably the only way to build one -- anything else -/// added here gains the ability to forge a team. -/// -/// `Copy`, unlike the [`TeamScope`] types it comes from -- those are deliberately not, so a live -/// scope cannot be stashed where it outlives its window. A resolved snapshot has no such hazard, -/// so `ResponseStream` can reuse one capture across every retry rather than re-resolving to -/// whatever team its window switched to since. +/// The validated team scope for an outbound request, sent in `X-Warp-Team-Uid` when selected. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RequestTeamScope(Option); From 278f67bc691b9960168cbffe65f4989a921b3c82 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:18:23 +0000 Subject: [PATCH 7/7] Narrow schedule and environment scope resolution --- app/src/ai/agent_sdk/ambient.rs | 3 +- app/src/ai/agent_sdk/common.rs | 12 +-- app/src/ai/agent_sdk/common_tests.rs | 6 +- app/src/ai/agent_sdk/schedule.rs | 17 +++- app/src/ai/agent_sdk/schedule_tests.rs | 89 ++++++++++++++++++- .../team_workspace_settings.rs | 14 +-- .../user_workspaces/user_workspaces_tests.rs | 24 ----- 7 files changed, 114 insertions(+), 51 deletions(-) diff --git a/app/src/ai/agent_sdk/ambient.rs b/app/src/ai/agent_sdk/ambient.rs index 898ac9c5fde..6c45a4452c3 100644 --- a/app/src/ai/agent_sdk/ambient.rs +++ b/app/src/ai/agent_sdk/ambient.rs @@ -387,7 +387,8 @@ impl AmbientAgentRunner { vec![] }; - let team_scope = match super::common::resolve_object_scope(&args.scope, ctx) { + let team_scope = + match super::common::resolve_environment_team_scope(&args.scope, ctx) { Ok(team_scope) => team_scope, Err(err) => { super::report_fatal_error(err, ctx); diff --git a/app/src/ai/agent_sdk/common.rs b/app/src/ai/agent_sdk/common.rs index 51ebf2fa1d0..2c5b9aef0eb 100644 --- a/app/src/ai/agent_sdk/common.rs +++ b/app/src/ai/agent_sdk/common.rs @@ -159,13 +159,15 @@ pub(super) fn resolve_team_scope( .map_err(|err| describe_team_resolution_error(err, ctx)) } -pub(super) fn resolve_object_scope( - object_scope: &ObjectScope, +pub(super) fn resolve_environment_team_scope( + scope: &ObjectScope, ctx: &AppContext, ) -> anyhow::Result { - UserWorkspaces::as_ref(ctx) - .team_scope_for_cli_object(object_scope) - .map_err(|err| describe_team_resolution_error(err, ctx)) + if scope.personal { + Ok(TeamScopeForCli::Personal) + } else { + resolve_team_scope(&scope.team_selection, ctx) + } } pub(super) fn validate_agent_mode_base_model_id_for_scope( diff --git a/app/src/ai/agent_sdk/common_tests.rs b/app/src/ai/agent_sdk/common_tests.rs index 9a17f0777b5..b757836d513 100644 --- a/app/src/ai/agent_sdk/common_tests.rs +++ b/app/src/ai/agent_sdk/common_tests.rs @@ -7,7 +7,7 @@ use warpui::App; use super::{ EnvironmentChoice, classify_agent_mode_base_model_id, environment_is_visible_to_scope, - parse_ambient_task_id, resolve_object_scope, validate_agent_mode_base_model_id, + parse_ambient_task_id, resolve_environment_team_scope, validate_agent_mode_base_model_id, validate_agent_mode_base_model_id_for_scope, }; use crate::LaunchMode; @@ -120,7 +120,7 @@ fn multi_team_personal_scope_includes_only_personal_environments() { ); }); let implicit_scope = app.read(|ctx| { - resolve_object_scope( + resolve_environment_team_scope( &ObjectScope { team_selection: TeamSelection { team: None }, personal: false, @@ -130,7 +130,7 @@ fn multi_team_personal_scope_includes_only_personal_environments() { }); let personal_scope = app .read(|ctx| { - resolve_object_scope( + resolve_environment_team_scope( &ObjectScope { team_selection: TeamSelection { team: None }, personal: true, diff --git a/app/src/ai/agent_sdk/schedule.rs b/app/src/ai/agent_sdk/schedule.rs index 8e7ed3ed4c9..f4d61dc6ba5 100644 --- a/app/src/ai/agent_sdk/schedule.rs +++ b/app/src/ai/agent_sdk/schedule.rs @@ -8,7 +8,7 @@ use warp_cli::schedule::{ CreateScheduleArgs, DeleteScheduleArgs, GetScheduleArgs, PauseScheduleArgs, ScheduleCommand, ScheduleSubcommand, UnpauseScheduleArgs, UpdateScheduleArgs, }; -use warp_cli::scope::TeamSelection; +use warp_cli::scope::{ObjectScope, TeamSelection}; use warp_graphql::queries::get_scheduled_agent_history::ScheduledAgentHistory; use warpui::platform::TerminationMode; use warpui::{AppContext, SingletonEntity}; @@ -22,7 +22,7 @@ use crate::ai::ambient_agents::scheduled::{ use crate::cloud_object::{CloudObject, CloudObjectLookup as _}; use crate::server::ids::{ServerId, SyncId}; use crate::util::time_format::format_approx_duration_from_now_utc; -use crate::workspaces::user_workspaces::TeamScope; +use crate::workspaces::user_workspaces::{TeamScope, TeamScopeForCli}; /// Run a scheduled agent command. pub fn run( @@ -50,7 +50,7 @@ fn create(ctx: &mut AppContext, args: CreateScheduleArgs) -> anyhow::Result<()> super::report_fatal_error(err, ctx); return; } - let team_scope = match super::common::resolve_object_scope(&args.scope, ctx) { + let team_scope = match resolve_schedule_team_scope(&args.scope, ctx) { Ok(team_scope) => team_scope, Err(err) => { super::report_fatal_error(err, ctx); @@ -199,6 +199,17 @@ fn create(ctx: &mut AppContext, args: CreateScheduleArgs) -> anyhow::Result<()> Ok(()) } +fn resolve_schedule_team_scope( + scope: &ObjectScope, + ctx: &AppContext, +) -> anyhow::Result { + if scope.personal { + Ok(TeamScopeForCli::Personal) + } else { + super::common::resolve_team_scope(&scope.team_selection, ctx) + } +} + #[derive(Serialize)] struct ScheduleInfo { id: String, diff --git a/app/src/ai/agent_sdk/schedule_tests.rs b/app/src/ai/agent_sdk/schedule_tests.rs index 6b37b03c6ac..ea46c02298f 100644 --- a/app/src/ai/agent_sdk/schedule_tests.rs +++ b/app/src/ai/agent_sdk/schedule_tests.rs @@ -1,10 +1,17 @@ -use super::schedule_is_visible_to_scope; +use warp_cli::scope::{ObjectScope, TeamSelection}; +use warpui::App; + +use super::{resolve_schedule_team_scope, schedule_is_visible_to_scope}; use crate::ai::ambient_agents::scheduled::{ CloudScheduledAmbientAgent, CloudScheduledAmbientAgentModel, ScheduledAmbientAgent, }; use crate::cloud_object::{CloudObjectMetadata, CloudObjectPermissions, Owner}; use crate::server::ids::{ServerId, SyncId}; -use crate::workspaces::user_workspaces::{TeamContextForOperation, TeamlessScopeForTest}; +use crate::settings::PrivacySettings; +use crate::test_util::settings::initialize_settings_for_tests; +use crate::workspaces::user_workspaces::{ + TeamContextForOperation, TeamScope, TeamlessScopeForTest, UserWorkspaces, +}; fn schedule_with_owner(id: i64, owner: Owner) -> CloudScheduledAmbientAgent { let mut permissions = CloudObjectPermissions::mock_personal(); @@ -22,6 +29,84 @@ fn schedule_with_owner(id: i64, owner: Owner) -> CloudScheduledAmbientAgent { ) } +#[test] +fn schedule_scope_resolves_team_personal_and_ambiguity() { + App::test((), |mut app| async move { + initialize_settings_for_tests(&mut app); + app.add_singleton_model(PrivacySettings::mock); + let user_workspaces = app.add_singleton_model(UserWorkspaces::default_mock); + user_workspaces.update(&mut app, |user_workspaces, ctx| { + user_workspaces.setup_test_workspace(ctx); + user_workspaces.update_current_workspace( + |workspace| { + let mut second_team = workspace.teams[0].clone(); + second_team.uid = ServerId::from(456); + second_team.name = "Second team".to_string(); + workspace.teams.push(second_team); + }, + ctx, + ); + }); + let selected_team_uid = user_workspaces.read(&app, |user_workspaces, _| { + user_workspaces.current_workspace().unwrap().teams[0].uid + }); + + app.read(|ctx| { + let team_scope = resolve_schedule_team_scope( + &ObjectScope { + team_selection: TeamSelection { + team: Some(Some(selected_team_uid.to_string())), + }, + personal: false, + }, + ctx, + ) + .expect("an explicit member team should resolve"); + assert_eq!(team_scope.team_uid(), Some(selected_team_uid)); + + let personal_scope = resolve_schedule_team_scope( + &ObjectScope { + team_selection: TeamSelection { team: None }, + personal: true, + }, + ctx, + ) + .expect("explicit personal scope should not require a sole team"); + assert_eq!(personal_scope.team_uid(), None); + + let ambiguous_scope = resolve_schedule_team_scope( + &ObjectScope { + team_selection: TeamSelection { team: None }, + personal: false, + }, + ctx, + ); + assert!(ambiguous_scope.is_err()); + }); + }); +} + +#[test] +fn schedule_scope_is_teamless_without_teams() { + App::test((), |mut app| async move { + initialize_settings_for_tests(&mut app); + app.add_singleton_model(PrivacySettings::mock); + app.add_singleton_model(UserWorkspaces::default_mock); + + app.read(|ctx| { + let scope = resolve_schedule_team_scope( + &ObjectScope { + team_selection: TeamSelection { team: None }, + personal: false, + }, + ctx, + ) + .expect("implicit scope should be teamless when the user has no teams"); + assert_eq!(scope.team_uid(), None); + }); + }); +} + #[test] fn schedule_scope_includes_personal_and_matching_team_schedules() { let selected_team_uid = ServerId::from(123); diff --git a/app/src/workspaces/user_workspaces/team_workspace_settings.rs b/app/src/workspaces/user_workspaces/team_workspace_settings.rs index 76d4ce2484b..d52ece239df 100644 --- a/app/src/workspaces/user_workspaces/team_workspace_settings.rs +++ b/app/src/workspaces/user_workspaces/team_workspace_settings.rs @@ -18,7 +18,7 @@ use std::sync::OnceLock; use regex::Regex; use settings::Setting; #[cfg(not(target_family = "wasm"))] -use warp_cli::scope::{ObjectScope, TeamSelection}; +use warp_cli::scope::TeamSelection; use warp_core::features::FeatureFlag; use warpui::{AppContext, Entity, SingletonEntity, ViewContext, WeakViewHandle, WindowId}; @@ -240,18 +240,6 @@ impl UserWorkspaces { }) } - #[cfg(not(target_family = "wasm"))] - pub(crate) fn team_scope_for_cli_object( - &self, - object_scope: &ObjectScope, - ) -> Result { - if object_scope.personal { - Ok(TeamScopeForCli::Personal) - } else { - self.team_scope_for_cli(&object_scope.team_selection) - } - } - pub(crate) fn team_context_for_view(&self, ctx: &ViewContext) -> TeamContext<'_> { self.team_context_for_window_id(ctx.window_id()) } diff --git a/app/src/workspaces/user_workspaces/user_workspaces_tests.rs b/app/src/workspaces/user_workspaces/user_workspaces_tests.rs index 838afe1efb1..2cead747334 100644 --- a/app/src/workspaces/user_workspaces/user_workspaces_tests.rs +++ b/app/src/workspaces/user_workspaces/user_workspaces_tests.rs @@ -944,13 +944,6 @@ fn team_selection(team: Option>) -> warp_cli::scope::TeamSelectio warp_cli::scope::TeamSelection { team } } -fn object_scope(team: Option>, personal: bool) -> warp_cli::scope::ObjectScope { - warp_cli::scope::ObjectScope { - team_selection: team_selection(team), - personal, - } -} - #[test] fn cli_scope_without_selection_is_teamless_without_teams() { App::test((), |mut app| async move { @@ -1002,23 +995,6 @@ fn cli_scope_without_selection_rejects_multiple_teams() { }) } -#[test] -fn cli_object_scope_personal_is_teamless_with_multiple_teams() { - let (first_team, second_team) = two_teams(); - App::test((), |mut app| async move { - initialize_window_team_test_app( - &mut app, - vec![workspace_for_teams(vec![first_team, second_team])], - ); - - app.read(|ctx| { - let scope = UserWorkspaces::as_ref(ctx) - .team_scope_for_cli_object(&object_scope(None, true)) - .expect("explicit personal scope should not require a team"); - assert!(matches!(scope, TeamScopeForCli::Personal)); - }); - }) -} #[test] fn cli_scope_bare_team_requires_a_sole_team() { App::test((), |mut app| async move {