diff --git a/README.md b/README.md index 53417a0b..0af7c4d6 100644 --- a/README.md +++ b/README.md @@ -113,8 +113,10 @@ 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. 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. +`[codex.accounts].fixed_account` in `~/.codex/decodex/config.toml` to pin all new +account-pool runs to one account. When that global selector is absent, Decodex balances +new runs across the pool. The operator dashboard Accounts UI writes and clears the same +global selector; project configs do not pin specific accounts. `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 733b15ba..0b0a7917 100644 --- a/apps/decodex/src/agent/codex_accounts.rs +++ b/apps/decodex/src/agent/codex_accounts.rs @@ -43,11 +43,13 @@ pub(crate) struct CodexAccountPool { } impl CodexAccountPool { pub(crate) fn from_config(config: &ProjectCodexAccountsConfig) -> crate::prelude::Result { + let fixed_account = runtime::global_fixed_account_selector()?; + 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(), + fixed_account.as_deref(), ) } diff --git a/apps/decodex/src/config.rs b/apps/decodex/src/config.rs index c7364762..4410fa30 100644 --- a/apps/decodex/src/config.rs +++ b/apps/decodex/src/config.rs @@ -215,7 +215,6 @@ 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. @@ -228,11 +227,6 @@ 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", @@ -242,10 +236,6 @@ 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(()) } @@ -1099,7 +1089,6 @@ 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"); @@ -1107,7 +1096,30 @@ 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] + fn rejects_project_scoped_codex_fixed_account() { + let temp_dir = TempDir::new().expect("temp dir should exist"); + let config_path = write_config_file( + temp_dir.path(), + r#" + service_id = "pubfi" + + [tracker] + api_key_env_var = "HOME" + + [github] + token_env_var = "HOME" + + [codex.accounts] + fixed_account = "primary@example.com" + "#, + ); + let error = ServiceConfig::from_path(&config_path) + .expect_err("project-scoped account selection should fail"); + + assert!(error.to_string().contains("fixed_account")); } #[test] diff --git a/apps/decodex/src/orchestrator.rs b/apps/decodex/src/orchestrator.rs index df3db304..893ac5f5 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, 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}}; +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}}; include!("orchestrator/types.rs"); diff --git a/apps/decodex/src/orchestrator/entrypoints.rs b/apps/decodex/src/orchestrator/entrypoints.rs index 5ad84c81..aefa6f5d 100644 --- a/apps/decodex/src/orchestrator/entrypoints.rs +++ b/apps/decodex/src/orchestrator/entrypoints.rs @@ -376,6 +376,7 @@ where aggregate_control_plane_snapshot(registered_project_count, project_snapshots); snapshot.projects = project_statuses; + snapshot.account_control = global_codex_account_control_status(); for warning in snapshot_warnings { add_operator_snapshot_warning(&mut snapshot, warning); diff --git a/apps/decodex/src/orchestrator/operator_dashboard.html b/apps/decodex/src/orchestrator/operator_dashboard.html index d00b6975..3da61c3e 100644 --- a/apps/decodex/src/orchestrator/operator_dashboard.html +++ b/apps/decodex/src/orchestrator/operator_dashboard.html @@ -783,6 +783,16 @@ background: var(--section-tone); } + .section-marker-meta { + flex: 0 1 auto; + min-width: 0; + max-width: min(320px, 50vw); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: right; + } + .section-marker-control { --section-tone: var(--tone-muted); } @@ -814,15 +824,6 @@ transform var(--medium) var(--ease); } - #account-pool-panel { - padding-bottom: var(--space-md); - background: transparent; - } - - #account-pool-panel .panel-body { - padding-bottom: 4px; - } - #active-panel { background: transparent; } @@ -1893,6 +1894,8 @@ .account-row { --account-accent: var(--tone-muted); + --account-confirm-accent: var(--tone-run); + --account-confirm-cycle: 1.45s; position: relative; isolation: isolate; display: grid; @@ -1961,10 +1964,6 @@ --account-accent: var(--success); } - .account-row.is-fixed { - --account-accent: var(--success); - } - .account-row.is-ready { --account-accent: var(--success); } @@ -1988,6 +1987,10 @@ --account-accent: var(--danger); } + .account-row.is-fixed { + --account-accent: var(--info); + } + .account-row.is-warn::after, .account-row.is-danger::after { opacity: 0.35; @@ -2005,12 +2008,67 @@ transform: scaleX(1.45) scaleY(1.08); } + .account-row.is-armed:hover::before, + .account-row.is-armed:focus-within::before { + transform: none; + } + + .account-row.is-armed:not(.is-selected):not(.is-fixed):hover::before, + .account-row.is-armed:not(.is-selected):not(.is-fixed):focus-within::before { + box-shadow: none; + } + + .account-row.is-armed.is-selected:hover::before, + .account-row.is-armed.is-selected:focus-within::before, + .account-row.is-armed.is-fixed:hover::before, + .account-row.is-armed.is-fixed:focus-within::before { + box-shadow: 0 0 10px color-mix(in srgb, var(--account-accent) 22%, transparent); + } + .account-row:hover::after, .account-row:focus-within::after { opacity: 0.85; transform: scaleX(1); } + @keyframes account-confirm-name-breathe { + 0%, + 100% { + color: color-mix(in srgb, var(--account-confirm-accent) 46%, var(--muted)); + text-shadow: none; + } + 50% { + color: var(--account-confirm-accent); + text-shadow: + 0 0 6px color-mix(in srgb, var(--account-confirm-accent) 34%, transparent), + 0 0 15px color-mix(in srgb, var(--account-confirm-accent) 20%, transparent); + } + } + + @keyframes account-confirm-bracket-left { + 0%, + 100% { + opacity: 0.36; + transform: translate(0.18ch, -50%); + } + 50% { + opacity: 0.98; + transform: translate(-0.12ch, -50%); + } + } + + @keyframes account-confirm-bracket-right { + 0%, + 100% { + opacity: 0.36; + transform: translate(-0.18ch, -50%); + } + 50% { + opacity: 0.98; + transform: translate(0.12ch, -50%); + } + } + .account-row-id { grid-area: id; display: flex; @@ -2035,80 +2093,135 @@ font-weight: var(--weight-label); } - .account-name-reroll { + .account-name-button { + position: relative; display: inline-flex; align-items: center; justify-content: center; - flex: 0 0 auto; - width: 16px; - height: 16px; - padding: 0; + min-width: 0; + max-width: 100%; + padding: 0 2px; border: 0; - border-radius: 999px; background: transparent; - color: var(--muted); + color: var(--text); + font-family: var(--mono); + font-size: var(--type-label); + font-variant-ligatures: none; + font-weight: var(--weight-label); + letter-spacing: 0; + line-height: 1.25; cursor: pointer; transition: - background-color var(--fast) var(--ease), - color var(--fast) var(--ease); + color var(--fast) var(--ease), + text-shadow var(--medium) var(--ease); } - .account-name-reroll svg { + .account-name-button::before, + .account-name-button::after { + position: absolute; + top: 50%; display: block; - width: 12px; - height: 12px; - stroke-width: 2.1; + color: var(--account-confirm-accent); + font-weight: var(--weight-strong); + line-height: 1; + opacity: 0; + pointer-events: none; + text-shadow: + 0 0 8px color-mix(in srgb, var(--account-confirm-accent) 42%, transparent), + 0 0 16px color-mix(in srgb, var(--account-confirm-accent) 22%, transparent); + will-change: opacity, transform; } - .account-name-reroll:hover { - background: var(--surface-muted); - color: var(--text); + .account-name-button::before { + content: ">"; + left: -1.35ch; + transform: translate(0.18ch, -50%); } - .account-name-reroll:focus-visible { - color: var(--text); + .account-name-button::after { + content: "<"; + right: -1.35ch; + transform: translate(-0.18ch, -50%); + } + + .account-name-button .account-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .account-name-button:hover, + .account-name-button.is-fixed { + color: var(--account-confirm-accent); + } + + .account-name-button.is-fixed { + text-shadow: + 0 0 6px color-mix(in srgb, var(--account-confirm-accent) 34%, transparent), + 0 0 15px color-mix(in srgb, var(--account-confirm-accent) 20%, transparent); + } + + .account-name-button.is-fixed::before, + .account-name-button.is-fixed::after { + opacity: 0.72; + transform: translate(0, -50%); + } + + .account-name-button.is-armed { + animation: account-confirm-name-breathe var(--account-confirm-cycle) var(--ease) infinite; + will-change: color, text-shadow; + } + + .account-name-button.is-armed::before { + animation: account-confirm-bracket-left var(--account-confirm-cycle) var(--ease) infinite; + } + + .account-name-button.is-armed::after { + animation: account-confirm-bracket-right var(--account-confirm-cycle) var(--ease) infinite; + } + + .account-name-button:focus-visible { outline: 2px solid var(--info); - outline-offset: 2px; + outline-offset: 4px; } - .account-select-button { + .account-name-reroll { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; - width: 18px; - height: 18px; + width: 16px; + height: 16px; padding: 0; - border: 1px solid color-mix(in srgb, var(--line-strong) 70%, transparent); + border: 0; border-radius: 999px; - background: color-mix(in srgb, var(--surface-muted) 74%, transparent); + background: 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); + color var(--fast) var(--ease); } - .account-select-button svg { + .account-name-button + .account-name-reroll { + margin-left: 8px; + } + + .account-name-reroll 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); + stroke-width: 2.1; } - .account-select-button:hover { - transform: translateY(-1px); + .account-name-reroll:hover { + background: var(--surface-muted); + color: var(--text); } - .account-select-button:focus-visible { + .account-name-reroll:focus-visible { + color: var(--text); outline: 2px solid var(--info); outline-offset: 2px; } @@ -2282,6 +2395,7 @@ } .account-row.is-selected .account-status, + .account-row.is-fixed .account-status, .account-row.is-ready .account-status { color: var(--account-accent); } @@ -3168,6 +3282,7 @@

Decodex

aria-label="Accounts group" > Accounts +

@@ -3238,7 +3353,7 @@

Intake Queue

Review & Landing

-

Snapshot pending

+

0 PRs · 0 need attention · 0 ready · 0 waiting

@@ -3399,6 +3514,7 @@

Run History

themeButtons: [...document.querySelectorAll("[data-theme-choice]")], workspace: document.getElementById("workspace"), primaryStack: document.getElementById("primary-stack"), + accountModeMeta: document.getElementById("account-mode-meta"), panels: { projects: document.getElementById("projects-panel"), accountPool: document.getElementById("account-pool-panel"), @@ -3448,6 +3564,7 @@

Run History

let accountEmailsHidden = loadAccountPrivacy(); let accountNameOffsets = loadAccountNameOffsets(); let accountPoolSort = loadAccountPoolSort(); + let accountSelectionConfirmation = null; let projectFilterMode = loadProjectFilterMode(); let projectLocationsHidden = loadProjectLocationPrivacy(); let projectSort = loadProjectSort(); @@ -3471,7 +3588,6 @@

Run History

runningInlineMetaPlural: "already running", protocolEvent: "Protocol event", staleClosed: "closed labels", - waitingSnapshot: "Snapshot pending", }; function escapeHtml(value) { @@ -4907,10 +5023,8 @@

Run History

`; } - function renderRoutineEmptyList(container, snapshot, waitingCopy = "") { - container.innerHTML = snapshot - ? "" - : renderEmptyState(COPY.waitingSnapshot, waitingCopy); + function renderRoutineEmptyList(container) { + container.innerHTML = ""; } function keyedPatchNodeKey(node) { @@ -5584,25 +5698,14 @@

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(); + const accountControl = snapshot?.account_control || {}; + + return String(accountControl.account_selector || "").trim(); } function codexAccountMatchesConfiguredSelector(account, snapshot) { @@ -5617,6 +5720,94 @@

Run History

); } + function accountSelectionConfirmationKey(action, selector) { + return `${String(action || "").trim()}:${String(selector || "").trim()}`; + } + + function accountSelectionConfirmationMatches(action, selector) { + if (!accountSelectionConfirmation) { + return false; + } + + return ( + accountSelectionConfirmation.key === + accountSelectionConfirmationKey(action, selector) + ); + } + + function accountSelectionControlTitle(action, displayTitle, armed) { + const prefix = + action === "clearAccountSelection" + ? armed + ? "Click again to return the global account pool to balanced selection" + : "Click once, then again to return the global account pool to balanced selection" + : armed + ? "Click again to use this account for new global runs" + : "Click once, then again to use this account for new global runs"; + + return displayTitle ? `${prefix}: ${displayTitle}` : prefix; + } + + function syncAccountSelectionConfirmationDom() { + for (const button of nodes.accountPool.querySelectorAll("[data-account-confirm-action]")) { + const action = button.dataset.accountConfirmAction; + const selector = button.dataset.accountSelector || ""; + const armed = accountSelectionConfirmationMatches(action, selector); + const row = button.closest(".account-row"); + const title = accountSelectionControlTitle( + action, + button.dataset.accountDisplayTitle || "", + armed, + ); + + button.classList.toggle("is-armed", armed); + button.setAttribute("aria-label", title); + button.setAttribute("title", title); + if (row) { + row.classList.toggle("is-armed", armed); + } + } + } + + function clearAccountSelectionConfirmation(syncDom = true) { + accountSelectionConfirmation = null; + if (syncDom) { + syncAccountSelectionConfirmationDom(); + } + } + + function armAccountSelectionConfirmation(action, selector) { + accountSelectionConfirmation = { + key: accountSelectionConfirmationKey(action, selector), + action, + selector, + }; + syncAccountSelectionConfirmationDom(); + } + + function confirmAccountSelection(action, selector) { + clearAccountSelectionConfirmation(false); + if (action === "selectAccount") { + sendDashboardControl(action, { accountSelector: selector }); + } else if (action === "clearAccountSelection") { + sendDashboardControl(action); + } + syncAccountSelectionConfirmationDom(); + } + + function handleAccountSelectionConfirmation(action, selector) { + if (!selector || !["selectAccount", "clearAccountSelection"].includes(action)) { + return; + } + + if (accountSelectionConfirmationMatches(action, selector)) { + confirmAccountSelection(action, selector); + return; + } + + armAccountSelectionConfirmation(action, selector); + } + function codexAccountEmail(account) { return String(account?.account_email || account?.email || "").trim(); } @@ -5724,29 +5915,24 @@

Run History

`; } - function renderCodexAccountSelectButton(account, snapshot) { - const projectId = codexAccountControlProjectId(snapshot); + function renderCodexAccountNameControl(account, snapshot) { const selector = codexAccountControlSelector(account); - if (!projectId || !selector) { - return ""; + const displayTitle = codexAccountDisplayTitle(account); + const visibleName = codexAccountVisibleName(account); + if (!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 armed = accountSelectionConfirmationMatches(action, selector); const fixedClass = fixed ? " is-fixed" : ""; + const armedClass = armed ? " is-armed" : ""; + const title = accountSelectionControlTitle(action, displayTitle, armed); return ` - `; } @@ -6123,14 +6309,15 @@

Run History

} 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 fixed = codexAccountMatchesConfiguredSelector(account, snapshot); + const fixedClass = fixed ? " is-fixed" : ""; + const selector = codexAccountControlSelector(account); + const action = fixed ? "clearAccountSelection" : "selectAccount"; + const armedClass = + selector && accountSelectionConfirmationMatches(action, selector) ? " is-armed" : ""; const selectedClass = String(account.status || "").toLowerCase() === "selected" ? " is-selected" : ""; const metaFacts = codexAccountMetaFacts(account); @@ -6140,11 +6327,10 @@

Run History

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