diff --git a/app/src/ai/agent_sdk/ambient.rs b/app/src/ai/agent_sdk/ambient.rs index 549dd2c9976..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); @@ -404,12 +405,12 @@ 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 - }, + } Ok(EnvironmentChoice::Environment { id, .. }) => Some(id), Err(ResolveConfigurationError::Canceled) => { ctx.terminate_app(TerminationMode::ForceTerminate, None); diff --git a/app/src/ai/agent_sdk/common.rs b/app/src/ai/agent_sdk/common.rs index 0c47bbf37ef..2c5b9aef0eb 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, @@ -157,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( @@ -222,6 +226,15 @@ pub fn resolve_owner(scope: &ObjectScope, ctx: &AppContext) -> anyhow::Result bool { + match environment.permissions().owner { + Owner::User { .. } => true, + Owner::Team { team_uid } => team_scope.team_uid() == Some(team_uid), + } +} pub(super) fn resolve_owner_for_team_scope( team_scope: &impl TeamScope, ctx: &AppContext, @@ -268,6 +281,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_request_scope(team_scope, 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). @@ -336,10 +365,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 { @@ -351,6 +381,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 098ca3d9079..b757836d513 100644 --- a/app/src/ai/agent_sdk/common_tests.rs +++ b/app/src/ai/agent_sdk/common_tests.rs @@ -1,13 +1,19 @@ use std::collections::HashMap; use std::sync::Arc; +use warp_cli::environment::EnvironmentCreateArgs; +use warp_cli::scope::{ObjectScope, TeamSelection}; 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, resolve_environment_team_scope, validate_agent_mode_base_model_id, validate_agent_mode_base_model_id_for_scope, }; 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, @@ -17,21 +23,186 @@ 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; +use crate::server::ids::{ServerId, SyncId}; use crate::server::server_api::ServerApiProvider; use crate::server::server_api::team::MockTeamClient; use crate::server::server_api::workspace::MockWorkspaceClient; use crate::server::sync_queue::SyncQueue; +use crate::settings::PrivacySettings; use crate::test_util::settings::initialize_settings_for_tests; use crate::workspaces::team::{Team, TeamVisibility}; use crate::workspaces::team_tester::TeamTesterStatus; use crate::workspaces::user_workspaces::{ - TeamContextForOperation, TeamlessScopeForTest, UserWorkspaces, + TeamContextForOperation, TeamScope, TeamlessScopeForTest, UserWorkspaces, }; use crate::workspaces::workspace::{Workspace, WorkspaceUid}; +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 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!(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] +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() { let task_id = 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 ed7fe3eff3c..0376968b9b8 100644 --- a/app/src/ai/agent_sdk/mod.rs +++ b/app/src/ai/agent_sdk/mod.rs @@ -1636,7 +1636,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, @@ -1862,7 +1862,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 } @@ -1931,7 +1933,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 2e1b2ba815d..f4d61dc6ba5 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::{ObjectScope, 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, TeamScopeForCli}; /// 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), @@ -42,55 +44,13 @@ 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; } - - 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 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 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); @@ -104,76 +64,134 @@ fn create(ctx: &mut AppContext, args: CreateScheduleArgs) -> anyhow::Result<()> return; } }; - - let cli_mcp_servers = - match super::mcp_config::build_mcp_servers_from_specs(&args.mcp_specs) { - Ok(mcp_servers) => mcp_servers, + 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, request_team_scope, 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 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, - &team_scope, - ctx, - ) - }) - .transpose() - { - Ok(id) => id.map(|id| id.to_string()), - Err(err) => { - super::report_fatal_error(anyhow::anyhow!(err), ctx); - return; + 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 agent_config = merged_config; - agent_config.model_id = model_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; + } + 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 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; + } + }; - // 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); - } + 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, + &team_scope, + ctx, + ) + }) + .transpose() + { + Ok(id) => id.map(|id| id.to_string()), + Err(err) => { + super::report_fatal_error(anyhow::anyhow!(err), ctx); + return; + } + }; + + 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); + } + }); }); }); }); @@ -181,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, @@ -547,55 +576,100 @@ 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 warp_drive_sync_future = super::common::refresh_warp_drive(ctx); - ctx.spawn(warp_drive_sync_future, move |manager, result, ctx| { + 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; } - - let mut schedules = manager.list_schedules(ctx); - 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 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 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, request_team_scope, 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 output_format = output_format; - ctx.spawn( - futures::future::join_all(futures), - move |_manager, infos, ctx| { - output::print_list(infos, output_format); + 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); - }, - ); + ctx.terminate_app(TerminationMode::ForceTerminate, None); + }, + ); + }); }); }); @@ -682,3 +756,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..ea46c02298f --- /dev/null +++ b/app/src/ai/agent_sdk/schedule_tests.rs @@ -0,0 +1,160 @@ +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::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(); + 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_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); + 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/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 9dce190decb..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}; @@ -1099,6 +1100,40 @@ impl UpdateManager { } } + pub(crate) fn apply_scoped_refresh( + &mut self, + 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/server_api/object.rs b/app/src/server/server_api/object.rs index d6d529e0a88..432cd415ce3 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_for_team(operation, 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/team_scope.rs b/app/src/server/team_scope.rs index b8179f7d7fe..e35c48e3ae5 100644 --- a/app/src/server/team_scope.rs +++ b/app/src/server/team_scope.rs @@ -1,18 +1,8 @@ +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); @@ -25,4 +15,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), + } + } } 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 { 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 53bee4a1fce..e590b1495a0 100644 --- a/crates/warp_cli/src/lib_tests.rs +++ b/crates/warp_cli/src/lib_tests.rs @@ -2204,6 +2204,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() { @@ -2225,6 +2253,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([ @@ -2305,6 +2376,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() { 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.