Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
pub mod gql_convert;
|
||||
pub mod team;
|
||||
pub mod team_tester;
|
||||
pub mod update_manager;
|
||||
pub mod user_profiles;
|
||||
pub mod user_workspaces;
|
||||
pub mod workspace;
|
||||
@@ -0,0 +1,164 @@
|
||||
use crate::{auth::UserUid, server::ids::ServerId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use super::workspace::{
|
||||
BillingMetadata, EmailInvite, InviteLinkDomainRestriction, WorkspaceInviteCode,
|
||||
WorkspaceSettings,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Eq, PartialEq, Debug, Serialize, Deserialize)]
|
||||
pub enum MembershipRole {
|
||||
Owner,
|
||||
Admin,
|
||||
User,
|
||||
}
|
||||
|
||||
impl MembershipRole {
|
||||
pub fn is_admin_or_owner(&self) -> bool {
|
||||
matches!(self, MembershipRole::Admin | MembershipRole::Owner)
|
||||
}
|
||||
|
||||
pub fn is_owner(&self) -> bool {
|
||||
matches!(self, MembershipRole::Owner)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Eq, PartialEq, Debug, Serialize, Deserialize)]
|
||||
pub struct TeamMember {
|
||||
pub uid: UserUid,
|
||||
pub email: String,
|
||||
pub role: MembershipRole,
|
||||
}
|
||||
|
||||
impl PartialOrd for TeamMember {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for TeamMember {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.email.cmp(&other.email)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DiscoverableTeam {
|
||||
pub team_uid: String,
|
||||
pub num_members: i64,
|
||||
pub name: String,
|
||||
pub team_accepting_invites: bool,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Clone)]
|
||||
pub enum TeamDeleteDisabledReason {
|
||||
ActivePaidSubscription,
|
||||
RemainingBonusCredits,
|
||||
OtherMembers,
|
||||
}
|
||||
|
||||
impl TeamDeleteDisabledReason {
|
||||
pub fn user_facing_message(&self) -> &str {
|
||||
match self {
|
||||
TeamDeleteDisabledReason::ActivePaidSubscription => {
|
||||
"Your team cannot be deleted with an active subscription."
|
||||
}
|
||||
TeamDeleteDisabledReason::RemainingBonusCredits => {
|
||||
"Your team cannot be deleted with unused add-on credits."
|
||||
}
|
||||
TeamDeleteDisabledReason::OtherMembers => {
|
||||
"Your team cannot be deleted with other team members."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Team {
|
||||
pub uid: ServerId,
|
||||
pub name: String,
|
||||
pub invite_code: Option<WorkspaceInviteCode>,
|
||||
pub members: Vec<TeamMember>,
|
||||
pub pending_email_invites: Vec<EmailInvite>,
|
||||
pub invite_link_domain_restrictions: Vec<InviteLinkDomainRestriction>,
|
||||
pub billing_metadata: BillingMetadata,
|
||||
pub stripe_customer_id: Option<String>,
|
||||
pub organization_settings: WorkspaceSettings,
|
||||
/// If the team is eligible for discovery, then show toggle for setting discoverability to the team's admin
|
||||
pub is_eligible_for_discovery: bool,
|
||||
pub has_billing_history: bool,
|
||||
}
|
||||
|
||||
impl Team {
|
||||
pub fn from_local_cache(
|
||||
uid: ServerId,
|
||||
name: String,
|
||||
workspace_settings: Option<WorkspaceSettings>,
|
||||
billing_metadata: Option<BillingMetadata>,
|
||||
members: Option<Vec<TeamMember>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
uid,
|
||||
name,
|
||||
invite_code: Default::default(),
|
||||
members: members.unwrap_or_default(),
|
||||
pending_email_invites: Default::default(),
|
||||
invite_link_domain_restrictions: Default::default(),
|
||||
billing_metadata: billing_metadata.unwrap_or_default(),
|
||||
stripe_customer_id: Default::default(),
|
||||
organization_settings: workspace_settings.unwrap_or_default(),
|
||||
is_eligible_for_discovery: false,
|
||||
has_billing_history: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_member_by_email(&self, email: &str) -> Option<&TeamMember> {
|
||||
self.members.iter().find(|member| member.email == email)
|
||||
}
|
||||
|
||||
pub fn has_owner_permissions(&self, user_email: &str) -> bool {
|
||||
self.get_member_by_email(user_email)
|
||||
.is_some_and(|member| member.role.is_owner())
|
||||
}
|
||||
|
||||
pub fn is_multi_admin_enabled(&self) -> bool {
|
||||
self.billing_metadata
|
||||
.tier
|
||||
.multi_admin_policy
|
||||
.is_some_and(|policy| policy.enabled)
|
||||
}
|
||||
|
||||
pub fn has_admin_permissions(&self, user_email: &str) -> bool {
|
||||
self.get_member_by_email(user_email).is_some_and(|member| {
|
||||
member.role.is_owner()
|
||||
|| (member.role == MembershipRole::Admin && self.is_multi_admin_enabled())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_delete_disabled_reason(
|
||||
&self,
|
||||
current_user_email: &str,
|
||||
remaining_workspace_credits: i32,
|
||||
) -> Option<TeamDeleteDisabledReason> {
|
||||
if self.members.len() > 1
|
||||
|| self
|
||||
.members
|
||||
.first()
|
||||
.is_none_or(|m| m.email != current_user_email)
|
||||
{
|
||||
return Some(TeamDeleteDisabledReason::OtherMembers);
|
||||
}
|
||||
if self.billing_metadata.is_user_on_paid_plan() {
|
||||
return Some(TeamDeleteDisabledReason::ActivePaidSubscription);
|
||||
}
|
||||
if remaining_workspace_credits > 0 {
|
||||
return Some(TeamDeleteDisabledReason::RemainingBonusCredits);
|
||||
}
|
||||
None // No reason found, team can be deleted
|
||||
}
|
||||
|
||||
pub fn is_custom_llm_enabled(&self) -> bool {
|
||||
self.organization_settings.llm_settings.enabled
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use warpui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TeamTesterStatus {}
|
||||
|
||||
impl TeamTesterStatus {
|
||||
pub fn new(_ctx: &mut ModelContext<Self>) -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn mock(ctx: &mut ModelContext<Self>) -> Self {
|
||||
Self::new(ctx)
|
||||
}
|
||||
|
||||
/// Emit an event to start or force-refresh the cloud object and workspace metadata pollers.
|
||||
/// Polling is started when a user logs in; this method is also called with
|
||||
/// `force_refresh: true` when data is known to be invalidated (e.g. joining a team via an
|
||||
/// intent link).
|
||||
pub fn initiate_data_pollers(&mut self, force_refresh: bool, ctx: &mut ModelContext<Self>) {
|
||||
ctx.emit(TeamTesterStatusEvent::InitiateDataPollers { force_refresh })
|
||||
}
|
||||
}
|
||||
|
||||
pub enum TeamTesterStatusEvent {
|
||||
InitiateDataPollers {
|
||||
/// If true, the subscriber should attempt to refresh any state
|
||||
/// immediately rather than just wait for the next poll.
|
||||
/// Specifically used when a user joins a team via an intent link.
|
||||
force_refresh: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl Entity for TeamTesterStatus {
|
||||
type Event = TeamTesterStatusEvent;
|
||||
}
|
||||
|
||||
impl SingletonEntity for TeamTesterStatus {}
|
||||
@@ -0,0 +1,526 @@
|
||||
use super::team_tester::{TeamTesterStatus, TeamTesterStatusEvent};
|
||||
use super::user_workspaces::{
|
||||
CreateTeamResponse, UserWorkspaces, WorkspacesMetadataResponse, WorkspacesMetadataWithPricing,
|
||||
};
|
||||
use super::workspace::WorkspaceUid;
|
||||
use crate::ai::llms::LLMPreferences;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::CloudObjectEventEntrypoint;
|
||||
use crate::network::{NetworkStatus, NetworkStatusEvent, NetworkStatusKind};
|
||||
use crate::persistence::ModelEvent;
|
||||
use crate::pricing::PricingInfoModel;
|
||||
use crate::server::cloud_objects::update_manager::UpdateManager;
|
||||
use crate::server::ids::ServerId;
|
||||
use crate::server::retry_strategies::{
|
||||
OUT_OF_BAND_REQUEST_RETRY_STRATEGY, PERIODIC_POLL, PERIODIC_POLL_RETRY_STRATEGY,
|
||||
};
|
||||
use crate::server::server_api::team::TeamClient;
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::{report_error, report_if_error};
|
||||
use anyhow::{Context, Result};
|
||||
use futures::channel::oneshot::{self, Receiver};
|
||||
use futures::stream::AbortHandle;
|
||||
use std::sync::mpsc::SyncSender;
|
||||
use std::sync::Arc;
|
||||
use warpui::r#async::Timer;
|
||||
use warpui::{duration_with_jitter, RequestState};
|
||||
use warpui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
pub enum TeamUpdateManagerEvent {
|
||||
LeaveSuccess,
|
||||
LeaveError,
|
||||
RenameTeamSuccess,
|
||||
RenameTeamError,
|
||||
}
|
||||
|
||||
/// TeamUpdateManager is a singleton model responsible for communicating with the server and local
|
||||
/// database regarding teams' metadata.
|
||||
/// It emits events that are later processed by UserWorkspaces model (which is an in-memory store for
|
||||
/// the workspace metadata).
|
||||
/// TeamUpdateManager is used when sending a team-related request to the server and processing the
|
||||
/// response, but also controls the periodic polling from the server (also controlled by calling
|
||||
/// `force_refresh` method).
|
||||
pub struct TeamUpdateManager {
|
||||
team_client: Arc<dyn TeamClient>,
|
||||
model_event_sender: Option<SyncSender<ModelEvent>>,
|
||||
should_poll_for_workspace_metadata_updates: bool,
|
||||
|
||||
/// The abort handle for the timer that waits a fixed duration
|
||||
/// before making an outbound request for workspace metadata, if any.
|
||||
next_poll_abort_handle: Option<AbortHandle>,
|
||||
|
||||
/// The abort handle for the in flight request of workspace metadata,
|
||||
/// if any.
|
||||
in_flight_request_abort_handle: Option<AbortHandle>,
|
||||
}
|
||||
|
||||
impl TeamUpdateManager {
|
||||
pub fn new(
|
||||
team_client: Arc<dyn TeamClient>,
|
||||
model_event_sender: Option<SyncSender<ModelEvent>>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
let network_status = NetworkStatus::handle(ctx);
|
||||
ctx.subscribe_to_model(&network_status, Self::handle_network_status_changed);
|
||||
|
||||
let team_tester_status = TeamTesterStatus::handle(ctx);
|
||||
ctx.subscribe_to_model(&team_tester_status, Self::handle_team_tester_status_changed);
|
||||
|
||||
Self {
|
||||
team_client,
|
||||
model_event_sender,
|
||||
should_poll_for_workspace_metadata_updates: false,
|
||||
next_poll_abort_handle: None,
|
||||
in_flight_request_abort_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_network_status_changed(
|
||||
&mut self,
|
||||
network_status: &NetworkStatusEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
match network_status {
|
||||
NetworkStatusEvent::NetworkStatusChanged { new_status } => match new_status {
|
||||
NetworkStatusKind::Online => {
|
||||
// TODO: this will cause us to reset our polling very frequently
|
||||
// if the client's network conn is repeatedly flipping between on and off.
|
||||
self.start_polling_for_workspace_metadata_updates(ctx);
|
||||
}
|
||||
NetworkStatusKind::Offline => self.stop_polling_for_workspace_metadata_updates(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_team_tester_status_changed(
|
||||
&mut self,
|
||||
event: &TeamTesterStatusEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let TeamTesterStatusEvent::InitiateDataPollers { force_refresh } = event;
|
||||
if *force_refresh {
|
||||
std::mem::drop(self.refresh_workspace_metadata(ctx));
|
||||
}
|
||||
|
||||
self.start_polling_for_workspace_metadata_updates(ctx);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn mock(ctx: &mut ModelContext<Self>) -> Self {
|
||||
use crate::server::server_api::team::MockTeamClient;
|
||||
|
||||
// This mock API is used in test contexts where we don't care which teams the user is on.
|
||||
// Since the mocked `TeamClient` is inaccessible to tests, stub the metadata polling to
|
||||
// avoid noisy `No matching expectation found` errors.
|
||||
let mut team_client = MockTeamClient::new();
|
||||
team_client.expect_workspaces_metadata().returning(|| {
|
||||
Ok(WorkspacesMetadataWithPricing {
|
||||
metadata: WorkspacesMetadataResponse {
|
||||
workspaces: vec![],
|
||||
joinable_teams: vec![],
|
||||
experiments: None,
|
||||
feature_model_choices: None,
|
||||
},
|
||||
pricing_info: None,
|
||||
})
|
||||
});
|
||||
|
||||
Self::new(Arc::new(team_client), Default::default(), ctx)
|
||||
}
|
||||
|
||||
/// Starts a periodic poll for workspace metadata changes, if there isn't already
|
||||
/// an existing poll queued up.
|
||||
pub fn start_polling_for_workspace_metadata_updates(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
let is_online = NetworkStatus::as_ref(ctx).is_online();
|
||||
if !self.should_poll_for_workspace_metadata_updates && is_online {
|
||||
self.should_poll_for_workspace_metadata_updates = true;
|
||||
self.poll_for_workspace_metadata_changes(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop_polling_for_workspace_metadata_updates(&mut self) {
|
||||
self.should_poll_for_workspace_metadata_updates = false;
|
||||
self.abort_existing_poll();
|
||||
}
|
||||
|
||||
/// Out-of-band (from the regular poll) refresh of workspace metadata.
|
||||
/// Returns a oneshot Receiver that resolves when the refresh completes (success or final failure).
|
||||
pub fn refresh_workspace_metadata(&mut self, ctx: &mut ModelContext<Self>) -> Receiver<()> {
|
||||
// Skip the refresh when logged out to avoid noisy auth errors.
|
||||
if !AuthStateProvider::as_ref(ctx).get().is_logged_in() {
|
||||
let (tx, rx) = oneshot::channel::<()>();
|
||||
let _ = tx.send(());
|
||||
return rx;
|
||||
}
|
||||
|
||||
let team_client = self.team_client.clone();
|
||||
let (tx, rx) = oneshot::channel::<()>();
|
||||
let mut tx = Some(tx);
|
||||
ctx.spawn_with_retry_on_error(
|
||||
move || {
|
||||
let team_client = team_client.clone();
|
||||
async move { team_client.workspaces_metadata().await }
|
||||
},
|
||||
OUT_OF_BAND_REQUEST_RETRY_STRATEGY,
|
||||
move |update_manager, request_state, ctx| {
|
||||
// Only signal once there are no more retries left.
|
||||
let is_final = !request_state.has_pending_retries();
|
||||
update_manager.handle_workspace_metadata_with_request_state(request_state, ctx);
|
||||
if is_final {
|
||||
if let Some(sender) = tx.take() {
|
||||
let _ = sender.send(());
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
rx
|
||||
}
|
||||
|
||||
fn abort_existing_poll(&mut self) {
|
||||
if let Some(abort_handle) = self.in_flight_request_abort_handle.take() {
|
||||
abort_handle.abort();
|
||||
}
|
||||
|
||||
if let Some(abort_handle) = self.next_poll_abort_handle.take() {
|
||||
abort_handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/// Only call this method if you need to restart the poll and force a refresh.
|
||||
/// Currently called when
|
||||
/// - we decide that a feature flag needs to change
|
||||
/// - find out that a user is a team tester
|
||||
/// - a network status changes (we go from offline to online state)
|
||||
///
|
||||
/// Note: the gql query for this poll also pulls in experiment state. If we change
|
||||
/// the behaviour for polling workspace metadata, we should consider what ramifications
|
||||
/// that has on querying experiment state.
|
||||
fn poll_for_workspace_metadata_changes(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.abort_existing_poll();
|
||||
|
||||
if !self.should_poll_for_workspace_metadata_updates {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't poll when the user is logged out to avoid spamming auth errors in the logs.
|
||||
// Polling will be restarted when the user logs in via `initiate_data_pollers`.
|
||||
if !AuthStateProvider::as_ref(ctx).get().is_logged_in() {
|
||||
self.should_poll_for_workspace_metadata_updates = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let team_client = self.team_client.clone();
|
||||
// We retry a few times here in case there are any transient network errors.
|
||||
let spawn_handle = ctx.spawn_with_retry_on_error(
|
||||
move || {
|
||||
let team_client = team_client.clone();
|
||||
async move {
|
||||
team_client
|
||||
.workspaces_metadata()
|
||||
.await
|
||||
.context("Error polling for workspace metadata changes")
|
||||
}
|
||||
},
|
||||
PERIODIC_POLL_RETRY_STRATEGY,
|
||||
|update_manager, res, ctx| {
|
||||
// Only poll if `spawn_with_retry_on_error` is not going to retry again so we don't end up with multiple
|
||||
// polls running simultaneously.
|
||||
let should_poll_again = !res.has_pending_retries();
|
||||
update_manager.handle_workspace_metadata_with_request_state(res, ctx);
|
||||
|
||||
if should_poll_again {
|
||||
let next_poll_handle = ctx.spawn(
|
||||
async move {
|
||||
Timer::after(duration_with_jitter(
|
||||
PERIODIC_POLL,
|
||||
0.2, /* max_jitter_multiplier */
|
||||
))
|
||||
.await
|
||||
},
|
||||
|update_manager, _, ctx| {
|
||||
update_manager.poll_for_workspace_metadata_changes(ctx);
|
||||
},
|
||||
);
|
||||
update_manager.next_poll_abort_handle = Some(next_poll_handle.abort_handle());
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
self.in_flight_request_abort_handle = Some(spawn_handle.abort_handle());
|
||||
}
|
||||
|
||||
fn save_to_db(&self, events: impl IntoIterator<Item = ModelEvent>) {
|
||||
let model_event_sender = self.model_event_sender.clone();
|
||||
if let Some(model_event_sender) = &model_event_sender {
|
||||
for event in events {
|
||||
report_if_error!(model_event_sender
|
||||
.send(event)
|
||||
.context("Unable to save teams metadata to sqlite"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_team(
|
||||
&mut self,
|
||||
team_name: String,
|
||||
entrypoint: CloudObjectEventEntrypoint,
|
||||
discoverable: Option<bool>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let team_client = self.team_client.clone();
|
||||
let _ = ctx.spawn(
|
||||
async move {
|
||||
team_client
|
||||
.create_team(team_name, entrypoint, discoverable)
|
||||
.await
|
||||
.context("Error creating team")
|
||||
},
|
||||
Self::on_team_created,
|
||||
);
|
||||
}
|
||||
|
||||
fn on_team_created(
|
||||
&mut self,
|
||||
create_team_response: Result<CreateTeamResponse>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// TODO we should implement a similar mechanism to cloud objects with local team id
|
||||
report_if_error!(create_team_response);
|
||||
let Ok(create_team_response) = create_team_response else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Update sqlite
|
||||
self.save_to_db([ModelEvent::UpsertWorkspace {
|
||||
workspace: Box::new(create_team_response.workspace.clone()),
|
||||
}]);
|
||||
|
||||
// Update UserWorkspaces
|
||||
UserWorkspaces::handle(ctx).update(ctx, |user_workspaces, ctx| {
|
||||
user_workspaces.team_created(&create_team_response, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn leave_team(
|
||||
&mut self,
|
||||
team_uid: ServerId,
|
||||
entrypoint: CloudObjectEventEntrypoint,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// Handle server update
|
||||
let user_uid = AuthStateProvider::as_ref(ctx).get().user_id();
|
||||
if let Some(user_uid) = user_uid {
|
||||
let team_client = self.team_client.clone();
|
||||
let _ = ctx.spawn(
|
||||
async move {
|
||||
team_client
|
||||
.leave_team(user_uid, team_uid, entrypoint)
|
||||
.await
|
||||
.context("Error leaving team")
|
||||
},
|
||||
move |me, result, ctx| {
|
||||
me.on_team_left(team_uid, result, ctx);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
log::warn!("User is not authenticated, cannot leave team");
|
||||
ctx.emit(TeamUpdateManagerEvent::LeaveError);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_team_left(
|
||||
&mut self,
|
||||
left_team_uid: ServerId,
|
||||
result: Result<WorkspacesMetadataWithPricing>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
match result {
|
||||
Ok(response) => {
|
||||
if let Some(pricing_info) = response.pricing_info {
|
||||
PricingInfoModel::handle(ctx).update(ctx, |model, ctx| {
|
||||
model.update_pricing_info(pricing_info, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
let workspaces = response.metadata.workspaces;
|
||||
let joinable_teams = response.metadata.joinable_teams;
|
||||
|
||||
UserWorkspaces::handle(ctx).update(ctx, |user_workspaces, ctx| {
|
||||
user_workspaces.update_workspaces(workspaces.clone(), ctx);
|
||||
user_workspaces.update_joinable_teams(joinable_teams, ctx);
|
||||
});
|
||||
|
||||
// Check if the current workspace is still in the list of workspaces.
|
||||
// If it's not, then set the current workspace to the first workspace in the list.
|
||||
if let Some(current_workspace) = UserWorkspaces::as_ref(ctx).current_workspace() {
|
||||
if !workspaces.iter().any(|w| w.uid == current_workspace.uid) {
|
||||
if let Some(workspace_uid) = workspaces.first().map(|w| w.uid) {
|
||||
self.set_current_workspace_uid(workspace_uid, ctx);
|
||||
};
|
||||
}
|
||||
} else if let Some(workspace_uid) = workspaces.first().map(|w| w.uid) {
|
||||
self.set_current_workspace_uid(workspace_uid, ctx);
|
||||
}
|
||||
|
||||
// Update sqlite
|
||||
self.save_to_db([ModelEvent::UpsertWorkspaces { workspaces }]);
|
||||
|
||||
// Remove objects owned by the team that was left.
|
||||
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
||||
// We first remove team objects from local state so that they're not shown to the user.
|
||||
// Then, refresh all objects to fetch any that were independently shared.
|
||||
update_manager.remove_team_objects(left_team_uid, ctx);
|
||||
update_manager.refresh_updated_objects(ctx);
|
||||
});
|
||||
|
||||
ctx.emit(TeamUpdateManagerEvent::LeaveSuccess);
|
||||
}
|
||||
Err(e) => {
|
||||
report_error!(e);
|
||||
|
||||
ctx.emit(TeamUpdateManagerEvent::LeaveError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rename_team(&mut self, new_name: String, ctx: &mut ModelContext<Self>) {
|
||||
let team_client = self.team_client.clone();
|
||||
let team_uid = UserWorkspaces::handle(ctx).read(ctx, |user_workspaces, _| {
|
||||
user_workspaces.current_team().map(|team| team.uid)
|
||||
});
|
||||
if let Some(team_uid) = team_uid {
|
||||
let _ = ctx.spawn(
|
||||
async move { team_client.rename_team(new_name, team_uid).await },
|
||||
Self::on_team_renamed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_team_renamed(
|
||||
&mut self,
|
||||
result: Result<WorkspacesMetadataWithPricing>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
match result {
|
||||
Err(_) => ctx.emit(TeamUpdateManagerEvent::RenameTeamError),
|
||||
Ok(response) => {
|
||||
if let Some(pricing_info) = response.pricing_info.clone() {
|
||||
PricingInfoModel::handle(ctx).update(ctx, |model, ctx| {
|
||||
model.update_pricing_info(pricing_info, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
self.on_workspaces_updated(Ok(response.metadata.clone()), ctx);
|
||||
|
||||
// Update sqlite
|
||||
self.save_to_db([ModelEvent::UpsertWorkspaces {
|
||||
workspaces: response.metadata.workspaces,
|
||||
}]);
|
||||
|
||||
ctx.emit(TeamUpdateManagerEvent::RenameTeamSuccess);
|
||||
}
|
||||
};
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn handle_workspace_metadata_with_request_state(
|
||||
&mut self,
|
||||
request_state: RequestState<WorkspacesMetadataWithPricing>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
match request_state {
|
||||
RequestState::RequestSucceeded(response) => {
|
||||
if let Some(pricing_info) = response.pricing_info.clone() {
|
||||
PricingInfoModel::handle(ctx).update(ctx, |model, ctx| {
|
||||
model.update_pricing_info(pricing_info, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
// Right now, this function is coupled with how we handle leaving a team.
|
||||
// TODO(zheng) refactor so we can separate these two cases and have clearer logic.
|
||||
self.on_workspaces_updated(Ok(response.metadata), ctx);
|
||||
}
|
||||
RequestState::RequestFailedRetryPending(err) => {
|
||||
log::info!(
|
||||
"get_workspaces_metadata_for_user: request failed with error {err:#}. Trying again."
|
||||
);
|
||||
}
|
||||
RequestState::RequestFailed(err) => {
|
||||
log::info!("get_workspaces_metadata_for_user: request failed with error {err:#}. Retries exhausted.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_workspaces_updated(
|
||||
&mut self,
|
||||
result: Result<WorkspacesMetadataResponse>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
match result {
|
||||
Ok(user_workspaces_access) => {
|
||||
let workspaces = user_workspaces_access.workspaces;
|
||||
let joinable_teams = user_workspaces_access.joinable_teams;
|
||||
let experiments = user_workspaces_access.experiments;
|
||||
|
||||
UserWorkspaces::handle(ctx).update(ctx, |user_workspaces, ctx| {
|
||||
user_workspaces.update_workspaces(workspaces.clone(), ctx);
|
||||
user_workspaces.update_joinable_teams(joinable_teams.clone(), ctx);
|
||||
});
|
||||
|
||||
// Check if the current workspace is still in the list of workspaces.
|
||||
// If it's not, then set the current workspace to the first workspace in the list.
|
||||
if let Some(current_workspace) = UserWorkspaces::as_ref(ctx).current_workspace() {
|
||||
if !workspaces.iter().any(|w| w.uid == current_workspace.uid) {
|
||||
if let Some(workspace_uid) = workspaces.first().map(|w| w.uid) {
|
||||
self.set_current_workspace_uid(workspace_uid, ctx);
|
||||
};
|
||||
}
|
||||
} else if let Some(workspace_uid) = workspaces.first().map(|w| w.uid) {
|
||||
self.set_current_workspace_uid(workspace_uid, ctx);
|
||||
}
|
||||
|
||||
if let Some(experiments) = experiments {
|
||||
ServerApiProvider::handle(ctx).update(ctx, |provider, ctx| {
|
||||
provider.handle_experiments_fetched(experiments, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(feature_model_choices) = user_workspaces_access.feature_model_choices {
|
||||
LLMPreferences::handle(ctx).update(ctx, |llm_preferences, ctx| {
|
||||
llm_preferences
|
||||
.update_feature_model_choices(feature_model_choices.try_into(), ctx);
|
||||
});
|
||||
}
|
||||
|
||||
// Update sqlite
|
||||
self.save_to_db([ModelEvent::UpsertWorkspaces { workspaces }]);
|
||||
}
|
||||
Err(e) => {
|
||||
report_error!(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_current_workspace_uid(
|
||||
&mut self,
|
||||
workspace_uid: WorkspaceUid,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
UserWorkspaces::handle(ctx).update(ctx, |user_workspaces, ctx| {
|
||||
user_workspaces.set_current_workspace_uid(workspace_uid, ctx);
|
||||
});
|
||||
|
||||
// Update sqlite
|
||||
self.save_to_db([ModelEvent::SetCurrentWorkspace { workspace_uid }]);
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for TeamUpdateManager {
|
||||
type Event = TeamUpdateManagerEvent;
|
||||
}
|
||||
|
||||
impl SingletonEntity for TeamUpdateManager {}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "update_manager_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,218 @@
|
||||
use chrono::Utc;
|
||||
use itertools::Itertools;
|
||||
use warpui::{AddSingletonModel, App};
|
||||
|
||||
use crate::{
|
||||
auth::AuthManager,
|
||||
cloud_object::{
|
||||
model::{actions::ObjectActions, persistence::CloudModel},
|
||||
Owner, Revision, ServerMetadata, ServerPermissions, ServerWorkflow,
|
||||
},
|
||||
server::{
|
||||
cloud_objects::update_manager::InitialLoadResponse,
|
||||
ids::SyncId,
|
||||
server_api::{
|
||||
object::MockObjectClient,
|
||||
team::MockTeamClient,
|
||||
workspace::{MockWorkspaceClient, WorkspaceClient},
|
||||
},
|
||||
sync_queue::SyncQueue,
|
||||
telemetry::context_provider::AppTelemetryContextProvider,
|
||||
},
|
||||
settings::PrivacySettings,
|
||||
system::SystemStats,
|
||||
workflows::{workflow::Workflow, CloudWorkflow, CloudWorkflowModel, WorkflowId},
|
||||
workspaces::{
|
||||
team::Team,
|
||||
user_profiles::UserProfiles,
|
||||
workspace::{Workspace, WorkspaceUid},
|
||||
},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn initialize_app(
|
||||
team_client: Arc<dyn TeamClient>,
|
||||
workspace_client: Arc<dyn WorkspaceClient>,
|
||||
workspaces: Vec<Workspace>,
|
||||
app: &mut App,
|
||||
) {
|
||||
app.add_singleton_model(|_| NetworkStatus::new());
|
||||
app.add_singleton_model(|_| SystemStats::new());
|
||||
app.add_singleton_model(TeamTesterStatus::new);
|
||||
app.add_singleton_model(|ctx| {
|
||||
UserWorkspaces::mock(
|
||||
team_client.clone(),
|
||||
workspace_client.clone(),
|
||||
workspaces,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
app.add_singleton_model(SyncQueue::mock);
|
||||
app.add_singleton_model(CloudModel::mock);
|
||||
app.add_singleton_model(|_| ObjectActions::new(vec![]));
|
||||
app.add_singleton_model(PrivacySettings::mock);
|
||||
app.add_singleton_model(|_| UserProfiles::new(vec![]));
|
||||
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
|
||||
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
||||
app.add_singleton_model(AppTelemetryContextProvider::new_context_provider);
|
||||
app.add_singleton_model(AuthManager::new_for_test);
|
||||
}
|
||||
|
||||
fn mock_workflow(id: WorkflowId, owner: Owner) -> CloudWorkflow {
|
||||
CloudWorkflow::new_from_server(mock_server_workflow(id, owner))
|
||||
}
|
||||
|
||||
fn mock_server_workflow(id: WorkflowId, owner: Owner) -> ServerWorkflow {
|
||||
ServerWorkflow {
|
||||
id: SyncId::ServerId(id.into()),
|
||||
model: CloudWorkflowModel::new(Workflow::new("Test Workflow", "echo hello")),
|
||||
metadata: 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,
|
||||
},
|
||||
permissions: ServerPermissions {
|
||||
space: owner,
|
||||
permissions_last_updated_ts: Utc::now().into(),
|
||||
anyone_link_sharing: None,
|
||||
guests: vec![],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_leaving_team_removes_objects() {
|
||||
App::test((), |mut app| async move {
|
||||
let workspace_uid: WorkspaceUid = WorkspaceUid::from(ServerId::from(987));
|
||||
let team_uid: ServerId = ServerId::from(123);
|
||||
let team_workflow_id = WorkflowId::from(1);
|
||||
let personal_workflow_id = WorkflowId::from(2);
|
||||
let shared_workflow_id = WorkflowId::from(3);
|
||||
let shared_workflow = mock_server_workflow(shared_workflow_id, Owner::Team { team_uid });
|
||||
|
||||
let mut team_client = MockTeamClient::new();
|
||||
team_client.expect_workspaces_metadata().returning(|| {
|
||||
Ok(WorkspacesMetadataWithPricing {
|
||||
metadata: WorkspacesMetadataResponse {
|
||||
workspaces: vec![],
|
||||
joinable_teams: vec![],
|
||||
experiments: None,
|
||||
feature_model_choices: None,
|
||||
},
|
||||
pricing_info: None,
|
||||
})
|
||||
});
|
||||
|
||||
let workspace_client = MockWorkspaceClient::new();
|
||||
let team_client = Arc::new(team_client);
|
||||
let workspace_client = Arc::new(workspace_client);
|
||||
initialize_app(
|
||||
team_client.clone(),
|
||||
workspace_client.clone(),
|
||||
vec![Workspace::from_local_cache(
|
||||
workspace_uid,
|
||||
"Test Workspace".to_owned(),
|
||||
Some(vec![Team::from_local_cache(
|
||||
team_uid,
|
||||
"Test Team".to_owned(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)]),
|
||||
)],
|
||||
&mut app,
|
||||
);
|
||||
|
||||
// Add the initial Warp Drive objects.
|
||||
CloudModel::handle(&app).update(&mut app, |cloud_model, _| {
|
||||
cloud_model.add_object(
|
||||
SyncId::ServerId(team_workflow_id.into()),
|
||||
mock_workflow(team_workflow_id, Owner::Team { team_uid }),
|
||||
);
|
||||
|
||||
cloud_model.add_object(
|
||||
SyncId::ServerId(shared_workflow_id.into()),
|
||||
CloudWorkflow::new_from_server(shared_workflow.clone()),
|
||||
);
|
||||
|
||||
cloud_model.add_object(
|
||||
SyncId::ServerId(personal_workflow_id.into()),
|
||||
mock_workflow(personal_workflow_id, Owner::mock_current_user()),
|
||||
);
|
||||
});
|
||||
|
||||
let mut cloud_server_api = MockObjectClient::new();
|
||||
cloud_server_api
|
||||
.expect_fetch_changed_objects()
|
||||
.returning(move |_, _| {
|
||||
Ok(InitialLoadResponse {
|
||||
updated_workflows: vec![shared_workflow.clone()],
|
||||
..Default::default()
|
||||
})
|
||||
});
|
||||
|
||||
let team_update_manager =
|
||||
app.add_singleton_model(|ctx| TeamUpdateManager::new(team_client, None, ctx));
|
||||
|
||||
let cloud_update_manager = app
|
||||
.add_singleton_model(|ctx| UpdateManager::new(None, Arc::new(cloud_server_api), ctx));
|
||||
|
||||
// Simulate leaving the team.
|
||||
team_update_manager.update(&mut app, |team_manager, ctx| {
|
||||
team_manager.on_team_left(
|
||||
team_uid,
|
||||
Ok(WorkspacesMetadataWithPricing {
|
||||
metadata: WorkspacesMetadataResponse {
|
||||
workspaces: vec![],
|
||||
joinable_teams: vec![],
|
||||
experiments: None,
|
||||
feature_model_choices: None,
|
||||
},
|
||||
pricing_info: None,
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
// Both team-owned objects should be removed.
|
||||
CloudModel::handle(&app).read(&app, |cloud_model, _| {
|
||||
assert_eq!(
|
||||
cloud_model
|
||||
.cloud_objects()
|
||||
.map(|obj| obj.uid())
|
||||
.collect_vec(),
|
||||
vec![personal_workflow_id.to_string()]
|
||||
);
|
||||
});
|
||||
|
||||
// This should also trigger a refresh.
|
||||
cloud_update_manager
|
||||
.update(&mut app, |update_manager, ctx| {
|
||||
ctx.await_spawned_future(update_manager.spawned_futures()[0])
|
||||
})
|
||||
.await;
|
||||
|
||||
// The refresh will then re-add the shared workflow.
|
||||
CloudModel::handle(&app).read(&app, |cloud_model, _| {
|
||||
let mut objects = cloud_model
|
||||
.cloud_objects()
|
||||
.map(|obj| obj.uid())
|
||||
.collect_vec();
|
||||
objects.sort();
|
||||
assert_eq!(
|
||||
objects,
|
||||
vec![
|
||||
personal_workflow_id.to_string(),
|
||||
shared_workflow_id.to_string()
|
||||
]
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use session_sharing_protocol::common::ProfileData;
|
||||
use warpui::{Entity, SingletonEntity};
|
||||
|
||||
use crate::auth::UserUid;
|
||||
|
||||
pub enum UserProfilesEvent {}
|
||||
|
||||
/// Public struct for storing all the UserProfile data that's fed in from either sqlite or the server.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct UserProfileWithUID {
|
||||
pub firebase_uid: UserUid,
|
||||
pub display_name: Option<String>,
|
||||
pub email: String,
|
||||
pub photo_url: String,
|
||||
}
|
||||
|
||||
impl From<ProfileData> for UserProfileWithUID {
|
||||
fn from(data: ProfileData) -> Self {
|
||||
Self {
|
||||
firebase_uid: UserUid::new(&data.firebase_uid),
|
||||
display_name: Some(data.display_name),
|
||||
email: data.email.unwrap_or_default(),
|
||||
photo_url: data.photo_url.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::persistence::model::UserProfile> for UserProfileWithUID {
|
||||
fn from(user_profile: crate::persistence::model::UserProfile) -> Self {
|
||||
UserProfileWithUID {
|
||||
firebase_uid: UserUid::new(&user_profile.firebase_uid),
|
||||
display_name: user_profile.display_name,
|
||||
email: user_profile.email,
|
||||
photo_url: user_profile.photo_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Private struct for internal mapping between the user's uid and the important information we might
|
||||
/// want to query about them.
|
||||
pub struct UserProfileData {
|
||||
pub display_name: Option<String>,
|
||||
pub email: String,
|
||||
#[allow(dead_code)]
|
||||
pub photo_url: String,
|
||||
}
|
||||
|
||||
/// UserProfiles is a singleton model storing data on adjacent users (e.g., teammates or former teammates). The
|
||||
/// purpose of this model is to quickly convert the UID for some user into displayable information about them;
|
||||
/// for example, their name, email, or profile photo. This allows us to display a richer view into the history
|
||||
/// of objects and the users who have created, executed, or edited them, etc.
|
||||
pub struct UserProfiles {
|
||||
users_by_id: HashMap<UserUid, UserProfileData>,
|
||||
}
|
||||
|
||||
impl UserProfiles {
|
||||
pub fn new(user_profiles: Vec<UserProfileWithUID>) -> Self {
|
||||
let mut model = Self {
|
||||
users_by_id: HashMap::new(),
|
||||
};
|
||||
|
||||
model.insert_profiles(&user_profiles);
|
||||
|
||||
model
|
||||
}
|
||||
|
||||
/// Accepts a vector of user profiles and inserts them into the model, overwriting
|
||||
/// the old version of a profile if it already exists.
|
||||
pub fn insert_profiles(&mut self, user_profiles: &Vec<UserProfileWithUID>) {
|
||||
for user_profile in user_profiles {
|
||||
self.users_by_id.insert(
|
||||
user_profile.firebase_uid,
|
||||
UserProfileData {
|
||||
display_name: user_profile.display_name.clone(),
|
||||
email: user_profile.email.clone(),
|
||||
photo_url: user_profile.photo_url.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_profiles(&mut self) {
|
||||
self.users_by_id.clear()
|
||||
}
|
||||
|
||||
pub fn profile_for_uid(&self, uid: UserUid) -> Option<&UserProfileData> {
|
||||
self.users_by_id.get(&uid)
|
||||
}
|
||||
|
||||
pub fn displayable_identifier_for_uid(&self, uid: UserUid) -> Option<String> {
|
||||
self.users_by_id
|
||||
.get(&uid)
|
||||
.map(UserProfileData::displayable_identifier)
|
||||
}
|
||||
|
||||
/// Get the display name for the user with the given email address. If the user is unknown,
|
||||
/// returns `None`.
|
||||
pub fn displayable_identifier_for_email(&self, email: &str) -> Option<String> {
|
||||
self.users_by_id
|
||||
.values()
|
||||
.find(|profile| profile.email == email)
|
||||
.map(UserProfileData::displayable_identifier)
|
||||
}
|
||||
}
|
||||
|
||||
impl UserProfileData {
|
||||
pub fn displayable_identifier(&self) -> String {
|
||||
self.display_name
|
||||
.as_ref()
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or(&self.email)
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for UserProfiles {
|
||||
type Event = UserProfilesEvent;
|
||||
}
|
||||
|
||||
impl SingletonEntity for UserProfiles {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,698 @@
|
||||
use crate::ai::llms::LLMModelHost;
|
||||
use crate::auth::AuthManager;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::network::NetworkStatus;
|
||||
use crate::server::cloud_objects::update_manager::UpdateManager;
|
||||
use crate::server::ids::ClientId;
|
||||
use crate::server::server_api::team::{MockTeamClient, TeamClient};
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::server::sync_queue::SyncQueue;
|
||||
use crate::server::telemetry::context_provider::AppTelemetryContextProvider;
|
||||
use crate::settings::{AISettings, CodeSettings, FocusedTerminalInfo};
|
||||
use crate::system::SystemStats;
|
||||
use crate::workflows::workflow::Workflow;
|
||||
use crate::workflows::{CloudWorkflow, CloudWorkflowModel};
|
||||
use crate::workspaces::team::Team;
|
||||
use crate::workspaces::team_tester::TeamTesterStatus;
|
||||
use crate::workspaces::update_manager::TeamUpdateManager;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::workspaces::workspace::{
|
||||
AdminEnablementSetting, CodebaseContextSettings, HostEnablementSetting, LlmHostSettings,
|
||||
Workspace,
|
||||
};
|
||||
|
||||
use mockall::Sequence;
|
||||
use settings::{PrivatePreferences, PublicPreferences};
|
||||
use std::time::Duration;
|
||||
use warpui::{AddSingletonModel, App};
|
||||
use warpui_extras::user_preferences;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
struct CachedResources {
|
||||
workspaces: Vec<Workspace>,
|
||||
}
|
||||
|
||||
fn initialize_app(
|
||||
app: &mut App,
|
||||
resources: CachedResources,
|
||||
team_client: Arc<dyn TeamClient>,
|
||||
workspace_client: Arc<dyn WorkspaceClient>,
|
||||
) {
|
||||
// Add the necessary singleton models to the App
|
||||
app.add_singleton_model(|_| NetworkStatus::new());
|
||||
app.add_singleton_model(|_| SystemStats::new());
|
||||
app.add_singleton_model(TeamTesterStatus::new);
|
||||
app.add_singleton_model(SyncQueue::mock);
|
||||
app.add_singleton_model(CloudModel::mock);
|
||||
app.add_singleton_model(|ctx| {
|
||||
UserWorkspaces::mock(
|
||||
team_client.clone(),
|
||||
workspace_client.clone(),
|
||||
resources.workspaces,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
app.add_singleton_model(|ctx| TeamUpdateManager::new(team_client.clone(), None, ctx));
|
||||
app.add_singleton_model(UpdateManager::mock);
|
||||
app.add_singleton_model(PrivacySettings::mock);
|
||||
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
|
||||
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
||||
app.add_singleton_model(AuthManager::new_for_test);
|
||||
app.add_singleton_model(AppTelemetryContextProvider::new_context_provider);
|
||||
app.add_singleton_model(|_| {
|
||||
PublicPreferences::new(Box::<user_preferences::in_memory::InMemoryPreferences>::default())
|
||||
});
|
||||
app.add_singleton_model(|_| {
|
||||
PrivatePreferences::new(Box::<user_preferences::in_memory::InMemoryPreferences>::default())
|
||||
});
|
||||
|
||||
app.add_singleton_model(CodeSettings::new_with_defaults);
|
||||
app.add_singleton_model(AISettings::new_with_defaults);
|
||||
app.add_singleton_model(FocusedTerminalInfo::new);
|
||||
|
||||
// The start of polling is normally triggered by authentication completion, but
|
||||
// we need to do it manually for tests.
|
||||
TeamTesterStatus::handle(app).update(app, |team_tester, ctx| {
|
||||
team_tester.initiate_data_pollers(false, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_loading_all_spaces_after_switching_from_offline() {
|
||||
let _flag = FeatureFlag::KnowledgeSidebar.override_enabled(true);
|
||||
|
||||
let team = Team {
|
||||
uid: 123.into(),
|
||||
name: "test".to_string(),
|
||||
invite_code: None,
|
||||
members: vec![],
|
||||
pending_email_invites: vec![],
|
||||
invite_link_domain_restrictions: vec![],
|
||||
billing_metadata: Default::default(),
|
||||
stripe_customer_id: None,
|
||||
organization_settings: Default::default(),
|
||||
is_eligible_for_discovery: false,
|
||||
has_billing_history: false,
|
||||
};
|
||||
|
||||
let workspace = Workspace {
|
||||
uid: "workspace_uid123456789".to_string().into(),
|
||||
name: "test".to_string(),
|
||||
stripe_customer_id: None,
|
||||
teams: vec![team.clone()],
|
||||
billing_metadata: Default::default(),
|
||||
bonus_grants_purchased_this_month: Default::default(),
|
||||
has_billing_history: false,
|
||||
settings: Default::default(),
|
||||
invite_code: None,
|
||||
invite_link_domain_restrictions: vec![],
|
||||
pending_email_invites: vec![],
|
||||
is_eligible_for_discovery: false,
|
||||
members: vec![],
|
||||
total_requests_used_since_last_refresh: 0,
|
||||
};
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
// Sequences used for ordering requests (so first call will return something different than
|
||||
// next etc.)
|
||||
let mut team_sequence = Sequence::new();
|
||||
|
||||
// Lets start by initializing the server api mock
|
||||
let mut team_client = MockTeamClient::new();
|
||||
|
||||
// On first call to workspaces_metadata we return no workspaces (and expect it to be called just once)
|
||||
team_client
|
||||
.expect_workspaces_metadata()
|
||||
.times(1)
|
||||
.in_sequence(&mut team_sequence)
|
||||
.returning(|| {
|
||||
Ok(WorkspacesMetadataWithPricing {
|
||||
metadata: WorkspacesMetadataResponse {
|
||||
workspaces: vec![],
|
||||
joinable_teams: vec![],
|
||||
experiments: None,
|
||||
feature_model_choices: None,
|
||||
},
|
||||
pricing_info: None,
|
||||
})
|
||||
});
|
||||
|
||||
// Second call will return list of teams (one team specifically) and we also expect only 1
|
||||
team_client
|
||||
.expect_workspaces_metadata()
|
||||
.times(1)
|
||||
.in_sequence(&mut team_sequence)
|
||||
.returning(move || {
|
||||
Ok(WorkspacesMetadataWithPricing {
|
||||
metadata: WorkspacesMetadataResponse {
|
||||
workspaces: vec![workspace.clone()],
|
||||
joinable_teams: vec![],
|
||||
experiments: None,
|
||||
feature_model_choices: None,
|
||||
},
|
||||
pricing_info: None,
|
||||
})
|
||||
});
|
||||
|
||||
initialize_app(
|
||||
&mut app,
|
||||
CachedResources { workspaces: vec![] },
|
||||
Arc::new(team_client),
|
||||
Arc::new(MockWorkspaceClient::new()),
|
||||
);
|
||||
|
||||
// We also ensure that UserWorkspaces stores no teams.
|
||||
UserWorkspaces::handle(&app).read(&app, |teams, _| {
|
||||
assert!(!teams.has_teams());
|
||||
});
|
||||
|
||||
// Spend time waiting for the initial load to finish etc.
|
||||
warpui::r#async::Timer::after(Duration::from_secs(1)).await;
|
||||
|
||||
// Lets go offline
|
||||
NetworkStatus::handle(&app).update(&mut app, |network_status, ctx| {
|
||||
network_status.reachability_changed(false, ctx);
|
||||
});
|
||||
|
||||
// Lets go back online
|
||||
NetworkStatus::handle(&app).update(&mut app, |network_status, ctx| {
|
||||
network_status.reachability_changed(true, ctx);
|
||||
});
|
||||
|
||||
// Spend time waiting for the load to finish etc.
|
||||
warpui::r#async::Timer::after(Duration::from_secs(1)).await;
|
||||
|
||||
// We also ensure that UserWorkspaces stores a team
|
||||
UserWorkspaces::handle(&app).read(&app, |teams, _| {
|
||||
assert!(teams.has_teams());
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codebase_context_enabled_with_no_workspace() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(
|
||||
&mut app,
|
||||
CachedResources { workspaces: vec![] },
|
||||
Arc::new(MockTeamClient::new()),
|
||||
Arc::new(MockWorkspaceClient::new()),
|
||||
);
|
||||
|
||||
app.read(|ctx| {
|
||||
let codebase_context_enabled =
|
||||
UserWorkspaces::as_ref(ctx).is_codebase_context_enabled(ctx);
|
||||
assert!(
|
||||
codebase_context_enabled,
|
||||
"codebase context should be on by default"
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
fn team_for_test() -> Team {
|
||||
Team {
|
||||
uid: 123.into(),
|
||||
name: "test".to_string(),
|
||||
invite_code: None,
|
||||
members: vec![],
|
||||
pending_email_invites: vec![],
|
||||
invite_link_domain_restrictions: vec![],
|
||||
billing_metadata: Default::default(),
|
||||
stripe_customer_id: None,
|
||||
organization_settings: Default::default(),
|
||||
is_eligible_for_discovery: false,
|
||||
has_billing_history: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aws_bedrock_credentials_default_off_when_admin_respects_user_setting() {
|
||||
let team = team_for_test();
|
||||
let mut workspace = workspace_for_test(&team);
|
||||
workspace.settings.llm_settings.enabled = true;
|
||||
workspace.settings.llm_settings.host_configs.insert(
|
||||
LLMModelHost::AwsBedrock,
|
||||
LlmHostSettings {
|
||||
enabled: true,
|
||||
enablement_setting: HostEnablementSetting::RespectUserSetting,
|
||||
},
|
||||
);
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(
|
||||
&mut app,
|
||||
CachedResources {
|
||||
workspaces: vec![workspace],
|
||||
},
|
||||
Arc::new(MockTeamClient::new()),
|
||||
Arc::new(MockWorkspaceClient::new()),
|
||||
);
|
||||
|
||||
app.read(|ctx| {
|
||||
assert!(
|
||||
!UserWorkspaces::as_ref(ctx).is_aws_bedrock_credentials_enabled(ctx),
|
||||
"respect-user-setting should default the local Bedrock credentials toggle to off"
|
||||
);
|
||||
assert!(
|
||||
UserWorkspaces::as_ref(ctx).is_aws_bedrock_credentials_toggleable(),
|
||||
"respect-user-setting should leave the local Bedrock credentials toggle editable"
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aws_bedrock_credentials_respect_user_setting() {
|
||||
let team = team_for_test();
|
||||
let mut workspace = workspace_for_test(&team);
|
||||
workspace.settings.llm_settings.enabled = true;
|
||||
workspace.settings.llm_settings.host_configs.insert(
|
||||
LLMModelHost::AwsBedrock,
|
||||
LlmHostSettings {
|
||||
enabled: true,
|
||||
enablement_setting: HostEnablementSetting::RespectUserSetting,
|
||||
},
|
||||
);
|
||||
let mut team_client = MockTeamClient::new();
|
||||
let workspace_for_poll = workspace.clone();
|
||||
team_client.expect_workspaces_metadata().returning(move || {
|
||||
Ok(WorkspacesMetadataWithPricing {
|
||||
metadata: WorkspacesMetadataResponse {
|
||||
workspaces: vec![workspace_for_poll.clone()],
|
||||
joinable_teams: vec![],
|
||||
experiments: None,
|
||||
feature_model_choices: None,
|
||||
},
|
||||
pricing_info: None,
|
||||
})
|
||||
});
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(
|
||||
&mut app,
|
||||
CachedResources {
|
||||
workspaces: vec![workspace],
|
||||
},
|
||||
Arc::new(team_client),
|
||||
Arc::new(MockWorkspaceClient::new()),
|
||||
);
|
||||
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
let _ = settings
|
||||
.aws_bedrock_credentials_enabled
|
||||
.set_value(false, ctx);
|
||||
});
|
||||
|
||||
app.read(|ctx| {
|
||||
assert!(
|
||||
!UserWorkspaces::as_ref(ctx).is_aws_bedrock_credentials_enabled(ctx),
|
||||
"respect-user-setting should honor the local Bedrock credentials toggle"
|
||||
);
|
||||
assert!(
|
||||
UserWorkspaces::as_ref(ctx).is_aws_bedrock_credentials_toggleable(),
|
||||
"respect-user-setting should leave the local Bedrock credentials toggle editable"
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aws_bedrock_credentials_enforced_by_admin() {
|
||||
let team = team_for_test();
|
||||
let mut workspace = workspace_for_test(&team);
|
||||
workspace.settings.llm_settings.enabled = true;
|
||||
workspace.settings.llm_settings.host_configs.insert(
|
||||
LLMModelHost::AwsBedrock,
|
||||
LlmHostSettings {
|
||||
enabled: true,
|
||||
enablement_setting: HostEnablementSetting::Enforce,
|
||||
},
|
||||
);
|
||||
let mut team_client = MockTeamClient::new();
|
||||
let workspace_for_poll = workspace.clone();
|
||||
team_client.expect_workspaces_metadata().returning(move || {
|
||||
Ok(WorkspacesMetadataWithPricing {
|
||||
metadata: WorkspacesMetadataResponse {
|
||||
workspaces: vec![workspace_for_poll.clone()],
|
||||
joinable_teams: vec![],
|
||||
experiments: None,
|
||||
feature_model_choices: None,
|
||||
},
|
||||
pricing_info: None,
|
||||
})
|
||||
});
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(
|
||||
&mut app,
|
||||
CachedResources {
|
||||
workspaces: vec![workspace],
|
||||
},
|
||||
Arc::new(MockTeamClient::new()),
|
||||
Arc::new(MockWorkspaceClient::new()),
|
||||
);
|
||||
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
let _ = settings
|
||||
.aws_bedrock_credentials_enabled
|
||||
.set_value(false, ctx);
|
||||
});
|
||||
|
||||
app.read(|ctx| {
|
||||
assert!(
|
||||
UserWorkspaces::as_ref(ctx).is_aws_bedrock_credentials_enabled(ctx),
|
||||
"enforced Bedrock host policy should ignore the local Bedrock credentials toggle"
|
||||
);
|
||||
assert!(
|
||||
!UserWorkspaces::as_ref(ctx).is_aws_bedrock_credentials_toggleable(),
|
||||
"enforced Bedrock host policy should disable the local Bedrock credentials toggle"
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
fn workspace_for_test(team: &Team) -> Workspace {
|
||||
Workspace {
|
||||
uid: "workspace_uid123456789".to_string().into(),
|
||||
name: "test".to_string(),
|
||||
stripe_customer_id: None,
|
||||
teams: vec![team.clone()],
|
||||
billing_metadata: Default::default(),
|
||||
bonus_grants_purchased_this_month: Default::default(),
|
||||
has_billing_history: false,
|
||||
settings: Default::default(),
|
||||
invite_code: None,
|
||||
invite_link_domain_restrictions: vec![],
|
||||
pending_email_invites: vec![],
|
||||
is_eligible_for_discovery: false,
|
||||
members: vec![],
|
||||
total_requests_used_since_last_refresh: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codebase_context_enabled_by_team_disabled_by_user() {
|
||||
// Enable codebase context on a team level
|
||||
let mut team = team_for_test();
|
||||
team.organization_settings.codebase_context_settings.setting = AdminEnablementSetting::Enable;
|
||||
|
||||
// Disable codebase context on the user level
|
||||
let mut workspace = workspace_for_test(&team);
|
||||
workspace.settings.codebase_context_settings = CodebaseContextSettings {
|
||||
setting: AdminEnablementSetting::Enable, // This doesn't matter since team setting overrides
|
||||
};
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(
|
||||
&mut app,
|
||||
CachedResources {
|
||||
workspaces: vec![workspace],
|
||||
},
|
||||
Arc::new(MockTeamClient::new()),
|
||||
Arc::new(MockWorkspaceClient::new()),
|
||||
);
|
||||
|
||||
app.read(|ctx| {
|
||||
let codebase_context_enabled = UserWorkspaces::as_ref(ctx)
|
||||
.is_codebase_context_enabled(ctx);
|
||||
assert!(codebase_context_enabled,
|
||||
"codebase context should be on when it's enabled by the team, regardless of user setting");
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codebase_context_enabled_by_team_and_user() {
|
||||
// Enable codebase context on a team level
|
||||
let mut team = team_for_test();
|
||||
team.organization_settings.codebase_context_settings.setting = AdminEnablementSetting::Enable;
|
||||
|
||||
// Enable codebase context on the user level (this doesn't matter since team overrides)
|
||||
let mut workspace = workspace_for_test(&team);
|
||||
workspace.settings.codebase_context_settings = CodebaseContextSettings {
|
||||
setting: AdminEnablementSetting::Enable,
|
||||
};
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(
|
||||
&mut app,
|
||||
CachedResources {
|
||||
workspaces: vec![workspace],
|
||||
},
|
||||
Arc::new(MockTeamClient::new()),
|
||||
Arc::new(MockWorkspaceClient::new()),
|
||||
);
|
||||
|
||||
app.read(|ctx| {
|
||||
let codebase_context_enabled =
|
||||
UserWorkspaces::as_ref(ctx).is_codebase_context_enabled(ctx);
|
||||
assert!(
|
||||
codebase_context_enabled,
|
||||
"codebase context should be on when it's enabled by the team"
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codebase_context_disabled_by_team() {
|
||||
// Disable codebase context on a team level
|
||||
let mut team = team_for_test();
|
||||
team.organization_settings.codebase_context_settings.setting = AdminEnablementSetting::Disable;
|
||||
|
||||
// Enable codebase context on the user level (this doesn't matter since team overrides)
|
||||
let mut workspace = workspace_for_test(&team);
|
||||
workspace.settings.codebase_context_settings = CodebaseContextSettings {
|
||||
setting: AdminEnablementSetting::Enable,
|
||||
};
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(
|
||||
&mut app,
|
||||
CachedResources {
|
||||
workspaces: vec![workspace],
|
||||
},
|
||||
Arc::new(MockTeamClient::new()),
|
||||
Arc::new(MockWorkspaceClient::new()),
|
||||
);
|
||||
|
||||
app.read(|ctx| {
|
||||
let codebase_context_enabled = UserWorkspaces::as_ref(ctx)
|
||||
.is_codebase_context_enabled(ctx);
|
||||
assert!(
|
||||
!codebase_context_enabled,
|
||||
"codebase context should be off when it's disabled by the team, regardless of the user's settings"
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codebase_context_respect_user_setting() {
|
||||
// Set team to respect user setting
|
||||
let mut team = team_for_test();
|
||||
team.organization_settings.codebase_context_settings.setting =
|
||||
AdminEnablementSetting::RespectUserSetting;
|
||||
|
||||
let workspace = workspace_for_test(&team);
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(
|
||||
&mut app,
|
||||
CachedResources {
|
||||
workspaces: vec![workspace],
|
||||
},
|
||||
Arc::new(MockTeamClient::new()),
|
||||
Arc::new(MockWorkspaceClient::new()),
|
||||
);
|
||||
|
||||
app.read(|ctx| {
|
||||
let codebase_context_enabled = UserWorkspaces::as_ref(ctx)
|
||||
.is_codebase_context_enabled(ctx);
|
||||
// Should respect user setting, which defaults to true when AI is enabled
|
||||
assert!(
|
||||
codebase_context_enabled,
|
||||
"codebase context should respect user setting when team setting is RespectUserSetting"
|
||||
);
|
||||
|
||||
// Test that team_allows_codebase_context returns the correct setting
|
||||
let team_setting = UserWorkspaces::as_ref(ctx)
|
||||
.team_allows_codebase_context();
|
||||
assert_eq!(
|
||||
team_setting,
|
||||
AdminEnablementSetting::RespectUserSetting,
|
||||
"team_allows_codebase_context should return RespectUserSetting"
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_joining_team_moves_objects() {
|
||||
let _flag = FeatureFlag::SharedWithMe.override_enabled(true);
|
||||
|
||||
let team = Team {
|
||||
uid: 123.into(),
|
||||
name: "test".to_string(),
|
||||
invite_code: None,
|
||||
members: vec![],
|
||||
pending_email_invites: vec![],
|
||||
invite_link_domain_restrictions: vec![],
|
||||
billing_metadata: Default::default(),
|
||||
stripe_customer_id: None,
|
||||
organization_settings: Default::default(),
|
||||
is_eligible_for_discovery: false,
|
||||
has_billing_history: false,
|
||||
};
|
||||
let team_uid = team.uid;
|
||||
let workspace = Workspace {
|
||||
uid: "workspace_uid123456789".to_string().into(),
|
||||
name: "test".to_string(),
|
||||
stripe_customer_id: None,
|
||||
teams: vec![team.clone()],
|
||||
billing_metadata: Default::default(),
|
||||
bonus_grants_purchased_this_month: Default::default(),
|
||||
has_billing_history: false,
|
||||
settings: Default::default(),
|
||||
invite_code: None,
|
||||
invite_link_domain_restrictions: vec![],
|
||||
pending_email_invites: vec![],
|
||||
is_eligible_for_discovery: false,
|
||||
members: vec![],
|
||||
total_requests_used_since_last_refresh: 0,
|
||||
};
|
||||
|
||||
let shared_object = CloudWorkflow::new_local(
|
||||
CloudWorkflowModel {
|
||||
data: Workflow::new("shared workflow", "echo shared"),
|
||||
},
|
||||
Owner::Team { team_uid },
|
||||
None,
|
||||
ClientId::default(),
|
||||
);
|
||||
let object_id = shared_object.id;
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(
|
||||
&mut app,
|
||||
CachedResources { workspaces: vec![] },
|
||||
Arc::new(MockTeamClient::new()),
|
||||
Arc::new(MockWorkspaceClient::new()),
|
||||
);
|
||||
CloudModel::handle(&app).update(&mut app, |cloud_model, _| {
|
||||
cloud_model.add_object(object_id, shared_object);
|
||||
});
|
||||
|
||||
// At first, the object is shared.
|
||||
app.read(|ctx| {
|
||||
assert!(!UserWorkspaces::as_ref(ctx).has_teams());
|
||||
|
||||
let space = CloudModel::as_ref(ctx)
|
||||
.get_by_uid(&object_id.uid())
|
||||
.unwrap()
|
||||
.space(ctx);
|
||||
assert_eq!(space, Space::Shared);
|
||||
});
|
||||
|
||||
// Now, the user joins the owning team.
|
||||
UserWorkspaces::handle(&app).update(&mut app, |user_workspaces, ctx| {
|
||||
user_workspaces.update_workspaces(vec![workspace], ctx);
|
||||
});
|
||||
|
||||
// This migrates the object into the team drive.
|
||||
app.read(|ctx: &AppContext| {
|
||||
let space = CloudModel::as_ref(ctx)
|
||||
.get_by_uid(&object_id.uid())
|
||||
.unwrap()
|
||||
.space(ctx);
|
||||
assert_eq!(space, Space::Team { team_uid });
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_leaving_team_moves_objects() {
|
||||
let _flag = FeatureFlag::SharedWithMe.override_enabled(true);
|
||||
|
||||
let team = Team {
|
||||
uid: 123.into(),
|
||||
name: "test".to_string(),
|
||||
invite_code: None,
|
||||
members: vec![],
|
||||
pending_email_invites: vec![],
|
||||
invite_link_domain_restrictions: vec![],
|
||||
billing_metadata: Default::default(),
|
||||
stripe_customer_id: None,
|
||||
organization_settings: Default::default(),
|
||||
is_eligible_for_discovery: false,
|
||||
has_billing_history: false,
|
||||
};
|
||||
let team_uid = team.uid;
|
||||
let workspace = Workspace {
|
||||
uid: "workspace_uid123456789".to_string().into(),
|
||||
name: "test".to_string(),
|
||||
stripe_customer_id: None,
|
||||
teams: vec![team.clone()],
|
||||
billing_metadata: Default::default(),
|
||||
bonus_grants_purchased_this_month: Default::default(),
|
||||
has_billing_history: false,
|
||||
settings: Default::default(),
|
||||
invite_code: None,
|
||||
invite_link_domain_restrictions: vec![],
|
||||
pending_email_invites: vec![],
|
||||
is_eligible_for_discovery: false,
|
||||
members: vec![],
|
||||
total_requests_used_since_last_refresh: 0,
|
||||
};
|
||||
|
||||
let shared_object = CloudWorkflow::new_local(
|
||||
CloudWorkflowModel {
|
||||
data: Workflow::new("shared workflow", "echo shared"),
|
||||
},
|
||||
Owner::Team { team_uid },
|
||||
None,
|
||||
ClientId::default(),
|
||||
);
|
||||
let object_id = shared_object.id;
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(
|
||||
&mut app,
|
||||
CachedResources {
|
||||
workspaces: vec![workspace],
|
||||
},
|
||||
Arc::new(MockTeamClient::new()),
|
||||
Arc::new(MockWorkspaceClient::new()),
|
||||
);
|
||||
CloudModel::handle(&app).update(&mut app, |cloud_model, _| {
|
||||
cloud_model.add_object(object_id, shared_object);
|
||||
});
|
||||
|
||||
// At first, the object is in the team drive.
|
||||
app.read(|ctx| {
|
||||
let space = CloudModel::as_ref(ctx)
|
||||
.get_by_uid(&object_id.uid())
|
||||
.unwrap()
|
||||
.space(ctx);
|
||||
assert_eq!(space, Space::Team { team_uid });
|
||||
});
|
||||
|
||||
// Now, the user leaves the owning team. However, the object is still shared with them.
|
||||
UserWorkspaces::handle(&app).update(&mut app, |user_workspaces, ctx| {
|
||||
user_workspaces.update_workspaces(vec![], ctx);
|
||||
});
|
||||
|
||||
// This migrates the object into the shared space.
|
||||
app.read(|ctx| {
|
||||
let space = CloudModel::as_ref(ctx)
|
||||
.get_by_uid(&object_id.uid())
|
||||
.unwrap()
|
||||
.space(ctx);
|
||||
assert_eq!(space, Space::Shared);
|
||||
});
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,777 @@
|
||||
use crate::ai::execution_profiles::{
|
||||
ActionPermission, ComputerUsePermission, WriteToPtyPermission,
|
||||
};
|
||||
use crate::ai::llms::LLMModelHost;
|
||||
use crate::{auth::UserUid, server::ids::ServerId, settings::AgentModeCommandExecutionPredicate};
|
||||
use chrono::Utc;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{cmp::Ordering, path::PathBuf};
|
||||
use warp_graphql::billing::{AddonCreditAutoReloadStatus, ServiceAgreement, ServiceAgreementType};
|
||||
|
||||
use super::team::{MembershipRole, Team};
|
||||
|
||||
#[derive(Clone, Copy, Hash, Debug, PartialEq, Eq)]
|
||||
pub struct WorkspaceUid(ServerId);
|
||||
impl From<String> for WorkspaceUid {
|
||||
fn from(uid: String) -> Self {
|
||||
WorkspaceUid(ServerId::from_string_lossy(uid))
|
||||
}
|
||||
}
|
||||
impl From<WorkspaceUid> for String {
|
||||
fn from(workspace_uid: WorkspaceUid) -> String {
|
||||
workspace_uid.0.to_string()
|
||||
}
|
||||
}
|
||||
impl From<ServerId> for WorkspaceUid {
|
||||
fn from(uid: ServerId) -> Self {
|
||||
WorkspaceUid(uid)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Workspace {
|
||||
pub uid: WorkspaceUid,
|
||||
pub name: String,
|
||||
pub stripe_customer_id: Option<String>,
|
||||
pub teams: Vec<Team>,
|
||||
pub billing_metadata: BillingMetadata,
|
||||
pub bonus_grants_purchased_this_month: BonusGrantsPurchased,
|
||||
pub has_billing_history: bool,
|
||||
pub settings: WorkspaceSettings,
|
||||
pub invite_code: Option<WorkspaceInviteCode>,
|
||||
pub invite_link_domain_restrictions: Vec<InviteLinkDomainRestriction>,
|
||||
pub pending_email_invites: Vec<EmailInvite>,
|
||||
// If the team is eligible for discovery, then show toggle for setting discoverability to the team's admin
|
||||
pub is_eligible_for_discovery: bool,
|
||||
pub members: Vec<WorkspaceMember>,
|
||||
pub total_requests_used_since_last_refresh: i32,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
pub fn from_local_cache(uid: WorkspaceUid, name: String, teams: Option<Vec<Team>>) -> Self {
|
||||
// Derive the workspace billing metadata from the first team's cached billing
|
||||
// metadata, if available. This ensures the workspace-level billing info is
|
||||
// consistent with team-level data loaded from the cache.
|
||||
let billing_metadata = teams
|
||||
.as_ref()
|
||||
.and_then(|t| t.first())
|
||||
.map(|team| team.billing_metadata.clone())
|
||||
.unwrap_or_default();
|
||||
Self {
|
||||
uid,
|
||||
name,
|
||||
stripe_customer_id: Default::default(),
|
||||
teams: teams.unwrap_or_default(),
|
||||
billing_metadata,
|
||||
bonus_grants_purchased_this_month: Default::default(),
|
||||
has_billing_history: false,
|
||||
settings: Default::default(), // TODO: persistence wrapper instead of default
|
||||
invite_code: Default::default(),
|
||||
invite_link_domain_restrictions: Default::default(),
|
||||
pending_email_invites: Default::default(),
|
||||
is_eligible_for_discovery: false,
|
||||
members: Default::default(),
|
||||
total_requests_used_since_last_refresh: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_member_by_email(&self, email: &str) -> Option<&WorkspaceMember> {
|
||||
self.members.iter().find(|member| member.email == email)
|
||||
}
|
||||
|
||||
pub fn is_workspace_admin(&self, user_email: &str) -> bool {
|
||||
self.get_member_by_email(user_email)
|
||||
.is_some_and(|member| member.role.is_admin_or_owner())
|
||||
}
|
||||
|
||||
pub fn can_be_deleted(&self, current_user_email: &str) -> bool {
|
||||
// Current user needs to be an admin and be the only user remaining
|
||||
self.is_workspace_admin(current_user_email)
|
||||
&& self.members.len() == 1
|
||||
&& self
|
||||
.members
|
||||
.first()
|
||||
.is_some_and(|m| m.email == current_user_email)
|
||||
}
|
||||
|
||||
pub fn is_custom_llm_enabled(&self) -> bool {
|
||||
self.settings.llm_settings.enabled
|
||||
}
|
||||
|
||||
pub fn are_overages_toggleable(&self) -> bool {
|
||||
self.billing_metadata
|
||||
.tier
|
||||
.usage_based_pricing_policy
|
||||
.is_some_and(|policy| policy.toggleable)
|
||||
}
|
||||
|
||||
pub fn are_overages_enabled(&self) -> bool {
|
||||
self.settings.usage_based_pricing_settings.enabled
|
||||
}
|
||||
|
||||
pub fn are_overages_remaining(&self) -> bool {
|
||||
if self.settings.usage_based_pricing_settings.enabled {
|
||||
if let Some(max_spend_cents) = self
|
||||
.settings
|
||||
.usage_based_pricing_settings
|
||||
.max_monthly_spend_cents
|
||||
{
|
||||
if let Some(ai_overages) = &self.billing_metadata.ai_overages {
|
||||
return ai_overages.current_monthly_request_cost_cents < max_spend_cents as i32;
|
||||
} else {
|
||||
// If they have the setting enabled but no overages usage so far,
|
||||
// that means they have no database entry, so they have overages remaining.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub fn is_byo_api_key_enabled(&self) -> bool {
|
||||
self.billing_metadata.is_byo_api_key_enabled()
|
||||
}
|
||||
|
||||
/// Returns true if the workspace has reached or exceeded its monthly addon credits spend limit.
|
||||
pub fn is_at_addon_credits_monthly_limit(&self) -> bool {
|
||||
if let Some(limit) = self.settings.addon_credits_settings.max_monthly_spend_cents {
|
||||
self.bonus_grants_purchased_this_month.cents_spent >= limit
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if purchasing addon credits at the given price would reach or exceed the monthly limit.
|
||||
pub fn would_addon_purchase_reach_limit(&self, price_cents: i32) -> bool {
|
||||
if let Some(limit) = self.settings.addon_credits_settings.max_monthly_spend_cents {
|
||||
self.bonus_grants_purchased_this_month.cents_spent + price_cents > limit
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the price in cents for the selected auto-reload credit denomination.
|
||||
/// Returns None if auto-reload is not configured or if the denomination can't be found in pricing options.
|
||||
pub fn get_auto_reload_price_cents(
|
||||
&self,
|
||||
addon_credits_options: &[warp_graphql::billing::AddonCreditsOption],
|
||||
) -> Option<i32> {
|
||||
let selected_credits = self
|
||||
.settings
|
||||
.addon_credits_settings
|
||||
.selected_auto_reload_credit_denomination?;
|
||||
|
||||
addon_credits_options
|
||||
.iter()
|
||||
.find(|option| option.credits == selected_credits)
|
||||
.map(|option| option.price_usd_cents)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WorkspaceInviteCode {
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Eq, PartialEq, Debug)]
|
||||
pub struct WorkspaceMember {
|
||||
pub uid: UserUid,
|
||||
pub email: String,
|
||||
pub role: MembershipRole,
|
||||
pub usage_info: WorkspaceMemberUsageInfo,
|
||||
}
|
||||
|
||||
#[derive(Clone, Eq, PartialEq, Debug)]
|
||||
pub struct WorkspaceMemberUsageInfo {
|
||||
pub is_unlimited: bool,
|
||||
pub request_limit: i32,
|
||||
pub requests_used_since_last_refresh: i32,
|
||||
pub is_request_limit_prorated: bool,
|
||||
}
|
||||
|
||||
impl PartialOrd for WorkspaceMember {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for WorkspaceMember {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.email.cmp(&other.email)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Eq, PartialEq, Debug)]
|
||||
pub struct EmailInvite {
|
||||
pub invitee_email: String,
|
||||
pub expired: bool,
|
||||
}
|
||||
|
||||
impl PartialOrd for EmailInvite {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for EmailInvite {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.invitee_email.cmp(&other.invitee_email)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Eq, PartialEq, Debug)]
|
||||
pub struct InviteLinkDomainRestriction {
|
||||
pub uid: ServerId,
|
||||
pub domain: String,
|
||||
}
|
||||
|
||||
impl PartialOrd for InviteLinkDomainRestriction {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for InviteLinkDomainRestriction {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.domain.cmp(&other.domain)
|
||||
}
|
||||
}
|
||||
|
||||
/// This enum is the rust represenation of `CustomerType` from the GraphQL Schema.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub enum CustomerType {
|
||||
#[default]
|
||||
Free,
|
||||
Turbo,
|
||||
SelfServe,
|
||||
Prosumer,
|
||||
Legacy,
|
||||
Enterprise,
|
||||
Business,
|
||||
Lightspeed,
|
||||
Build,
|
||||
BuildMax,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl CustomerType {
|
||||
pub fn to_display_string(self) -> String {
|
||||
match self {
|
||||
CustomerType::Free => "Free".to_string(),
|
||||
CustomerType::Turbo => "Turbo".to_string(),
|
||||
CustomerType::SelfServe => "Team".to_string(),
|
||||
CustomerType::Prosumer => "Pro".to_string(),
|
||||
CustomerType::Legacy => "Early adopter".to_string(),
|
||||
CustomerType::Enterprise => "Enterprise".to_string(),
|
||||
CustomerType::Business => "Business".to_string(),
|
||||
CustomerType::Lightspeed => "Lightspeed".to_string(),
|
||||
CustomerType::Build => "Build".to_string(),
|
||||
CustomerType::BuildMax => "Max".to_string(),
|
||||
CustomerType::Unknown => "".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This enum is the rust representation of `DelinquencyStatus` from the GraphQL Schema.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub enum DelinquencyStatus {
|
||||
#[default]
|
||||
NoDelinquency,
|
||||
PastDue,
|
||||
Unpaid,
|
||||
TeamLimitExceeded,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Rust representation of feature policies from the GraphQL Schema.
|
||||
#[derive(Clone, Debug, Copy, Serialize, Deserialize)]
|
||||
pub struct WarpAiPolicy {
|
||||
pub limit: i64,
|
||||
pub is_code_suggestions_toggleable: bool,
|
||||
pub is_prompt_suggestions_toggleable: bool,
|
||||
pub is_next_command_enabled: bool,
|
||||
pub is_voice_enabled: bool,
|
||||
}
|
||||
#[derive(Clone, Debug, Copy, Serialize, Deserialize)]
|
||||
pub struct WorkspaceSizePolicy {
|
||||
pub is_unlimited: bool,
|
||||
pub limit: i64,
|
||||
}
|
||||
#[derive(Clone, Debug, Copy, Serialize, Deserialize)]
|
||||
pub struct SharedNotebooksPolicy {
|
||||
pub is_unlimited: bool,
|
||||
pub limit: i64,
|
||||
}
|
||||
#[derive(Clone, Debug, Copy, Serialize, Deserialize)]
|
||||
pub struct SharedWorkflowsPolicy {
|
||||
pub is_unlimited: bool,
|
||||
pub limit: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Copy, Serialize, Deserialize)]
|
||||
pub struct SessionSharingPolicy {
|
||||
pub is_enabled: bool,
|
||||
pub max_session_size: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Copy, Serialize, Deserialize)]
|
||||
pub struct AIAutonomyPolicy {
|
||||
pub is_enabled: bool,
|
||||
pub toggleable: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Copy, Serialize, Deserialize)]
|
||||
pub struct TelemetryDataCollectionPolicy {
|
||||
pub default: bool,
|
||||
pub toggleable: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct UgcDataCollectionPolicy {
|
||||
pub default_setting: UgcCollectionEnablementSetting,
|
||||
pub toggleable: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Copy, Serialize, Deserialize)]
|
||||
pub struct UsageBasedPricingPolicy {
|
||||
pub toggleable: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Copy, Serialize, Deserialize)]
|
||||
pub struct CodebaseContextPolicy {
|
||||
pub toggleable: bool,
|
||||
pub index_limit: Option<u32>,
|
||||
pub max_files_per_repo: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Copy, Serialize, Deserialize)]
|
||||
pub struct ByoApiKeyPolicy {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Copy, Serialize, Deserialize)]
|
||||
pub struct PurchaseAddOnCreditsPolicy {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Copy, Serialize, Deserialize)]
|
||||
pub struct EnterprisePayAsYouGoPolicy {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Copy, Serialize, Deserialize)]
|
||||
pub struct EnterpriseCreditsAutoReloadPolicy {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Copy, Serialize, Deserialize)]
|
||||
pub struct MultiAdminPolicy {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Copy, Serialize, Deserialize)]
|
||||
pub struct AmbientAgentsPolicy {
|
||||
pub max_concurrent_agents: i32,
|
||||
pub instance_shape: Option<InstanceShape>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Copy, Serialize, Deserialize)]
|
||||
pub struct InstanceShape {
|
||||
pub vcpus: i32,
|
||||
pub memory_gb: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub enum HostEnablementSetting {
|
||||
Enforce,
|
||||
#[default]
|
||||
RespectUserSetting,
|
||||
}
|
||||
|
||||
/// This struct is the rust representation of `Tier` from the GraphQL Schema.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Tier {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub warp_ai_policy: Option<WarpAiPolicy>,
|
||||
pub workspace_size_policy: Option<WorkspaceSizePolicy>,
|
||||
pub shared_notebooks_policy: Option<SharedNotebooksPolicy>,
|
||||
pub shared_workflows_policy: Option<SharedWorkflowsPolicy>,
|
||||
pub session_sharing_policy: Option<SessionSharingPolicy>,
|
||||
pub ai_autonomy_policy: Option<AIAutonomyPolicy>,
|
||||
pub telemetry_data_collection_policy: Option<TelemetryDataCollectionPolicy>,
|
||||
pub ugc_data_collection_policy: Option<UgcDataCollectionPolicy>,
|
||||
pub usage_based_pricing_policy: Option<UsageBasedPricingPolicy>,
|
||||
pub codebase_context_policy: Option<CodebaseContextPolicy>,
|
||||
pub byo_api_key_policy: Option<ByoApiKeyPolicy>,
|
||||
pub purchase_add_on_credits_policy: Option<PurchaseAddOnCreditsPolicy>,
|
||||
pub enterprise_pay_as_you_go_policy: Option<EnterprisePayAsYouGoPolicy>,
|
||||
pub enterprise_credits_auto_reload_policy: Option<EnterpriseCreditsAutoReloadPolicy>,
|
||||
pub multi_admin_policy: Option<MultiAdminPolicy>,
|
||||
pub ambient_agents_policy: Option<AmbientAgentsPolicy>,
|
||||
}
|
||||
|
||||
/// This struct is the rust representation of `BillingMetadata` from the GraphQL Schema.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct BillingMetadata {
|
||||
pub tier: Tier,
|
||||
pub customer_type: CustomerType,
|
||||
pub delinquency_status: DelinquencyStatus,
|
||||
#[serde(skip)]
|
||||
pub service_agreements: Vec<ServiceAgreement>,
|
||||
#[serde(skip)]
|
||||
pub ai_overages: Option<AiOverages>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct BonusGrantsPurchased {
|
||||
pub total_credits_purchased: i32,
|
||||
pub cents_spent: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AiOverages {
|
||||
pub current_monthly_request_cost_cents: i32,
|
||||
pub current_monthly_requests_used: i32,
|
||||
pub current_period_end: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl BillingMetadata {
|
||||
/// Returns whether the current tier has a usage-based pricing policy that can be toggled.
|
||||
pub fn is_usage_based_pricing_toggleable(&self) -> bool {
|
||||
self.tier
|
||||
.usage_based_pricing_policy
|
||||
.as_ref()
|
||||
.is_some_and(|policy| policy.toggleable)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether customer can upgrade to the Build plan based on their current tier.
|
||||
*/
|
||||
pub fn can_upgrade_to_build_plan(&self) -> bool {
|
||||
match self.customer_type {
|
||||
CustomerType::Unknown
|
||||
| CustomerType::Business
|
||||
| CustomerType::Enterprise
|
||||
| CustomerType::Build
|
||||
| CustomerType::BuildMax => false,
|
||||
CustomerType::Free
|
||||
| CustomerType::Legacy
|
||||
| CustomerType::Prosumer
|
||||
| CustomerType::Turbo
|
||||
| CustomerType::SelfServe
|
||||
| CustomerType::Lightspeed => true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether customer can upgrade to the Build Max plan based on their current tier.
|
||||
* Users on Build can upgrade to Build Max.
|
||||
*/
|
||||
pub fn can_upgrade_to_build_max_plan(&self) -> bool {
|
||||
self.can_upgrade_to_build_plan() || self.customer_type == CustomerType::Build
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether customer can upgrade to a higher tier based on their current tier.
|
||||
*/
|
||||
pub fn can_upgrade_to_higher_tier_plan(&self) -> bool {
|
||||
self.can_upgrade_to_build_plan()
|
||||
}
|
||||
|
||||
pub fn is_stripe_paid_plan(customer_type: CustomerType) -> bool {
|
||||
match customer_type {
|
||||
CustomerType::Turbo
|
||||
| CustomerType::SelfServe
|
||||
| CustomerType::Prosumer
|
||||
| CustomerType::Business
|
||||
| CustomerType::Lightspeed
|
||||
| CustomerType::Build
|
||||
| CustomerType::BuildMax => true,
|
||||
CustomerType::Free
|
||||
| CustomerType::Enterprise
|
||||
| CustomerType::Legacy
|
||||
| CustomerType::Unknown => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_user_on_paid_plan(&self) -> bool {
|
||||
match self.customer_type {
|
||||
CustomerType::Turbo
|
||||
| CustomerType::SelfServe
|
||||
| CustomerType::Prosumer
|
||||
| CustomerType::Business
|
||||
| CustomerType::Lightspeed
|
||||
| CustomerType::Enterprise
|
||||
| CustomerType::Legacy
|
||||
| CustomerType::Build
|
||||
| CustomerType::BuildMax => true,
|
||||
CustomerType::Free | CustomerType::Unknown => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_on_stripe_paid_plan(&self) -> bool {
|
||||
BillingMetadata::is_stripe_paid_plan(self.customer_type)
|
||||
}
|
||||
|
||||
pub fn is_on_build_plan(&self) -> bool {
|
||||
self.customer_type == CustomerType::Build
|
||||
}
|
||||
|
||||
pub fn is_on_build_max_plan(&self) -> bool {
|
||||
self.customer_type == CustomerType::BuildMax
|
||||
}
|
||||
|
||||
pub fn is_on_build_business_plan(&self) -> bool {
|
||||
self.customer_type == CustomerType::Business
|
||||
}
|
||||
|
||||
pub fn is_on_legacy_paid_plan(&self) -> bool {
|
||||
match self.customer_type {
|
||||
CustomerType::Prosumer
|
||||
| CustomerType::Turbo
|
||||
| CustomerType::Lightspeed
|
||||
| CustomerType::SelfServe => true,
|
||||
CustomerType::Business => {
|
||||
// Legacy Business has a non-SelfServe service agreement type;
|
||||
// Build Business uses SelfServe. See gql_convert.rs for context.
|
||||
!matches!(
|
||||
self.service_agreements.first().map(|sa| &sa.type_),
|
||||
Some(ServiceAgreementType::SelfServe)
|
||||
)
|
||||
}
|
||||
CustomerType::Free
|
||||
| CustomerType::Legacy
|
||||
| CustomerType::Enterprise
|
||||
| CustomerType::Build
|
||||
| CustomerType::BuildMax
|
||||
| CustomerType::Unknown => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_delinquent_due_to_payment_issue(&self) -> bool {
|
||||
self.delinquency_status == DelinquencyStatus::PastDue
|
||||
|| self.delinquency_status == DelinquencyStatus::Unpaid
|
||||
}
|
||||
|
||||
// Whether the enterprise customer is our Stable Warp Enterprise team (internal team of Warpers).
|
||||
pub fn is_warp_plan(&self) -> bool {
|
||||
self.tier.name == "Warp Plan"
|
||||
}
|
||||
|
||||
pub fn has_active_subscription(&self) -> bool {
|
||||
if let Some(newest_service_agreement) = self.service_agreements.first() {
|
||||
let not_expired = Utc::now() < newest_service_agreement.current_period_end.utc();
|
||||
let not_delinquent = !self.is_delinquent_due_to_payment_issue();
|
||||
not_expired && not_delinquent
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_byo_api_key_enabled(&self) -> bool {
|
||||
self.tier
|
||||
.byo_api_key_policy
|
||||
.is_some_and(|policy| policy.enabled)
|
||||
}
|
||||
|
||||
pub fn has_overages_used(&self) -> bool {
|
||||
self.ai_overages
|
||||
.as_ref()
|
||||
.is_some_and(|ai_overages| ai_overages.current_monthly_requests_used > 0)
|
||||
}
|
||||
|
||||
pub fn has_failed_addon_credit_auto_reload_status(&self) -> bool {
|
||||
self.service_agreements
|
||||
.first()
|
||||
.and_then(|sa| sa.addon_credit_auto_reload_status)
|
||||
.is_some_and(|status| matches!(status, AddonCreditAutoReloadStatus::Failed))
|
||||
}
|
||||
|
||||
pub fn is_enterprise_pay_as_you_go_enabled(&self) -> bool {
|
||||
self.customer_type == CustomerType::Enterprise
|
||||
&& self
|
||||
.tier
|
||||
.enterprise_pay_as_you_go_policy
|
||||
.is_some_and(|policy| policy.enabled)
|
||||
}
|
||||
|
||||
pub fn is_enterprise_auto_reload_enabled(&self) -> bool {
|
||||
self.customer_type == CustomerType::Enterprise
|
||||
&& self
|
||||
.tier
|
||||
.enterprise_credits_auto_reload_policy
|
||||
.is_some_and(|policy| policy.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct LlmHostSettings {
|
||||
pub enabled: bool,
|
||||
pub enablement_setting: HostEnablementSetting,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct LlmSettings {
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub host_configs: std::collections::HashMap<LLMModelHost, LlmHostSettings>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct TelemetrySettings {
|
||||
pub force_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub enum UgcCollectionEnablementSetting {
|
||||
Disable,
|
||||
Enable,
|
||||
#[default]
|
||||
RespectUserSetting,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct UgcCollectionSettings {
|
||||
pub setting: UgcCollectionEnablementSetting,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub enum AdminEnablementSetting {
|
||||
Disable,
|
||||
Enable,
|
||||
#[default]
|
||||
RespectUserSetting,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct CloudConversationStorageSettings {
|
||||
pub setting: AdminEnablementSetting,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct AiPermissionsSettings {
|
||||
pub allow_ai_in_remote_sessions: bool,
|
||||
#[serde(with = "serde_regex")]
|
||||
pub remote_session_regex_list: Vec<Regex>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct AiAutonomySettings {
|
||||
pub apply_code_diffs_setting: Option<ActionPermission>,
|
||||
pub read_files_setting: Option<ActionPermission>,
|
||||
pub read_files_allowlist: Option<Vec<PathBuf>>,
|
||||
pub execute_commands_setting: Option<ActionPermission>,
|
||||
pub execute_commands_allowlist: Option<Vec<AgentModeCommandExecutionPredicate>>,
|
||||
pub execute_commands_denylist: Option<Vec<AgentModeCommandExecutionPredicate>>,
|
||||
pub write_to_pty_setting: Option<WriteToPtyPermission>,
|
||||
pub computer_use_setting: Option<ComputerUsePermission>,
|
||||
}
|
||||
|
||||
impl AiAutonomySettings {
|
||||
pub fn has_any_overrides(&self) -> bool {
|
||||
self.apply_code_diffs_setting.is_some()
|
||||
|| self.read_files_setting.is_some()
|
||||
|| self.read_files_allowlist.is_some()
|
||||
|| self.execute_commands_setting.is_some()
|
||||
|| self.execute_commands_allowlist.is_some()
|
||||
|| self.execute_commands_denylist.is_some()
|
||||
|| self.write_to_pty_setting.is_some()
|
||||
|| self.computer_use_setting.is_some()
|
||||
}
|
||||
|
||||
pub fn has_override_for_code_diffs(&self) -> bool {
|
||||
self.apply_code_diffs_setting.is_some()
|
||||
}
|
||||
|
||||
pub fn has_override_for_read_files(&self) -> bool {
|
||||
self.read_files_setting.is_some()
|
||||
}
|
||||
|
||||
pub fn has_override_for_read_files_allowlist(&self) -> bool {
|
||||
self.read_files_allowlist.is_some()
|
||||
}
|
||||
|
||||
pub fn has_override_for_execute_commands(&self) -> bool {
|
||||
self.execute_commands_setting.is_some()
|
||||
}
|
||||
|
||||
pub fn has_override_for_execute_commands_allowlist(&self) -> bool {
|
||||
self.execute_commands_allowlist.is_some()
|
||||
}
|
||||
|
||||
pub fn has_override_for_execute_commands_denylist(&self) -> bool {
|
||||
self.execute_commands_denylist.is_some()
|
||||
}
|
||||
|
||||
pub fn has_override_for_write_to_pty(&self) -> bool {
|
||||
self.write_to_pty_setting.is_some()
|
||||
}
|
||||
|
||||
pub fn has_override_for_computer_use(&self) -> bool {
|
||||
self.computer_use_setting.is_some()
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct LinkSharingSettings {
|
||||
pub anyone_with_link_sharing_enabled: bool,
|
||||
pub direct_link_sharing_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct EnterpriseSecretRegex {
|
||||
pub pattern: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct SecretRedactionSettings {
|
||||
pub enabled: bool,
|
||||
pub regexes: Vec<EnterpriseSecretRegex>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct UsageBasedPricingSettings {
|
||||
pub enabled: bool,
|
||||
pub max_monthly_spend_cents: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct AddonCreditsSettings {
|
||||
pub auto_reload_enabled: bool,
|
||||
pub max_monthly_spend_cents: Option<i32>,
|
||||
pub selected_auto_reload_credit_denomination: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct CodebaseContextSettings {
|
||||
pub setting: AdminEnablementSetting,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct SandboxedAgentSettings {
|
||||
pub execute_commands_denylist: Option<Vec<AgentModeCommandExecutionPredicate>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct WorkspaceSettings {
|
||||
pub llm_settings: LlmSettings,
|
||||
pub telemetry_settings: TelemetrySettings,
|
||||
pub ugc_collection_settings: UgcCollectionSettings,
|
||||
pub cloud_conversation_storage_settings: CloudConversationStorageSettings,
|
||||
pub link_sharing_settings: LinkSharingSettings,
|
||||
pub secret_redaction_settings: SecretRedactionSettings,
|
||||
pub ai_permissions_settings: AiPermissionsSettings,
|
||||
pub ai_autonomy_settings: AiAutonomySettings,
|
||||
pub is_invite_link_enabled: bool,
|
||||
pub is_discoverable: bool,
|
||||
pub usage_based_pricing_settings: UsageBasedPricingSettings,
|
||||
pub addon_credits_settings: AddonCreditsSettings,
|
||||
pub codebase_context_settings: CodebaseContextSettings,
|
||||
pub sandboxed_agent_settings: Option<SandboxedAgentSettings>,
|
||||
}
|
||||
Reference in New Issue
Block a user