Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+7
View File
@@ -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;
+164
View File
@@ -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
}
}
+38
View File
@@ -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 {}
+526
View File
@@ -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;
+218
View File
@@ -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()
]
);
});
});
}
+122
View File
@@ -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
+698
View File
@@ -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);
});
})
}
+777
View File
@@ -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>,
}