Option to Notify Workspace Owner When Usage Limit is Reached (#16969)

## Summary
- Replace the manual `/notify-owner` flow with an inline confirmation
prompt when a usage-based workspace member hits a credits-depleted
limit.
- Fetch the current workspace role from the live ChatGPT
`accounts/check/v4-2023-04-27` endpoint so owner/member behavior matches
the desktop and web clients.
- Keep owner, member, and spend-cap messaging distinct so we only offer
the owner nudge when the workspace is actually out of credits.

## What Changed
- `backend-client`
- Added a typed fetch for the current account role from
`accounts/check`.
  - Mapped backend role values into a Rust workspace-role enum.
- `app-server` and protocol
  - Added `workspaceRole` to `account/read` and `account/updated`.
- Derived `isWorkspaceOwner` from the live role, with a fallback to the
cached token claim when the role fetch is unavailable.
- `tui`
  - Removed the explicit `/notify-owner` slash command.
- When a member is blocked because the workspace is out of credits, the
error now prompts:
- `Your workspace is out of credits. Request more from your workspace
owner? [y/N]`
  - Choosing `y` sends the existing owner-notification request.
- Choosing `n`, pressing `Esc`, or accepting the default selection
dismisses the prompt without sending anything.
- Selection popups now honor explicit item shortcuts, which is how the
`y` / `n` interaction is wired.

## Reviewer Notes
- The main behavior change is scoped to usage-based workspace members
whose workspace credits are depleted.
- Spend-cap reached should not show the owner-notification prompt.
- Owners and admins should continue to see `/usage` guidance instead of
the member prompt.
- The live role fetch is best-effort; if it fails, we fall back to the
existing token-derived ownership signal.

## Testing
- Manual verification
  - Workspace owner does not see the member prompt.
- Workspace member with depleted credits sees the confirmation prompt
and can send the nudge with `y`.
- Workspace member with spend cap reached does not see the
owner-notification prompt.

### Workspace member out of usage

https://github.com/user-attachments/assets/341ac396-eff4-4a7f-bf0c-60660becbea1

### Workspace owner
<img width="1728" height="1086" alt="Screenshot 2026-04-09 at 11 48
22 AM"
src="https://github.com/user-attachments/assets/06262a45-e3fc-4cc4-8326-1cbedad46ed6"
/>
This commit is contained in:
richardopenai
2026-04-09 21:15:17 -07:00
committed by GitHub
parent 36712d8546
commit 9f2a585153
82 changed files with 3233 additions and 60 deletions

View File

@@ -116,6 +116,8 @@ use codex_app_server_protocol::SkillsConfigWriteResponse;
use codex_app_server_protocol::SkillsListParams;
use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadAddCreditsNudgeEmailParams;
use codex_app_server_protocol::ThreadAddCreditsNudgeEmailResponse;
use codex_app_server_protocol::ThreadArchiveParams;
use codex_app_server_protocol::ThreadArchiveResponse;
use codex_app_server_protocol::ThreadArchivedNotification;
@@ -184,6 +186,7 @@ use codex_app_server_protocol::WindowsSandboxSetupCompletedNotification;
use codex_app_server_protocol::WindowsSandboxSetupMode;
use codex_app_server_protocol::WindowsSandboxSetupStartParams;
use codex_app_server_protocol::WindowsSandboxSetupStartResponse;
use codex_app_server_protocol::WorkspaceRole;
use codex_app_server_protocol::build_turns_from_rollout_items;
use codex_arg0::Arg0DispatchPaths;
use codex_backend_client::Client as BackendClient;
@@ -200,6 +203,7 @@ use codex_core::SteerInputError;
use codex_core::ThreadConfigSnapshot;
use codex_core::ThreadManager;
use codex_core::ThreadSortKey as CoreThreadSortKey;
use codex_core::WorkspaceRole as CoreWorkspaceRole;
use codex_core::append_thread_name;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
@@ -486,6 +490,96 @@ pub(crate) struct CodexMessageProcessorArgs {
pub(crate) log_db: Option<LogDbLayer>,
}
async fn resolve_workspace_role_and_owner_for_auth(
chatgpt_base_url: &str,
auth: Option<&CodexAuth>,
) -> (Option<WorkspaceRole>, Option<bool>) {
let ownership =
codex_core::resolve_workspace_role_and_owner_for_auth(chatgpt_base_url, auth).await;
(
ownership.workspace_role.map(workspace_role_to_v2),
ownership.is_workspace_owner,
)
}
fn workspace_role_to_v2(role: CoreWorkspaceRole) -> WorkspaceRole {
match role {
CoreWorkspaceRole::AccountOwner => WorkspaceRole::AccountOwner,
CoreWorkspaceRole::AccountAdmin => WorkspaceRole::AccountAdmin,
CoreWorkspaceRole::StandardUser => WorkspaceRole::StandardUser,
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct AuthIdentity {
auth_mode: AuthMode,
account_id: Option<String>,
account_email: Option<String>,
chatgpt_user_id: Option<String>,
}
fn auth_identity(auth: &CodexAuth) -> AuthIdentity {
AuthIdentity {
auth_mode: auth.api_auth_mode(),
account_id: auth.get_account_id(),
account_email: auth.get_account_email(),
chatgpt_user_id: auth.get_chatgpt_user_id(),
}
}
fn cached_workspace_role_and_owner_for_auth(
auth: Option<&CodexAuth>,
) -> (Option<WorkspaceRole>, Option<bool>) {
(None, auth.and_then(CodexAuth::is_workspace_owner))
}
fn account_updated_notification_for_auth(
auth: Option<&CodexAuth>,
workspace_role: Option<WorkspaceRole>,
is_workspace_owner: Option<bool>,
) -> AccountUpdatedNotification {
AccountUpdatedNotification {
auth_mode: auth.map(CodexAuth::api_auth_mode),
plan_type: auth.and_then(CodexAuth::account_plan_type),
workspace_role,
is_workspace_owner,
}
}
fn spawn_live_workspace_role_update_for_auth(
outgoing: Arc<OutgoingMessageSender>,
auth_manager: Arc<AuthManager>,
chatgpt_base_url: String,
auth: Option<CodexAuth>,
) {
let Some(auth) = auth.filter(CodexAuth::is_chatgpt_auth) else {
return;
};
let expected_identity = auth_identity(&auth);
tokio::spawn(async move {
let (workspace_role, is_workspace_owner) =
resolve_workspace_role_and_owner_for_auth(&chatgpt_base_url, Some(&auth)).await;
let Some(workspace_role) = workspace_role else {
return;
};
let current_auth = auth_manager.auth_cached();
if current_auth.as_ref().map(auth_identity) != Some(expected_identity) {
return;
}
let payload = account_updated_notification_for_auth(
current_auth.as_ref(),
Some(workspace_role),
is_workspace_owner,
);
outgoing
.send_server_notification(ServerNotification::AccountUpdated(payload))
.await;
});
}
impl CodexMessageProcessor {
pub(crate) fn handle_config_mutation(&self) {
self.clear_plugin_related_caches();
@@ -498,10 +592,18 @@ impl CodexMessageProcessor {
fn current_account_updated_notification(&self) -> AccountUpdatedNotification {
let auth = self.auth_manager.auth_cached();
AccountUpdatedNotification {
auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode),
plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type),
}
let (workspace_role, is_workspace_owner) =
cached_workspace_role_and_owner_for_auth(auth.as_ref());
account_updated_notification_for_auth(auth.as_ref(), workspace_role, is_workspace_owner)
}
fn spawn_live_workspace_role_update(&self, auth: Option<CodexAuth>) {
spawn_live_workspace_role_update_for_auth(
self.outgoing.clone(),
self.auth_manager.clone(),
self.config.chatgpt_base_url.clone(),
auth,
);
}
async fn load_thread(
@@ -785,6 +887,10 @@ impl CodexMessageProcessor {
self.thread_shell_command(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::ThreadAddCreditsNudgeEmail { request_id, params } => {
self.thread_add_credits_nudge_email(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::SkillsList { request_id, params } => {
self.skills_list(to_connection_request_id(request_id), params)
.await;
@@ -1104,6 +1210,7 @@ impl CodexMessageProcessor {
self.current_account_updated_notification(),
))
.await;
self.spawn_live_workspace_role_update(self.auth_manager.auth_cached());
}
Err(error) => {
self.outgoing.send_error(request_id, error).await;
@@ -1224,7 +1331,7 @@ impl CodexMessageProcessor {
replace_cloud_requirements_loader(
cloud_requirements.as_ref(),
auth_manager.clone(),
chatgpt_base_url,
chatgpt_base_url.clone(),
codex_home,
);
sync_default_client_residency_requirement(
@@ -1233,17 +1340,27 @@ impl CodexMessageProcessor {
)
.await;
// Notify clients with the actual current auth mode.
// Notify clients with the actual current auth mode immediately; the
// live workspace role is fetched below without delaying this update.
let auth = auth_manager.auth_cached();
let payload_v2 = AccountUpdatedNotification {
auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode),
plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type),
};
let (workspace_role, is_workspace_owner) =
cached_workspace_role_and_owner_for_auth(auth.as_ref());
let payload_v2 = account_updated_notification_for_auth(
auth.as_ref(),
workspace_role,
is_workspace_owner,
);
outgoing_clone
.send_server_notification(ServerNotification::AccountUpdated(
payload_v2,
))
.await;
spawn_live_workspace_role_update_for_auth(
outgoing_clone.clone(),
auth_manager.clone(),
chatgpt_base_url.clone(),
auth,
);
}
// Clear the active login if it matches this attempt. It may have been replaced or cancelled.
@@ -1338,7 +1455,7 @@ impl CodexMessageProcessor {
replace_cloud_requirements_loader(
cloud_requirements.as_ref(),
auth_manager.clone(),
chatgpt_base_url,
chatgpt_base_url.clone(),
codex_home,
);
sync_default_client_residency_requirement(
@@ -1348,15 +1465,24 @@ impl CodexMessageProcessor {
.await;
let auth = auth_manager.auth_cached();
let payload_v2 = AccountUpdatedNotification {
auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode),
plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type),
};
let (workspace_role, is_workspace_owner) =
cached_workspace_role_and_owner_for_auth(auth.as_ref());
let payload_v2 = account_updated_notification_for_auth(
auth.as_ref(),
workspace_role,
is_workspace_owner,
);
outgoing_clone
.send_server_notification(ServerNotification::AccountUpdated(
payload_v2,
))
.await;
spawn_live_workspace_role_update_for_auth(
outgoing_clone.clone(),
auth_manager.clone(),
chatgpt_base_url.clone(),
auth,
);
}
let mut guard = active_login.lock().await;
@@ -1505,6 +1631,7 @@ impl CodexMessageProcessor {
self.current_account_updated_notification(),
))
.await;
self.spawn_live_workspace_role_update(self.auth_manager.auth_cached());
}
async fn logout_common(&self) -> std::result::Result<Option<AuthMode>, JSONRPCErrorError> {
@@ -1542,6 +1669,8 @@ impl CodexMessageProcessor {
let payload_v2 = AccountUpdatedNotification {
auth_mode: current_auth_method,
plan_type: None,
workspace_role: None,
is_workspace_owner: None,
};
self.outgoing
.send_server_notification(ServerNotification::AccountUpdated(payload_v2))
@@ -1640,13 +1769,16 @@ impl CodexMessageProcessor {
if !requires_openai_auth {
let response = GetAccountResponse {
account: None,
workspace_role: None,
is_workspace_owner: None,
requires_openai_auth,
};
self.outgoing.send_response(request_id, response).await;
return;
}
let account = match self.auth_manager.auth_cached() {
let auth = self.auth_manager.auth_cached();
let account = match auth.as_ref() {
Some(auth) => match auth.auth_mode() {
CoreAuthMode::ApiKey => Some(Account::ApiKey {}),
CoreAuthMode::Chatgpt | CoreAuthMode::ChatgptAuthTokens => {
@@ -1673,12 +1805,17 @@ impl CodexMessageProcessor {
},
None => None,
};
let (workspace_role, is_workspace_owner) =
cached_workspace_role_and_owner_for_auth(auth.as_ref());
let response = GetAccountResponse {
account,
workspace_role,
is_workspace_owner,
requires_openai_auth,
};
self.outgoing.send_response(request_id, response).await;
self.spawn_live_workspace_role_update(auth);
}
async fn get_account_rate_limits(&self, request_id: ConnectionRequestId) {
@@ -1696,6 +1833,11 @@ impl CodexMessageProcessor {
self.outgoing.send_response(request_id, response).await;
}
Err(error) => {
tracing::warn!(
?request_id,
error = %error.message,
"account/rateLimits/read request failed"
);
self.outgoing.send_error(request_id, error).await;
}
}
@@ -3396,6 +3538,40 @@ impl CodexMessageProcessor {
}
}
async fn thread_add_credits_nudge_email(
&self,
request_id: ConnectionRequestId,
params: ThreadAddCreditsNudgeEmailParams,
) {
let ThreadAddCreditsNudgeEmailParams { thread_id } = params;
let (_, thread) = match self.load_thread(&thread_id).await {
Ok(v) => v,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
};
match self
.submit_core_op(&request_id, thread.as_ref(), Op::SendAddCreditsNudgeEmail)
.await
{
Ok(_) => {
self.outgoing
.send_response(request_id, ThreadAddCreditsNudgeEmailResponse {})
.await;
}
Err(err) => {
self.send_internal_error(
request_id,
format!("failed to request add-credits nudge email: {err}"),
)
.await;
}
}
}
async fn thread_list(&self, request_id: ConnectionRequestId, params: ThreadListParams) {
let ThreadListParams {
cursor,