From 34a4ecb746846e61f0ece93020969c631cf36801 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Tue, 12 May 2026 13:52:57 +0800 Subject: [PATCH 1/2] {"schema":"decodex/commit/1","summary":"Add fixed Codex account selection","authority":"manual"} --- README.md | 4 +- apps/decodex/src/agent/codex_accounts.rs | 340 +++++++++++++----- apps/decodex/src/config.rs | 12 + apps/decodex/src/orchestrator.rs | 2 +- apps/decodex/src/orchestrator/entrypoints.rs | 4 + .../src/orchestrator/operator_dashboard.html | 153 +++++++- .../decodex/src/orchestrator/operator_http.rs | 151 ++++++++ apps/decodex/src/orchestrator/status.rs | 15 + apps/decodex/src/orchestrator/tests.rs | 4 +- .../tests/operator/status/agent_evidence.rs | 4 + .../tests/operator/status/dashboard.rs | 12 +- .../tests/operator/status/http.rs | 42 +++ .../tests/operator/status/text.rs | 24 ++ apps/decodex/src/orchestrator/types.rs | 7 + decodex.example.toml | 1 + docs/reference/operator-control-plane.md | 2 +- docs/runbook/self-dogfood-pilot.md | 4 +- docs/spec/app-server.md | 20 +- 18 files changed, 694 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index d5c399ea..53417a0b 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,9 @@ Project contracts are managed outside checkouts under The redacted template for a project config lives at `decodex.example.toml`. When a project enables `[codex.accounts]`, the shared ChatGPT account pool is `~/.codex/decodex/accounts.jsonl`; it is global Decodex state, not a project-local -file, and project configs do not own an account-pool path override. +file, and project configs do not own an account-pool path override. Set +`[codex.accounts].fixed_account` to pin runs to one account; the operator dashboard can +write the same project config field from the Accounts UI. `decodex diagnose --json` writes the local agent evidence index under `~/.codex/decodex/agent-evidence//` and prints the same handoff index for diff --git a/apps/decodex/src/agent/codex_accounts.rs b/apps/decodex/src/agent/codex_accounts.rs index 05b95b7c..55d9d9f4 100644 --- a/apps/decodex/src/agent/codex_accounts.rs +++ b/apps/decodex/src/agent/codex_accounts.rs @@ -37,22 +37,25 @@ pub(crate) struct CodexAccountPool { path: PathBuf, usage_endpoint: String, refresh_endpoint: String, + fixed_account: Option, client: Client, selected_account_id: Mutex>, } impl CodexAccountPool { pub(crate) fn from_config(config: &ProjectCodexAccountsConfig) -> crate::prelude::Result { - Self::new( + Self::new_with_fixed_account( runtime::accounts_path()?, config.usage_endpoint().unwrap_or(DEFAULT_USAGE_ENDPOINT), config.refresh_endpoint().unwrap_or(DEFAULT_REFRESH_ENDPOINT), + config.fixed_account(), ) } - pub(crate) fn new( + fn new_with_fixed_account( path: impl AsRef, usage_endpoint: impl Into, refresh_endpoint: impl Into, + fixed_account: Option<&str>, ) -> crate::prelude::Result { let client = Client::builder().timeout(HTTP_TIMEOUT).build()?; @@ -60,6 +63,10 @@ impl CodexAccountPool { path: path.as_ref().to_path_buf(), usage_endpoint: usage_endpoint.into(), refresh_endpoint: refresh_endpoint.into(), + fixed_account: fixed_account + .map(str::trim) + .filter(|selector| !selector.is_empty()) + .map(str::to_owned), client, selected_account_id: Mutex::new(None), }) @@ -111,80 +118,24 @@ impl CodexAccountPool { &self, records: &mut [AccountPoolRecord], ) -> crate::prelude::Result { + if let Some(selector) = self.fixed_account.as_deref() { + return self.select_fixed_from_records(records, selector); + } + let now = OffsetDateTime::now_utc().unix_timestamp(); let mut candidates = Vec::new(); let mut skipped = Vec::new(); let mut records_changed = false; for (index, record) in records.iter_mut().enumerate() { - if record.disabled { - skipped.push(format!("line {} disabled", index + 1)); - - continue; - } - if record.cooldown_until_unix_epoch.is_some_and(|cooldown| cooldown > now) { - skipped.push(format!("line {} cooling down", index + 1)); - - continue; - } - if record.account_id().is_none() { - skipped.push(format!("line {} missing account id", index + 1)); - - continue; - } - if record.access_token().is_none() { - skipped.push(format!("line {} missing access token", index + 1)); - - continue; - } - - let refresh_status = match self.proactive_refresh_record(record, now) { - Ok(status) => { - if status == RefreshStatus::Succeeded { - records_changed = true; - } - - status.as_str() - }, - Err(error) if error.requires_skip => { - skipped.push(format!( - "{} proactive refresh failed: {}", - record.display_name(), - error.source - )); - - continue; - }, - Err(error) => { - skipped.push(format!( - "{} proactive refresh failed; probing existing token: {}", - record.display_name(), - error.source - )); - - RefreshStatus::Failed.as_str() - }, - }; - - match self.probe_record_usage(record) { - Ok(usage) => candidates.push(record.login_from_usage(usage, refresh_status)?), - Err(error) if error.unauthorized && record.refresh_token().is_some() => { - self.refresh_record(record)?; - - records_changed = true; - - let usage = self.probe_record_usage(record).map_err(|retry_error| { - eyre::eyre!( - "Codex account `{}` refreshed but usage probe still failed: {retry_error}", - record.display_name() - ) - })?; - - candidates.push(record.login_from_usage(usage, "succeeded")?); - }, - Err(error) => { - skipped.push(format!("{} usage probe failed: {error}", record.display_name())); - }, + match self.account_candidate_from_record( + record, + index + 1, + now, + &mut records_changed, + )? { + Ok(candidate) => candidates.push(candidate), + Err(reason) => skipped.push(reason), } } @@ -224,6 +175,103 @@ impl CodexAccountPool { Ok(selected) } + fn select_fixed_from_records( + &self, + records: &mut [AccountPoolRecord], + selector: &str, + ) -> crate::prelude::Result { + let record_index = self.fixed_record_index(records, selector)?; + let now = OffsetDateTime::now_utc().unix_timestamp(); + let mut records_changed = false; + let mut selected = match self.account_candidate_from_record( + &mut records[record_index], + record_index + 1, + now, + &mut records_changed, + )? { + Ok(candidate) => candidate, + Err(reason) => { + eyre::bail!( + "Configured Codex fixed account `{selector}` from `{}` is not usable: {reason}", + self.path.display() + ); + }, + }; + + selected.mark_selected(now); + records[record_index].last_selected_at_unix_epoch = Some(now); + records_changed = true; + + let account_summaries = account_summaries(&selected, &[]); + let selected = selected.with_account_summaries(account_summaries); + + if records_changed { + self.save_records(records)?; + } + + self.remember_selected_account(&selected.account_id)?; + + Ok(selected) + } + + fn account_candidate_from_record( + &self, + record: &mut AccountPoolRecord, + line_number: usize, + now: i64, + records_changed: &mut bool, + ) -> crate::prelude::Result> { + if record.disabled { + return Ok(Err(format!("line {line_number} disabled"))); + } + if record.cooldown_until_unix_epoch.is_some_and(|cooldown| cooldown > now) { + return Ok(Err(format!("line {line_number} cooling down"))); + } + if record.account_id().is_none() { + return Ok(Err(format!("line {line_number} missing account id"))); + } + if record.access_token().is_none() { + return Ok(Err(format!("line {line_number} missing access token"))); + } + + let refresh_status = match self.proactive_refresh_record(record, now) { + Ok(status) => { + if status == RefreshStatus::Succeeded { + *records_changed = true; + } + + status.as_str() + }, + Err(error) if error.requires_skip => { + return Ok(Err(format!( + "{} proactive refresh failed: {}", + record.display_name(), + error.source + ))); + }, + Err(_error) => RefreshStatus::Failed.as_str(), + }; + + match self.probe_record_usage(record) { + Ok(usage) => Ok(Ok(record.login_from_usage(usage, refresh_status)?)), + Err(error) if error.unauthorized && record.refresh_token().is_some() => { + self.refresh_record(record)?; + + *records_changed = true; + + let usage = self.probe_record_usage(record).map_err(|retry_error| { + eyre::eyre!( + "Codex account `{}` refreshed but usage probe still failed: {retry_error}", + record.display_name() + ) + })?; + + Ok(Ok(record.login_from_usage(usage, "succeeded")?)) + }, + Err(error) => Ok(Err(format!("{} usage probe failed: {error}", record.display_name()))), + } + } + fn probe_account_activity_summaries( &self, records: &mut [AccountPoolRecord], @@ -342,16 +390,23 @@ impl CodexAccountPool { records: &mut [AccountPoolRecord], previous_account_id: Option<&str>, ) -> crate::prelude::Result { - let selected_account_id = self.selected_account_id()?; - let target_account_id = previous_account_id.or(selected_account_id.as_deref()); - let Some(record_index) = records.iter().position(|record| { - target_account_id.is_none_or(|target| record.account_id() == Some(target)) - }) else { - eyre::bail!( - "Codex account refresh requested an account that is not in the configured accounts." - ); + let record_index = if let Some(selector) = self.fixed_account.as_deref() { + self.fixed_record_index(records, selector)? + } else { + let selected_account_id = self.selected_account_id()?; + let target_account_id = previous_account_id.or(selected_account_id.as_deref()); + + records + .iter() + .position(|record| { + target_account_id.is_none_or(|target| record.account_id() == Some(target)) + }) + .ok_or_else(|| { + eyre::eyre!( + "Codex account refresh requested an account that is not in the configured accounts." + ) + })? }; - self.refresh_record(&mut records[record_index])?; let usage = self.probe_record_usage(&records[record_index])?; @@ -371,6 +426,32 @@ impl CodexAccountPool { Ok(selected) } + fn fixed_record_index( + &self, + records: &[AccountPoolRecord], + selector: &str, + ) -> crate::prelude::Result { + let matches = records + .iter() + .enumerate() + .filter_map(|(index, record)| { + record.matches_account_selector(selector).then_some(index) + }) + .collect::>(); + + match matches.as_slice() { + [] => eyre::bail!( + "Configured Codex fixed account `{selector}` does not match any account in `{}`.", + self.path.display() + ), + [index] => Ok(*index), + _ => eyre::bail!( + "Configured Codex fixed account `{selector}` matched multiple accounts in `{}`.", + self.path.display() + ), + } + } + fn probe_record_usage( &self, record: &AccountPoolRecord, @@ -631,6 +712,14 @@ impl AccountPoolRecord { .unwrap_or_else(|| String::from("unnamed account")) } + fn matches_account_selector(&self, selector: &str) -> bool { + let selector = selector.trim(); + + self.email().as_deref() == Some(selector) + || self.account_id() == Some(selector) + || self.account_id().map(redact_account_id).as_deref() == Some(selector) + } + fn access_token(&self) -> Option<&str> { self.tokens .as_ref() @@ -1157,9 +1246,19 @@ const fn is_false(value: &bool) -> bool { #[cfg(test)] mod tests { + use std::{ + fs, + io::{Read, Write}, + net::TcpListener, + thread, + }; + + use tempfile::TempDir; + use crate::agent::codex_accounts::{ - self, AccountPoolRecord, CodexAccountActivitySummary, CodexAccountLogin, CodexTokenData, - CreditsSnapshot, Path, ProactiveRefreshReason, UsageWindow, compare_account_candidates, + self, AccountPoolRecord, CodexAccountActivitySummary, CodexAccountLogin, CodexAccountPool, + CodexAccountProvider, CodexTokenData, CreditsSnapshot, DEFAULT_REFRESH_ENDPOINT, Path, + ProactiveRefreshReason, UsageWindow, compare_account_candidates, }; #[test] @@ -1179,6 +1278,62 @@ mod tests { assert_eq!(records[1].email().as_deref(), Some("wrapped@example.com")); } + #[test] + fn account_selector_matches_email_full_id_and_fingerprint() { + let record = AccountPoolRecord { + email: Some(String::from("selected@example.com")), + disabled: false, + cooldown_until_unix_epoch: None, + cooldown_until: None, + last_selected_at_unix_epoch: None, + auth_mode: Some(String::from("chatgpt")), + openai_api_key: None, + tokens: Some(CodexTokenData { + email: None, + id_token: None, + access_token: String::from("access"), + refresh_token: String::from("refresh"), + account_id: Some(String::from("acct_fixed_123456")), + }), + last_refresh: None, + }; + + assert!(record.matches_account_selector("selected@example.com")); + assert!(record.matches_account_selector("acct_fixed_123456")); + assert!(record.matches_account_selector("...123456")); + assert!(!record.matches_account_selector("other@example.com")); + } + + #[test] + fn fixed_account_selection_uses_configured_account_without_balancing() { + let temp_dir = TempDir::new().expect("temp dir should exist"); + let accounts_path = temp_dir.path().join("accounts.jsonl"); + let usage_endpoint = start_codex_usage_fixture_server(vec![ + r#"{"plan_type":"plus","rate_limit":{"primary_window":{"used_percent":85},"secondary_window":{"used_percent":90}}}"#, + ]); + + fs::write( + &accounts_path, + r#"{"email":"default@example.com","auth_mode":"chatgpt","tokens":{"access_token":"access-default","refresh_token":"refresh-default","account_id":"acct_default"}} +{"email":"copy@example.com","auth_mode":"chatgpt","tokens":{"access_token":"access-copy","refresh_token":"refresh-copy","account_id":"acct_copy"}} +"#, + ) + .expect("accounts fixture should write"); + + let pool = CodexAccountPool::new_with_fixed_account( + &accounts_path, + usage_endpoint, + DEFAULT_REFRESH_ENDPOINT, + Some("copy@example.com"), + ) + .expect("account pool should initialize"); + let account = pool.select_account().expect("fixed account should select"); + + assert_eq!(account.account_id(), "acct_copy"); + assert_eq!(account.summary().email.as_deref(), Some("copy@example.com")); + assert_eq!(account.account_summaries().len(), 1); + } + #[test] fn usage_summary_parses_codex_rate_limit_payload() { let payload = serde_json::json!({ @@ -1470,4 +1625,27 @@ mod tests { assert_eq!(candidates[0].account_id(), "acct_b"); } + + fn start_codex_usage_fixture_server(responses: Vec<&'static str>) -> String { + let listener = TcpListener::bind("127.0.0.1:0").expect("usage fixture server should bind"); + let address = listener.local_addr().expect("usage fixture address should resolve"); + + thread::spawn(move || { + for body in responses { + let (mut stream, _peer) = + listener.accept().expect("usage fixture should accept request"); + let mut buffer = [0_u8; 4096]; + let _bytes_read = stream.read(&mut buffer).expect("request should read"); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + + stream.write_all(response.as_bytes()).expect("usage response should write"); + } + }); + + format!("http://{address}/usage") + } } diff --git a/apps/decodex/src/config.rs b/apps/decodex/src/config.rs index c0f0b6f6..c7364762 100644 --- a/apps/decodex/src/config.rs +++ b/apps/decodex/src/config.rs @@ -215,6 +215,7 @@ impl Default for ProjectCodexConfig { pub struct ProjectCodexAccountsConfig { usage_endpoint: Option, refresh_endpoint: Option, + fixed_account: Option, } impl ProjectCodexAccountsConfig { /// Override for ChatGPT usage probes. Defaults to the Codex `/wham/usage` endpoint. @@ -227,6 +228,11 @@ impl ProjectCodexAccountsConfig { self.refresh_endpoint.as_deref() } + /// Fixed account selector for runs. Matches an account email, id, or displayed fingerprint. + pub fn fixed_account(&self) -> Option<&str> { + self.fixed_account.as_deref() + } + fn validate(&self) -> Result<()> { validate_optional_nonempty_string( "codex.accounts.usage_endpoint", @@ -236,6 +242,10 @@ impl ProjectCodexAccountsConfig { "codex.accounts.refresh_endpoint", self.refresh_endpoint.as_deref(), )?; + validate_optional_nonempty_string( + "codex.accounts.fixed_account", + self.fixed_account.as_deref(), + )?; Ok(()) } @@ -1089,6 +1099,7 @@ mod tests { [codex.accounts] usage_endpoint = "http://127.0.0.1:1234/wham/usage" refresh_endpoint = "http://127.0.0.1:1234/oauth/token" + fixed_account = "primary@example.com" "#, ); let config = ServiceConfig::from_path(&config_path).expect("accounts should parse"); @@ -1096,6 +1107,7 @@ mod tests { assert_eq!(accounts.usage_endpoint(), Some("http://127.0.0.1:1234/wham/usage")); assert_eq!(accounts.refresh_endpoint(), Some("http://127.0.0.1:1234/oauth/token")); + assert_eq!(accounts.fixed_account(), Some("primary@example.com")); } #[test] diff --git a/apps/decodex/src/orchestrator.rs b/apps/decodex/src/orchestrator.rs index 893ac5f5..df3db304 100644 --- a/apps/decodex/src/orchestrator.rs +++ b/apps/decodex/src/orchestrator.rs @@ -27,7 +27,7 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use crate::{agent, default_branch_sync, git_credentials, state}; #[rustfmt::skip] -use crate::{agent::{ACTIVE_RUN_IDLE_TIMEOUT, AppServerCapabilityPreflightFailure, AppServerDynamicToolFailure, AppServerHomePreflightFailure, AppServerProcessEnv, AppServerRunRequest, AppServerRunResult, AppServerTransportFailure, AppServerTurnFailure, ISSUE_DELIVERY_CLOSEOUT_COMPLETE_TOOL_NAME, ISSUE_LABEL_ADD_TOOL_NAME, ISSUE_PROGRESS_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_HANDOFF_TOOL_NAME, ISSUE_REVIEW_REPAIR_COMPLETE_TOOL_NAME, ISSUE_TERMINAL_FINALIZE_TOOL_NAME, ISSUE_TRANSITION_TOOL_NAME, DecodexRunContext, DecodexToolBridge, ReviewExecutionMode, ReviewHandoffContext, ReviewHandoffWritebackFailed, ReviewPolicyStopReason, ReviewPolicyStopRequested, RunCompletionDisposition, TrackerToolBridge, TurnContinuationGuard}, config::{InternalReviewMode, ServiceConfig}, git_credentials::GitCredentialSource, github, prelude::{Result, eyre}, state::{ChildAgentActivityBucket, ChildAgentActivitySummary, CodexAccountActivitySummary, ProjectRegistration, ProjectRunStatus, ProtocolActivitySummary, RUN_OPERATION_AGENT_RUN, RUN_OPERATION_APP_SERVER_PREFLIGHT, RUN_OPERATION_GIT_CREDENTIALS, RUN_OPERATION_IDLE, RUN_OPERATION_RECONCILIATION, RUN_OPERATION_REPO_GATE, RUN_OPERATION_REVIEW_WRITEBACK, RUN_OPERATION_WAITING_EXTERNAL, ReviewHandoffMarker, ReviewOrchestrationMarker, RunActivityMarker, RunAttempt, StateStore, WorktreeMapping}, tracker::{IssueTracker, TrackerComment, TrackerIssue, linear::LinearClient, records}, workflow::{WorkflowDocument, WorkflowExecution}, worktree::{WorktreeManager, WorktreeSpec}}; +use crate::{agent::{ACTIVE_RUN_IDLE_TIMEOUT, AppServerCapabilityPreflightFailure, AppServerDynamicToolFailure, AppServerHomePreflightFailure, AppServerProcessEnv, AppServerRunRequest, AppServerRunResult, AppServerTransportFailure, AppServerTurnFailure, ISSUE_DELIVERY_CLOSEOUT_COMPLETE_TOOL_NAME, ISSUE_LABEL_ADD_TOOL_NAME, ISSUE_PROGRESS_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_HANDOFF_TOOL_NAME, ISSUE_REVIEW_REPAIR_COMPLETE_TOOL_NAME, ISSUE_TERMINAL_FINALIZE_TOOL_NAME, ISSUE_TRANSITION_TOOL_NAME, DecodexRunContext, DecodexToolBridge, ReviewExecutionMode, ReviewHandoffContext, ReviewHandoffWritebackFailed, ReviewPolicyStopReason, ReviewPolicyStopRequested, RunCompletionDisposition, TrackerToolBridge, TurnContinuationGuard}, config::{InternalReviewMode, ProjectCodexAccountsConfig, ServiceConfig}, git_credentials::GitCredentialSource, github, prelude::{Result, eyre}, state::{ChildAgentActivityBucket, ChildAgentActivitySummary, CodexAccountActivitySummary, ProjectRegistration, ProjectRunStatus, ProtocolActivitySummary, RUN_OPERATION_AGENT_RUN, RUN_OPERATION_APP_SERVER_PREFLIGHT, RUN_OPERATION_GIT_CREDENTIALS, RUN_OPERATION_IDLE, RUN_OPERATION_RECONCILIATION, RUN_OPERATION_REPO_GATE, RUN_OPERATION_REVIEW_WRITEBACK, RUN_OPERATION_WAITING_EXTERNAL, ReviewHandoffMarker, ReviewOrchestrationMarker, RunActivityMarker, RunAttempt, StateStore, WorktreeMapping}, tracker::{IssueTracker, TrackerComment, TrackerIssue, linear::LinearClient, records}, workflow::{WorkflowDocument, WorkflowExecution}, worktree::{WorktreeManager, WorktreeSpec}}; include!("orchestrator/types.rs"); diff --git a/apps/decodex/src/orchestrator/entrypoints.rs b/apps/decodex/src/orchestrator/entrypoints.rs index b1dc7856..5ad84c81 100644 --- a/apps/decodex/src/orchestrator/entrypoints.rs +++ b/apps/decodex/src/orchestrator/entrypoints.rs @@ -696,6 +696,10 @@ fn empty_control_plane_snapshot(limit: usize) -> OperatorStatusSnapshot { warnings: Vec::new(), connector_backoffs: Vec::new(), projects: Vec::new(), + account_control: OperatorCodexAccountControlStatus { + mode: String::from("balanced"), + account_selector: None, + }, accounts: Vec::new(), active_runs: Vec::new(), recent_runs: Vec::new(), diff --git a/apps/decodex/src/orchestrator/operator_dashboard.html b/apps/decodex/src/orchestrator/operator_dashboard.html index 3e06191f..d00b6975 100644 --- a/apps/decodex/src/orchestrator/operator_dashboard.html +++ b/apps/decodex/src/orchestrator/operator_dashboard.html @@ -1961,15 +1961,21 @@ --account-accent: var(--success); } + .account-row.is-fixed { + --account-accent: var(--success); + } + .account-row.is-ready { --account-accent: var(--success); } - .account-row.is-selected::before { + .account-row.is-selected::before, + .account-row.is-fixed::before { box-shadow: 0 0 10px color-mix(in srgb, var(--account-accent) 22%, transparent); } - .account-row.is-selected::after { + .account-row.is-selected::after, + .account-row.is-fixed::after { opacity: 0.45; transform: scaleX(1); } @@ -2065,6 +2071,48 @@ outline-offset: 2px; } + .account-select-button { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 18px; + height: 18px; + padding: 0; + border: 1px solid color-mix(in srgb, var(--line-strong) 70%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--surface-muted) 74%, transparent); + color: var(--muted); + cursor: pointer; + transition: + background-color var(--fast) var(--ease), + border-color var(--fast) var(--ease), + color var(--fast) var(--ease), + transform var(--fast) var(--ease); + } + + .account-select-button svg { + display: block; + width: 12px; + height: 12px; + stroke-width: 2.2; + } + + .account-select-button:hover, + .account-select-button.is-fixed { + border-color: color-mix(in srgb, var(--success) 58%, var(--line-strong)); + color: var(--success); + } + + .account-select-button:hover { + transform: translateY(-1px); + } + + .account-select-button:focus-visible { + outline: 2px solid var(--info); + outline-offset: 2px; + } + .account-row-plan { grid-area: plan; justify-self: center; @@ -5523,8 +5571,12 @@

Run History

return [selected, ...accounts]; } + function codexAccountFingerprint(account) { + return String(account?.account_fingerprint || "").trim(); + } + function codexAccountIdentity(account) { - const fingerprint = account?.account_fingerprint || ""; + const fingerprint = codexAccountFingerprint(account); if (fingerprint) { return fingerprint; } @@ -5532,6 +5584,39 @@

Run History

return account?.plan_type || ""; } + function codexAccountControlProjectId(snapshot) { + const snapshotProjectId = String(snapshot?.project_id || "").trim(); + if (snapshotProjectId && snapshotProjectId !== "all") { + return snapshotProjectId; + } + + const projects = Array.isArray(snapshot?.projects) + ? snapshot.projects.filter((project) => project?.project_id) + : []; + + return projects.length === 1 ? String(projects[0].project_id) : ""; + } + + function codexAccountControlSelector(account) { + return codexAccountEmail(account) || codexAccountFingerprint(account); + } + + function codexAccountConfiguredSelector(snapshot) { + return String(snapshot?.account_control?.account_selector || "").trim(); + } + + function codexAccountMatchesConfiguredSelector(account, snapshot) { + const selector = codexAccountConfiguredSelector(snapshot); + if (!selector) { + return false; + } + + return ( + selector === codexAccountEmail(account) || + selector === codexAccountFingerprint(account) + ); + } + function codexAccountEmail(account) { return String(account?.account_email || account?.email || "").trim(); } @@ -5639,6 +5724,33 @@

Run History

`; } + function renderCodexAccountSelectButton(account, snapshot) { + const projectId = codexAccountControlProjectId(snapshot); + const selector = codexAccountControlSelector(account); + if (!projectId || !selector) { + return ""; + } + + const fixed = codexAccountMatchesConfiguredSelector(account, snapshot); + const action = fixed ? "clearAccountSelection" : "selectAccount"; + const title = fixed + ? "Return this project to balanced account selection" + : "Use this account for new runs"; + const fixedClass = fixed ? " is-fixed" : ""; + + return ` + + `; + } + function codexAccountDisplayName(account) { const email = codexAccountEmail(account); return codexAccountShowsEmail(account) ? email : codexAccountRandomName(account); @@ -6010,12 +6122,15 @@

Run History

`; } - function renderCodexAccountPoolRow(account) { + function renderCodexAccountPoolRow(account, snapshot) { const displayTitle = codexAccountDisplayTitle(account); const visibleName = codexAccountVisibleName(account); const plan = codexAccountPlanLabel(account); const statusTone = codexAccountStatusTone(account); const toneClass = statusTone ? ` is-${statusTone}` : ""; + const fixedClass = codexAccountMatchesConfiguredSelector(account, snapshot) + ? " is-fixed" + : ""; const selectedClass = String(account.status || "").toLowerCase() === "selected" ? " is-selected" : ""; const metaFacts = codexAccountMetaFacts(account); @@ -6025,10 +6140,11 @@

Run History

const identityClass = codexAccountShowsEmail(account) ? " is-machine" : ""; return ` -