mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
feat(mobile): add safe Codex rate-limit resets (#9394)
* feat(mobile): add safe Codex rate-limit resets * fix(mobile): address reset credit review feedback * review: purge removed-account reset attempts, shared capability constant, rebase test mocks * review: preserve host compatibility and reset durability * fix(mobile): recover reset capability after cutover * fix(mobile): validate runtime capability payloads * fix(mobile): enforce capability payload contract * fix(mobile): route mock terminals to selected worktree * test(mobile): pin malformed probe retry behavior --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
co-authored by
Brennan Benson
parent
402b5a1cc8
commit
4a71a0ecb2
@@ -13,7 +13,6 @@ import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { ChevronLeft, Check, RefreshCw, User } from 'lucide-react-native'
|
||||
import { loadHosts } from '../../../src/transport/host-store'
|
||||
import { useHostClient } from '../../../src/transport/client-context'
|
||||
import type { RpcSuccess } from '../../../src/transport/types'
|
||||
import { colors, spacing } from '../../../src/theme/mobile-theme'
|
||||
import { styles } from './accounts-screen-styles'
|
||||
import { useNow } from '../../../src/hooks/use-now'
|
||||
@@ -21,6 +20,7 @@ import { ClaudeIcon, OpenAIIcon } from '../../../src/components/AgentIcons'
|
||||
import {
|
||||
type AccountsSnapshot,
|
||||
type ProviderKey,
|
||||
decodeAccountsSnapshot,
|
||||
getActiveProviderRateLimits,
|
||||
getInactiveProviderUsage,
|
||||
getUsageBarState,
|
||||
@@ -28,6 +28,12 @@ import {
|
||||
hasActiveProviderUsage,
|
||||
UsageBar
|
||||
} from '../../../src/components/AccountUsage'
|
||||
import {
|
||||
getActiveCodexAccountIdForRateLimitTarget,
|
||||
getCodexResetCreditSummary
|
||||
} from '../../../src/components/codex-reset-credit'
|
||||
import { CodexResetCreditAction } from '../../../src/components/CodexResetCreditAction'
|
||||
import { useCodexResetCreditAction } from '../../../src/components/use-codex-reset-credit-action'
|
||||
|
||||
export default function AccountsScreen() {
|
||||
const router = useRouter()
|
||||
@@ -43,6 +49,31 @@ export default function AccountsScreen() {
|
||||
const [busyAccountId, setBusyAccountId] = useState<string | null>(null)
|
||||
const [clockEnabled, setClockEnabled] = useState(false)
|
||||
|
||||
const acceptSnapshot = useCallback((nextSnapshot: AccountsSnapshot) => {
|
||||
setSnapshot(nextSnapshot)
|
||||
setError(null)
|
||||
}, [])
|
||||
const rejectInvalidSnapshot = useCallback(() => {
|
||||
// Why: a stale snapshot can expose a finite reset action for the wrong
|
||||
// account; fail closed if a host sends a shape this mobile cannot prove.
|
||||
setSnapshot(null)
|
||||
setError('Invalid accounts snapshot from host')
|
||||
}, [])
|
||||
const {
|
||||
supported: codexResetSupported,
|
||||
resetting: resettingCodex,
|
||||
resetScope,
|
||||
scopeLabel: resetScopeLabel,
|
||||
confirmReset: confirmCodexReset
|
||||
} = useCodexResetCreditAction({
|
||||
client,
|
||||
connected: connState === 'connected',
|
||||
hostId,
|
||||
snapshot,
|
||||
accountMutationBusy: busyAccountId !== null,
|
||||
onSnapshot: acceptSnapshot
|
||||
})
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
setClockEnabled(true)
|
||||
@@ -85,14 +116,17 @@ export default function AccountsScreen() {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return
|
||||
}
|
||||
const evt = payload as { type?: string; snapshot?: AccountsSnapshot }
|
||||
if ((evt.type === 'ready' || evt.type === 'snapshot') && evt.snapshot) {
|
||||
setSnapshot(evt.snapshot)
|
||||
setError(null)
|
||||
const evt = payload as { type?: string; snapshot?: unknown }
|
||||
if (evt.type === 'ready' || evt.type === 'snapshot') {
|
||||
try {
|
||||
acceptSnapshot(decodeAccountsSnapshot(evt.snapshot))
|
||||
} catch {
|
||||
rejectInvalidSnapshot()
|
||||
}
|
||||
}
|
||||
})
|
||||
return unsubscribe
|
||||
}, [client, connState])
|
||||
}, [acceptSnapshot, client, connState, rejectInvalidSnapshot])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!client) {
|
||||
@@ -102,27 +136,43 @@ export default function AccountsScreen() {
|
||||
try {
|
||||
const res = await client.sendRequest('accounts.list')
|
||||
if (res.ok) {
|
||||
setSnapshot((res as RpcSuccess).result as AccountsSnapshot)
|
||||
setError(null)
|
||||
acceptSnapshot(decodeAccountsSnapshot(res.result))
|
||||
} else {
|
||||
setError(res.error.message)
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
if (e instanceof Error && e.message === 'Invalid accounts snapshot from host') {
|
||||
rejectInvalidSnapshot()
|
||||
} else {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [client])
|
||||
}, [acceptSnapshot, client, rejectInvalidSnapshot])
|
||||
|
||||
const selectAccount = useCallback(
|
||||
async (provider: ProviderKey, accountId: string | null) => {
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
const codexTarget = provider === 'codex' ? snapshot?.rateLimits.codexTarget : null
|
||||
if (provider === 'codex' && !codexTarget) {
|
||||
return
|
||||
}
|
||||
setBusyAccountId(accountId ?? `${provider}:default`)
|
||||
const method = provider === 'claude' ? 'accounts.selectClaude' : 'accounts.selectCodex'
|
||||
const method =
|
||||
provider === 'claude'
|
||||
? 'accounts.selectClaude'
|
||||
: codexTarget?.runtime === 'wsl'
|
||||
? 'accounts.selectCodexForTarget'
|
||||
: 'accounts.selectCodex'
|
||||
try {
|
||||
const res = await client.sendRequest(method, { accountId })
|
||||
// Why: old hosts silently strip unknown target fields. Use the distinct
|
||||
// targeted RPC for WSL so version skew fails before mutating host state.
|
||||
const params =
|
||||
codexTarget?.runtime === 'wsl' ? { accountId, target: codexTarget } : { accountId }
|
||||
const res = await client.sendRequest(method, params)
|
||||
if (!res.ok) {
|
||||
Alert.alert('Could not switch account', res.error.message)
|
||||
} else {
|
||||
@@ -137,7 +187,7 @@ export default function AccountsScreen() {
|
||||
setBusyAccountId(null)
|
||||
}
|
||||
},
|
||||
[client, refresh]
|
||||
[client, refresh, snapshot]
|
||||
)
|
||||
|
||||
const renderProviderSection = (provider: ProviderKey, title: string) => {
|
||||
@@ -145,9 +195,14 @@ export default function AccountsScreen() {
|
||||
return null
|
||||
}
|
||||
const state = provider === 'claude' ? snapshot.claude : snapshot.codex
|
||||
const activeAccountId =
|
||||
provider === 'codex' && snapshot.codex.activeAccountIdsByRuntime
|
||||
? getActiveCodexAccountIdForRateLimitTarget(snapshot)
|
||||
: state.activeAccountId
|
||||
const activeUsage = getActiveProviderRateLimits(snapshot, provider)
|
||||
const activeSessionBar = getUsageBarState(activeUsage, 'session')
|
||||
const activeWeeklyBar = getUsageBarState(activeUsage, 'weekly')
|
||||
const resetCredit = provider === 'codex' ? getCodexResetCreditSummary(activeUsage, now) : null
|
||||
const Icon = provider === 'claude' ? ClaudeIcon : OpenAIIcon
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
@@ -160,7 +215,7 @@ export default function AccountsScreen() {
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => selectAccount(provider, null)}
|
||||
disabled={busyAccountId !== null || connState !== 'connected'}
|
||||
disabled={busyAccountId !== null || resettingCodex || connState !== 'connected'}
|
||||
>
|
||||
<View style={styles.rowMain}>
|
||||
<Text style={styles.rowTitle}>System default</Text>
|
||||
@@ -168,7 +223,7 @@ export default function AccountsScreen() {
|
||||
{/* Why: when system default is the active selection, activeUsage
|
||||
holds the system-default login's rate limits — surface them
|
||||
here so non-managed users still see their usage. */}
|
||||
{state.activeAccountId === null && hasActiveProviderUsage(activeUsage) ? (
|
||||
{activeAccountId === null && hasActiveProviderUsage(activeUsage) ? (
|
||||
<View style={styles.usageRow}>
|
||||
<UsageBar
|
||||
label="5h"
|
||||
@@ -188,7 +243,7 @@ export default function AccountsScreen() {
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.rowTrailing}>
|
||||
{state.activeAccountId === null ? (
|
||||
{activeAccountId === null ? (
|
||||
<Check size={16} color={colors.accentBlue} />
|
||||
) : busyAccountId === `${provider}:default` ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
@@ -197,7 +252,7 @@ export default function AccountsScreen() {
|
||||
</Pressable>
|
||||
|
||||
{state.accounts.map((account) => {
|
||||
const isActive = state.activeAccountId === account.id
|
||||
const isActive = activeAccountId === account.id
|
||||
const inactiveEntry = !isActive
|
||||
? getInactiveProviderUsage(snapshot, provider, account.id)
|
||||
: null
|
||||
@@ -213,7 +268,12 @@ export default function AccountsScreen() {
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => selectAccount(provider, account.id)}
|
||||
disabled={busyAccountId !== null || connState !== 'connected' || isActive}
|
||||
disabled={
|
||||
busyAccountId !== null ||
|
||||
resettingCodex ||
|
||||
connState !== 'connected' ||
|
||||
isActive
|
||||
}
|
||||
>
|
||||
<View style={styles.rowMain}>
|
||||
<Text style={styles.rowTitle} numberOfLines={1}>
|
||||
@@ -252,6 +312,15 @@ export default function AccountsScreen() {
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
{resetCredit && codexResetSupported && resetScope && connState === 'connected' ? (
|
||||
<CodexResetCreditAction
|
||||
summary={resetCredit}
|
||||
scopeLabel={resetScopeLabel}
|
||||
busy={resettingCodex}
|
||||
disabled={resettingCodex || busyAccountId !== null || connState !== 'connected'}
|
||||
onPress={confirmCodexReset}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
|
||||
+11
-4
@@ -17,6 +17,7 @@ import { ClaudeIcon, OpenAIIcon } from '../src/components/AgentIcons'
|
||||
import {
|
||||
type AccountsSnapshot,
|
||||
type ProviderKey,
|
||||
decodeAccountsSnapshot,
|
||||
getActiveProviderRateLimits,
|
||||
getUsageBarState,
|
||||
hasActiveProviderUsage,
|
||||
@@ -231,7 +232,7 @@ function fetchAccountsSnapshot(
|
||||
return
|
||||
}
|
||||
if (response.ok) {
|
||||
const snapshot = response.result as AccountsSnapshot
|
||||
const snapshot = decodeAccountsSnapshot(response.result)
|
||||
setSnapshots((prev) => ({ ...prev, [hostId]: snapshot }))
|
||||
}
|
||||
})
|
||||
@@ -503,9 +504,15 @@ export default function HomeScreen() {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return
|
||||
}
|
||||
const evt = payload as { type?: string; snapshot?: AccountsSnapshot }
|
||||
if ((evt.type === 'ready' || evt.type === 'snapshot') && evt.snapshot) {
|
||||
setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: evt.snapshot! }))
|
||||
const evt = payload as { type?: string; snapshot?: unknown }
|
||||
if (evt.type === 'ready' || evt.type === 'snapshot') {
|
||||
try {
|
||||
const snapshot = decodeAccountsSnapshot(evt.snapshot)
|
||||
setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: snapshot }))
|
||||
} catch {
|
||||
// Keep the last proven snapshot; malformed remote data must
|
||||
// not enter render state or crash the home host cards.
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { RpcRequest, RpcResponse } from './mock-server-rpc-handlers'
|
||||
import {
|
||||
consumeMockCodexResetCredit,
|
||||
createMockAccountsSnapshot,
|
||||
selectMockClaudeAccount,
|
||||
selectMockCodexAccount
|
||||
} from './mock-server-account-state'
|
||||
|
||||
type Respond = (response: RpcResponse) => void
|
||||
type Success = (id: string, result: unknown, streaming?: boolean) => RpcResponse
|
||||
type ErrorResponse = (id: string, code: string, message: string) => RpcResponse
|
||||
|
||||
const accountSubscribers = new Map<string, { requestId: string; respond: Respond }>()
|
||||
|
||||
function notifyAccountSubscribers(success: Success): void {
|
||||
for (const { requestId, respond } of accountSubscribers.values()) {
|
||||
respond(success(requestId, { type: 'snapshot', snapshot: createMockAccountsSnapshot() }, true))
|
||||
}
|
||||
}
|
||||
|
||||
export function handleMockAccountRequest(
|
||||
request: RpcRequest,
|
||||
respond: Respond,
|
||||
success: Success,
|
||||
error: ErrorResponse
|
||||
): boolean {
|
||||
try {
|
||||
switch (request.method) {
|
||||
case 'accounts.list':
|
||||
respond(success(request.id, createMockAccountsSnapshot()))
|
||||
return true
|
||||
case 'accounts.selectClaude':
|
||||
selectMockClaudeAccount(request.params?.accountId)
|
||||
respond(success(request.id, createMockAccountsSnapshot().claude))
|
||||
notifyAccountSubscribers(success)
|
||||
return true
|
||||
case 'accounts.selectCodex':
|
||||
case 'accounts.selectCodexForTarget':
|
||||
selectMockCodexAccount(request.params?.accountId)
|
||||
respond(success(request.id, createMockAccountsSnapshot().codex))
|
||||
notifyAccountSubscribers(success)
|
||||
return true
|
||||
case 'accounts.consumeCodexResetCredit': {
|
||||
const result = consumeMockCodexResetCredit(
|
||||
request.params?.idempotencyKey,
|
||||
request.params?.expectedScope
|
||||
)
|
||||
respond(
|
||||
success(request.id, {
|
||||
...result,
|
||||
snapshot: createMockAccountsSnapshot()
|
||||
})
|
||||
)
|
||||
notifyAccountSubscribers(success)
|
||||
return true
|
||||
}
|
||||
case 'accounts.subscribe':
|
||||
accountSubscribers.set(`accounts-${request.id}`, { requestId: request.id, respond })
|
||||
respond(
|
||||
success(
|
||||
request.id,
|
||||
{
|
||||
type: 'ready',
|
||||
subscriptionId: `accounts-${request.id}`,
|
||||
snapshot: createMockAccountsSnapshot()
|
||||
},
|
||||
true
|
||||
)
|
||||
)
|
||||
return true
|
||||
case 'accounts.unsubscribe':
|
||||
if (typeof request.params?.subscriptionId === 'string') {
|
||||
accountSubscribers.delete(request.params.subscriptionId)
|
||||
}
|
||||
respond(success(request.id, { unsubscribed: true }))
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
} catch (caught) {
|
||||
respond(
|
||||
error(
|
||||
request.id,
|
||||
'invalid_params',
|
||||
caught instanceof Error ? caught.message : 'Invalid account request'
|
||||
)
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import {
|
||||
buildCodexResetCreditExpectedScope,
|
||||
type CodexResetCreditExpectedScope
|
||||
} from '../../src/shared/codex-reset-credit-scope'
|
||||
|
||||
type MockCodexUsage = {
|
||||
availableResetCredits: number
|
||||
sessionUsedPercent: number
|
||||
updatedAt: number
|
||||
nextExpiresAt: number
|
||||
}
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
const CODEX_ACCOUNTS = [
|
||||
{
|
||||
id: 'codex-personal',
|
||||
email: 'dev@example.com',
|
||||
workspaceLabel: 'Personal',
|
||||
managedHomeRuntime: 'host' as const,
|
||||
wslDistro: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
},
|
||||
{
|
||||
id: 'codex-team',
|
||||
email: 'dev@example.com',
|
||||
workspaceLabel: 'Example Team',
|
||||
managedHomeRuntime: 'host' as const,
|
||||
wslDistro: null,
|
||||
createdAt: 2,
|
||||
updatedAt: 2,
|
||||
lastAuthenticatedAt: 2
|
||||
}
|
||||
] as const
|
||||
|
||||
let fixtureStartedAt = Date.now()
|
||||
let activeClaudeAccountId: string | null = 'claude-team'
|
||||
let activeCodexAccountId: string | null = 'codex-personal'
|
||||
let codexUsageByAccount = new Map<string, MockCodexUsage>()
|
||||
let resetOperations = new Map<string, { scopeKey: string; outcome: 'reset' | 'noCredit' }>()
|
||||
let resetOfferOwners = new Map<string, string>()
|
||||
|
||||
function createInitialCodexUsage(accountOffset: number): MockCodexUsage {
|
||||
return {
|
||||
availableResetCredits: 1,
|
||||
sessionUsedPercent: 100,
|
||||
updatedAt: fixtureStartedAt + accountOffset,
|
||||
nextExpiresAt: fixtureStartedAt + (5 + accountOffset) * 24 * 60 * 60 * 1000
|
||||
}
|
||||
}
|
||||
|
||||
export function resetMockAccountState(now = Date.now()): void {
|
||||
fixtureStartedAt = now
|
||||
activeClaudeAccountId = 'claude-team'
|
||||
activeCodexAccountId = 'codex-personal'
|
||||
codexUsageByAccount = new Map([
|
||||
['codex-personal', createInitialCodexUsage(0)],
|
||||
['codex-team', createInitialCodexUsage(1)]
|
||||
])
|
||||
resetOperations = new Map()
|
||||
resetOfferOwners = new Map()
|
||||
}
|
||||
|
||||
resetMockAccountState(fixtureStartedAt)
|
||||
|
||||
export function selectMockClaudeAccount(accountId: unknown): void {
|
||||
if (accountId === null) {
|
||||
activeClaudeAccountId = null
|
||||
return
|
||||
}
|
||||
if (accountId !== 'claude-team' && accountId !== 'claude-personal') {
|
||||
throw new Error('Unknown Claude account')
|
||||
}
|
||||
activeClaudeAccountId = accountId
|
||||
}
|
||||
|
||||
export function selectMockCodexAccount(accountId: unknown): void {
|
||||
if (accountId === null) {
|
||||
activeCodexAccountId = null
|
||||
return
|
||||
}
|
||||
if (
|
||||
typeof accountId !== 'string' ||
|
||||
!CODEX_ACCOUNTS.some((account) => account.id === accountId)
|
||||
) {
|
||||
throw new Error('Unknown Codex account')
|
||||
}
|
||||
activeCodexAccountId = accountId
|
||||
}
|
||||
|
||||
function codexLimitsFor(accountId: string | null) {
|
||||
const usage = accountId ? codexUsageByAccount.get(accountId) : null
|
||||
if (!usage) {
|
||||
return {
|
||||
provider: 'codex' as const,
|
||||
session: null,
|
||||
weekly: null,
|
||||
rateLimitResetCredits: { availableCount: 0, totalEarnedCount: 0, nextExpiresAt: null },
|
||||
updatedAt: fixtureStartedAt,
|
||||
error: 'No managed Codex account selected',
|
||||
status: 'unavailable' as const
|
||||
}
|
||||
}
|
||||
return {
|
||||
provider: 'codex' as const,
|
||||
session: {
|
||||
usedPercent: usage.sessionUsedPercent,
|
||||
windowMinutes: 300,
|
||||
resetsAt: fixtureStartedAt + 90 * 60 * 1000,
|
||||
resetDescription: null
|
||||
},
|
||||
weekly: {
|
||||
usedPercent: 77,
|
||||
windowMinutes: 10_080,
|
||||
resetsAt: fixtureStartedAt + 3 * 24 * 60 * 60 * 1000,
|
||||
resetDescription: null
|
||||
},
|
||||
rateLimitResetCredits: {
|
||||
availableCount: usage.availableResetCredits,
|
||||
totalEarnedCount: 2,
|
||||
nextExpiresAt: usage.availableResetCredits > 0 ? usage.nextExpiresAt : null
|
||||
},
|
||||
updatedAt: usage.updatedAt,
|
||||
error: null,
|
||||
status: 'ok' as const
|
||||
}
|
||||
}
|
||||
|
||||
export function getMockCodexResetScope(): CodexResetCreditExpectedScope | null {
|
||||
const account = CODEX_ACCOUNTS.find((candidate) => candidate.id === activeCodexAccountId) ?? null
|
||||
return buildCodexResetCreditExpectedScope({
|
||||
target: { runtime: 'host', wslDistro: null },
|
||||
account,
|
||||
limits: codexLimitsFor(activeCodexAccountId)
|
||||
})
|
||||
}
|
||||
|
||||
export function consumeMockCodexResetCredit(
|
||||
idempotencyKey: unknown,
|
||||
expectedScope: unknown
|
||||
):
|
||||
| { outcome: 'reset' | 'noCredit'; scope: CodexResetCreditExpectedScope }
|
||||
| {
|
||||
status: 'rejectedBeforeProvider'
|
||||
retryDisposition: 'discardAttempt'
|
||||
reason: 'offerChanged'
|
||||
scope: CodexResetCreditExpectedScope
|
||||
} {
|
||||
if (typeof idempotencyKey !== 'string' || !UUID_PATTERN.test(idempotencyKey)) {
|
||||
throw new Error('Invalid idempotencyKey')
|
||||
}
|
||||
if (!expectedScope || typeof expectedScope !== 'object') {
|
||||
throw new Error('Missing expectedScope')
|
||||
}
|
||||
const suppliedScopeKey = JSON.stringify(expectedScope)
|
||||
const previous = resetOperations.get(idempotencyKey)
|
||||
if (previous) {
|
||||
if (previous.scopeKey !== suppliedScopeKey) {
|
||||
throw new Error('The reset operation belongs to a different account scope')
|
||||
}
|
||||
return {
|
||||
outcome: previous.outcome,
|
||||
scope: expectedScope as CodexResetCreditExpectedScope
|
||||
}
|
||||
}
|
||||
|
||||
const currentScope = getMockCodexResetScope()
|
||||
if (!currentScope || JSON.stringify(currentScope) !== suppliedScopeKey) {
|
||||
return {
|
||||
status: 'rejectedBeforeProvider',
|
||||
retryDisposition: 'discardAttempt',
|
||||
reason: 'offerChanged',
|
||||
scope: expectedScope as CodexResetCreditExpectedScope
|
||||
}
|
||||
}
|
||||
const offerKey = suppliedScopeKey
|
||||
const owner = resetOfferOwners.get(offerKey)
|
||||
if (owner && owner !== idempotencyKey) {
|
||||
throw new Error('That reset offer is already being redeemed')
|
||||
}
|
||||
resetOfferOwners.set(offerKey, idempotencyKey)
|
||||
|
||||
const usage = codexUsageByAccount.get(currentScope.accountId)
|
||||
const outcome = usage && usage.availableResetCredits > 0 ? 'reset' : 'noCredit'
|
||||
resetOperations.set(idempotencyKey, { scopeKey: suppliedScopeKey, outcome })
|
||||
if (usage && outcome === 'reset') {
|
||||
usage.availableResetCredits = 0
|
||||
usage.sessionUsedPercent = 0
|
||||
usage.updatedAt += 1
|
||||
}
|
||||
return { outcome, scope: currentScope }
|
||||
}
|
||||
|
||||
export function createMockAccountsSnapshot() {
|
||||
const codexLimits = codexLimitsFor(activeCodexAccountId)
|
||||
return {
|
||||
claude: {
|
||||
accounts: [
|
||||
{ id: 'claude-team', email: 'dev@example.com', organizationName: 'Example Team' },
|
||||
{ id: 'claude-personal', email: 'personal@example.com', organizationName: null }
|
||||
],
|
||||
activeAccountId: activeClaudeAccountId
|
||||
},
|
||||
codex: {
|
||||
accounts: CODEX_ACCOUNTS.map((account) => ({ ...account })),
|
||||
activeAccountId: activeCodexAccountId,
|
||||
activeAccountIdsByRuntime: { host: activeCodexAccountId, wsl: {} }
|
||||
},
|
||||
rateLimits: {
|
||||
claude: {
|
||||
provider: 'claude' as const,
|
||||
session: {
|
||||
usedPercent: 38,
|
||||
windowMinutes: 300,
|
||||
resetsAt: fixtureStartedAt + 2 * 60 * 60 * 1000,
|
||||
resetDescription: null
|
||||
},
|
||||
weekly: {
|
||||
usedPercent: 61,
|
||||
windowMinutes: 10_080,
|
||||
resetsAt: fixtureStartedAt + 4 * 24 * 60 * 60 * 1000,
|
||||
resetDescription: null
|
||||
},
|
||||
updatedAt: fixtureStartedAt,
|
||||
error: null,
|
||||
status: 'ok' as const
|
||||
},
|
||||
codex: codexLimits,
|
||||
claudeTarget: { runtime: 'host' as const, wslDistro: null },
|
||||
codexTarget: { runtime: 'host' as const, wslDistro: null },
|
||||
inactiveClaudeAccounts: [],
|
||||
inactiveCodexAccounts: CODEX_ACCOUNTS.filter(
|
||||
(account) => account.id !== activeCodexAccountId
|
||||
).map((account) => ({
|
||||
accountId: account.id,
|
||||
rateLimits: codexLimitsFor(account.id),
|
||||
updatedAt: codexUsageByAccount.get(account.id)?.updatedAt ?? fixtureStartedAt,
|
||||
isFetching: false
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,12 @@ import {
|
||||
import type { TerminalQuickCommand } from '../../src/shared/types'
|
||||
import { handleMockFilePreviewRequest } from './mock-server-file-preview-data'
|
||||
import { handleMockGitRequest } from './mock-server-git-state'
|
||||
import { FAKE_SCROLLBACK, STREAMING_CHUNKS } from './mock-server-terminal-fixtures'
|
||||
import { handleMockAccountRequest } from './mock-server-account-rpc'
|
||||
import {
|
||||
createMockTerminals,
|
||||
FAKE_SCROLLBACK,
|
||||
STREAMING_CHUNKS
|
||||
} from './mock-server-terminal-fixtures'
|
||||
import { createMockRepos, createMockWorktrees, readScenarioNumber } from './mobile-lag-scenario'
|
||||
|
||||
const MOCK_REPO_COUNT = readScenarioNumber('MOCK_REPO_COUNT', 2)
|
||||
@@ -41,23 +46,6 @@ let fakeQuickCommands: TerminalQuickCommand[] = [
|
||||
}
|
||||
]
|
||||
|
||||
const FAKE_TERMINALS = [
|
||||
{
|
||||
handle: 'term-1',
|
||||
worktreeId: fakeWorktrees[0]?.worktreeId ?? 'repo-1::/tmp/orca-mobile-repro/orca',
|
||||
title: 'Claude — auth refactor',
|
||||
isActive: true,
|
||||
hasRunningProcess: true
|
||||
},
|
||||
{
|
||||
handle: 'term-2',
|
||||
worktreeId: fakeWorktrees[0]?.worktreeId ?? 'repo-1::/tmp/orca-mobile-repro/orca',
|
||||
title: 'zsh',
|
||||
isActive: false,
|
||||
hasRunningProcess: false
|
||||
}
|
||||
]
|
||||
|
||||
export type RpcRequest = {
|
||||
id: string
|
||||
method: string
|
||||
@@ -109,6 +97,13 @@ function repoSelectorToId(repoSelector: unknown): string | null {
|
||||
return repoSelector.startsWith('id:') ? repoSelector.slice(3) : repoSelector
|
||||
}
|
||||
|
||||
function terminalListWorktreeId(worktreeSelector: unknown): string | undefined {
|
||||
if (typeof worktreeSelector === 'string' && worktreeSelector.length > 0) {
|
||||
return worktreeSelector.startsWith('id:') ? worktreeSelector.slice(3) : worktreeSelector
|
||||
}
|
||||
return fakeWorktrees.find((worktree) => worktree.isActive)?.worktreeId
|
||||
}
|
||||
|
||||
export function handleRequest(
|
||||
request: RpcRequest,
|
||||
send: (response: RpcResponse) => void,
|
||||
@@ -129,6 +124,9 @@ export function handleRequest(
|
||||
if (handleMockFilePreviewRequest(request, respond, success, error)) {
|
||||
return
|
||||
}
|
||||
if (handleMockAccountRequest(request, respond, success, error)) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (request.method) {
|
||||
case 'status.get':
|
||||
@@ -137,6 +135,7 @@ export function handleRequest(
|
||||
runtimeId: 'mock-runtime',
|
||||
protocolVersion: DESKTOP_PROTOCOL_VERSION,
|
||||
minCompatibleMobileVersion: MIN_COMPATIBLE_MOBILE_VERSION,
|
||||
capabilities: ['accounts.codex-reset-credit.v1'],
|
||||
graphStatus: 'ready',
|
||||
windowCount: 1,
|
||||
tabCount: 2,
|
||||
@@ -277,15 +276,17 @@ export function handleRequest(
|
||||
break
|
||||
}
|
||||
|
||||
case 'terminal.list':
|
||||
case 'terminal.list': {
|
||||
const terminals = createMockTerminals(terminalListWorktreeId(request.params?.worktree))
|
||||
respond(
|
||||
success(request.id, {
|
||||
terminals: FAKE_TERMINALS,
|
||||
totalCount: FAKE_TERMINALS.length,
|
||||
terminals,
|
||||
totalCount: terminals.length,
|
||||
truncated: false
|
||||
})
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case 'terminal.subscribe': {
|
||||
respond(success(request.id, { type: 'scrollback', lines: FAKE_SCROLLBACK, truncated: false }))
|
||||
|
||||
@@ -19,3 +19,23 @@ export const STREAMING_CHUNKS = [
|
||||
"I'll replace it with jsonwebtoken.\n",
|
||||
'\nUpdating src/auth/middleware.ts...\n'
|
||||
]
|
||||
|
||||
export function createMockTerminals(worktreeId?: string) {
|
||||
const resolvedWorktreeId = worktreeId ?? 'repo-1::/tmp/orca-mobile-repro/orca'
|
||||
return [
|
||||
{
|
||||
handle: 'term-1',
|
||||
worktreeId: resolvedWorktreeId,
|
||||
title: 'Claude — auth refactor',
|
||||
isActive: true,
|
||||
hasRunningProcess: true
|
||||
},
|
||||
{
|
||||
handle: 'term-2',
|
||||
worktreeId: resolvedWorktreeId,
|
||||
title: 'zsh',
|
||||
isActive: false,
|
||||
hasRunningProcess: false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AccountsScreen from '../app/h/[hostId]/accounts'
|
||||
import { resetCodexResetAttemptJournalForTests } from './storage/codex-reset-attempt-journal'
|
||||
|
||||
const dependencies = vi.hoisted(() => ({
|
||||
alert: vi.fn(),
|
||||
back: vi.fn(),
|
||||
loadHosts: vi.fn(),
|
||||
randomUUID: vi.fn(),
|
||||
resetRequest: vi.fn(),
|
||||
selectRequest: vi.fn(),
|
||||
statusCapabilities: vi.fn(),
|
||||
subscriptionListeners: [] as Array<(payload: unknown) => void>,
|
||||
asyncStorage: {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: dependencies.asyncStorage
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
Alert: { alert: dependencies.alert },
|
||||
AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) },
|
||||
Pressable: 'Pressable',
|
||||
RefreshControl: 'RefreshControl',
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('react-native-safe-area-context', () => ({
|
||||
SafeAreaView: 'SafeAreaView',
|
||||
useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 })
|
||||
}))
|
||||
|
||||
vi.mock('expo-router', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
useFocusEffect(effect: () => void | (() => void)): void {
|
||||
React.useEffect(effect, [effect])
|
||||
},
|
||||
useLocalSearchParams: () => ({ hostId: 'host-1' }),
|
||||
useRouter: () => ({ back: dependencies.back })
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('expo-crypto', () => ({ randomUUID: dependencies.randomUUID }))
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
Check: 'Check',
|
||||
ChevronLeft: 'ChevronLeft',
|
||||
RefreshCw: 'RefreshCw',
|
||||
RotateCcw: 'RotateCcw',
|
||||
User: 'User'
|
||||
}))
|
||||
|
||||
vi.mock('./transport/host-store', () => ({ loadHosts: dependencies.loadHosts }))
|
||||
|
||||
vi.mock('./transport/client-context', () => {
|
||||
const client = {
|
||||
sendRequest: async (method: string, params?: unknown, options?: unknown) => {
|
||||
if (method === 'status.get') {
|
||||
return {
|
||||
id: 'status',
|
||||
ok: true,
|
||||
result: { capabilities: dependencies.statusCapabilities() },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
}
|
||||
if (method === 'accounts.consumeCodexResetCredit') {
|
||||
return dependencies.resetRequest(params, options)
|
||||
}
|
||||
if (
|
||||
method === 'accounts.selectCodex' ||
|
||||
method === 'accounts.selectCodexForTarget' ||
|
||||
method === 'accounts.selectClaude'
|
||||
) {
|
||||
return dependencies.selectRequest(method, params)
|
||||
}
|
||||
if (method === 'accounts.list') {
|
||||
return { id: 'list', ok: true, result: AVAILABLE_SNAPSHOT }
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`)
|
||||
},
|
||||
subscribe: (_method: string, _params: unknown, onData: (payload: unknown) => void) => {
|
||||
dependencies.subscriptionListeners.push(onData)
|
||||
onData({ type: 'ready', snapshot: AVAILABLE_SNAPSHOT })
|
||||
return vi.fn()
|
||||
}
|
||||
}
|
||||
return {
|
||||
useHostClient: () => ({ client, state: 'connected' })
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./components/AgentIcons', () => ({
|
||||
ClaudeIcon: 'ClaudeIcon',
|
||||
OpenAIIcon: 'OpenAIIcon'
|
||||
}))
|
||||
|
||||
const AVAILABLE_SNAPSHOT = {
|
||||
claude: { accounts: [], activeAccountId: null },
|
||||
codex: {
|
||||
accounts: [
|
||||
{
|
||||
id: 'codex-1',
|
||||
email: 'dev@example.com',
|
||||
managedHomeRuntime: 'host',
|
||||
wslDistro: null,
|
||||
updatedAt: 10
|
||||
}
|
||||
],
|
||||
activeAccountId: 'codex-1',
|
||||
activeAccountIdsByRuntime: { host: 'codex-1', wsl: {} }
|
||||
},
|
||||
rateLimits: {
|
||||
claude: null,
|
||||
codex: {
|
||||
provider: 'codex',
|
||||
session: {
|
||||
usedPercent: 100,
|
||||
windowMinutes: 300,
|
||||
resetsAt: 2_000_000_000_000,
|
||||
resetDescription: null
|
||||
},
|
||||
weekly: null,
|
||||
rateLimitResetCredits: { availableCount: 1, nextExpiresAt: null },
|
||||
updatedAt: 100,
|
||||
error: null,
|
||||
status: 'ok'
|
||||
},
|
||||
claudeTarget: { runtime: 'host', wslDistro: null },
|
||||
codexTarget: { runtime: 'host', wslDistro: null },
|
||||
inactiveClaudeAccounts: [],
|
||||
inactiveCodexAccounts: []
|
||||
}
|
||||
} as const
|
||||
|
||||
const RESET_SNAPSHOT = {
|
||||
...AVAILABLE_SNAPSHOT,
|
||||
rateLimits: {
|
||||
...AVAILABLE_SNAPSHOT.rateLimits,
|
||||
codex: {
|
||||
...AVAILABLE_SNAPSHOT.rateLimits.codex,
|
||||
session: { ...AVAILABLE_SNAPSHOT.rateLimits.codex.session, usedPercent: 0 },
|
||||
rateLimitResetCredits: { availableCount: 0, nextExpiresAt: null },
|
||||
updatedAt: 101
|
||||
}
|
||||
}
|
||||
} as const
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
async function renderAccountsRoute(): Promise<ReactTestRenderer> {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(AccountsScreen))
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
if (!renderer) {
|
||||
throw new Error('Accounts route did not render')
|
||||
}
|
||||
return renderer
|
||||
}
|
||||
|
||||
function resetButtons(renderer: ReactTestRenderer) {
|
||||
return renderer.root
|
||||
.findAllByType('Pressable')
|
||||
.filter((node) => node.props.accessibilityLabel === 'Use Codex rate-limit reset')
|
||||
}
|
||||
|
||||
function systemDefaultButtons(renderer: ReactTestRenderer) {
|
||||
return renderer.root
|
||||
.findAllByType('Pressable')
|
||||
.filter((node) =>
|
||||
node.findAllByType('Text').some((textNode) => textNode.children.join('') === 'System default')
|
||||
)
|
||||
}
|
||||
|
||||
async function findResetButton(renderer: ReactTestRenderer) {
|
||||
await vi.waitFor(() => expect(resetButtons(renderer)).toHaveLength(1))
|
||||
return resetButtons(renderer)[0]!
|
||||
}
|
||||
|
||||
function getLatestConfirmAction(): () => void {
|
||||
const call = dependencies.alert.mock.calls
|
||||
.toReversed()
|
||||
.find(([title]) => title === 'Use a rate-limit reset?')
|
||||
const action = call?.[2]?.[1]?.onPress
|
||||
if (typeof action !== 'function') {
|
||||
throw new Error('Reset confirmation action not found')
|
||||
}
|
||||
return action
|
||||
}
|
||||
|
||||
async function confirmReset(renderer: ReactTestRenderer): Promise<void> {
|
||||
const button = await findResetButton(renderer)
|
||||
await act(async () => button.props.onPress())
|
||||
await act(async () => {
|
||||
getLatestConfirmAction()()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
describe('accounts route Codex reset credit', () => {
|
||||
let storedValues: Map<string, string>
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
resetCodexResetAttemptJournalForTests()
|
||||
storedValues = new Map()
|
||||
dependencies.alert.mockReset()
|
||||
dependencies.loadHosts.mockReset().mockResolvedValue([
|
||||
{
|
||||
id: 'host-1',
|
||||
name: 'Desk',
|
||||
endpoint: 'ws://127.0.0.1:6768',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: 'public-key',
|
||||
lastConnected: 1
|
||||
}
|
||||
])
|
||||
dependencies.randomUUID.mockReset().mockReturnValue('11111111-1111-4111-8111-111111111111')
|
||||
dependencies.statusCapabilities.mockReset().mockReturnValue(['accounts.codex-reset-credit.v1'])
|
||||
dependencies.resetRequest.mockReset().mockImplementation((params) => ({
|
||||
id: 'reset',
|
||||
ok: true,
|
||||
result: {
|
||||
outcome: 'reset',
|
||||
scope: (params as { expectedScope: unknown }).expectedScope,
|
||||
snapshot: RESET_SNAPSHOT
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}))
|
||||
dependencies.selectRequest.mockReset().mockResolvedValue({
|
||||
id: 'select',
|
||||
ok: true,
|
||||
result: AVAILABLE_SNAPSHOT.codex
|
||||
})
|
||||
dependencies.subscriptionListeners.length = 0
|
||||
dependencies.asyncStorage.getItem
|
||||
.mockReset()
|
||||
.mockImplementation(async (key: string) => storedValues.get(key) ?? null)
|
||||
dependencies.asyncStorage.setItem
|
||||
.mockReset()
|
||||
.mockImplementation(async (key: string, value: string) => {
|
||||
storedValues.set(key, value)
|
||||
})
|
||||
dependencies.asyncStorage.removeItem.mockReset().mockImplementation(async (key: string) => {
|
||||
storedValues.delete(key)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('hides the scarce action when an older host does not advertise the capability', async () => {
|
||||
dependencies.statusCapabilities.mockReturnValue([])
|
||||
const renderer = await renderAccountsRoute()
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(resetButtons(renderer)).toHaveLength(0)
|
||||
expect(dependencies.resetRequest).not.toHaveBeenCalled()
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
|
||||
it('persists before RPC and reuses the UUID after unmounting an ambiguous request', async () => {
|
||||
dependencies.resetRequest
|
||||
.mockRejectedValueOnce(new Error('Connection lost'))
|
||||
.mockImplementationOnce((params) => ({
|
||||
id: 'reset-2',
|
||||
ok: true,
|
||||
result: {
|
||||
outcome: 'alreadyRedeemed',
|
||||
scope: (params as { expectedScope: unknown }).expectedScope,
|
||||
snapshot: RESET_SNAPSHOT
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}))
|
||||
|
||||
const firstRenderer = await renderAccountsRoute()
|
||||
await confirmReset(firstRenderer)
|
||||
expect(dependencies.alert).toHaveBeenCalledWith(
|
||||
'Could not reset rate limits',
|
||||
'Connection lost'
|
||||
)
|
||||
expect(storedValues.size).toBe(1)
|
||||
act(() => firstRenderer.unmount())
|
||||
|
||||
resetCodexResetAttemptJournalForTests()
|
||||
const secondRenderer = await renderAccountsRoute()
|
||||
await confirmReset(secondRenderer)
|
||||
|
||||
expect(dependencies.randomUUID).toHaveBeenCalledTimes(1)
|
||||
expect(dependencies.resetRequest).toHaveBeenCalledTimes(2)
|
||||
const [firstParams, firstOptions] = dependencies.resetRequest.mock.calls[0]!
|
||||
const [secondParams, secondOptions] = dependencies.resetRequest.mock.calls[1]!
|
||||
expect(firstParams).toEqual(secondParams)
|
||||
expect(firstOptions).toEqual({ timeoutMs: 90_000 })
|
||||
expect(secondOptions).toEqual({ timeoutMs: 90_000 })
|
||||
expect(storedValues.size).toBe(0)
|
||||
expect(dependencies.alert).toHaveBeenCalledWith(
|
||||
'Reset already applied',
|
||||
'Codex usage has been refreshed.'
|
||||
)
|
||||
act(() => secondRenderer.unmount())
|
||||
})
|
||||
|
||||
it('keeps the exact confirmed scope when a subscription changes before confirmation', async () => {
|
||||
const renderer = await renderAccountsRoute()
|
||||
const button = await findResetButton(renderer)
|
||||
await act(async () => button.props.onPress())
|
||||
const action = getLatestConfirmAction()
|
||||
|
||||
const changedSnapshot = {
|
||||
...AVAILABLE_SNAPSHOT,
|
||||
codex: {
|
||||
...AVAILABLE_SNAPSHOT.codex,
|
||||
activeAccountId: null,
|
||||
activeAccountIdsByRuntime: { host: null, wsl: {} }
|
||||
}
|
||||
}
|
||||
dependencies.resetRequest.mockImplementation((params) => ({
|
||||
id: 'reset',
|
||||
ok: true,
|
||||
result: {
|
||||
status: 'rejectedBeforeProvider',
|
||||
retryDisposition: 'discardAttempt',
|
||||
reason: 'accountChanged',
|
||||
scope: (params as { expectedScope: unknown }).expectedScope,
|
||||
snapshot: changedSnapshot
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}))
|
||||
act(() => {
|
||||
dependencies.subscriptionListeners[0]?.({ type: 'snapshot', snapshot: changedSnapshot })
|
||||
})
|
||||
await act(async () => {
|
||||
action()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(dependencies.resetRequest).toHaveBeenCalledOnce()
|
||||
expect(dependencies.resetRequest.mock.calls[0]?.[0]).toMatchObject({
|
||||
expectedScope: { accountId: 'codex-1', accountRevision: 10 }
|
||||
})
|
||||
expect(dependencies.alert).toHaveBeenCalledWith(
|
||||
'Reset details changed',
|
||||
'The account or reset offer changed before the host contacted Codex. Review the updated details, then confirm again.'
|
||||
)
|
||||
expect(storedValues.size).toBe(0)
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
|
||||
it('passes the active WSL target when clearing the Codex selection', async () => {
|
||||
const renderer = await renderAccountsRoute()
|
||||
const wslSnapshot = {
|
||||
...AVAILABLE_SNAPSHOT,
|
||||
codex: {
|
||||
accounts: [
|
||||
{
|
||||
...AVAILABLE_SNAPSHOT.codex.accounts[0],
|
||||
managedHomeRuntime: 'wsl',
|
||||
wslDistro: 'Ubuntu'
|
||||
}
|
||||
],
|
||||
activeAccountId: null,
|
||||
activeAccountIdsByRuntime: { host: null, wsl: { Ubuntu: 'codex-1' } }
|
||||
},
|
||||
rateLimits: {
|
||||
...AVAILABLE_SNAPSHOT.rateLimits,
|
||||
codexTarget: { runtime: 'wsl', wslDistro: 'Ubuntu' }
|
||||
}
|
||||
} as const
|
||||
|
||||
act(() => {
|
||||
dependencies.subscriptionListeners[0]?.({ type: 'snapshot', snapshot: wslSnapshot })
|
||||
})
|
||||
const codexSystemDefault = systemDefaultButtons(renderer).at(-1)
|
||||
expect(codexSystemDefault).toBeDefined()
|
||||
|
||||
await act(async () => {
|
||||
await codexSystemDefault?.props.onPress()
|
||||
})
|
||||
|
||||
expect(dependencies.selectRequest).toHaveBeenCalledWith('accounts.selectCodexForTarget', {
|
||||
accountId: null,
|
||||
target: { runtime: 'wsl', wslDistro: 'Ubuntu' }
|
||||
})
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
|
||||
it('recovers from UUID generation failure without leaving the action busy', async () => {
|
||||
dependencies.randomUUID
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('UUID unavailable')
|
||||
})
|
||||
.mockReturnValueOnce('11111111-1111-4111-8111-111111111111')
|
||||
const renderer = await renderAccountsRoute()
|
||||
|
||||
await confirmReset(renderer)
|
||||
expect(dependencies.alert).toHaveBeenCalledWith(
|
||||
'Could not reset rate limits',
|
||||
'UUID unavailable'
|
||||
)
|
||||
expect((await findResetButton(renderer)).props.accessibilityState).toEqual({
|
||||
busy: false,
|
||||
disabled: false
|
||||
})
|
||||
|
||||
await confirmReset(renderer)
|
||||
expect(dependencies.resetRequest).toHaveBeenCalledOnce()
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,7 @@ export type {
|
||||
UsageBarState
|
||||
} from './account-usage-state'
|
||||
export {
|
||||
decodeAccountsSnapshot,
|
||||
getActiveProviderRateLimits,
|
||||
getInactiveProviderUsage,
|
||||
getUsageBarState,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CodexResetCreditAction } from './CodexResetCreditAction'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react-native', () => ({ RotateCcw: 'RotateCcw' }))
|
||||
|
||||
const summary = {
|
||||
availableCount: 1,
|
||||
availabilityLabel: '1 reset available',
|
||||
expiryLabel: 'Expires in 5d'
|
||||
}
|
||||
|
||||
function suppressRendererWarning(): () => void {
|
||||
const original = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
function renderAction(busy: boolean, disabled: boolean): ReactTestRenderer {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(CodexResetCreditAction, {
|
||||
summary,
|
||||
scopeLabel: 'dev@example.com on the host',
|
||||
busy,
|
||||
disabled,
|
||||
onPress: vi.fn()
|
||||
})
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
if (!renderer) {
|
||||
throw new Error('Reset action did not render')
|
||||
}
|
||||
return renderer
|
||||
}
|
||||
|
||||
describe('CodexResetCreditAction', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('exposes a 44pt touch target and enabled accessibility state', () => {
|
||||
const renderer = renderAction(false, false)
|
||||
const button = renderer.root.findByType('Pressable')
|
||||
|
||||
expect(button.props.accessibilityLabel).toBe('Use Codex rate-limit reset')
|
||||
expect(button.props.accessibilityState).toEqual({ busy: false, disabled: false })
|
||||
expect(button.props.accessibilityHint).toContain('dev@example.com on the host')
|
||||
expect(button.props.hitSlop).toBe(8)
|
||||
expect(button.props.style({ pressed: false })[0]).toMatchObject({ minHeight: 44 })
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
|
||||
it('announces progress and visually dims a busy disabled action', () => {
|
||||
const renderer = renderAction(true, true)
|
||||
const button = renderer.root.findByType('Pressable')
|
||||
const text = renderer.root
|
||||
.findAllByType('Text')
|
||||
.map((node) => node.children.filter((child) => typeof child === 'string').join(''))
|
||||
|
||||
expect(button.props.accessibilityLabel).toBe('Resetting Codex rate limits')
|
||||
expect(button.props.accessibilityState).toEqual({ busy: true, disabled: true })
|
||||
expect(button.props.style({ pressed: false })[1]).toMatchObject({ opacity: 0.5 })
|
||||
expect(text).toContain('Resetting…')
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { RotateCcw } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import type { CodexResetCreditSummary } from './codex-reset-credit'
|
||||
|
||||
export function CodexResetCreditAction({
|
||||
summary,
|
||||
scopeLabel,
|
||||
busy,
|
||||
disabled,
|
||||
onPress
|
||||
}: {
|
||||
summary: CodexResetCreditSummary
|
||||
scopeLabel?: string | null
|
||||
busy: boolean
|
||||
disabled: boolean
|
||||
onPress: () => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<View style={styles.separator} />
|
||||
<View style={styles.row}>
|
||||
<View style={styles.copy}>
|
||||
<Text style={styles.title}>{summary.availabilityLabel}</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{[summary.expiryLabel, scopeLabel].filter(Boolean).join(' · ') ||
|
||||
'Earned Codex rate-limit reset'}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.button,
|
||||
disabled && styles.buttonDisabled,
|
||||
pressed && !disabled && styles.buttonPressed
|
||||
]}
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={busy ? 'Resetting Codex rate limits' : 'Use Codex rate-limit reset'}
|
||||
accessibilityHint={
|
||||
scopeLabel
|
||||
? `Uses one earned reset for ${scopeLabel}`
|
||||
: 'Uses one earned reset for the active Codex account'
|
||||
}
|
||||
accessibilityState={{ busy, disabled }}
|
||||
hitSlop={8}
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator size="small" color={colors.textPrimary} />
|
||||
) : (
|
||||
<RotateCcw size={14} color={colors.textPrimary} />
|
||||
)}
|
||||
<Text style={styles.buttonText}>{busy ? 'Resetting…' : 'Use reset'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
copy: {
|
||||
flex: 1,
|
||||
gap: spacing.xs
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
button: {
|
||||
minHeight: 44,
|
||||
width: 104,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
buttonPressed: {
|
||||
opacity: 0.72
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.5
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
}
|
||||
})
|
||||
@@ -35,11 +35,21 @@ function makeSnapshot(
|
||||
} = {}
|
||||
): AccountsSnapshot {
|
||||
return {
|
||||
claude: { accounts: overrides.claudeAccounts ?? [], activeAccountId: null },
|
||||
codex: { accounts: overrides.codexAccounts ?? [], activeAccountId: null },
|
||||
claude: {
|
||||
accounts: overrides.claudeAccounts ?? [],
|
||||
activeAccountId: null,
|
||||
activeAccountIdsByRuntime: { host: null, wsl: {} }
|
||||
},
|
||||
codex: {
|
||||
accounts: overrides.codexAccounts ?? [],
|
||||
activeAccountId: null,
|
||||
activeAccountIdsByRuntime: { host: null, wsl: {} }
|
||||
},
|
||||
rateLimits: {
|
||||
claude: overrides.claudeLimits ?? null,
|
||||
codex: overrides.codexLimits ?? null,
|
||||
claudeTarget: { runtime: 'host', wslDistro: null },
|
||||
codexTarget: { runtime: 'host', wslDistro: null },
|
||||
inactiveClaudeAccounts: overrides.inactiveClaudeAccounts ?? [],
|
||||
inactiveCodexAccounts: overrides.inactiveCodexAccounts ?? []
|
||||
}
|
||||
|
||||
@@ -6,54 +6,25 @@
|
||||
// unit-tested directly; AccountUsage.tsx re-exports them alongside the
|
||||
// UsageBar component.
|
||||
import { formatResetCountdown } from '../../../src/shared/rate-limit-reset-format'
|
||||
import type {
|
||||
AccountsSnapshot,
|
||||
InactiveAccountUsage,
|
||||
ProviderRateLimits
|
||||
} from './accounts-snapshot'
|
||||
|
||||
export type RateLimitWindow = {
|
||||
usedPercent: number
|
||||
windowMinutes: number
|
||||
resetsAt: number | null
|
||||
resetDescription: string | null
|
||||
}
|
||||
|
||||
export type ProviderRateLimits = {
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi'
|
||||
session: RateLimitWindow | null
|
||||
weekly: RateLimitWindow | null
|
||||
monthly?: RateLimitWindow | null
|
||||
buckets?: Array<RateLimitWindow & { name: string }>
|
||||
updatedAt: number
|
||||
error: string | null
|
||||
status: 'idle' | 'fetching' | 'ok' | 'error' | 'unavailable'
|
||||
}
|
||||
|
||||
export type InactiveAccountUsage = {
|
||||
accountId: string
|
||||
rateLimits: ProviderRateLimits | null
|
||||
updatedAt: number
|
||||
isFetching: boolean
|
||||
}
|
||||
|
||||
export type ClaudeAccountSummary = {
|
||||
id: string
|
||||
email: string
|
||||
organizationName?: string | null
|
||||
}
|
||||
|
||||
export type CodexAccountSummary = {
|
||||
id: string
|
||||
email: string
|
||||
workspaceLabel?: string | null
|
||||
}
|
||||
|
||||
export type AccountsSnapshot = {
|
||||
claude: { accounts: ClaudeAccountSummary[]; activeAccountId: string | null }
|
||||
codex: { accounts: CodexAccountSummary[]; activeAccountId: string | null }
|
||||
rateLimits: {
|
||||
claude: ProviderRateLimits | null
|
||||
codex: ProviderRateLimits | null
|
||||
inactiveClaudeAccounts: InactiveAccountUsage[]
|
||||
inactiveCodexAccounts: InactiveAccountUsage[]
|
||||
}
|
||||
}
|
||||
export {
|
||||
AccountsSnapshotSchema,
|
||||
decodeAccountsSnapshot,
|
||||
ProviderRateLimitsSchema,
|
||||
RateLimitRuntimeTargetSchema,
|
||||
type AccountsSnapshot,
|
||||
type ClaudeAccountSummary,
|
||||
type CodexAccountSummary,
|
||||
type InactiveAccountUsage,
|
||||
type ProviderRateLimits,
|
||||
type RateLimitRuntimeTarget,
|
||||
type RateLimitWindow
|
||||
} from './accounts-snapshot'
|
||||
|
||||
export type ProviderKey = 'claude' | 'codex'
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { decodeAccountsSnapshot } from './accounts-snapshot'
|
||||
|
||||
function makeSnapshot(): unknown {
|
||||
return {
|
||||
extensionField: { retained: true },
|
||||
claude: {
|
||||
accounts: [],
|
||||
activeAccountId: null,
|
||||
activeAccountIdsByRuntime: { host: null, wsl: {} }
|
||||
},
|
||||
codex: {
|
||||
accounts: [
|
||||
{
|
||||
id: 'codex-host',
|
||||
email: 'host@example.com',
|
||||
managedHomeRuntime: 'host',
|
||||
wslDistro: null,
|
||||
updatedAt: 100,
|
||||
extensionField: 'account-extra'
|
||||
}
|
||||
],
|
||||
activeAccountId: 'codex-host',
|
||||
activeAccountIdsByRuntime: {
|
||||
host: 'codex-host',
|
||||
wsl: { Ubuntu: 'codex-wsl' }
|
||||
}
|
||||
},
|
||||
rateLimits: {
|
||||
extensionField: 'limits-extra',
|
||||
claude: null,
|
||||
codex: {
|
||||
provider: 'codex',
|
||||
session: {
|
||||
usedPercent: 100,
|
||||
windowMinutes: 300,
|
||||
resetsAt: 200,
|
||||
resetDescription: 'soon'
|
||||
},
|
||||
weekly: null,
|
||||
rateLimitResetCredits: {
|
||||
availableCount: 1,
|
||||
totalEarnedCount: 2,
|
||||
nextExpiresAt: 300,
|
||||
credits: [{ status: 'available', expiresAt: 300, grantedAt: 50 }]
|
||||
},
|
||||
updatedAt: 100,
|
||||
error: null,
|
||||
status: 'ok',
|
||||
extensionField: 'provider-extra'
|
||||
},
|
||||
claudeTarget: { runtime: 'host', wslDistro: null },
|
||||
codexTarget: { runtime: 'host', wslDistro: null },
|
||||
inactiveClaudeAccounts: [],
|
||||
inactiveCodexAccounts: [
|
||||
{
|
||||
accountId: 'codex-inactive',
|
||||
rateLimits: null,
|
||||
updatedAt: 99,
|
||||
isFetching: false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setPath(root: unknown, path: string[], value: unknown): void {
|
||||
let current: unknown = root
|
||||
for (const segment of path.slice(0, -1)) {
|
||||
if (!current || typeof current !== 'object' || Array.isArray(current)) {
|
||||
throw new Error(`Invalid fixture path: ${path.join('.')}`)
|
||||
}
|
||||
current = (current as Record<string, unknown>)[segment]
|
||||
}
|
||||
if (!current || typeof current !== 'object' || Array.isArray(current)) {
|
||||
throw new Error(`Invalid fixture path: ${path.join('.')}`)
|
||||
}
|
||||
const record = current as Record<string, unknown>
|
||||
record[path.at(-1)!] = value
|
||||
}
|
||||
|
||||
describe('decodeAccountsSnapshot', () => {
|
||||
it('validates nested account/rate-limit state and preserves forward-compatible fields', () => {
|
||||
const snapshot = decodeAccountsSnapshot(makeSnapshot())
|
||||
|
||||
expect(snapshot.extensionField).toEqual({ retained: true })
|
||||
expect(snapshot.codex.accounts[0]?.extensionField).toBe('account-extra')
|
||||
expect(snapshot.rateLimits.extensionField).toBe('limits-extra')
|
||||
expect(snapshot.rateLimits.codex?.extensionField).toBe('provider-extra')
|
||||
})
|
||||
|
||||
it('defaults missing runtime targets for older host-only snapshots', () => {
|
||||
const raw = makeSnapshot() as {
|
||||
rateLimits: { claudeTarget?: unknown; codexTarget?: unknown }
|
||||
}
|
||||
delete raw.rateLimits.claudeTarget
|
||||
delete raw.rateLimits.codexTarget
|
||||
|
||||
const snapshot = decodeAccountsSnapshot(raw)
|
||||
|
||||
expect(snapshot.rateLimits.claudeTarget).toEqual({ runtime: 'host', wslDistro: null })
|
||||
expect(snapshot.rateLimits.codexTarget).toEqual({ runtime: 'host', wslDistro: null })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['account arrays', ['codex', 'accounts'], {}],
|
||||
['active account IDs', ['codex', 'activeAccountId'], 42],
|
||||
['runtime selections', ['codex', 'activeAccountIdsByRuntime', 'wsl'], []],
|
||||
['targets', ['rateLimits', 'codexTarget', 'runtime'], 'remote'],
|
||||
['provider identity', ['rateLimits', 'codex', 'provider'], 'claude'],
|
||||
['inactive account arrays', ['rateLimits', 'inactiveCodexAccounts'], {}],
|
||||
['window percentages', ['rateLimits', 'codex', 'session', 'usedPercent'], 101],
|
||||
['credit counts', ['rateLimits', 'codex', 'rateLimitResetCredits', 'availableCount'], -1],
|
||||
[
|
||||
'credit status',
|
||||
['rateLimits', 'codex', 'rateLimitResetCredits', 'credits'],
|
||||
[{ status: '', expiresAt: 300, grantedAt: 50 }]
|
||||
],
|
||||
['credit expiry', ['rateLimits', 'codex', 'rateLimitResetCredits', 'nextExpiresAt'], 'soon']
|
||||
] satisfies Array<[string, string[], unknown]>)('rejects malformed %s', (_name, path, value) => {
|
||||
const snapshot = makeSnapshot()
|
||||
setPath(snapshot, path, value)
|
||||
|
||||
expect(() => decodeAccountsSnapshot(snapshot)).toThrow('Invalid accounts snapshot from host')
|
||||
})
|
||||
|
||||
it('rejects a host target that smuggles a WSL distro', () => {
|
||||
const snapshot = makeSnapshot()
|
||||
setPath(snapshot, ['rateLimits', 'codexTarget', 'wslDistro'], 'Ubuntu')
|
||||
|
||||
expect(() => decodeAccountsSnapshot(snapshot)).toThrow('Invalid accounts snapshot from host')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,236 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const TimestampSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
|
||||
const AccountIdSchema = z.string().min(1)
|
||||
|
||||
const RateLimitWindowSchema = z
|
||||
.object({
|
||||
usedPercent: z.number().finite().min(0).max(100),
|
||||
windowMinutes: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
|
||||
resetsAt: TimestampSchema.nullable(),
|
||||
resetDescription: z.string().nullable()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const RateLimitResetCreditSchema = z
|
||||
.object({
|
||||
status: z.string().min(1),
|
||||
expiresAt: TimestampSchema.nullable(),
|
||||
grantedAt: TimestampSchema.nullable()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const RateLimitResetCreditsSchema = z
|
||||
.object({
|
||||
availableCount: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
||||
totalEarnedCount: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(),
|
||||
nextExpiresAt: TimestampSchema.nullable().optional(),
|
||||
credits: z.array(RateLimitResetCreditSchema).optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export const ProviderRateLimitsSchema = z
|
||||
.object({
|
||||
provider: z.enum([
|
||||
'claude',
|
||||
'codex',
|
||||
'gemini',
|
||||
'opencode-go',
|
||||
'kimi',
|
||||
'minimax',
|
||||
'grok',
|
||||
'antigravity'
|
||||
]),
|
||||
session: RateLimitWindowSchema.nullable(),
|
||||
weekly: RateLimitWindowSchema.nullable(),
|
||||
fableWeekly: RateLimitWindowSchema.nullable().optional(),
|
||||
monthly: RateLimitWindowSchema.nullable().optional(),
|
||||
buckets: z
|
||||
.array(RateLimitWindowSchema.extend({ name: z.string().min(1) }).passthrough())
|
||||
.optional(),
|
||||
rateLimitResetCredits: RateLimitResetCreditsSchema.nullable().optional(),
|
||||
updatedAt: TimestampSchema,
|
||||
error: z.string().nullable(),
|
||||
status: z.enum(['idle', 'fetching', 'ok', 'error', 'unavailable'])
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const InactiveAccountUsageSchema = z
|
||||
.object({
|
||||
accountId: AccountIdSchema,
|
||||
rateLimits: ProviderRateLimitsSchema.nullable(),
|
||||
updatedAt: TimestampSchema,
|
||||
isFetching: z.boolean()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const RuntimeSelectionSchema = z
|
||||
.object({
|
||||
host: AccountIdSchema.nullable(),
|
||||
wsl: z.record(z.string().min(1), AccountIdSchema.nullable())
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
export const RateLimitRuntimeTargetSchema = z
|
||||
.object({
|
||||
runtime: z.enum(['host', 'wsl']),
|
||||
wslDistro: z.string().min(1).nullable()
|
||||
})
|
||||
.passthrough()
|
||||
.superRefine((target, context) => {
|
||||
if (target.runtime === 'host' && target.wslDistro !== null) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Host rate-limit targets cannot name a WSL distro',
|
||||
path: ['wslDistro']
|
||||
})
|
||||
}
|
||||
if (
|
||||
target.runtime === 'wsl' &&
|
||||
target.wslDistro !== null &&
|
||||
target.wslDistro.trim() !== target.wslDistro
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'WSL rate-limit targets require an exact distro',
|
||||
path: ['wslDistro']
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const HostRateLimitRuntimeTarget = {
|
||||
runtime: 'host' as const,
|
||||
wslDistro: null
|
||||
}
|
||||
|
||||
const ClaudeAccountSummarySchema = z
|
||||
.object({
|
||||
id: AccountIdSchema,
|
||||
email: z.string().min(1),
|
||||
managedAuthRuntime: z.enum(['host', 'wsl']).optional(),
|
||||
wslDistro: z.string().nullable().optional(),
|
||||
authMethod: z.enum(['subscription-oauth', 'unknown']).optional(),
|
||||
organizationUuid: z.string().nullable().optional(),
|
||||
organizationName: z.string().nullable().optional(),
|
||||
createdAt: TimestampSchema.optional(),
|
||||
updatedAt: TimestampSchema.optional(),
|
||||
lastAuthenticatedAt: TimestampSchema.optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const CodexAccountSummarySchema = z
|
||||
.object({
|
||||
id: AccountIdSchema,
|
||||
email: z.string().min(1),
|
||||
managedHomeRuntime: z.enum(['host', 'wsl']).optional(),
|
||||
wslDistro: z.string().nullable().optional(),
|
||||
providerAccountId: z.string().nullable().optional(),
|
||||
workspaceLabel: z.string().nullable().optional(),
|
||||
workspaceAccountId: z.string().nullable().optional(),
|
||||
createdAt: TimestampSchema.optional(),
|
||||
updatedAt: TimestampSchema,
|
||||
lastAuthenticatedAt: TimestampSchema.optional()
|
||||
})
|
||||
.passthrough()
|
||||
.superRefine((account, context) => {
|
||||
const runtime = account.managedHomeRuntime ?? 'host'
|
||||
if (runtime === 'host' && account.wslDistro != null) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Host Codex accounts cannot name a WSL distro',
|
||||
path: ['wslDistro']
|
||||
})
|
||||
}
|
||||
if (
|
||||
runtime === 'wsl' &&
|
||||
account.wslDistro != null &&
|
||||
account.wslDistro.trim() !== account.wslDistro
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'WSL Codex accounts require an exact distro',
|
||||
path: ['wslDistro']
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const AccountsSnapshotSchema = z
|
||||
.object({
|
||||
claude: z
|
||||
.object({
|
||||
accounts: z.array(ClaudeAccountSummarySchema),
|
||||
activeAccountId: AccountIdSchema.nullable(),
|
||||
activeAccountIdsByRuntime: RuntimeSelectionSchema.optional()
|
||||
})
|
||||
.passthrough(),
|
||||
codex: z
|
||||
.object({
|
||||
accounts: z.array(CodexAccountSummarySchema),
|
||||
activeAccountId: AccountIdSchema.nullable(),
|
||||
activeAccountIdsByRuntime: RuntimeSelectionSchema.optional()
|
||||
})
|
||||
.passthrough(),
|
||||
rateLimits: z
|
||||
.object({
|
||||
claude: ProviderRateLimitsSchema.nullable(),
|
||||
codex: ProviderRateLimitsSchema.nullable(),
|
||||
// Why: protocol-compatible hosts from before runtime targeting omit
|
||||
// these fields; their account selection semantics were host-only.
|
||||
claudeTarget: RateLimitRuntimeTargetSchema.default(HostRateLimitRuntimeTarget),
|
||||
codexTarget: RateLimitRuntimeTargetSchema.default(HostRateLimitRuntimeTarget),
|
||||
inactiveClaudeAccounts: z.array(InactiveAccountUsageSchema),
|
||||
inactiveCodexAccounts: z.array(InactiveAccountUsageSchema)
|
||||
})
|
||||
.passthrough()
|
||||
})
|
||||
.passthrough()
|
||||
.superRefine((snapshot, context) => {
|
||||
if (snapshot.rateLimits.claude && snapshot.rateLimits.claude.provider !== 'claude') {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Claude limits use the wrong provider identity',
|
||||
path: ['rateLimits', 'claude', 'provider']
|
||||
})
|
||||
}
|
||||
if (snapshot.rateLimits.codex && snapshot.rateLimits.codex.provider !== 'codex') {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Codex limits use the wrong provider identity',
|
||||
path: ['rateLimits', 'codex', 'provider']
|
||||
})
|
||||
}
|
||||
for (const [index, entry] of snapshot.rateLimits.inactiveClaudeAccounts.entries()) {
|
||||
if (entry.rateLimits && entry.rateLimits.provider !== 'claude') {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Inactive Claude limits use the wrong provider identity',
|
||||
path: ['rateLimits', 'inactiveClaudeAccounts', index, 'rateLimits', 'provider']
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const [index, entry] of snapshot.rateLimits.inactiveCodexAccounts.entries()) {
|
||||
if (entry.rateLimits && entry.rateLimits.provider !== 'codex') {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Inactive Codex limits use the wrong provider identity',
|
||||
path: ['rateLimits', 'inactiveCodexAccounts', index, 'rateLimits', 'provider']
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export type RateLimitWindow = z.infer<typeof RateLimitWindowSchema>
|
||||
export type ProviderRateLimits = z.infer<typeof ProviderRateLimitsSchema>
|
||||
export type InactiveAccountUsage = z.infer<typeof InactiveAccountUsageSchema>
|
||||
export type RateLimitRuntimeTarget = z.infer<typeof RateLimitRuntimeTargetSchema>
|
||||
export type ClaudeAccountSummary = z.infer<typeof ClaudeAccountSummarySchema>
|
||||
export type CodexAccountSummary = z.infer<typeof CodexAccountSummarySchema>
|
||||
export type AccountsSnapshot = z.infer<typeof AccountsSnapshotSchema>
|
||||
|
||||
export function decodeAccountsSnapshot(value: unknown): AccountsSnapshot {
|
||||
const result = AccountsSnapshotSchema.safeParse(value)
|
||||
if (!result.success) {
|
||||
throw new Error('Invalid accounts snapshot from host')
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
|
||||
const probe = vi.hoisted(() => ({
|
||||
start: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../transport/runtime-capability-probe', () => ({
|
||||
startRuntimeCapabilityProbe: probe.start
|
||||
}))
|
||||
|
||||
import {
|
||||
MOBILE_CODEX_RESET_CREDIT_CAPABILITY,
|
||||
readCodexResetCreditCapability,
|
||||
useCodexResetCreditCapability
|
||||
} from './codex-reset-credit-capability'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
probe.start.mockReset()
|
||||
})
|
||||
|
||||
describe('readCodexResetCreditCapability', () => {
|
||||
it('enables reset only when the host explicitly advertises the contract', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
result: { capabilities: ['mobile.tasks.v1', MOBILE_CODEX_RESET_CREDIT_CAPABILITY] }
|
||||
})
|
||||
|
||||
await expect(readCodexResetCreditCapability({ sendRequest })).resolves.toBe(true)
|
||||
expect(sendRequest).toHaveBeenCalledWith('status.get')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ ok: true, result: { capabilities: ['mobile.tasks.v1'] } },
|
||||
{ ok: true, result: { capabilities: 'accounts.codex-reset-credit.v1' } },
|
||||
{ ok: false, error: { code: 'old-host', message: 'unsupported' } }
|
||||
])('fails closed for an unsupported or malformed host response', async (response) => {
|
||||
const sendRequest = vi.fn().mockResolvedValue(response)
|
||||
await expect(readCodexResetCreditCapability({ sendRequest })).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed when the capability probe cannot complete', async () => {
|
||||
const sendRequest = vi.fn().mockRejectedValue(new Error('connection lost'))
|
||||
await expect(readCodexResetCreditCapability({ sendRequest })).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCodexResetCreditCapability', () => {
|
||||
it('uses the reconnect-safe probe and cancels it on unmount', () => {
|
||||
const cancel = vi.fn()
|
||||
let publish: ((capabilities: readonly string[]) => void) | null = null
|
||||
probe.start.mockImplementation(
|
||||
(_client: RpcClient, onCapabilities: (capabilities: readonly string[]) => void) => {
|
||||
publish = onCapabilities
|
||||
return cancel
|
||||
}
|
||||
)
|
||||
const client = { sendRequest: vi.fn() } as unknown as RpcClient
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
function Harness() {
|
||||
const supported = useCodexResetCreditCapability(client, true)
|
||||
return createElement('CapabilityResult', { supported })
|
||||
}
|
||||
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
expect(renderer!.root.findByType('CapabilityResult').props.supported).toBe(false)
|
||||
|
||||
act(() => publish?.([MOBILE_CODEX_RESET_CREDIT_CAPABILITY]))
|
||||
expect(renderer!.root.findByType('CapabilityResult').props.supported).toBe(true)
|
||||
|
||||
act(() => renderer!.unmount())
|
||||
expect(cancel).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CODEX_RESET_CREDIT_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { startRuntimeCapabilityProbe } from '../transport/runtime-capability-probe'
|
||||
|
||||
// Why: source the capability string from the shared contract so a host bump can never
|
||||
// silently drift from the mobile probe.
|
||||
export const MOBILE_CODEX_RESET_CREDIT_CAPABILITY = CODEX_RESET_CREDIT_RUNTIME_CAPABILITY
|
||||
|
||||
export async function readCodexResetCreditCapability(
|
||||
client: Pick<RpcClient, 'sendRequest'>
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await client.sendRequest('status.get')
|
||||
if (!response.ok || !response.result || typeof response.result !== 'object') {
|
||||
return false
|
||||
}
|
||||
const capabilities = (response.result as { capabilities?: unknown }).capabilities
|
||||
return (
|
||||
Array.isArray(capabilities) && capabilities.includes(MOBILE_CODEX_RESET_CREDIT_CAPABILITY)
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function useCodexResetCreditCapability(
|
||||
client: RpcClient | null,
|
||||
connected: boolean
|
||||
): boolean {
|
||||
const [supported, setSupported] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setSupported(false)
|
||||
if (!client || !connected) {
|
||||
return
|
||||
}
|
||||
return startRuntimeCapabilityProbe(client, (capabilities) => {
|
||||
setSupported(capabilities.includes(MOBILE_CODEX_RESET_CREDIT_CAPABILITY))
|
||||
})
|
||||
}, [client, connected])
|
||||
|
||||
return supported
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const asyncStorage = vi.hoisted(() => ({
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
|
||||
|
||||
import { resetCodexResetAttemptJournalForTests } from '../storage/codex-reset-attempt-journal'
|
||||
import type { AccountsSnapshot, ProviderRateLimits } from './accounts-snapshot'
|
||||
import {
|
||||
getActiveCodexAccountIdForRateLimitTarget,
|
||||
getCodexResetCreditOutcomeCopy,
|
||||
getCodexResetCreditScope,
|
||||
getCodexResetCreditSummary,
|
||||
resetCodexResetCreditRequestsForTests,
|
||||
requestCodexResetCredit
|
||||
} from './codex-reset-credit'
|
||||
|
||||
const UUID = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
function makeLimits(availableCount: number, nextExpiresAt: number | null): ProviderRateLimits {
|
||||
return {
|
||||
provider: 'codex',
|
||||
session: null,
|
||||
weekly: null,
|
||||
rateLimitResetCredits: { availableCount, nextExpiresAt },
|
||||
updatedAt: 100,
|
||||
error: null,
|
||||
status: 'ok'
|
||||
}
|
||||
}
|
||||
|
||||
function makeSnapshot(
|
||||
options: {
|
||||
target?: AccountsSnapshot['rateLimits']['codexTarget']
|
||||
activeHostId?: string | null
|
||||
activeWslIds?: Record<string, string | null>
|
||||
accounts?: AccountsSnapshot['codex']['accounts']
|
||||
availableCount?: number
|
||||
} = {}
|
||||
): AccountsSnapshot {
|
||||
const activeHostId = options.activeHostId === undefined ? 'account-host' : options.activeHostId
|
||||
return {
|
||||
claude: {
|
||||
accounts: [],
|
||||
activeAccountId: null,
|
||||
activeAccountIdsByRuntime: { host: null, wsl: {} }
|
||||
},
|
||||
codex: {
|
||||
accounts: options.accounts ?? [
|
||||
{
|
||||
id: 'account-host',
|
||||
email: 'host@example.com',
|
||||
managedHomeRuntime: 'host',
|
||||
wslDistro: null,
|
||||
updatedAt: 10
|
||||
}
|
||||
],
|
||||
activeAccountId: activeHostId,
|
||||
activeAccountIdsByRuntime: {
|
||||
host: activeHostId,
|
||||
wsl: options.activeWslIds ?? {}
|
||||
}
|
||||
},
|
||||
rateLimits: {
|
||||
claude: null,
|
||||
codex: makeLimits(options.availableCount ?? 1, null),
|
||||
claudeTarget: { runtime: 'host', wslDistro: null },
|
||||
codexTarget: options.target ?? { runtime: 'host', wslDistro: null },
|
||||
inactiveClaudeAccounts: [],
|
||||
inactiveCodexAccounts: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('getCodexResetCreditSummary', () => {
|
||||
const now = 1_700_000_000_000
|
||||
|
||||
it('hides the action when no earned credit is available', () => {
|
||||
expect(getCodexResetCreditSummary(null, now)).toBeNull()
|
||||
expect(getCodexResetCreditSummary(makeLimits(0, now + 60_000), now)).toBeNull()
|
||||
})
|
||||
|
||||
it('formats singular and plural availability with the next expiry', () => {
|
||||
expect(getCodexResetCreditSummary(makeLimits(1, now + 2 * 60 * 60_000), now)).toEqual({
|
||||
availableCount: 1,
|
||||
availabilityLabel: '1 reset available',
|
||||
expiryLabel: 'Expires in 2h'
|
||||
})
|
||||
expect(getCodexResetCreditSummary(makeLimits(2, now + 90 * 60_000), now)).toEqual({
|
||||
availableCount: 2,
|
||||
availabilityLabel: '2 resets available',
|
||||
expiryLabel: 'Next expires in 1h 30m'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Codex reset credit scope', () => {
|
||||
it('binds a host offer to the exact managed active account and revision', () => {
|
||||
const snapshot = makeSnapshot()
|
||||
|
||||
expect(getActiveCodexAccountIdForRateLimitTarget(snapshot)).toBe('account-host')
|
||||
expect(getCodexResetCreditScope(snapshot)).toMatchObject({
|
||||
target: { runtime: 'host', wslDistro: null },
|
||||
accountId: 'account-host',
|
||||
accountRevision: 10,
|
||||
offerRevision: expect.stringMatching(/^v1:/)
|
||||
})
|
||||
})
|
||||
|
||||
it('binds a WSL offer only to the exact distro selection and account', () => {
|
||||
const snapshot = makeSnapshot({
|
||||
target: { runtime: 'wsl', wslDistro: 'Ubuntu' },
|
||||
activeWslIds: { Ubuntu: 'account-wsl', Debian: 'account-debian' },
|
||||
accounts: [
|
||||
{
|
||||
id: 'account-wsl',
|
||||
email: 'wsl@example.com',
|
||||
managedHomeRuntime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
updatedAt: 20
|
||||
},
|
||||
{
|
||||
id: 'account-debian',
|
||||
email: 'debian@example.com',
|
||||
managedHomeRuntime: 'wsl',
|
||||
wslDistro: 'Debian',
|
||||
updatedAt: 30
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(getActiveCodexAccountIdForRateLimitTarget(snapshot)).toBe('account-wsl')
|
||||
expect(getCodexResetCreditScope(snapshot)).toMatchObject({
|
||||
target: { runtime: 'wsl', wslDistro: 'Ubuntu' },
|
||||
accountId: 'account-wsl',
|
||||
accountRevision: 20
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed for system-default, unknown WSL distro, and account/target mismatch', () => {
|
||||
const systemDefault = makeSnapshot({ activeHostId: null })
|
||||
expect(getActiveCodexAccountIdForRateLimitTarget(systemDefault)).toBeNull()
|
||||
expect(getCodexResetCreditScope(systemDefault)).toBeNull()
|
||||
|
||||
const unknownDistro = makeSnapshot({
|
||||
target: { runtime: 'wsl', wslDistro: null },
|
||||
activeWslIds: { __default__: 'account-host' }
|
||||
})
|
||||
expect(getActiveCodexAccountIdForRateLimitTarget(unknownDistro)).toBeNull()
|
||||
expect(getCodexResetCreditScope(unknownDistro)).toBeNull()
|
||||
|
||||
const mismatch = makeSnapshot({
|
||||
target: { runtime: 'wsl', wslDistro: 'Ubuntu' },
|
||||
activeWslIds: { Ubuntu: 'account-host' }
|
||||
})
|
||||
expect(getCodexResetCreditScope(mismatch)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCodexResetCreditOutcomeCopy', () => {
|
||||
it.each([
|
||||
['reset', 'Rate limits reset', 'Codex usage has been refreshed.'],
|
||||
['alreadyRedeemed', 'Reset already applied', 'Codex usage has been refreshed.'],
|
||||
['nothingToReset', 'Nothing to reset', 'No eligible Codex rate-limit window is exhausted.'],
|
||||
['noCredit', 'No reset available', 'This account has no earned reset credits available.']
|
||||
] as const)('maps %s to user-facing copy', (outcome, title, message) => {
|
||||
expect(getCodexResetCreditOutcomeCopy(outcome)).toEqual({ title, message })
|
||||
})
|
||||
})
|
||||
|
||||
describe('requestCodexResetCredit', () => {
|
||||
let values: Map<string, string>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetCodexResetAttemptJournalForTests()
|
||||
resetCodexResetCreditRequestsForTests()
|
||||
values = new Map()
|
||||
asyncStorage.getItem.mockImplementation(async (key: string) => values.get(key) ?? null)
|
||||
asyncStorage.setItem.mockImplementation(async (key: string, value: string) => {
|
||||
values.set(key, value)
|
||||
})
|
||||
asyncStorage.removeItem.mockImplementation(async (key: string) => {
|
||||
values.delete(key)
|
||||
})
|
||||
})
|
||||
|
||||
it('persists before RPC, sends the exact scope with a 90s timeout, then clears', async () => {
|
||||
const snapshot = makeSnapshot()
|
||||
const expectedScope = getCodexResetCreditScope(snapshot)!
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
id: 'request-1',
|
||||
ok: true,
|
||||
result: { outcome: 'reset', scope: expectedScope, snapshot },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
|
||||
await expect(
|
||||
requestCodexResetCredit(
|
||||
{ sendRequest },
|
||||
{ hostId: 'host-a', expectedScope, createIdempotencyKey: () => UUID }
|
||||
)
|
||||
).resolves.toEqual({
|
||||
outcome: 'reset',
|
||||
scope: expectedScope,
|
||||
snapshot,
|
||||
attemptJournalRetained: false
|
||||
})
|
||||
expect(asyncStorage.setItem.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
sendRequest.mock.invocationCallOrder[0]!
|
||||
)
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'accounts.consumeCodexResetCredit',
|
||||
{ idempotencyKey: UUID, expectedScope },
|
||||
{ timeoutMs: 90_000 }
|
||||
)
|
||||
expect(values.size).toBe(0)
|
||||
})
|
||||
|
||||
it('replays the original scope and UUID after an ambiguous response and offer refresh', async () => {
|
||||
const snapshot = makeSnapshot()
|
||||
const expectedScope = getCodexResetCreditScope(snapshot)!
|
||||
const firstRequest = vi.fn().mockRejectedValue(new Error('connection lost'))
|
||||
|
||||
await expect(
|
||||
requestCodexResetCredit(
|
||||
{ sendRequest: firstRequest },
|
||||
{ hostId: 'host-a', expectedScope, createIdempotencyKey: () => UUID }
|
||||
)
|
||||
).rejects.toThrow('connection lost')
|
||||
expect(values.size).toBe(1)
|
||||
|
||||
resetCodexResetAttemptJournalForTests()
|
||||
const refreshedSnapshot = makeSnapshot()
|
||||
refreshedSnapshot.rateLimits.codex!.updatedAt = 101
|
||||
const refreshedScope = getCodexResetCreditScope(refreshedSnapshot)!
|
||||
expect(refreshedScope.offerRevision).not.toBe(expectedScope.offerRevision)
|
||||
const createRetryKey = vi.fn(() => '22222222-2222-4222-8222-222222222222')
|
||||
const retry = vi.fn().mockResolvedValue({
|
||||
id: 'request-2',
|
||||
ok: true,
|
||||
result: { outcome: 'alreadyRedeemed', scope: expectedScope, snapshot: refreshedSnapshot },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
const result = await requestCodexResetCredit(
|
||||
{ sendRequest: retry },
|
||||
{ hostId: 'host-a', expectedScope: refreshedScope, createIdempotencyKey: createRetryKey }
|
||||
)
|
||||
|
||||
expect(result.scope).toEqual(expectedScope)
|
||||
expect(createRetryKey).not.toHaveBeenCalled()
|
||||
expect(retry).toHaveBeenCalledWith(
|
||||
'accounts.consumeCodexResetCredit',
|
||||
{ idempotencyKey: UUID, expectedScope },
|
||||
{ timeoutMs: 90_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('discards a definite stale-offer attempt and creates a new key only after another confirmation', async () => {
|
||||
const originalSnapshot = makeSnapshot()
|
||||
const originalScope = getCodexResetCreditScope(originalSnapshot)!
|
||||
const refreshedSnapshot = makeSnapshot()
|
||||
refreshedSnapshot.rateLimits.codex!.updatedAt = 101
|
||||
const refreshedScope = getCodexResetCreditScope(refreshedSnapshot)!
|
||||
const staleResponse = vi.fn().mockResolvedValue({
|
||||
id: 'request-stale',
|
||||
ok: true,
|
||||
result: {
|
||||
status: 'rejectedBeforeProvider',
|
||||
retryDisposition: 'discardAttempt',
|
||||
reason: 'offerChanged',
|
||||
scope: originalScope,
|
||||
snapshot: refreshedSnapshot
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
|
||||
await expect(
|
||||
requestCodexResetCredit(
|
||||
{ sendRequest: staleResponse },
|
||||
{ hostId: 'host-a', expectedScope: originalScope, createIdempotencyKey: () => UUID }
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
status: 'rejectedBeforeProvider',
|
||||
retryDisposition: 'discardAttempt',
|
||||
reason: 'offerChanged',
|
||||
scope: originalScope,
|
||||
snapshot: refreshedSnapshot,
|
||||
attemptJournalRetained: false
|
||||
})
|
||||
expect(values.size).toBe(0)
|
||||
|
||||
const nextKey = '22222222-2222-4222-8222-222222222222'
|
||||
const createNextKey = vi.fn(() => nextKey)
|
||||
const acceptedResponse = vi.fn().mockResolvedValue({
|
||||
id: 'request-next',
|
||||
ok: true,
|
||||
result: { outcome: 'reset', scope: refreshedScope, snapshot: refreshedSnapshot },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
await requestCodexResetCredit(
|
||||
{ sendRequest: acceptedResponse },
|
||||
{ hostId: 'host-a', expectedScope: refreshedScope, createIdempotencyKey: createNextKey }
|
||||
)
|
||||
|
||||
expect(createNextKey).toHaveBeenCalledOnce()
|
||||
expect(acceptedResponse).toHaveBeenCalledWith(
|
||||
'accounts.consumeCodexResetCredit',
|
||||
{ idempotencyKey: nextKey, expectedScope: refreshedScope },
|
||||
{ timeoutMs: 90_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('singleflights concurrent requests across offer refreshes in the same account scope', async () => {
|
||||
const snapshot = makeSnapshot()
|
||||
const expectedScope = getCodexResetCreditScope(snapshot)!
|
||||
const refreshedSnapshot = makeSnapshot()
|
||||
refreshedSnapshot.rateLimits.codex!.updatedAt = 101
|
||||
const refreshedScope = getCodexResetCreditScope(refreshedSnapshot)!
|
||||
let releaseRequest!: () => void
|
||||
const requestGate = new Promise<void>((resolve) => {
|
||||
releaseRequest = resolve
|
||||
})
|
||||
const sendRequest = vi.fn().mockImplementation(async () => {
|
||||
await requestGate
|
||||
return {
|
||||
id: 'request-1',
|
||||
ok: true,
|
||||
result: { outcome: 'reset', scope: expectedScope, snapshot },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
})
|
||||
const createSecondKey = vi.fn(() => '22222222-2222-4222-8222-222222222222')
|
||||
|
||||
const first = requestCodexResetCredit(
|
||||
{ sendRequest },
|
||||
{ hostId: 'host-a', expectedScope, createIdempotencyKey: () => UUID }
|
||||
)
|
||||
await vi.waitFor(() => expect(sendRequest).toHaveBeenCalledTimes(1))
|
||||
const second = requestCodexResetCredit(
|
||||
{ sendRequest },
|
||||
{ hostId: 'host-a', expectedScope: refreshedScope, createIdempotencyKey: createSecondKey }
|
||||
)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(createSecondKey).not.toHaveBeenCalled()
|
||||
|
||||
releaseRequest()
|
||||
const [firstResult, secondResult] = await Promise.all([first, second])
|
||||
expect(secondResult).toEqual(firstResult)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects a mismatched scope or malformed nested snapshot without clearing', async () => {
|
||||
const snapshot = makeSnapshot()
|
||||
const expectedScope = getCodexResetCreditScope(snapshot)!
|
||||
const mismatchedScope = { ...expectedScope, accountId: 'other-account' }
|
||||
const mismatch = vi.fn().mockResolvedValue({
|
||||
id: 'request-1',
|
||||
ok: true,
|
||||
result: { outcome: 'reset', scope: mismatchedScope, snapshot },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
await expect(
|
||||
requestCodexResetCredit(
|
||||
{ sendRequest: mismatch },
|
||||
{ hostId: 'host-a', expectedScope, createIdempotencyKey: () => UUID }
|
||||
)
|
||||
).rejects.toThrow('Invalid reset response from host')
|
||||
expect(values.size).toBe(1)
|
||||
|
||||
const malformed = vi.fn().mockResolvedValue({
|
||||
id: 'request-2',
|
||||
ok: true,
|
||||
result: {
|
||||
outcome: 'reset',
|
||||
scope: expectedScope,
|
||||
snapshot: { ...snapshot, codex: { ...snapshot.codex, accounts: {} } }
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
await expect(
|
||||
requestCodexResetCredit(
|
||||
{ sendRequest: malformed },
|
||||
{ hostId: 'host-a', expectedScope, createIdempotencyKey: () => UUID }
|
||||
)
|
||||
).rejects.toThrow('Invalid accounts snapshot from host')
|
||||
expect(values.size).toBe(1)
|
||||
})
|
||||
|
||||
it('does not clear the journal for a mismatched definite-rejection response', async () => {
|
||||
const snapshot = makeSnapshot()
|
||||
const expectedScope = getCodexResetCreditScope(snapshot)!
|
||||
const mismatch = vi.fn().mockResolvedValue({
|
||||
id: 'request-mismatch',
|
||||
ok: true,
|
||||
result: {
|
||||
status: 'rejectedBeforeProvider',
|
||||
retryDisposition: 'discardAttempt',
|
||||
reason: 'offerChanged',
|
||||
scope: { ...expectedScope, offerRevision: 'v1:wrong' },
|
||||
snapshot
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
|
||||
await expect(
|
||||
requestCodexResetCredit(
|
||||
{ sendRequest: mismatch },
|
||||
{ hostId: 'host-a', expectedScope, createIdempotencyKey: () => UUID }
|
||||
)
|
||||
).rejects.toThrow('Invalid reset response from host')
|
||||
expect(values.size).toBe(1)
|
||||
expect(asyncStorage.removeItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a valid snapshot that does not describe the returned redeemed scope', async () => {
|
||||
const snapshot = makeSnapshot()
|
||||
const expectedScope = getCodexResetCreditScope(snapshot)!
|
||||
const wrongAccountSnapshot = makeSnapshot({ activeHostId: null })
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
id: 'request-1',
|
||||
ok: true,
|
||||
result: { outcome: 'reset', scope: expectedScope, snapshot: wrongAccountSnapshot },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
|
||||
await expect(
|
||||
requestCodexResetCredit(
|
||||
{ sendRequest },
|
||||
{ hostId: 'host-a', expectedScope, createIdempotencyKey: () => UUID }
|
||||
)
|
||||
).rejects.toThrow('Invalid reset response from host')
|
||||
expect(values.size).toBe(1)
|
||||
})
|
||||
|
||||
it('returns an authoritative result while reporting a failed journal cleanup', async () => {
|
||||
const snapshot = makeSnapshot()
|
||||
const expectedScope = getCodexResetCreditScope(snapshot)!
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
id: 'request-1',
|
||||
ok: true,
|
||||
result: { outcome: 'reset', scope: expectedScope, snapshot },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
asyncStorage.removeItem.mockRejectedValueOnce(new Error('storage unavailable'))
|
||||
|
||||
await expect(
|
||||
requestCodexResetCredit(
|
||||
{ sendRequest },
|
||||
{ hostId: 'host-a', expectedScope, createIdempotencyKey: () => UUID }
|
||||
)
|
||||
).resolves.toMatchObject({ outcome: 'reset', attemptJournalRetained: true })
|
||||
expect(values.size).toBe(1)
|
||||
})
|
||||
|
||||
it('retains and safely replays a definite rejection when journal cleanup fails', async () => {
|
||||
const originalSnapshot = makeSnapshot()
|
||||
const originalScope = getCodexResetCreditScope(originalSnapshot)!
|
||||
const refreshedSnapshot = makeSnapshot()
|
||||
refreshedSnapshot.rateLimits.codex!.updatedAt = 101
|
||||
const refreshedScope = getCodexResetCreditScope(refreshedSnapshot)!
|
||||
const rejectionResult = {
|
||||
status: 'rejectedBeforeProvider',
|
||||
retryDisposition: 'discardAttempt',
|
||||
reason: 'offerChanged',
|
||||
scope: originalScope,
|
||||
snapshot: refreshedSnapshot
|
||||
}
|
||||
const firstResponse = vi.fn().mockResolvedValue({
|
||||
id: 'request-1',
|
||||
ok: true,
|
||||
result: rejectionResult,
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
asyncStorage.removeItem.mockRejectedValueOnce(new Error('storage unavailable'))
|
||||
|
||||
await expect(
|
||||
requestCodexResetCredit(
|
||||
{ sendRequest: firstResponse },
|
||||
{ hostId: 'host-a', expectedScope: originalScope, createIdempotencyKey: () => UUID }
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
status: 'rejectedBeforeProvider',
|
||||
attemptJournalRetained: true
|
||||
})
|
||||
expect(values.size).toBe(1)
|
||||
|
||||
const createRetryKey = vi.fn(() => '22222222-2222-4222-8222-222222222222')
|
||||
const retryResponse = vi.fn().mockResolvedValue({
|
||||
id: 'request-2',
|
||||
ok: true,
|
||||
result: rejectionResult,
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
await expect(
|
||||
requestCodexResetCredit(
|
||||
{ sendRequest: retryResponse },
|
||||
{
|
||||
hostId: 'host-a',
|
||||
expectedScope: refreshedScope,
|
||||
createIdempotencyKey: createRetryKey
|
||||
}
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
status: 'rejectedBeforeProvider',
|
||||
attemptJournalRetained: false
|
||||
})
|
||||
expect(createRetryKey).not.toHaveBeenCalled()
|
||||
expect(retryResponse).toHaveBeenCalledWith(
|
||||
'accounts.consumeCodexResetCredit',
|
||||
{ idempotencyKey: UUID, expectedScope: originalScope },
|
||||
{ timeoutMs: 90_000 }
|
||||
)
|
||||
expect(values.size).toBe(0)
|
||||
})
|
||||
|
||||
it('fails closed before RPC when the journal cannot be read or written', async () => {
|
||||
const snapshot = makeSnapshot()
|
||||
const expectedScope = getCodexResetCreditScope(snapshot)!
|
||||
const sendRequest = vi.fn()
|
||||
asyncStorage.getItem.mockRejectedValueOnce(new Error('storage unavailable'))
|
||||
await expect(
|
||||
requestCodexResetCredit(
|
||||
{ sendRequest },
|
||||
{ hostId: 'host-a', expectedScope, createIdempotencyKey: () => UUID }
|
||||
)
|
||||
).rejects.toThrow('storage unavailable')
|
||||
|
||||
asyncStorage.setItem.mockRejectedValueOnce(new Error('disk full'))
|
||||
await expect(
|
||||
requestCodexResetCredit(
|
||||
{ sendRequest },
|
||||
{ hostId: 'host-a', expectedScope, createIdempotencyKey: () => UUID }
|
||||
)
|
||||
).rejects.toThrow('disk full')
|
||||
expect(sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,282 @@
|
||||
import { formatResetCountdown } from '../../../src/shared/rate-limit-reset-format'
|
||||
import {
|
||||
buildCodexResetCreditExpectedScope,
|
||||
type CodexResetCreditExpectedScope
|
||||
} from '../../../src/shared/codex-reset-credit-scope'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import {
|
||||
clearCodexResetAttemptAfterAuthoritativeResponse,
|
||||
CodexResetCreditExpectedScopeSchema,
|
||||
getCodexResetAttemptIdentityKey,
|
||||
getOrCreateCodexResetAttempt
|
||||
} from '../storage/codex-reset-attempt-journal'
|
||||
import {
|
||||
decodeAccountsSnapshot,
|
||||
type AccountsSnapshot,
|
||||
type ProviderRateLimits
|
||||
} from './accounts-snapshot'
|
||||
|
||||
export type CodexResetCreditOutcome = 'reset' | 'nothingToReset' | 'noCredit' | 'alreadyRedeemed'
|
||||
|
||||
export type CodexResetCreditRejectedBeforeProviderReason =
|
||||
| 'targetChanged'
|
||||
| 'accountChanged'
|
||||
| 'accountRevisionChanged'
|
||||
| 'accountRuntimeChanged'
|
||||
| 'offerUnavailable'
|
||||
| 'offerChanged'
|
||||
|
||||
export type CodexResetCreditConsumedRpcResult = {
|
||||
outcome: CodexResetCreditOutcome
|
||||
scope: CodexResetCreditExpectedScope
|
||||
snapshot: AccountsSnapshot
|
||||
}
|
||||
|
||||
export type CodexResetCreditRejectedRpcResult = {
|
||||
status: 'rejectedBeforeProvider'
|
||||
retryDisposition: 'discardAttempt'
|
||||
reason: CodexResetCreditRejectedBeforeProviderReason
|
||||
scope: CodexResetCreditExpectedScope
|
||||
snapshot: AccountsSnapshot
|
||||
}
|
||||
|
||||
export type CodexResetCreditRpcResult =
|
||||
| CodexResetCreditConsumedRpcResult
|
||||
| CodexResetCreditRejectedRpcResult
|
||||
|
||||
export type CodexResetCreditRequestResult = CodexResetCreditRpcResult & {
|
||||
// A valid host result remains authoritative even if local cleanup fails.
|
||||
// The retained UUID makes a later retry idempotent instead of hiding success.
|
||||
attemptJournalRetained: boolean
|
||||
}
|
||||
|
||||
export type CodexResetCreditSummary = {
|
||||
availableCount: number
|
||||
availabilityLabel: string
|
||||
expiryLabel: string | null
|
||||
}
|
||||
|
||||
const RESET_RPC_TIMEOUT_MS = 90_000
|
||||
const resetRequests = new Map<string, Promise<CodexResetCreditRequestResult>>()
|
||||
|
||||
export function getCodexResetCreditSummary(
|
||||
limits: ProviderRateLimits | null,
|
||||
now: number
|
||||
): CodexResetCreditSummary | null {
|
||||
const credits = limits?.rateLimitResetCredits
|
||||
const count = credits?.availableCount ?? 0
|
||||
if (!Number.isInteger(count) || count <= 0) {
|
||||
return null
|
||||
}
|
||||
const expiry = credits?.nextExpiresAt
|
||||
const expiryLabel =
|
||||
typeof expiry === 'number' && Number.isFinite(expiry)
|
||||
? formatResetCountdown(expiry - now).replace(
|
||||
/^Resets/,
|
||||
count === 1 ? 'Expires' : 'Next expires'
|
||||
)
|
||||
: null
|
||||
return {
|
||||
availableCount: count,
|
||||
availabilityLabel: `${count} ${count === 1 ? 'reset' : 'resets'} available`,
|
||||
expiryLabel
|
||||
}
|
||||
}
|
||||
|
||||
export function getCodexResetCreditOutcomeCopy(outcome: CodexResetCreditOutcome): {
|
||||
title: string
|
||||
message: string
|
||||
} {
|
||||
switch (outcome) {
|
||||
case 'reset':
|
||||
return { title: 'Rate limits reset', message: 'Codex usage has been refreshed.' }
|
||||
case 'alreadyRedeemed':
|
||||
return { title: 'Reset already applied', message: 'Codex usage has been refreshed.' }
|
||||
case 'nothingToReset':
|
||||
return {
|
||||
title: 'Nothing to reset',
|
||||
message: 'No eligible Codex rate-limit window is exhausted.'
|
||||
}
|
||||
case 'noCredit':
|
||||
return {
|
||||
title: 'No reset available',
|
||||
message: 'This account has no earned reset credits available.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getActiveCodexAccountIdForRateLimitTarget(
|
||||
snapshot: AccountsSnapshot
|
||||
): string | null {
|
||||
const target = snapshot.rateLimits.codexTarget
|
||||
const selection = snapshot.codex.activeAccountIdsByRuntime
|
||||
if (!selection) {
|
||||
return null
|
||||
}
|
||||
if (target.runtime === 'host') {
|
||||
return target.wslDistro === null ? selection.host : null
|
||||
}
|
||||
const distro = target.wslDistro?.trim()
|
||||
return distro ? (selection.wsl[distro] ?? null) : null
|
||||
}
|
||||
|
||||
export function getCodexResetCreditScope(
|
||||
snapshot: AccountsSnapshot
|
||||
): CodexResetCreditExpectedScope | null {
|
||||
const activeAccountId = getActiveCodexAccountIdForRateLimitTarget(snapshot)
|
||||
const account = activeAccountId
|
||||
? (snapshot.codex.accounts.find((candidate) => candidate.id === activeAccountId) ?? null)
|
||||
: null
|
||||
const scope = buildCodexResetCreditExpectedScope({
|
||||
target: snapshot.rateLimits.codexTarget,
|
||||
account,
|
||||
limits: snapshot.rateLimits.codex
|
||||
})
|
||||
if (!scope) {
|
||||
return null
|
||||
}
|
||||
const parsed = CodexResetCreditExpectedScopeSchema.safeParse(scope)
|
||||
return parsed.success ? parsed.data : null
|
||||
}
|
||||
|
||||
function scopesEqual(
|
||||
left: CodexResetCreditExpectedScope,
|
||||
right: CodexResetCreditExpectedScope
|
||||
): boolean {
|
||||
return (
|
||||
left.target.runtime === right.target.runtime &&
|
||||
left.target.wslDistro === right.target.wslDistro &&
|
||||
left.accountId === right.accountId &&
|
||||
left.accountRevision === right.accountRevision &&
|
||||
left.offerRevision === right.offerRevision
|
||||
)
|
||||
}
|
||||
|
||||
function decodeResetResult(
|
||||
value: unknown,
|
||||
expectedScope: CodexResetCreditExpectedScope
|
||||
): CodexResetCreditRpcResult {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Invalid reset response from host')
|
||||
}
|
||||
const result = value as Record<string, unknown>
|
||||
const scope = CodexResetCreditExpectedScopeSchema.safeParse(result.scope)
|
||||
if (!scope.success || !scopesEqual(scope.data, expectedScope)) {
|
||||
throw new Error('Invalid reset response from host')
|
||||
}
|
||||
const snapshot = decodeAccountsSnapshot(result.snapshot)
|
||||
if (result.status === 'rejectedBeforeProvider') {
|
||||
const reason = result.reason
|
||||
if (
|
||||
result.retryDisposition !== 'discardAttempt' ||
|
||||
result.outcome !== undefined ||
|
||||
(reason !== 'targetChanged' &&
|
||||
reason !== 'accountChanged' &&
|
||||
reason !== 'accountRevisionChanged' &&
|
||||
reason !== 'accountRuntimeChanged' &&
|
||||
reason !== 'offerUnavailable' &&
|
||||
reason !== 'offerChanged')
|
||||
) {
|
||||
throw new Error('Invalid reset response from host')
|
||||
}
|
||||
return {
|
||||
status: 'rejectedBeforeProvider',
|
||||
retryDisposition: 'discardAttempt',
|
||||
reason,
|
||||
scope: scope.data,
|
||||
snapshot
|
||||
}
|
||||
}
|
||||
const outcome = result.outcome
|
||||
if (
|
||||
result.status !== undefined ||
|
||||
outcome === undefined ||
|
||||
(outcome !== 'reset' &&
|
||||
outcome !== 'nothingToReset' &&
|
||||
outcome !== 'noCredit' &&
|
||||
outcome !== 'alreadyRedeemed')
|
||||
) {
|
||||
throw new Error('Invalid reset response from host')
|
||||
}
|
||||
const snapshotAccount = snapshot.codex.accounts.find(
|
||||
(account) => account.id === scope.data.accountId
|
||||
)
|
||||
if (
|
||||
snapshot.rateLimits.codexTarget.runtime !== scope.data.target.runtime ||
|
||||
snapshot.rateLimits.codexTarget.wslDistro !== scope.data.target.wslDistro ||
|
||||
getActiveCodexAccountIdForRateLimitTarget(snapshot) !== scope.data.accountId ||
|
||||
snapshotAccount?.updatedAt !== scope.data.accountRevision
|
||||
) {
|
||||
throw new Error('Invalid reset response from host')
|
||||
}
|
||||
return {
|
||||
outcome,
|
||||
scope: scope.data,
|
||||
snapshot
|
||||
}
|
||||
}
|
||||
|
||||
async function performCodexResetCreditRequest(
|
||||
client: Pick<RpcClient, 'sendRequest'>,
|
||||
options: {
|
||||
hostId: string
|
||||
expectedScope: CodexResetCreditExpectedScope
|
||||
createIdempotencyKey: () => string
|
||||
}
|
||||
): Promise<CodexResetCreditRequestResult> {
|
||||
const attempt = await getOrCreateCodexResetAttempt(options)
|
||||
const response = await client.sendRequest(
|
||||
'accounts.consumeCodexResetCredit',
|
||||
{
|
||||
idempotencyKey: attempt.idempotencyKey,
|
||||
expectedScope: attempt.expectedScope
|
||||
},
|
||||
{ timeoutMs: RESET_RPC_TIMEOUT_MS }
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
const result = decodeResetResult(response.result, attempt.expectedScope)
|
||||
let attemptJournalRetained = false
|
||||
try {
|
||||
await clearCodexResetAttemptAfterAuthoritativeResponse({
|
||||
hostId: options.hostId,
|
||||
expectedScope: attempt.expectedScope,
|
||||
idempotencyKey: attempt.idempotencyKey
|
||||
})
|
||||
} catch {
|
||||
attemptJournalRetained = true
|
||||
}
|
||||
return { ...result, attemptJournalRetained }
|
||||
}
|
||||
|
||||
export async function requestCodexResetCredit(
|
||||
client: Pick<RpcClient, 'sendRequest'>,
|
||||
options: {
|
||||
hostId: string
|
||||
expectedScope: CodexResetCreditExpectedScope
|
||||
createIdempotencyKey: () => string
|
||||
}
|
||||
): Promise<CodexResetCreditRequestResult> {
|
||||
const requestKey = getCodexResetAttemptIdentityKey(options)
|
||||
const existing = resetRequests.get(requestKey)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
// Why: two mounted views can confirm the same offer concurrently. Share the
|
||||
// whole attempt so one authoritative response cannot clear the other's retry key.
|
||||
const operation = performCodexResetCreditRequest(client, options)
|
||||
resetRequests.set(requestKey, operation)
|
||||
try {
|
||||
return await operation
|
||||
} finally {
|
||||
if (resetRequests.get(requestKey) === operation) {
|
||||
resetRequests.delete(requestKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: clear request singleflight state between cases. */
|
||||
export function resetCodexResetCreditRequestsForTests(): void {
|
||||
resetRequests.clear()
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { Alert } from 'react-native'
|
||||
import * as ExpoCrypto from 'expo-crypto'
|
||||
import type { CodexResetCreditExpectedScope } from '../../../src/shared/codex-reset-credit-scope'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { AccountsSnapshot } from './account-usage-state'
|
||||
import {
|
||||
getCodexResetCreditOutcomeCopy,
|
||||
getCodexResetCreditScope,
|
||||
requestCodexResetCredit
|
||||
} from './codex-reset-credit'
|
||||
import { useCodexResetCreditCapability } from './codex-reset-credit-capability'
|
||||
|
||||
function describeScope(snapshot: AccountsSnapshot, scope: CodexResetCreditExpectedScope): string {
|
||||
const account = snapshot.codex.accounts.find((candidate) => candidate.id === scope.accountId)
|
||||
const identity = account?.email ?? 'the selected managed account'
|
||||
if (scope.target.runtime === 'host') {
|
||||
return `${identity} on the host`
|
||||
}
|
||||
return `${identity} on WSL ${scope.target.wslDistro}`
|
||||
}
|
||||
|
||||
export function useCodexResetCreditAction({
|
||||
client,
|
||||
connected,
|
||||
hostId,
|
||||
snapshot,
|
||||
accountMutationBusy,
|
||||
onSnapshot
|
||||
}: {
|
||||
client: RpcClient | null
|
||||
connected: boolean
|
||||
hostId: string | undefined
|
||||
snapshot: AccountsSnapshot | null
|
||||
accountMutationBusy: boolean
|
||||
onSnapshot: (snapshot: AccountsSnapshot) => void
|
||||
}): {
|
||||
supported: boolean
|
||||
resetting: boolean
|
||||
resetScope: CodexResetCreditExpectedScope | null
|
||||
scopeLabel: string | null
|
||||
confirmReset: () => void
|
||||
} {
|
||||
const supported = useCodexResetCreditCapability(client, connected)
|
||||
const [resetting, setResetting] = useState(false)
|
||||
const inFlightRef = useRef(false)
|
||||
const resetScope = useMemo(
|
||||
() => (snapshot ? getCodexResetCreditScope(snapshot) : null),
|
||||
[snapshot]
|
||||
)
|
||||
const scopeLabel = useMemo(
|
||||
() => (snapshot && resetScope ? describeScope(snapshot, resetScope) : null),
|
||||
[resetScope, snapshot]
|
||||
)
|
||||
|
||||
const consume = useCallback(
|
||||
async (expectedScope: CodexResetCreditExpectedScope) => {
|
||||
if (!client || !hostId || inFlightRef.current) {
|
||||
return
|
||||
}
|
||||
inFlightRef.current = true
|
||||
setResetting(true)
|
||||
try {
|
||||
const result = await requestCodexResetCredit(client, {
|
||||
hostId,
|
||||
expectedScope,
|
||||
createIdempotencyKey: () => ExpoCrypto.randomUUID()
|
||||
})
|
||||
onSnapshot(result.snapshot)
|
||||
if ('status' in result) {
|
||||
const cleanupWarning = result.attemptJournalRetained
|
||||
? '\n\nThis phone could not clear the discarded retry record. Retrying it is safe, but the record must be cleared before a new reset can be confirmed for this account.'
|
||||
: ''
|
||||
Alert.alert(
|
||||
'Reset details changed',
|
||||
`The account or reset offer changed before the host contacted Codex. Review the updated details, then confirm again.${cleanupWarning}`
|
||||
)
|
||||
return
|
||||
}
|
||||
const copy = getCodexResetCreditOutcomeCopy(result.outcome)
|
||||
const cleanupWarning = result.attemptJournalRetained
|
||||
? '\n\nThe host confirmed this attempt, but this phone could not clear its retry record. A later retry will reuse the same safe operation ID.'
|
||||
: ''
|
||||
Alert.alert(copy.title, `${copy.message}${cleanupWarning}`)
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
'Could not reset rate limits',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
} finally {
|
||||
inFlightRef.current = false
|
||||
setResetting(false)
|
||||
}
|
||||
},
|
||||
[client, hostId, onSnapshot]
|
||||
)
|
||||
|
||||
const confirmReset = useCallback(() => {
|
||||
if (!supported || !connected || accountMutationBusy || resetting || !resetScope || !snapshot) {
|
||||
return
|
||||
}
|
||||
const confirmedScope = resetScope
|
||||
const confirmedLabel = describeScope(snapshot, confirmedScope)
|
||||
Alert.alert(
|
||||
'Use a rate-limit reset?',
|
||||
`This spends one earned reset for ${confirmedLabel} and immediately resets eligible rate-limit windows.`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: 'Use reset', onPress: () => void consume(confirmedScope) }
|
||||
]
|
||||
)
|
||||
}, [accountMutationBusy, connected, consume, resetScope, resetting, snapshot, supported])
|
||||
|
||||
return { supported, resetting, resetScope, scopeLabel, confirmReset }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
consumeMockCodexResetCredit,
|
||||
createMockAccountsSnapshot,
|
||||
getMockCodexResetScope,
|
||||
resetMockAccountState,
|
||||
selectMockCodexAccount
|
||||
} from '../scripts/mock-server-account-state'
|
||||
|
||||
const FIRST_OPERATION_ID = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
describe('mock account reset state', () => {
|
||||
beforeEach(() => {
|
||||
resetMockAccountState(1_700_000_000_000)
|
||||
})
|
||||
|
||||
it('keeps reset and expiry deadlines fixed between snapshots', () => {
|
||||
const first = createMockAccountsSnapshot()
|
||||
const second = createMockAccountsSnapshot()
|
||||
|
||||
expect(second.rateLimits.codex.session?.resetsAt).toBe(first.rateLimits.codex.session?.resetsAt)
|
||||
expect(second.rateLimits.codex.rateLimitResetCredits.nextExpiresAt).toBe(
|
||||
first.rateLimits.codex.rateLimitResetCredits.nextExpiresAt
|
||||
)
|
||||
})
|
||||
|
||||
it('resets only the selected account and updates its visible usage', () => {
|
||||
const personalScope = getMockCodexResetScope()
|
||||
expect(personalScope).not.toBeNull()
|
||||
|
||||
expect(consumeMockCodexResetCredit(FIRST_OPERATION_ID, personalScope)).toMatchObject({
|
||||
outcome: 'reset',
|
||||
scope: personalScope
|
||||
})
|
||||
const personalAfter = createMockAccountsSnapshot()
|
||||
expect(personalAfter.rateLimits.codex.session?.usedPercent).toBe(0)
|
||||
expect(personalAfter.rateLimits.codex.rateLimitResetCredits.availableCount).toBe(0)
|
||||
|
||||
selectMockCodexAccount('codex-team')
|
||||
const team = createMockAccountsSnapshot()
|
||||
expect(team.rateLimits.codex.session?.usedPercent).toBe(100)
|
||||
expect(team.rateLimits.codex.rateLimitResetCredits.availableCount).toBe(1)
|
||||
expect(getMockCodexResetScope()?.accountId).toBe('codex-team')
|
||||
})
|
||||
|
||||
it('replays the same operation result and authoritatively discards a stale attempt', () => {
|
||||
const scope = getMockCodexResetScope()
|
||||
expect(scope).not.toBeNull()
|
||||
const first = consumeMockCodexResetCredit(FIRST_OPERATION_ID, scope)
|
||||
expect(consumeMockCodexResetCredit(FIRST_OPERATION_ID, scope)).toEqual(first)
|
||||
|
||||
expect(() => consumeMockCodexResetCredit('not-a-uuid', scope)).toThrow('Invalid idempotencyKey')
|
||||
expect(consumeMockCodexResetCredit('22222222-2222-4222-8222-222222222222', scope)).toEqual({
|
||||
status: 'rejectedBeforeProvider',
|
||||
retryDisposition: 'discardAttempt',
|
||||
reason: 'offerChanged',
|
||||
scope
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { WebSocket } from 'ws'
|
||||
import {
|
||||
handleRequest,
|
||||
type RpcRequest,
|
||||
type RpcResponse
|
||||
} from '../scripts/mock-server-rpc-handlers'
|
||||
|
||||
let requestSequence = 0
|
||||
|
||||
function sendMockRequest(method: string, params?: Record<string, unknown>): RpcResponse {
|
||||
let response: RpcResponse | undefined
|
||||
const request: RpcRequest = { id: `request-${++requestSequence}`, method, params }
|
||||
handleRequest(
|
||||
request,
|
||||
(nextResponse) => {
|
||||
response = nextResponse
|
||||
},
|
||||
{} as WebSocket
|
||||
)
|
||||
expect(response).toBeDefined()
|
||||
return response!
|
||||
}
|
||||
|
||||
function listedTerminalWorktreeIds(worktree?: string): string[] {
|
||||
const response = sendMockRequest('terminal.list', worktree ? { worktree } : undefined)
|
||||
const result = response.result as { terminals: Array<{ worktreeId: string }> }
|
||||
return [...new Set(result.terminals.map((terminal) => terminal.worktreeId))]
|
||||
}
|
||||
|
||||
describe('mock server terminal fixture routing', () => {
|
||||
it('follows worktree creation and activation', () => {
|
||||
const worktreeResponse = sendMockRequest('worktree.ps')
|
||||
const initialWorktreeId = (
|
||||
worktreeResponse.result as { worktrees: Array<{ worktreeId: string }> }
|
||||
).worktrees[0]!.worktreeId
|
||||
|
||||
const createResponse = sendMockRequest('worktree.create', {
|
||||
repo: 'id:repo-1',
|
||||
name: 'terminal-fixture-routing'
|
||||
})
|
||||
const createdWorktreeId = (createResponse.result as { worktree: { id: string } }).worktree.id
|
||||
expect(listedTerminalWorktreeIds()).toEqual([createdWorktreeId])
|
||||
expect(listedTerminalWorktreeIds(`id:${initialWorktreeId}`)).toEqual([initialWorktreeId])
|
||||
|
||||
sendMockRequest('worktree.activate', { worktree: `id:${initialWorktreeId}` })
|
||||
expect(listedTerminalWorktreeIds()).toEqual([initialWorktreeId])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,233 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { CodexResetCreditExpectedScope } from '../../../src/shared/codex-reset-credit-scope'
|
||||
|
||||
const asyncStorage = vi.hoisted(() => ({
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
|
||||
|
||||
import {
|
||||
clearCodexResetAttemptAfterAuthoritativeResponse,
|
||||
getOrCreateCodexResetAttempt,
|
||||
resetCodexResetAttemptJournalForTests
|
||||
} from './codex-reset-attempt-journal'
|
||||
|
||||
const FIRST_UUID = '11111111-1111-4111-8111-111111111111'
|
||||
const SECOND_UUID = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function makeScope(
|
||||
overrides: Partial<CodexResetCreditExpectedScope> = {}
|
||||
): CodexResetCreditExpectedScope {
|
||||
return {
|
||||
target: { runtime: 'host', wslDistro: null },
|
||||
accountId: 'account-a',
|
||||
accountRevision: 10,
|
||||
offerRevision: 'v1:offer-a',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('Codex reset attempt journal', () => {
|
||||
let values: Map<string, string>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetCodexResetAttemptJournalForTests()
|
||||
values = new Map()
|
||||
asyncStorage.getItem.mockImplementation(async (key: string) => values.get(key) ?? null)
|
||||
asyncStorage.setItem.mockImplementation(async (key: string, value: string) => {
|
||||
values.set(key, value)
|
||||
})
|
||||
asyncStorage.removeItem.mockImplementation(async (key: string) => {
|
||||
values.delete(key)
|
||||
})
|
||||
})
|
||||
|
||||
it('persists an unresolved UUID and reuses it after a module-level remount', async () => {
|
||||
const identity = { hostId: 'host-a', expectedScope: makeScope() }
|
||||
const createFirst = vi.fn(() => FIRST_UUID)
|
||||
const first = await getOrCreateCodexResetAttempt({
|
||||
...identity,
|
||||
createIdempotencyKey: createFirst
|
||||
})
|
||||
|
||||
resetCodexResetAttemptJournalForTests()
|
||||
const createAfterRemount = vi.fn(() => SECOND_UUID)
|
||||
const restored = await getOrCreateCodexResetAttempt({
|
||||
...identity,
|
||||
createIdempotencyKey: createAfterRemount
|
||||
})
|
||||
|
||||
expect(restored).toEqual(first)
|
||||
expect(createAfterRemount).not.toHaveBeenCalled()
|
||||
expect(values.size).toBe(1)
|
||||
})
|
||||
|
||||
it('isolates attempts by host and stable target/account revision scope', async () => {
|
||||
const variants = [
|
||||
{ hostId: 'host-a', expectedScope: makeScope() },
|
||||
{ hostId: 'host-b', expectedScope: makeScope() },
|
||||
{ hostId: 'host-a', expectedScope: makeScope({ accountId: 'account-b' }) },
|
||||
{ hostId: 'host-a', expectedScope: makeScope({ accountRevision: 11 }) },
|
||||
{
|
||||
hostId: 'host-a',
|
||||
expectedScope: makeScope({ target: { runtime: 'wsl', wslDistro: 'Ubuntu' } })
|
||||
}
|
||||
]
|
||||
|
||||
const attempts = await Promise.all(
|
||||
variants.map((identity, index) =>
|
||||
getOrCreateCodexResetAttempt({
|
||||
...identity,
|
||||
createIdempotencyKey: () =>
|
||||
`${String(index + 1).repeat(8)}-${String(index + 1).repeat(4)}-4${String(index + 1).repeat(3)}-8${String(index + 1).repeat(3)}-${String(index + 1).repeat(12)}`
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
expect(new Set(attempts.map((attempt) => attempt.idempotencyKey)).size).toBe(variants.length)
|
||||
expect(values.size).toBe(variants.length)
|
||||
})
|
||||
|
||||
it('replays the original exact offer after a refresh changes its offer revision', async () => {
|
||||
const originalScope = makeScope()
|
||||
const original = await getOrCreateCodexResetAttempt({
|
||||
hostId: 'host-a',
|
||||
expectedScope: originalScope,
|
||||
createIdempotencyKey: () => FIRST_UUID
|
||||
})
|
||||
|
||||
resetCodexResetAttemptJournalForTests()
|
||||
const createRefreshedKey = vi.fn(() => SECOND_UUID)
|
||||
const restored = await getOrCreateCodexResetAttempt({
|
||||
hostId: 'host-a',
|
||||
expectedScope: makeScope({ offerRevision: 'v1:refreshed-offer' }),
|
||||
createIdempotencyKey: createRefreshedKey
|
||||
})
|
||||
|
||||
expect(restored).toEqual(original)
|
||||
expect(restored.expectedScope).toEqual(originalScope)
|
||||
expect(createRefreshedKey).not.toHaveBeenCalled()
|
||||
expect(values.size).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps each account attempt while switching away and back', async () => {
|
||||
const accountA = makeScope({ accountId: 'account-a' })
|
||||
const accountB = makeScope({ accountId: 'account-b' })
|
||||
await getOrCreateCodexResetAttempt({
|
||||
hostId: 'host-a',
|
||||
expectedScope: accountA,
|
||||
createIdempotencyKey: () => FIRST_UUID
|
||||
})
|
||||
await getOrCreateCodexResetAttempt({
|
||||
hostId: 'host-a',
|
||||
expectedScope: accountB,
|
||||
createIdempotencyKey: () => SECOND_UUID
|
||||
})
|
||||
|
||||
const createAfterSwitchBack = vi.fn(() => '33333333-3333-4333-8333-333333333333')
|
||||
const restoredA = await getOrCreateCodexResetAttempt({
|
||||
hostId: 'host-a',
|
||||
expectedScope: { ...accountA, offerRevision: 'v1:after-switch-back' },
|
||||
createIdempotencyKey: createAfterSwitchBack
|
||||
})
|
||||
|
||||
expect(restoredA.idempotencyKey).toBe(FIRST_UUID)
|
||||
expect(createAfterSwitchBack).not.toHaveBeenCalled()
|
||||
expect(values.size).toBe(2)
|
||||
})
|
||||
|
||||
it('serializes same-scope creation so concurrent callers share one durable UUID', async () => {
|
||||
let releaseWrite!: () => void
|
||||
const writeGate = new Promise<void>((resolve) => {
|
||||
releaseWrite = resolve
|
||||
})
|
||||
asyncStorage.setItem.mockImplementationOnce(async (key: string, value: string) => {
|
||||
await writeGate
|
||||
values.set(key, value)
|
||||
})
|
||||
const identity = { hostId: 'host-a', expectedScope: makeScope() }
|
||||
const createFirst = vi.fn(() => FIRST_UUID)
|
||||
const createSecond = vi.fn(() => SECOND_UUID)
|
||||
|
||||
const first = getOrCreateCodexResetAttempt({
|
||||
...identity,
|
||||
createIdempotencyKey: createFirst
|
||||
})
|
||||
await vi.waitFor(() => expect(asyncStorage.setItem).toHaveBeenCalledTimes(1))
|
||||
const second = getOrCreateCodexResetAttempt({
|
||||
...identity,
|
||||
expectedScope: makeScope({ offerRevision: 'v1:refreshed-offer' }),
|
||||
createIdempotencyKey: createSecond
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(createSecond).not.toHaveBeenCalled()
|
||||
|
||||
releaseWrite()
|
||||
await expect(Promise.all([first, second])).resolves.toMatchObject([
|
||||
{ idempotencyKey: FIRST_UUID },
|
||||
{ idempotencyKey: FIRST_UUID }
|
||||
])
|
||||
expect(createSecond).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed on corrupt storage, read failures, write failures, and invalid UUIDs', async () => {
|
||||
const identity = { hostId: 'host-a', expectedScope: makeScope() }
|
||||
await getOrCreateCodexResetAttempt({
|
||||
...identity,
|
||||
createIdempotencyKey: () => FIRST_UUID
|
||||
})
|
||||
const [key] = values.keys()
|
||||
values.set(key!, '{not-json')
|
||||
await expect(
|
||||
getOrCreateCodexResetAttempt({ ...identity, createIdempotencyKey: () => SECOND_UUID })
|
||||
).rejects.toThrow(/unreadable/)
|
||||
|
||||
values.clear()
|
||||
asyncStorage.getItem.mockRejectedValueOnce(new Error('storage unavailable'))
|
||||
await expect(
|
||||
getOrCreateCodexResetAttempt({ ...identity, createIdempotencyKey: () => SECOND_UUID })
|
||||
).rejects.toThrow('storage unavailable')
|
||||
|
||||
asyncStorage.setItem.mockRejectedValueOnce(new Error('disk full'))
|
||||
await expect(
|
||||
getOrCreateCodexResetAttempt({ ...identity, createIdempotencyKey: () => SECOND_UUID })
|
||||
).rejects.toThrow('disk full')
|
||||
expect(values.size).toBe(0)
|
||||
|
||||
await expect(
|
||||
getOrCreateCodexResetAttempt({ ...identity, createIdempotencyKey: () => 'not-a-uuid' })
|
||||
).rejects.toThrow(/idempotency key is invalid/)
|
||||
})
|
||||
|
||||
it('never replaces a pending key based on age and clears only the matching authoritative attempt', async () => {
|
||||
const identity = { hostId: 'host-a', expectedScope: makeScope() }
|
||||
const createKey = vi.fn(() => FIRST_UUID)
|
||||
await getOrCreateCodexResetAttempt({ ...identity, createIdempotencyKey: createKey })
|
||||
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2036-01-01T00:00:00Z'))
|
||||
const oldAttempt = await getOrCreateCodexResetAttempt({
|
||||
...identity,
|
||||
createIdempotencyKey: () => SECOND_UUID
|
||||
})
|
||||
now.mockRestore()
|
||||
expect(oldAttempt.idempotencyKey).toBe(FIRST_UUID)
|
||||
|
||||
await expect(
|
||||
clearCodexResetAttemptAfterAuthoritativeResponse({
|
||||
...identity,
|
||||
idempotencyKey: SECOND_UUID
|
||||
})
|
||||
).rejects.toThrow(/identity changed/)
|
||||
expect(values.size).toBe(1)
|
||||
|
||||
await clearCodexResetAttemptAfterAuthoritativeResponse({
|
||||
...identity,
|
||||
idempotencyKey: FIRST_UUID
|
||||
})
|
||||
expect(values.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,182 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { z } from 'zod'
|
||||
import type { CodexResetCreditExpectedScope } from '../../../src/shared/codex-reset-credit-scope'
|
||||
|
||||
const STORAGE_PREFIX = 'orca:codex-reset-credit-attempt:v1:'
|
||||
const IdempotencyKeySchema = z.uuid()
|
||||
|
||||
export const CodexResetCreditExpectedScopeSchema = z
|
||||
.object({
|
||||
target: z
|
||||
.object({
|
||||
runtime: z.enum(['host', 'wsl']),
|
||||
wslDistro: z.string().min(1).max(255).nullable()
|
||||
})
|
||||
.strict(),
|
||||
accountId: z.string().min(1).max(512),
|
||||
accountRevision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
||||
offerRevision: z.string().startsWith('v1:').max(4_096)
|
||||
})
|
||||
.strict()
|
||||
.superRefine((scope, context) => {
|
||||
if (scope.target.runtime === 'host' && scope.target.wslDistro !== null) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Host reset scopes cannot name a WSL distro',
|
||||
path: ['target', 'wslDistro']
|
||||
})
|
||||
}
|
||||
if (
|
||||
scope.target.runtime === 'wsl' &&
|
||||
(scope.target.wslDistro === null || scope.target.wslDistro.trim() !== scope.target.wslDistro)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'WSL reset scopes require an exact distro',
|
||||
path: ['target', 'wslDistro']
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const CodexResetAttemptSchema = z
|
||||
.object({
|
||||
v: z.literal(1),
|
||||
hostId: z.string().min(1),
|
||||
expectedScope: CodexResetCreditExpectedScopeSchema,
|
||||
idempotencyKey: IdempotencyKeySchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type CodexResetAttempt = z.infer<typeof CodexResetAttemptSchema>
|
||||
|
||||
type AttemptIdentity = {
|
||||
hostId: string
|
||||
expectedScope: CodexResetCreditExpectedScope
|
||||
}
|
||||
|
||||
const scopeMutations = new Map<string, Promise<void>>()
|
||||
|
||||
// Why: a provider attempt's forced refresh changes offerRevision even when its
|
||||
// response is lost. Keep one unresolved original offer per stable account scope.
|
||||
function stableAccountScopePayload({ hostId, expectedScope }: AttemptIdentity): string {
|
||||
return JSON.stringify([
|
||||
hostId,
|
||||
expectedScope.target.runtime,
|
||||
expectedScope.target.wslDistro,
|
||||
expectedScope.accountId,
|
||||
expectedScope.accountRevision
|
||||
])
|
||||
}
|
||||
|
||||
function digestHex(value: string): string {
|
||||
return Array.from(sha256(value), (byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
function storageKey(identity: AttemptIdentity): string {
|
||||
return `${STORAGE_PREFIX}${digestHex(stableAccountScopePayload(identity))}`
|
||||
}
|
||||
|
||||
export function getCodexResetAttemptIdentityKey(identity: AttemptIdentity): string {
|
||||
return storageKey(identity)
|
||||
}
|
||||
|
||||
function stableAccountScopesEqual(
|
||||
left: CodexResetCreditExpectedScope,
|
||||
right: CodexResetCreditExpectedScope
|
||||
): boolean {
|
||||
return (
|
||||
left.target.runtime === right.target.runtime &&
|
||||
left.target.wslDistro === right.target.wslDistro &&
|
||||
left.accountId === right.accountId &&
|
||||
left.accountRevision === right.accountRevision
|
||||
)
|
||||
}
|
||||
|
||||
function parseAttempt(raw: string, identity: AttemptIdentity): CodexResetAttempt {
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(raw)
|
||||
} catch {
|
||||
throw new Error('Codex reset attempt journal is unreadable')
|
||||
}
|
||||
const result = CodexResetAttemptSchema.safeParse(value)
|
||||
if (
|
||||
!result.success ||
|
||||
result.data.hostId !== identity.hostId ||
|
||||
!stableAccountScopesEqual(result.data.expectedScope, identity.expectedScope)
|
||||
) {
|
||||
throw new Error('Codex reset attempt journal is unreadable')
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
async function withScopeMutation<T>(
|
||||
identity: AttemptIdentity,
|
||||
action: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const key = storageKey(identity)
|
||||
const previous = scopeMutations.get(key) ?? Promise.resolve()
|
||||
const operation = previous.then(action, action)
|
||||
const tail = operation.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
scopeMutations.set(key, tail)
|
||||
try {
|
||||
return await operation
|
||||
} finally {
|
||||
if (scopeMutations.get(key) === tail) {
|
||||
scopeMutations.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrCreateCodexResetAttempt(
|
||||
identity: AttemptIdentity & { createIdempotencyKey: () => string }
|
||||
): Promise<CodexResetAttempt> {
|
||||
return withScopeMutation(identity, async () => {
|
||||
const key = storageKey(identity)
|
||||
const raw = await AsyncStorage.getItem(key)
|
||||
if (raw !== null) {
|
||||
return parseAttempt(raw, identity)
|
||||
}
|
||||
|
||||
const idempotencyKey = identity.createIdempotencyKey()
|
||||
if (!IdempotencyKeySchema.safeParse(idempotencyKey).success) {
|
||||
throw new Error('Codex reset attempt idempotency key is invalid')
|
||||
}
|
||||
const attempt = CodexResetAttemptSchema.parse({
|
||||
v: 1,
|
||||
hostId: identity.hostId,
|
||||
expectedScope: identity.expectedScope,
|
||||
idempotencyKey
|
||||
})
|
||||
// Why: the key must survive a committed provider mutation whose response is
|
||||
// lost; no reset RPC may start until this write has completed successfully.
|
||||
await AsyncStorage.setItem(key, JSON.stringify(attempt))
|
||||
return attempt
|
||||
})
|
||||
}
|
||||
|
||||
export async function clearCodexResetAttemptAfterAuthoritativeResponse(
|
||||
identity: AttemptIdentity & { idempotencyKey: string }
|
||||
): Promise<void> {
|
||||
return withScopeMutation(identity, async () => {
|
||||
const key = storageKey(identity)
|
||||
const raw = await AsyncStorage.getItem(key)
|
||||
if (raw === null) {
|
||||
return
|
||||
}
|
||||
const current = parseAttempt(raw, identity)
|
||||
if (current.idempotencyKey !== identity.idempotencyKey) {
|
||||
throw new Error('Codex reset attempt journal identity changed')
|
||||
}
|
||||
await AsyncStorage.removeItem(key)
|
||||
})
|
||||
}
|
||||
|
||||
/** Test-only: drain in-memory queues while preserving the durable storage mock. */
|
||||
export function resetCodexResetAttemptJournalForTests(): void {
|
||||
scopeMutations.clear()
|
||||
}
|
||||
@@ -48,6 +48,54 @@ describe('startRuntimeCapabilityProbe', () => {
|
||||
cancel()
|
||||
})
|
||||
|
||||
it('treats malformed capabilities as unsupported', async () => {
|
||||
const response: RpcResponse = {
|
||||
ok: true,
|
||||
id: '1',
|
||||
result: { capabilities: 'a.v1' },
|
||||
_meta: { runtimeId: 'r1' }
|
||||
}
|
||||
const { client, calls } = makeClient([response])
|
||||
const seen: (readonly string[])[] = []
|
||||
const cancel = startRuntimeCapabilityProbe(client, (capabilities) => seen.push(capabilities))
|
||||
await flushMicrotasks()
|
||||
expect(seen).toEqual([[]])
|
||||
expect(calls()).toBe(1)
|
||||
cancel()
|
||||
})
|
||||
|
||||
it('rejects capability arrays containing non-string values', async () => {
|
||||
const response: RpcResponse = {
|
||||
ok: true,
|
||||
id: '1',
|
||||
result: { capabilities: ['a.v1', 42] },
|
||||
_meta: { runtimeId: 'r1' }
|
||||
}
|
||||
const { client, calls } = makeClient([response])
|
||||
const seen: (readonly string[])[] = []
|
||||
const cancel = startRuntimeCapabilityProbe(client, (capabilities) => seen.push(capabilities))
|
||||
await flushMicrotasks()
|
||||
expect(seen).toEqual([[]])
|
||||
expect(calls()).toBe(1)
|
||||
cancel()
|
||||
})
|
||||
|
||||
it('treats a malformed status result as unsupported', async () => {
|
||||
const response: RpcResponse = {
|
||||
ok: true,
|
||||
id: '1',
|
||||
result: null,
|
||||
_meta: { runtimeId: 'r1' }
|
||||
}
|
||||
const { client, calls } = makeClient([response])
|
||||
const seen: (readonly string[])[] = []
|
||||
const cancel = startRuntimeCapabilityProbe(client, (capabilities) => seen.push(capabilities))
|
||||
await flushMicrotasks()
|
||||
expect(seen).toEqual([[]])
|
||||
expect(calls()).toBe(1)
|
||||
cancel()
|
||||
})
|
||||
|
||||
it('retries promptly after a logical-client cutover rejection', async () => {
|
||||
const { client, calls } = makeClient([new LogicalClientCutoverError(), ok(['a.v1'])])
|
||||
const seen: (readonly string[])[] = []
|
||||
|
||||
@@ -27,8 +27,17 @@ export function startRuntimeCapabilityProbe(
|
||||
scheduleRetry(false)
|
||||
return
|
||||
}
|
||||
const status = (response as RpcSuccess).result as { capabilities?: string[] }
|
||||
onCapabilities(status.capabilities ?? [])
|
||||
const result = (response as RpcSuccess).result
|
||||
const rawCapabilities =
|
||||
result && typeof result === 'object'
|
||||
? (result as { capabilities?: unknown }).capabilities
|
||||
: null
|
||||
const capabilities =
|
||||
Array.isArray(rawCapabilities) &&
|
||||
rawCapabilities.every((value) => typeof value === 'string')
|
||||
? rawCapabilities
|
||||
: []
|
||||
onCapabilities(capabilities)
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (cancelled) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,20 @@ import type {
|
||||
CodexRateLimitAccountsState,
|
||||
CodexSystemDefaultIdentity
|
||||
} from '../../shared/types'
|
||||
import type {
|
||||
CodexRateLimitResetOutcome,
|
||||
CodexRateLimitResetResult,
|
||||
RateLimitState,
|
||||
RateLimitRuntimeTarget
|
||||
} from '../../shared/rate-limit-types'
|
||||
import {
|
||||
buildCodexResetCreditExpectedScope,
|
||||
type CodexResetCreditExpectedScope
|
||||
} from '../../shared/codex-reset-credit-scope'
|
||||
import type {
|
||||
CodexResetCreditAttemptLedger,
|
||||
DurableCodexResetCreditAttempt
|
||||
} from '../../shared/codex-reset-credit-attempt-ledger'
|
||||
import type { CodexRuntimeHomeService } from './runtime-home-service'
|
||||
import { writeFileAtomically } from './fs-utils'
|
||||
import { rewriteRelativePathConfigValues } from '../codex/codex-config-path-reference-rewrite'
|
||||
@@ -90,6 +104,79 @@ type ManagedHomeLocation = {
|
||||
wslLinuxHomePath: string | null
|
||||
}
|
||||
|
||||
export type CodexResetCreditRejectedBeforeProviderReason =
|
||||
| 'targetChanged'
|
||||
| 'accountChanged'
|
||||
| 'accountRevisionChanged'
|
||||
| 'accountRuntimeChanged'
|
||||
| 'offerUnavailable'
|
||||
| 'offerChanged'
|
||||
|
||||
export type CodexResetCreditConsumedResult = {
|
||||
outcome: CodexRateLimitResetOutcome
|
||||
scope: CodexResetCreditExpectedScope
|
||||
codex: CodexRateLimitAccountsState
|
||||
rateLimits: RateLimitState
|
||||
}
|
||||
|
||||
export type CodexResetCreditRejectedBeforeProviderResult = {
|
||||
status: 'rejectedBeforeProvider'
|
||||
retryDisposition: 'discardAttempt'
|
||||
reason: CodexResetCreditRejectedBeforeProviderReason
|
||||
scope: CodexResetCreditExpectedScope
|
||||
codex: CodexRateLimitAccountsState
|
||||
rateLimits: RateLimitState
|
||||
}
|
||||
|
||||
export type CodexResetCreditConsumeResult =
|
||||
| CodexResetCreditConsumedResult
|
||||
| CodexResetCreditRejectedBeforeProviderResult
|
||||
|
||||
type CodexResetCreditAttempt = {
|
||||
expectedScope: CodexResetCreditExpectedScope
|
||||
scopeKey: string
|
||||
accountScopeKey: string
|
||||
state: 'fresh' | 'providerPending' | 'settled'
|
||||
promise: Promise<CodexResetCreditConsumeResult> | null
|
||||
settledOutcome: CodexRateLimitResetOutcome | null
|
||||
}
|
||||
|
||||
class CodexResetCreditScopeRejection extends Error {
|
||||
constructor(
|
||||
readonly reason: CodexResetCreditRejectedBeforeProviderReason,
|
||||
readonly rateLimits: RateLimitState,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'CodexResetCreditScopeRejection'
|
||||
}
|
||||
}
|
||||
|
||||
function resetScopeKey(scope: CodexResetCreditExpectedScope): string {
|
||||
return JSON.stringify([
|
||||
scope.target.runtime,
|
||||
scope.target.wslDistro,
|
||||
scope.accountId,
|
||||
scope.accountRevision,
|
||||
scope.offerRevision
|
||||
])
|
||||
}
|
||||
|
||||
function resetAccountScopeKey(
|
||||
scope: Pick<CodexResetCreditExpectedScope, 'target' | 'accountId' | 'accountRevision'>
|
||||
): string {
|
||||
return JSON.stringify([
|
||||
scope.target.runtime,
|
||||
scope.target.wslDistro,
|
||||
scope.accountId,
|
||||
scope.accountRevision
|
||||
])
|
||||
}
|
||||
|
||||
function sameRateLimitTarget(left: RateLimitRuntimeTarget, right: RateLimitRuntimeTarget): boolean {
|
||||
return left.runtime === right.runtime && left.wslDistro === right.wslDistro
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`
|
||||
}
|
||||
@@ -158,6 +245,11 @@ function loginAuthChanged(
|
||||
export class CodexAccountService {
|
||||
// Why: serialize the read-modify-write of settings; overlapping calls (e.g. double-click Add) would lose updates.
|
||||
private mutationQueue: Promise<unknown> = Promise.resolve()
|
||||
private readonly resetAttemptsByKey = new Map<string, CodexResetCreditAttempt>()
|
||||
private readonly resetAttemptKeyByOffer = new Map<string, string>()
|
||||
private readonly unresolvedResetKeyByAccountScope = new Map<string, string>()
|
||||
private durableResetLedger: CodexResetCreditAttemptLedger | null = null
|
||||
private resetLedgerLoadError: Error | null = null
|
||||
|
||||
constructor(
|
||||
private readonly store: Store,
|
||||
@@ -165,6 +257,7 @@ export class CodexAccountService {
|
||||
private readonly runtimeHome: CodexRuntimeHomeService,
|
||||
private readonly lifecycle: CodexAccountServiceLifecycle = {}
|
||||
) {
|
||||
this.hydrateResetCreditAttempts()
|
||||
this.safeSyncCanonicalConfigToManagedHomes()
|
||||
}
|
||||
|
||||
@@ -202,6 +295,392 @@ export class CodexAccountService {
|
||||
return this.serializeMutation(() => this.doSelectAccount(accountId, target))
|
||||
}
|
||||
|
||||
consumeRateLimitResetCredit(
|
||||
idempotencyKey: string,
|
||||
expectedScope: CodexResetCreditExpectedScope
|
||||
): Promise<CodexResetCreditConsumeResult> {
|
||||
if (this.resetLedgerLoadError) {
|
||||
return Promise.reject(this.resetLedgerLoadError)
|
||||
}
|
||||
const scopeKey = resetScopeKey(expectedScope)
|
||||
const accountScopeKey = resetAccountScopeKey(expectedScope)
|
||||
const existing = this.resetAttemptsByKey.get(idempotencyKey)
|
||||
if (existing) {
|
||||
if (existing.scopeKey !== scopeKey) {
|
||||
return Promise.reject(new Error('That idempotency key belongs to a different reset scope.'))
|
||||
}
|
||||
if (existing.state === 'settled' && existing.settledOutcome) {
|
||||
return this.serializeMutation(async () => {
|
||||
const { rateLimits } = this.validateResetCreditScope(expectedScope, false)
|
||||
return {
|
||||
outcome: existing.settledOutcome!,
|
||||
scope: existing.expectedScope,
|
||||
codex: this.getSnapshot(),
|
||||
rateLimits
|
||||
}
|
||||
})
|
||||
}
|
||||
if (existing.promise) {
|
||||
return existing.promise
|
||||
}
|
||||
return this.startResetCreditAttempt(idempotencyKey, expectedScope, existing)
|
||||
}
|
||||
|
||||
const unresolvedKey = this.unresolvedResetKeyByAccountScope.get(accountScopeKey)
|
||||
if (unresolvedKey && unresolvedKey !== idempotencyKey) {
|
||||
return Promise.reject(
|
||||
new Error('A previous reset attempt for this account still has an unknown outcome.')
|
||||
)
|
||||
}
|
||||
const claimedKey = this.resetAttemptKeyByOffer.get(scopeKey)
|
||||
if (claimedKey && claimedKey !== idempotencyKey) {
|
||||
return Promise.reject(new Error('That reset-credit offer was already attempted.'))
|
||||
}
|
||||
|
||||
const attempt: CodexResetCreditAttempt = {
|
||||
expectedScope,
|
||||
scopeKey,
|
||||
accountScopeKey,
|
||||
state: 'fresh',
|
||||
promise: null,
|
||||
settledOutcome: null
|
||||
}
|
||||
this.resetAttemptsByKey.set(idempotencyKey, attempt)
|
||||
this.resetAttemptKeyByOffer.set(scopeKey, idempotencyKey)
|
||||
return this.startResetCreditAttempt(idempotencyKey, expectedScope, attempt)
|
||||
}
|
||||
|
||||
async consumeCurrentRateLimitResetCredit(): Promise<CodexRateLimitResetResult> {
|
||||
if (this.resetLedgerLoadError) {
|
||||
throw this.resetLedgerLoadError
|
||||
}
|
||||
const initialRateLimits = this.rateLimits.getState()
|
||||
const initialTarget = { ...initialRateLimits.codexTarget }
|
||||
const initialSettings = this.store.getSettings()
|
||||
const selectedAccountId = getSelectedCodexAccountIdForTarget(initialSettings, initialTarget)
|
||||
if (selectedAccountId) {
|
||||
const account = initialSettings.codexManagedAccounts.find(
|
||||
(candidate) => candidate.id === selectedAccountId
|
||||
)
|
||||
const pendingAttempt = account
|
||||
? this.getPendingResetAttemptForAccount(initialTarget, account)
|
||||
: null
|
||||
const expectedScope =
|
||||
pendingAttempt?.expectedScope ??
|
||||
(account
|
||||
? buildCodexResetCreditExpectedScope({
|
||||
target: initialTarget,
|
||||
account: this.toSummary(account),
|
||||
limits: initialRateLimits.codex
|
||||
})
|
||||
: null)
|
||||
if (!expectedScope) {
|
||||
throw new Error('The managed Codex reset-credit offer is no longer available.')
|
||||
}
|
||||
// Why: do not enter the mutation queue first; the coordinator owns that
|
||||
// queue and nested serialization would deadlock behind this operation.
|
||||
const result = await this.consumeRateLimitResetCredit(
|
||||
pendingAttempt?.idempotencyKey ?? randomUUID(),
|
||||
expectedScope
|
||||
)
|
||||
if ('status' in result) {
|
||||
throw new Error('The Codex account or reset offer changed before reset.')
|
||||
}
|
||||
return { outcome: result.outcome, state: result.rateLimits }
|
||||
}
|
||||
|
||||
return this.serializeMutation(async () => {
|
||||
if (this.resetLedgerLoadError) {
|
||||
throw this.resetLedgerLoadError
|
||||
}
|
||||
const target = this.rateLimits.getState().codexTarget
|
||||
if (!sameRateLimitTarget(target, initialTarget)) {
|
||||
throw new Error('The active Codex rate-limit target changed before reset.')
|
||||
}
|
||||
if (getSelectedCodexAccountIdForTarget(this.store.getSettings(), target)) {
|
||||
throw new Error('The selected Codex account changed before reset.')
|
||||
}
|
||||
if (this.hasPendingResetForTarget(target)) {
|
||||
throw new Error('A previous reset attempt for this target still has an unknown outcome.')
|
||||
}
|
||||
const codexHomePath = this.runtimeHome.prepareForRateLimitFetch(target)
|
||||
return this.rateLimits.consumeCodexRateLimitResetCredit({
|
||||
idempotencyKey: randomUUID(),
|
||||
target,
|
||||
codexHomePath
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private getPendingResetAttemptForAccount(
|
||||
target: RateLimitRuntimeTarget,
|
||||
account: CodexManagedAccount
|
||||
): { idempotencyKey: string; expectedScope: CodexResetCreditExpectedScope } | null {
|
||||
const accountScopeKey = resetAccountScopeKey({
|
||||
target,
|
||||
accountId: account.id,
|
||||
accountRevision: account.updatedAt
|
||||
})
|
||||
const idempotencyKey = this.unresolvedResetKeyByAccountScope.get(accountScopeKey)
|
||||
if (!idempotencyKey) {
|
||||
return null
|
||||
}
|
||||
const attempt = this.resetAttemptsByKey.get(idempotencyKey)
|
||||
if (attempt?.state !== 'providerPending') {
|
||||
throw new Error('Codex reset-credit attempt state is inconsistent.')
|
||||
}
|
||||
// Why: a durable providerPending attempt can only be resolved with its original key.
|
||||
return { idempotencyKey, expectedScope: attempt.expectedScope }
|
||||
}
|
||||
|
||||
private hasPendingResetForTarget(target: RateLimitRuntimeTarget): boolean {
|
||||
return [...this.resetAttemptsByKey.values()].some(
|
||||
(attempt) =>
|
||||
attempt.state === 'providerPending' &&
|
||||
sameRateLimitTarget(attempt.expectedScope.target, target)
|
||||
)
|
||||
}
|
||||
|
||||
private startResetCreditAttempt(
|
||||
idempotencyKey: string,
|
||||
expectedScope: CodexResetCreditExpectedScope,
|
||||
attempt: CodexResetCreditAttempt
|
||||
): Promise<CodexResetCreditConsumeResult> {
|
||||
const promise = this.serializeMutation(async (): Promise<CodexResetCreditConsumeResult> => {
|
||||
const isFresh = attempt.state === 'fresh'
|
||||
let validation: { managedHomePath: string; rateLimits: RateLimitState }
|
||||
try {
|
||||
validation = this.validateResetCreditScope(expectedScope, isFresh)
|
||||
} catch (error) {
|
||||
if (isFresh && error instanceof CodexResetCreditScopeRejection) {
|
||||
this.releaseFreshResetAttempt(idempotencyKey, attempt)
|
||||
return {
|
||||
status: 'rejectedBeforeProvider',
|
||||
retryDisposition: 'discardAttempt',
|
||||
reason: error.reason,
|
||||
scope: expectedScope,
|
||||
codex: this.getSnapshot(),
|
||||
rateLimits: error.rateLimits
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (isFresh) {
|
||||
this.persistResetAttempt({
|
||||
idempotencyKey,
|
||||
expectedScope,
|
||||
state: 'providerPending'
|
||||
})
|
||||
attempt.state = 'providerPending'
|
||||
this.unresolvedResetKeyByAccountScope.set(attempt.accountScopeKey, idempotencyKey)
|
||||
}
|
||||
const { outcome, state } = await this.rateLimits.consumeCodexRateLimitResetCredit({
|
||||
idempotencyKey,
|
||||
target: expectedScope.target,
|
||||
codexHomePath: validation.managedHomePath
|
||||
})
|
||||
// Why: queued account selection may start as soon as this mutation resolves;
|
||||
// capture both account selection and usage before releasing the queue.
|
||||
const result: CodexResetCreditConsumedResult = {
|
||||
outcome,
|
||||
scope: expectedScope,
|
||||
codex: this.getSnapshot(),
|
||||
rateLimits: state
|
||||
}
|
||||
this.persistResetAttempt({
|
||||
idempotencyKey,
|
||||
expectedScope,
|
||||
state: 'settled',
|
||||
outcome
|
||||
})
|
||||
attempt.state = 'settled'
|
||||
attempt.settledOutcome = outcome
|
||||
if (this.unresolvedResetKeyByAccountScope.get(attempt.accountScopeKey) === idempotencyKey) {
|
||||
this.unresolvedResetKeyByAccountScope.delete(attempt.accountScopeKey)
|
||||
}
|
||||
return result
|
||||
})
|
||||
attempt.promise = promise
|
||||
void promise.then(
|
||||
() => {
|
||||
attempt.promise = null
|
||||
},
|
||||
() => {
|
||||
attempt.promise = null
|
||||
if (attempt.state === 'fresh') {
|
||||
this.releaseFreshResetAttempt(idempotencyKey, attempt)
|
||||
}
|
||||
}
|
||||
)
|
||||
return promise
|
||||
}
|
||||
|
||||
private validateResetCreditScope(
|
||||
expectedScope: CodexResetCreditExpectedScope,
|
||||
requireCurrentOffer: boolean
|
||||
): { managedHomePath: string; rateLimits: RateLimitState } {
|
||||
const rateLimitState = this.rateLimits.getState()
|
||||
if (!sameRateLimitTarget(rateLimitState.codexTarget, expectedScope.target)) {
|
||||
throw new CodexResetCreditScopeRejection(
|
||||
'targetChanged',
|
||||
rateLimitState,
|
||||
'The active Codex rate-limit target changed before reset.'
|
||||
)
|
||||
}
|
||||
|
||||
const settings = this.store.getSettings()
|
||||
if (
|
||||
getSelectedCodexAccountIdForTarget(settings, expectedScope.target) !== expectedScope.accountId
|
||||
) {
|
||||
throw new CodexResetCreditScopeRejection(
|
||||
'accountChanged',
|
||||
rateLimitState,
|
||||
'The selected Codex account changed before reset.'
|
||||
)
|
||||
}
|
||||
const account = settings.codexManagedAccounts.find(
|
||||
(candidate) => candidate.id === expectedScope.accountId
|
||||
)
|
||||
if (!account || account.updatedAt !== expectedScope.accountRevision) {
|
||||
throw new CodexResetCreditScopeRejection(
|
||||
'accountRevisionChanged',
|
||||
rateLimitState,
|
||||
'The selected Codex account was updated before reset.'
|
||||
)
|
||||
}
|
||||
const normalizedAccountTarget = normalizeCodexAccountSelectionTarget(
|
||||
getCodexSelectionTargetForAccount(account)
|
||||
)
|
||||
if (!sameRateLimitTarget(normalizedAccountTarget, expectedScope.target)) {
|
||||
throw new CodexResetCreditScopeRejection(
|
||||
'accountRuntimeChanged',
|
||||
rateLimitState,
|
||||
'The selected Codex account belongs to a different runtime.'
|
||||
)
|
||||
}
|
||||
|
||||
const currentScope = buildCodexResetCreditExpectedScope({
|
||||
target: rateLimitState.codexTarget,
|
||||
account: this.toSummary(account),
|
||||
limits: rateLimitState.codex
|
||||
})
|
||||
// Why: a same-key replay resolves an already-started provider mutation;
|
||||
// its credit snapshot may have refreshed, but its account/runtime identity may not change.
|
||||
if (requireCurrentOffer && !currentScope) {
|
||||
throw new CodexResetCreditScopeRejection(
|
||||
'offerUnavailable',
|
||||
rateLimitState,
|
||||
'The Codex reset-credit offer is no longer available.'
|
||||
)
|
||||
}
|
||||
if (
|
||||
requireCurrentOffer &&
|
||||
currentScope &&
|
||||
resetScopeKey(expectedScope) !== resetScopeKey(currentScope)
|
||||
) {
|
||||
throw new CodexResetCreditScopeRejection(
|
||||
'offerChanged',
|
||||
rateLimitState,
|
||||
'The Codex reset-credit offer changed before reset.'
|
||||
)
|
||||
}
|
||||
|
||||
return { managedHomePath: account.managedHomePath, rateLimits: rateLimitState }
|
||||
}
|
||||
|
||||
private hydrateResetCreditAttempts(): void {
|
||||
try {
|
||||
const ledger = this.store.getCodexResetCreditAttemptLedger()
|
||||
this.durableResetLedger = ledger
|
||||
for (const durable of ledger.attempts) {
|
||||
const scopeKey = resetScopeKey(durable.expectedScope)
|
||||
const accountScopeKey = resetAccountScopeKey(durable.expectedScope)
|
||||
this.resetAttemptsByKey.set(durable.idempotencyKey, {
|
||||
expectedScope: durable.expectedScope,
|
||||
scopeKey,
|
||||
accountScopeKey,
|
||||
state: durable.state,
|
||||
promise: null,
|
||||
settledOutcome: durable.state === 'settled' ? durable.outcome : null
|
||||
})
|
||||
this.resetAttemptKeyByOffer.set(scopeKey, durable.idempotencyKey)
|
||||
if (durable.state === 'providerPending') {
|
||||
this.unresolvedResetKeyByAccountScope.set(accountScopeKey, durable.idempotencyKey)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.resetLedgerLoadError =
|
||||
error instanceof Error ? error : new Error('Codex reset-credit attempt ledger is corrupt')
|
||||
}
|
||||
}
|
||||
|
||||
private persistResetAttempt(nextAttempt: DurableCodexResetCreditAttempt): void {
|
||||
if (!this.durableResetLedger) {
|
||||
throw (
|
||||
this.resetLedgerLoadError ?? new Error('Codex reset-credit attempt ledger is unavailable')
|
||||
)
|
||||
}
|
||||
const index = this.durableResetLedger.attempts.findIndex(
|
||||
(attempt) => attempt.idempotencyKey === nextAttempt.idempotencyKey
|
||||
)
|
||||
const attempts = [...this.durableResetLedger.attempts]
|
||||
if (index === -1) {
|
||||
attempts.push(nextAttempt)
|
||||
} else {
|
||||
attempts[index] = nextAttempt
|
||||
}
|
||||
const nextLedger: CodexResetCreditAttemptLedger = { version: 1, attempts }
|
||||
this.store.replaceCodexResetCreditAttemptLedgerAndFlush(nextLedger)
|
||||
this.durableResetLedger = structuredClone(nextLedger)
|
||||
}
|
||||
|
||||
private releaseFreshResetAttempt(idempotencyKey: string, attempt: CodexResetCreditAttempt): void {
|
||||
if (attempt.state !== 'fresh') {
|
||||
return
|
||||
}
|
||||
this.resetAttemptsByKey.delete(idempotencyKey)
|
||||
if (this.resetAttemptKeyByOffer.get(attempt.scopeKey) === idempotencyKey) {
|
||||
this.resetAttemptKeyByOffer.delete(attempt.scopeKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a removed account's managed home is gone, so its unresolved providerPending
|
||||
// attempt can never validate or be replayed; drop it so a target-scoped default reset
|
||||
// is not wedged forever by hasPendingResetForTarget matching the orphan.
|
||||
private discardResetAttemptsForRemovedAccount(accountId: string): void {
|
||||
const staleAttempts: [string, CodexResetCreditAttempt][] = []
|
||||
for (const [idempotencyKey, attempt] of this.resetAttemptsByKey) {
|
||||
if (attempt.expectedScope.accountId === accountId) {
|
||||
staleAttempts.push([idempotencyKey, attempt])
|
||||
}
|
||||
}
|
||||
if (staleAttempts.length === 0) {
|
||||
return
|
||||
}
|
||||
const staleKeySet = new Set(staleAttempts.map(([idempotencyKey]) => idempotencyKey))
|
||||
if (this.durableResetLedger) {
|
||||
const attempts = this.durableResetLedger.attempts.filter(
|
||||
(attempt) => !staleKeySet.has(attempt.idempotencyKey)
|
||||
)
|
||||
if (attempts.length !== this.durableResetLedger.attempts.length) {
|
||||
const nextLedger: CodexResetCreditAttemptLedger = { version: 1, attempts }
|
||||
// Persist first so a failed durability barrier leaves the in-memory
|
||||
// fail-closed guards aligned with the ledger that will reload.
|
||||
this.store.replaceCodexResetCreditAttemptLedgerAndFlush(nextLedger)
|
||||
this.durableResetLedger = structuredClone(nextLedger)
|
||||
}
|
||||
}
|
||||
for (const [idempotencyKey, attempt] of staleAttempts) {
|
||||
this.resetAttemptsByKey.delete(idempotencyKey)
|
||||
if (this.resetAttemptKeyByOffer.get(attempt.scopeKey) === idempotencyKey) {
|
||||
this.resetAttemptKeyByOffer.delete(attempt.scopeKey)
|
||||
}
|
||||
if (this.unresolvedResetKeyByAccountScope.get(attempt.accountScopeKey) === idempotencyKey) {
|
||||
this.unresolvedResetKeyByAccountScope.delete(attempt.accountScopeKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: quota probes against a cold per-account CODEX_HOME can take 10–25s
|
||||
// (RPC + PTY fallback) and queue behind an in-flight global usage refresh.
|
||||
// The refresh synchronously flips usage to "fetching" before its first await,
|
||||
@@ -351,6 +830,7 @@ export class CodexAccountService {
|
||||
// Why: a removed account can no longer appear in the switcher dropdown,
|
||||
// so purge its cached usage to avoid stale entries.
|
||||
this.rateLimits.evictInactiveCodexCache(accountId)
|
||||
this.discardResetAttemptsForRemovedAccount(accountId)
|
||||
this.startQuotaRefreshInBackground(
|
||||
getSelectedCodexAccountIdForTarget(settings, getCodexSelectionTargetForAccount(account)) ===
|
||||
accountId
|
||||
|
||||
@@ -15,28 +15,46 @@ vi.mock('electron', () => ({
|
||||
import { registerRateLimitHandlers } from './rate-limits'
|
||||
import type { RateLimitService } from '../rate-limits/service'
|
||||
import type { RateLimitState } from '../../shared/rate-limit-types'
|
||||
import type { CodexAccountService } from '../codex-accounts/service'
|
||||
|
||||
function makeCodexAccounts() {
|
||||
const consumeCurrentRateLimitResetCredit = vi.fn(() =>
|
||||
Promise.resolve({ outcome: 'noCredit', state: {} as RateLimitState })
|
||||
)
|
||||
return {
|
||||
service: { consumeCurrentRateLimitResetCredit } as unknown as CodexAccountService,
|
||||
consumeCurrentRateLimitResetCredit
|
||||
}
|
||||
}
|
||||
|
||||
function makeService(): {
|
||||
service: RateLimitService
|
||||
refresh: ReturnType<typeof vi.fn>
|
||||
refreshGrok: ReturnType<typeof vi.fn>
|
||||
consumeCodexRateLimitResetCredit: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const refresh = vi.fn(() => Promise.resolve({} as RateLimitState))
|
||||
const refreshGrok = vi.fn(() => Promise.resolve({} as RateLimitState))
|
||||
const consumeCodexRateLimitResetCredit = vi.fn(() =>
|
||||
Promise.resolve({ outcome: 'noCredit', state: {} as RateLimitState })
|
||||
)
|
||||
const service = {
|
||||
getState: vi.fn(() => ({}) as RateLimitState),
|
||||
refresh,
|
||||
refreshGrok,
|
||||
refreshCodexForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)),
|
||||
refreshClaudeForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)),
|
||||
consumeCodexRateLimitResetCredit: vi.fn(() =>
|
||||
Promise.resolve({ outcome: 'noCredit', state: {} as RateLimitState })
|
||||
),
|
||||
consumeCodexRateLimitResetCredit,
|
||||
setPollingInterval: vi.fn(() => Promise.resolve()),
|
||||
fetchInactiveClaudeAccountsOnOpen: vi.fn(() => Promise.resolve()),
|
||||
fetchInactiveCodexAccountsOnOpen: vi.fn(() => Promise.resolve())
|
||||
}
|
||||
return { service: service as unknown as RateLimitService, refresh, refreshGrok }
|
||||
return {
|
||||
service: service as unknown as RateLimitService,
|
||||
refresh,
|
||||
refreshGrok,
|
||||
consumeCodexRateLimitResetCredit
|
||||
}
|
||||
}
|
||||
|
||||
describe('registerRateLimitHandlers', () => {
|
||||
@@ -46,7 +64,7 @@ describe('registerRateLimitHandlers', () => {
|
||||
|
||||
it('registers a refreshMiniMax channel that delegates to refresh()', async () => {
|
||||
const { service, refresh } = makeService()
|
||||
registerRateLimitHandlers(service)
|
||||
registerRateLimitHandlers(service, makeCodexAccounts().service)
|
||||
const handler = ipcState.handleHandlers.get('rateLimits:refreshMiniMax')
|
||||
expect(handler).toBeDefined()
|
||||
await handler!({})
|
||||
@@ -55,7 +73,7 @@ describe('registerRateLimitHandlers', () => {
|
||||
|
||||
it('keeps the existing rate-limit channels registered', () => {
|
||||
const { service } = makeService()
|
||||
registerRateLimitHandlers(service)
|
||||
registerRateLimitHandlers(service, makeCodexAccounts().service)
|
||||
expect(ipcState.handleHandlers.has('rateLimits:get')).toBe(true)
|
||||
expect(ipcState.handleHandlers.has('rateLimits:refresh')).toBe(true)
|
||||
expect(ipcState.handleHandlers.has('rateLimits:refreshMiniMax')).toBe(true)
|
||||
@@ -64,10 +82,22 @@ describe('registerRateLimitHandlers', () => {
|
||||
|
||||
it('registers a refreshGrok channel that delegates to refreshGrok()', async () => {
|
||||
const { service, refreshGrok } = makeService()
|
||||
registerRateLimitHandlers(service)
|
||||
registerRateLimitHandlers(service, makeCodexAccounts().service)
|
||||
const handler = ipcState.handleHandlers.get('rateLimits:refreshGrok')
|
||||
expect(handler).toBeDefined()
|
||||
await handler!({})
|
||||
expect(refreshGrok).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('serializes desktop reset consumption through CodexAccountService', async () => {
|
||||
const { service, consumeCodexRateLimitResetCredit } = makeService()
|
||||
const codexAccounts = makeCodexAccounts()
|
||||
registerRateLimitHandlers(service, codexAccounts.service)
|
||||
const handler = ipcState.handleHandlers.get('rateLimits:consumeCodexResetCredit')
|
||||
|
||||
await handler!({})
|
||||
|
||||
expect(codexAccounts.consumeCurrentRateLimitResetCredit).toHaveBeenCalledOnce()
|
||||
expect(consumeCodexRateLimitResetCredit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import type { RateLimitService } from '../rate-limits/service'
|
||||
import type { RateLimitRuntimeTarget } from '../../shared/rate-limit-types'
|
||||
import type { CodexAccountService } from '../codex-accounts/service'
|
||||
|
||||
export function registerRateLimitHandlers(rateLimits: RateLimitService): void {
|
||||
export function registerRateLimitHandlers(
|
||||
rateLimits: RateLimitService,
|
||||
codexAccounts: CodexAccountService
|
||||
): void {
|
||||
ipcMain.handle('rateLimits:get', () => rateLimits.getState())
|
||||
ipcMain.handle('rateLimits:refresh', () => rateLimits.refresh())
|
||||
ipcMain.handle('rateLimits:refreshCodexForTarget', (_event, target: RateLimitRuntimeTarget) =>
|
||||
rateLimits.refreshCodexForTarget(target)
|
||||
)
|
||||
// Why: managed desktop resets must share the mobile mutation queue and durable ledger.
|
||||
ipcMain.handle('rateLimits:consumeCodexResetCredit', () =>
|
||||
rateLimits.consumeCodexRateLimitResetCredit()
|
||||
codexAccounts.consumeCurrentRateLimitResetCredit()
|
||||
)
|
||||
ipcMain.handle('rateLimits:refreshClaudeForTarget', (_event, target: RateLimitRuntimeTarget) =>
|
||||
rateLimits.refreshClaudeForTarget(target)
|
||||
|
||||
@@ -494,7 +494,7 @@ describe('registerCoreHandlers', () => {
|
||||
expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts)
|
||||
expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits)
|
||||
expect(registerGrokAccountHandlersMock).toHaveBeenCalled()
|
||||
expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits)
|
||||
expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits, codexAccounts)
|
||||
expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats)
|
||||
expect(registerLinearHandlersMock).toHaveBeenCalled()
|
||||
expect(registerJiraHandlersMock).toHaveBeenCalled()
|
||||
|
||||
@@ -141,7 +141,7 @@ export function registerCoreHandlers(
|
||||
registerClaudeAccountHandlers(claudeAccounts)
|
||||
registerMiniMaxCredentialsHandlers(rateLimits)
|
||||
registerGrokAccountHandlers()
|
||||
registerRateLimitHandlers(rateLimits)
|
||||
registerRateLimitHandlers(rateLimits, codexAccounts)
|
||||
registerGitHubHandlers(store, stats)
|
||||
registerGitLabHandlers(store)
|
||||
registerHostedReviewHandlers(store, stats)
|
||||
|
||||
@@ -335,6 +335,75 @@ describe('Store', () => {
|
||||
expect(store.getRepos()).toEqual([])
|
||||
}, 15_000)
|
||||
|
||||
it('clone-reads and synchronously persists the main-owned Codex reset ledger', async () => {
|
||||
const store = await createStore()
|
||||
const ledger = {
|
||||
version: 1 as const,
|
||||
attempts: [
|
||||
{
|
||||
idempotencyKey: '11111111-1111-4111-8111-111111111111',
|
||||
expectedScope: {
|
||||
target: { runtime: 'host' as const, wslDistro: null },
|
||||
accountId: 'account-host',
|
||||
accountRevision: 42,
|
||||
offerRevision: 'v1:offer'
|
||||
},
|
||||
state: 'providerPending' as const
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
store.replaceCodexResetCreditAttemptLedgerAndFlush(ledger)
|
||||
const firstRead = store.getCodexResetCreditAttemptLedger()
|
||||
firstRead.attempts.splice(0, 1)
|
||||
|
||||
expect(store.getCodexResetCreditAttemptLedger()).toEqual(ledger)
|
||||
expect((readDataFile() as PersistedState).codexResetCreditAttemptLedger).toEqual(ledger)
|
||||
})
|
||||
|
||||
it('rolls the in-memory Codex reset ledger back when its sync flush fails', async () => {
|
||||
const store = await createStore()
|
||||
const before = store.getCodexResetCreditAttemptLedger()
|
||||
vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => {
|
||||
throw new Error('disk full')
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
store.replaceCodexResetCreditAttemptLedgerAndFlush({
|
||||
version: 1,
|
||||
attempts: [
|
||||
{
|
||||
idempotencyKey: '11111111-1111-4111-8111-111111111111',
|
||||
expectedScope: {
|
||||
target: { runtime: 'host', wslDistro: null },
|
||||
accountId: 'account-host',
|
||||
accountRevision: 42,
|
||||
offerRevision: 'v1:offer'
|
||||
},
|
||||
state: 'providerPending'
|
||||
}
|
||||
]
|
||||
})
|
||||
).toThrow('disk full')
|
||||
|
||||
expect(store.getCodexResetCreditAttemptLedger()).toEqual(before)
|
||||
})
|
||||
|
||||
it('preserves a corrupt Codex reset ledger as a fail-closed read error', async () => {
|
||||
writeDataFile({
|
||||
...getDefaultPersistedState(testState.dir),
|
||||
codexResetCreditAttemptLedger: {
|
||||
version: 1,
|
||||
attempts: [{ state: 'providerPending' }]
|
||||
}
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
expect(() => store.getCodexResetCreditAttemptLedger()).toThrow(
|
||||
'Codex reset-credit attempt ledger is corrupt'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not restore a terminal tab after its durable close flush returns', async () => {
|
||||
const store = await createStore()
|
||||
const worktreeId = 'repo-1::/tmp/worktree-1'
|
||||
|
||||
@@ -178,6 +178,10 @@ import {
|
||||
} from '../shared/feature-interactions'
|
||||
import { normalizeContextualTourIds } from '../shared/contextual-tours'
|
||||
import { normalizeFeatureTipIds } from '../shared/feature-tips'
|
||||
import {
|
||||
parseCodexResetCreditAttemptLedger,
|
||||
type CodexResetCreditAttemptLedger
|
||||
} from '../shared/codex-reset-credit-attempt-ledger'
|
||||
import { normalizeManualRepoOrder } from '../shared/manual-repo-order'
|
||||
import {
|
||||
DEFAULT_WORKSPACE_STATUS_ID,
|
||||
@@ -3741,6 +3745,29 @@ export class Store {
|
||||
this.activeViewPreference.flushOrThrow()
|
||||
}
|
||||
|
||||
getCodexResetCreditAttemptLedger(): CodexResetCreditAttemptLedger {
|
||||
return parseCodexResetCreditAttemptLedger(this.state.codexResetCreditAttemptLedger)
|
||||
}
|
||||
|
||||
replaceCodexResetCreditAttemptLedgerAndFlush(ledger: CodexResetCreditAttemptLedger): void {
|
||||
if (this.writesFrozen) {
|
||||
throw new Error('Cannot persist Codex reset-credit attempts while writes are frozen')
|
||||
}
|
||||
const next = parseCodexResetCreditAttemptLedger(ledger)
|
||||
const previous = this.state.codexResetCreditAttemptLedger
|
||||
? structuredClone(this.state.codexResetCreditAttemptLedger)
|
||||
: undefined
|
||||
this.state.codexResetCreditAttemptLedger = next
|
||||
try {
|
||||
this.flushOrThrow()
|
||||
} catch (error) {
|
||||
// Why: callers use a successful return as the durability barrier before
|
||||
// handing a scarce-credit mutation to the provider.
|
||||
this.state.codexResetCreditAttemptLedger = previous
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ── Repos ──────────────────────────────────────────────────────────
|
||||
|
||||
getRepos(): Repo[] {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { EventEmitter } from 'node:events'
|
||||
import type { ProviderRateLimits } from '../../shared/rate-limit-types'
|
||||
import { RateLimitService } from './service'
|
||||
import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher'
|
||||
import { fetchCodexRateLimits } from './codex-fetcher'
|
||||
import { consumeCodexRateLimitResetCredit, fetchCodexRateLimits } from './codex-fetcher'
|
||||
import { fetchGeminiRateLimits } from './gemini-usage-fetcher'
|
||||
import { fetchKimiRateLimits } from './kimi-fetcher'
|
||||
import { fetchMiniMaxRateLimits } from './minimax-fetcher'
|
||||
@@ -22,6 +22,7 @@ vi.mock('./claude-fetcher', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./codex-fetcher', () => ({
|
||||
consumeCodexRateLimitResetCredit: vi.fn(),
|
||||
fetchCodexRateLimits: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -1449,6 +1450,131 @@ describe('RateLimitService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('reuses a caller-provided idempotency key when consuming a Codex reset credit', async () => {
|
||||
const service = new RateLimitService()
|
||||
const idempotencyKey = '11111111-1111-4111-8111-111111111111'
|
||||
service.setCodexHomePathResolver(() => '/tmp/codex-home')
|
||||
vi.mocked(consumeCodexRateLimitResetCredit).mockResolvedValueOnce('reset')
|
||||
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 0, Date.now()))
|
||||
|
||||
await expect(
|
||||
service.consumeCodexRateLimitResetCredit({
|
||||
idempotencyKey,
|
||||
target: { runtime: 'host', wslDistro: null },
|
||||
codexHomePath: '/tmp/codex-home'
|
||||
})
|
||||
).resolves.toMatchObject({ outcome: 'reset' })
|
||||
expect(consumeCodexRateLimitResetCredit).toHaveBeenCalledWith({
|
||||
codexHomePath: '/tmp/codex-home',
|
||||
idempotencyKey
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a refreshed scoped state without overwriting a target selected during reset', async () => {
|
||||
const service = new RateLimitService()
|
||||
const idempotencyKey = '22222222-2222-4222-8222-222222222222'
|
||||
const consume = vi.mocked(consumeCodexRateLimitResetCredit)
|
||||
let resolveConsume: ((outcome: 'reset') => void) | undefined
|
||||
consume.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveConsume = resolve
|
||||
})
|
||||
)
|
||||
vi.mocked(fetchCodexRateLimits).mockResolvedValueOnce(okProvider('codex', 0, Date.now()))
|
||||
|
||||
service.setCodexHomePathResolver(() => '/tmp/new-selection')
|
||||
const pending = service.consumeCodexRateLimitResetCredit({
|
||||
idempotencyKey,
|
||||
target: { runtime: 'host', wslDistro: null },
|
||||
codexHomePath: '/tmp/approved-selection'
|
||||
})
|
||||
await vi.waitFor(() => expect(consume).toHaveBeenCalledOnce())
|
||||
service.setCodexFetchTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
resolveConsume?.('reset')
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
outcome: 'reset',
|
||||
state: {
|
||||
codexTarget: { runtime: 'host', wslDistro: null },
|
||||
codex: { session: { usedPercent: 0 } }
|
||||
}
|
||||
})
|
||||
expect(consume).toHaveBeenCalledWith({
|
||||
codexHomePath: '/tmp/approved-selection',
|
||||
idempotencyKey
|
||||
})
|
||||
expect(fetchCodexRateLimits).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
codexHomePath: '/tmp/approved-selection',
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
)
|
||||
expect(service.getState().codexTarget).toEqual({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
expect(service.getState().codex).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the reset result scoped when the active target changes during its refresh', async () => {
|
||||
const service = new RateLimitService()
|
||||
const idempotencyKey = '33333333-3333-4333-8333-333333333333'
|
||||
const hostRefresh = deferred<ProviderRateLimits>()
|
||||
service.setCodexHomePathResolver((target) =>
|
||||
target?.runtime === 'wsl' ? '/tmp/wsl-selection' : '/tmp/approved-selection'
|
||||
)
|
||||
vi.mocked(consumeCodexRateLimitResetCredit).mockResolvedValueOnce('reset')
|
||||
vi.mocked(fetchCodexRateLimits)
|
||||
.mockReturnValueOnce(hostRefresh.promise)
|
||||
.mockResolvedValueOnce(okProvider('codex', 73, Date.now()))
|
||||
|
||||
const pendingReset = service.consumeCodexRateLimitResetCredit({
|
||||
idempotencyKey,
|
||||
target: { runtime: 'host', wslDistro: null },
|
||||
codexHomePath: '/tmp/approved-selection'
|
||||
})
|
||||
await vi.waitFor(() => expect(fetchCodexRateLimits).toHaveBeenCalledOnce())
|
||||
|
||||
await service.refreshCodexForTarget({ runtime: 'wsl', wslDistro: 'Ubuntu' })
|
||||
hostRefresh.resolve(okProvider('codex', 0, Date.now()))
|
||||
|
||||
await expect(pendingReset).resolves.toMatchObject({
|
||||
outcome: 'reset',
|
||||
state: {
|
||||
codexTarget: { runtime: 'host', wslDistro: null },
|
||||
codex: { session: { usedPercent: 0 } }
|
||||
}
|
||||
})
|
||||
expect(service.getState()).toMatchObject({
|
||||
codexTarget: { runtime: 'wsl', wslDistro: 'Ubuntu' },
|
||||
codex: { session: { usedPercent: 73 } }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not let an older full refresh overwrite the post-reset Codex state', async () => {
|
||||
const service = new RateLimitService()
|
||||
const slowClaude = deferred<ProviderRateLimits>()
|
||||
service.setCodexHomePathResolver(() => '/tmp/approved-selection')
|
||||
vi.mocked(fetchClaudeRateLimits).mockReturnValueOnce(slowClaude.promise)
|
||||
vi.mocked(fetchCodexRateLimits)
|
||||
.mockResolvedValueOnce(okProvider('codex', 100, Date.now()))
|
||||
.mockResolvedValueOnce(okProvider('codex', 0, Date.now()))
|
||||
vi.mocked(consumeCodexRateLimitResetCredit).mockResolvedValueOnce('reset')
|
||||
|
||||
const olderRefresh = service.refresh()
|
||||
await vi.waitFor(() => expect(fetchCodexRateLimits).toHaveBeenCalledOnce())
|
||||
|
||||
await service.consumeCodexRateLimitResetCredit({
|
||||
idempotencyKey: '44444444-4444-4444-8444-444444444444',
|
||||
target: { runtime: 'host', wslDistro: null },
|
||||
codexHomePath: '/tmp/approved-selection'
|
||||
})
|
||||
expect(service.getState().codex?.session?.usedPercent).toBe(0)
|
||||
|
||||
slowClaude.resolve(okProvider('claude', 20, Date.now()))
|
||||
await olderRefresh
|
||||
|
||||
expect(service.getState().codex?.session?.usedPercent).toBe(0)
|
||||
})
|
||||
|
||||
it('uses the initialized WSL target for active Codex rate-limit fetches', async () => {
|
||||
const service = new RateLimitService()
|
||||
const wslCodexHome =
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/* eslint-disable max-lines -- Why: centralizes polling, stale-data handling, account-switch fetch semantics, and renderer push coordination in one place */
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type {
|
||||
CodexRateLimitResetResult,
|
||||
RateLimitState,
|
||||
ProviderRateLimits,
|
||||
InactiveAccountUsage
|
||||
InactiveAccountUsage,
|
||||
RateLimitRuntimeTarget
|
||||
} from '../../shared/rate-limit-types'
|
||||
import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher'
|
||||
import type { InactiveClaudeAccountInfo } from './claude-fetcher'
|
||||
@@ -415,25 +415,38 @@ export class RateLimitService {
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
async consumeCodexRateLimitResetCredit(): Promise<CodexRateLimitResetResult> {
|
||||
const codexTarget = this.codexFetchTarget
|
||||
const codexHomePath = this.codexHomePathResolver?.(codexTarget) ?? null
|
||||
async consumeCodexRateLimitResetCredit(options: {
|
||||
idempotencyKey: string
|
||||
target: RateLimitRuntimeTarget
|
||||
codexHomePath: string | null
|
||||
}): Promise<CodexRateLimitResetResult> {
|
||||
const codexTarget = normalizeCodexAccountSelectionTarget(options.target)
|
||||
const codexHomePath = options.codexHomePath
|
||||
const scopedStateBeforeReset = this.getState()
|
||||
const missingWslCodexHome = codexHomePath
|
||||
? null
|
||||
: this.getMissingWslCodexHomeResult(codexTarget)
|
||||
if (missingWslCodexHome) {
|
||||
await this.fetchCodexOnly({ force: true })
|
||||
if (this.isSameCodexTarget(this.codexFetchTarget, codexTarget)) {
|
||||
await this.fetchCodexOnly({ force: true })
|
||||
}
|
||||
throw new Error(missingWslCodexHome.error ?? 'Codex home unavailable')
|
||||
}
|
||||
try {
|
||||
const outcome = await consumeCodexRateLimitResetCredit({
|
||||
codexHomePath,
|
||||
idempotencyKey: randomUUID()
|
||||
idempotencyKey: options.idempotencyKey
|
||||
})
|
||||
await this.fetchCodexOnly({ force: true })
|
||||
return { outcome, state: this.getState() }
|
||||
const state = await this.fetchCodexResetResultState(
|
||||
codexTarget,
|
||||
codexHomePath,
|
||||
scopedStateBeforeReset
|
||||
)
|
||||
return { outcome, state }
|
||||
} catch (error) {
|
||||
await this.fetchCodexOnly({ force: true })
|
||||
if (this.isSameCodexTarget(this.codexFetchTarget, codexTarget)) {
|
||||
await this.fetchCodexOnly({ force: true })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1237,6 +1250,54 @@ export class RateLimitService {
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchCodexResetResultState(
|
||||
target: NormalizedCodexAccountSelectionTarget,
|
||||
codexHomePath: string | null,
|
||||
stateBeforeReset: RateLimitState
|
||||
): Promise<RateLimitState> {
|
||||
const controller = this.beginFetchCycle()
|
||||
let fresh: ProviderRateLimits
|
||||
try {
|
||||
fresh = await fetchCodexRateLimits({
|
||||
codexHomePath,
|
||||
allowPtyFallback: this.shouldAllowCodexPtyFallback(),
|
||||
signal: controller.signal
|
||||
})
|
||||
} catch (error) {
|
||||
fresh = {
|
||||
provider: 'codex',
|
||||
session: null,
|
||||
weekly: null,
|
||||
updatedAt: Date.now(),
|
||||
error: toErrorMessage(error),
|
||||
status: 'error'
|
||||
}
|
||||
} finally {
|
||||
this.finishFetchCycle(controller)
|
||||
}
|
||||
|
||||
const scopedCodex = this.applyStalePolicy(fresh, stateBeforeReset.codex)
|
||||
const currentHomePath = this.codexHomePathResolver?.(target) ?? null
|
||||
const stillActive =
|
||||
this.isSameCodexTarget(this.codexFetchTarget, target) &&
|
||||
this.getCodexProvenance(target, currentHomePath) ===
|
||||
this.getCodexProvenance(target, codexHomePath)
|
||||
if (stillActive) {
|
||||
// Why: this post-redemption read is newer than every Codex fetch that
|
||||
// started before it, so invalidate those results before publishing it.
|
||||
this.codexFetchGeneration += 1
|
||||
this.trackActiveFailureStreak('codex', fresh)
|
||||
this.updateState({
|
||||
...this.state,
|
||||
codex: this.applyStalePolicy(fresh, this.state.codex)
|
||||
})
|
||||
}
|
||||
|
||||
// Why: the caller must receive the redeemed target even if the global UI
|
||||
// switched targets while the provider mutation was in flight.
|
||||
return { ...stateBeforeReset, codex: scopedCodex, codexTarget: target }
|
||||
}
|
||||
|
||||
private shouldAllowCodexPtyFallback(): boolean {
|
||||
// Why: hidden PTY fallback can crash inside ConPTY on Windows; prefer RPC-only degradation there for background quota refresh.
|
||||
return process.platform !== 'win32'
|
||||
|
||||
@@ -8,6 +8,7 @@ const MOBILE_DYNAMIC_RPC_METHODS = [
|
||||
// mobile source scan below, but still must stay mobile-authorized.
|
||||
'accounts.selectClaude',
|
||||
'accounts.selectCodex',
|
||||
'accounts.selectCodexForTarget',
|
||||
'terminal.createAgentSession',
|
||||
'terminal.ensureAgentSession',
|
||||
'github.updateIssue',
|
||||
|
||||
@@ -1881,6 +1881,124 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(runtime.getStatus().capabilities).toContain('browser.screencast.v1')
|
||||
})
|
||||
|
||||
it('advertises safe Codex reset-credit RPC support as a static capability', () => {
|
||||
const runtime = createRuntime()
|
||||
|
||||
expect(runtime.getStatus().capabilities).toContain('accounts.codex-reset-credit.v1')
|
||||
})
|
||||
|
||||
it('routes mobile Codex reset consumption through the account mutation coordinator', async () => {
|
||||
const runtime = createRuntime()
|
||||
const expectedScope = {
|
||||
target: { runtime: 'host' as const, wslDistro: null },
|
||||
accountId: 'codex-account',
|
||||
accountRevision: 42,
|
||||
offerRevision: 'v1:offer'
|
||||
}
|
||||
const capturedCodex = {
|
||||
accounts: [],
|
||||
activeAccountId: expectedScope.accountId,
|
||||
activeAccountIdsByRuntime: { host: expectedScope.accountId, wsl: {} }
|
||||
}
|
||||
const capturedRateLimits = {
|
||||
codexTarget: expectedScope.target,
|
||||
marker: 'captured-before-queue-advanced'
|
||||
}
|
||||
const codexAccounts = {
|
||||
consumeRateLimitResetCredit: vi.fn().mockResolvedValue({
|
||||
outcome: 'reset',
|
||||
scope: expectedScope,
|
||||
codex: capturedCodex,
|
||||
rateLimits: capturedRateLimits
|
||||
}),
|
||||
listAccounts: vi.fn(() => ({
|
||||
accounts: [],
|
||||
activeAccountId: 'queued-next-account',
|
||||
activeAccountIdsByRuntime: { host: 'queued-next-account', wsl: {} }
|
||||
}))
|
||||
}
|
||||
const rateLimits = {
|
||||
consumeCodexRateLimitResetCredit: vi.fn(),
|
||||
getState: vi.fn(() => ({
|
||||
codexTarget: expectedScope.target,
|
||||
marker: 'after-queue-advanced'
|
||||
}))
|
||||
}
|
||||
runtime.setAccountServices({
|
||||
claudeAccounts: {
|
||||
listAccounts: vi.fn(() => ({ accounts: [], activeAccountId: null }))
|
||||
},
|
||||
codexAccounts,
|
||||
rateLimits
|
||||
} as never)
|
||||
|
||||
const result = await runtime.consumeCodexRateLimitResetCredit(
|
||||
'11111111-1111-4111-8111-111111111111',
|
||||
expectedScope
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'reset',
|
||||
scope: expectedScope,
|
||||
snapshot: { codex: capturedCodex, rateLimits: capturedRateLimits }
|
||||
})
|
||||
expect(codexAccounts.listAccounts).not.toHaveBeenCalled()
|
||||
expect(rateLimits.getState).not.toHaveBeenCalled()
|
||||
expect(codexAccounts.consumeRateLimitResetCredit).toHaveBeenCalledWith(
|
||||
'11111111-1111-4111-8111-111111111111',
|
||||
expectedScope
|
||||
)
|
||||
expect(rateLimits.consumeCodexRateLimitResetCredit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps a definite pre-provider rejection into an authoritative current snapshot', async () => {
|
||||
const runtime = createRuntime()
|
||||
const expectedScope = {
|
||||
target: { runtime: 'host' as const, wslDistro: null },
|
||||
accountId: 'codex-account',
|
||||
accountRevision: 42,
|
||||
offerRevision: 'v1:stale'
|
||||
}
|
||||
const codex = {
|
||||
accounts: [],
|
||||
activeAccountId: null,
|
||||
activeAccountIdsByRuntime: { host: null, wsl: {} }
|
||||
}
|
||||
const rateLimitState = {
|
||||
codexTarget: expectedScope.target,
|
||||
marker: 'current-after-rejection'
|
||||
}
|
||||
runtime.setAccountServices({
|
||||
claudeAccounts: {
|
||||
listAccounts: vi.fn(() => ({ accounts: [], activeAccountId: null }))
|
||||
},
|
||||
codexAccounts: {
|
||||
consumeRateLimitResetCredit: vi.fn().mockResolvedValue({
|
||||
status: 'rejectedBeforeProvider',
|
||||
retryDisposition: 'discardAttempt',
|
||||
reason: 'offerChanged',
|
||||
scope: expectedScope,
|
||||
codex,
|
||||
rateLimits: rateLimitState
|
||||
})
|
||||
},
|
||||
rateLimits: {}
|
||||
} as never)
|
||||
|
||||
await expect(
|
||||
runtime.consumeCodexRateLimitResetCredit(
|
||||
'11111111-1111-4111-8111-111111111111',
|
||||
expectedScope
|
||||
)
|
||||
).resolves.toMatchObject({
|
||||
status: 'rejectedBeforeProvider',
|
||||
retryDisposition: 'discardAttempt',
|
||||
reason: 'offerChanged',
|
||||
scope: expectedScope,
|
||||
snapshot: { codex, rateLimits: rateLimitState }
|
||||
})
|
||||
})
|
||||
|
||||
it('advertises headless browser capability when an offscreen backend backs a windowless host', () => {
|
||||
const runtime = createRuntime()
|
||||
runtime.setOffscreenBrowserBackend({ createTab: vi.fn(), closeTab: vi.fn() })
|
||||
@@ -1892,7 +2010,6 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(capabilities).toContain('browser.headless.v1')
|
||||
expect(capabilities).toContain('browser.certificate-trust.v1')
|
||||
})
|
||||
|
||||
it('surfaces live offscreen load failures in headless browser snapshots', () => {
|
||||
const runtime = createRuntime()
|
||||
runtime.setOffscreenBrowserBackend({ createTab: vi.fn(), closeTab: vi.fn() })
|
||||
|
||||
@@ -892,11 +892,16 @@ import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
|
||||
import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment'
|
||||
import { githubAvatarIcon } from '../../shared/repo-icon'
|
||||
import type { ClaudeAccountService } from '../claude-accounts/service'
|
||||
import type { CodexAccountService } from '../codex-accounts/service'
|
||||
import type {
|
||||
CodexAccountService,
|
||||
CodexResetCreditRejectedBeforeProviderReason
|
||||
} from '../codex-accounts/service'
|
||||
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
|
||||
import type { RateLimitService } from '../rate-limits/service'
|
||||
import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/types'
|
||||
import { applyPRBotAuthorOverride } from '../../shared/pr-bot-author-overrides'
|
||||
import type { RateLimitState } from '../../shared/rate-limit-types'
|
||||
import type { CodexRateLimitResetOutcome, RateLimitState } from '../../shared/rate-limit-types'
|
||||
import type { CodexResetCreditExpectedScope } from '../../shared/codex-reset-credit-scope'
|
||||
import type { VoiceSettings } from '../../shared/speech-types'
|
||||
import { getSpeechModelManager, getSpeechSttService } from '../speech/speech-runtime-service'
|
||||
import { getCatalogModel, isLocalSpeechModel, SPEECH_MODEL_CATALOG } from '../speech/model-catalog'
|
||||
@@ -938,6 +943,18 @@ export type AccountsSnapshot = {
|
||||
rateLimits: RateLimitState
|
||||
}
|
||||
|
||||
export type CodexRateLimitResetRpcResult = {
|
||||
scope: CodexResetCreditExpectedScope
|
||||
snapshot: AccountsSnapshot
|
||||
} & (
|
||||
| { outcome: CodexRateLimitResetOutcome }
|
||||
| {
|
||||
status: 'rejectedBeforeProvider'
|
||||
retryDisposition: 'discardAttempt'
|
||||
reason: CodexResetCreditRejectedBeforeProviderReason
|
||||
}
|
||||
)
|
||||
|
||||
type RuntimeStore = {
|
||||
getRepos: Store['getRepos']
|
||||
getRepo: Store['getRepo']
|
||||
@@ -10346,6 +10363,42 @@ export class OrcaRuntimeService {
|
||||
return this.requireAccountServices().codexAccounts.selectAccount(accountId)
|
||||
}
|
||||
|
||||
selectCodexAccountForTarget(
|
||||
accountId: string | null,
|
||||
target: CodexAccountSelectionTarget
|
||||
): Promise<CodexRateLimitAccountsState> {
|
||||
return this.requireAccountServices().codexAccounts.selectAccountForTarget(accountId, target)
|
||||
}
|
||||
|
||||
async consumeCodexRateLimitResetCredit(
|
||||
idempotencyKey: string,
|
||||
expectedScope: CodexResetCreditExpectedScope
|
||||
): Promise<CodexRateLimitResetRpcResult> {
|
||||
const { claudeAccounts, codexAccounts } = this.requireAccountServices()
|
||||
const result = await codexAccounts.consumeRateLimitResetCredit(idempotencyKey, expectedScope)
|
||||
// Why: Codex selection and usage were captured before its mutation queue
|
||||
// advanced. Re-reading them here could pair scope A with queued selection B.
|
||||
const snapshot = {
|
||||
claude: claudeAccounts.listAccounts(),
|
||||
codex: result.codex,
|
||||
rateLimits: result.rateLimits
|
||||
}
|
||||
if ('status' in result) {
|
||||
return {
|
||||
status: result.status,
|
||||
retryDisposition: result.retryDisposition,
|
||||
reason: result.reason,
|
||||
scope: result.scope,
|
||||
snapshot
|
||||
}
|
||||
}
|
||||
return {
|
||||
outcome: result.outcome,
|
||||
scope: result.scope,
|
||||
snapshot
|
||||
}
|
||||
}
|
||||
|
||||
removeClaudeAccount(accountId: string): Promise<ClaudeRateLimitAccountsState> {
|
||||
return this.requireAccountServices().claudeAccounts.removeAccount(accountId)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,96 @@ describe('account RPC methods', () => {
|
||||
expect(runtime.refreshAccountsForMobile).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('forwards a client idempotency key when consuming a Codex reset credit', async () => {
|
||||
const idempotencyKey = '11111111-1111-4111-8111-111111111111'
|
||||
const expectedScope = {
|
||||
target: { runtime: 'host' as const, wslDistro: null },
|
||||
accountId: 'codex-account',
|
||||
accountRevision: 42,
|
||||
offerRevision: 'v1:offer'
|
||||
}
|
||||
const result = {
|
||||
outcome: 'reset',
|
||||
scope: expectedScope,
|
||||
snapshot: { claude: null, codex: null }
|
||||
}
|
||||
const consumeCodexRateLimitResetCredit = vi.fn().mockResolvedValue(result)
|
||||
const runtime = { consumeCodexRateLimitResetCredit } as unknown as OrcaRuntimeService
|
||||
const reset = method('accounts.consumeCodexResetCredit')
|
||||
if (isStreamingMethod(reset)) {
|
||||
throw new Error('accounts.consumeCodexResetCredit must be a request method')
|
||||
}
|
||||
|
||||
expect(reset.params?.parse({ idempotencyKey, expectedScope })).toEqual({
|
||||
idempotencyKey,
|
||||
expectedScope
|
||||
})
|
||||
expect(() => reset.params?.parse({ idempotencyKey: 'not-a-uuid', expectedScope })).toThrow()
|
||||
expect(() =>
|
||||
reset.params?.parse({
|
||||
idempotencyKey,
|
||||
expectedScope: {
|
||||
...expectedScope,
|
||||
target: { runtime: 'host', wslDistro: 'Ubuntu' }
|
||||
}
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
reset.params?.parse({
|
||||
idempotencyKey,
|
||||
expectedScope: {
|
||||
...expectedScope,
|
||||
target: { runtime: 'wsl', wslDistro: null }
|
||||
}
|
||||
})
|
||||
).toThrow()
|
||||
expect(() => reset.params?.parse({ idempotencyKey, expectedScope, extra: true })).toThrow()
|
||||
await expect(reset.handler({ idempotencyKey, expectedScope }, { runtime })).resolves.toBe(
|
||||
result
|
||||
)
|
||||
expect(consumeCodexRateLimitResetCredit).toHaveBeenCalledWith(idempotencyKey, expectedScope)
|
||||
})
|
||||
|
||||
it('forwards the exact WSL target when selecting a Codex account', async () => {
|
||||
const selectCodexAccountForTarget = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ accounts: [], activeAccountId: null })
|
||||
const runtime = { selectCodexAccountForTarget } as unknown as OrcaRuntimeService
|
||||
const select = method('accounts.selectCodexForTarget')
|
||||
if (isStreamingMethod(select)) {
|
||||
throw new Error('accounts.selectCodexForTarget must be a request method')
|
||||
}
|
||||
const params = {
|
||||
accountId: null,
|
||||
target: { runtime: 'wsl' as const, wslDistro: 'Ubuntu' }
|
||||
}
|
||||
|
||||
expect(select.params?.parse(params)).toEqual(params)
|
||||
expect(
|
||||
select.params?.parse({
|
||||
accountId: null,
|
||||
target: { runtime: 'wsl', wslDistro: null }
|
||||
})
|
||||
).toEqual({ accountId: null, target: { runtime: 'wsl', wslDistro: null } })
|
||||
expect(() =>
|
||||
select.params?.parse({
|
||||
accountId: null,
|
||||
target: { runtime: 'host', wslDistro: 'Ubuntu' }
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
select.params?.parse({
|
||||
accountId: null,
|
||||
target: { runtime: 'wsl', wslDistro: ' ' }
|
||||
})
|
||||
).toThrow()
|
||||
await expect(select.handler(params, { runtime })).resolves.toEqual({
|
||||
accounts: [],
|
||||
activeAccountId: null
|
||||
})
|
||||
expect(selectCodexAccountForTarget).toHaveBeenCalledWith(null, params.target)
|
||||
})
|
||||
|
||||
it('uses a stale-aware refresh when a connection replays the subscription', async () => {
|
||||
const snapshot = { claude: null, codex: null }
|
||||
let cleanup: (() => void) | undefined
|
||||
|
||||
@@ -7,16 +7,55 @@ import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
|
||||
// registerSubscriptionCleanup's existing-key eviction path.
|
||||
let accountsSubscriptionSeq = 0
|
||||
|
||||
const CodexResetTarget = z.discriminatedUnion('runtime', [
|
||||
z.object({ runtime: z.literal('host'), wslDistro: z.null() }).strict(),
|
||||
// Why: reset scope must identify one exact WSL distro; null means all slots only for selection.
|
||||
z.object({ runtime: z.literal('wsl'), wslDistro: z.string().trim().min(1).max(255) }).strict()
|
||||
])
|
||||
|
||||
const CodexSelectionTarget = z.discriminatedUnion('runtime', [
|
||||
z.object({ runtime: z.literal('host'), wslDistro: z.null() }).strict(),
|
||||
z
|
||||
.object({
|
||||
runtime: z.literal('wsl'),
|
||||
// A null distro intentionally means all WSL selection slots.
|
||||
wslDistro: z.string().trim().min(1).max(255).nullable()
|
||||
})
|
||||
.strict()
|
||||
])
|
||||
|
||||
const SelectAccountParams = z.object({
|
||||
accountId: z
|
||||
.union([z.string().min(1, 'Missing accountId'), z.null()])
|
||||
.transform((v) => (v === null ? null : v))
|
||||
})
|
||||
|
||||
const SelectCodexAccountForTargetParams = SelectAccountParams.extend({
|
||||
target: CodexSelectionTarget
|
||||
})
|
||||
|
||||
const RemoveAccountParams = z.object({
|
||||
accountId: z.string().min(1, 'Missing accountId')
|
||||
})
|
||||
|
||||
const CodexResetExpectedScope = z
|
||||
.object({
|
||||
target: CodexResetTarget,
|
||||
accountId: z.string().min(1, 'Missing accountId').max(512),
|
||||
accountRevision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
||||
offerRevision: z.string().startsWith('v1:', 'Invalid offerRevision').max(4_096)
|
||||
})
|
||||
.strict()
|
||||
|
||||
const ConsumeCodexResetCreditParams = z
|
||||
.object({
|
||||
// Why: the phone owns the logical attempt key so a lost response can be
|
||||
// retried without spending a finite earned credit twice.
|
||||
idempotencyKey: z.uuid('Invalid idempotencyKey'),
|
||||
expectedScope: CodexResetExpectedScope
|
||||
})
|
||||
.strict()
|
||||
|
||||
const AccountsUnsubscribeParams = z.object({
|
||||
subscriptionId: z
|
||||
.unknown()
|
||||
@@ -52,6 +91,20 @@ export const ACCOUNT_METHODS: readonly RpcAnyMethod[] = [
|
||||
params: SelectAccountParams,
|
||||
handler: async (params, { runtime }) => runtime.selectCodexAccount(params.accountId)
|
||||
}),
|
||||
defineMethod({
|
||||
// Why: old hosts silently strip unknown target fields from selectCodex.
|
||||
// A distinct RPC makes version skew fail before it can clear the host slot.
|
||||
name: 'accounts.selectCodexForTarget',
|
||||
params: SelectCodexAccountForTargetParams,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.selectCodexAccountForTarget(params.accountId, params.target)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'accounts.consumeCodexResetCredit',
|
||||
params: ConsumeCodexResetCreditParams,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.consumeCodexRateLimitResetCredit(params.idempotencyKey, params.expectedScope)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'accounts.removeClaude',
|
||||
params: RemoveAccountParams,
|
||||
|
||||
@@ -1408,6 +1408,17 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
const pushRuntimeGit = vi.fn().mockResolvedValue({ ok: true })
|
||||
const selectClaudeAccount = vi.fn().mockResolvedValue({ ok: true })
|
||||
const selectCodexAccount = vi.fn().mockResolvedValue({ ok: true })
|
||||
const expectedCodexResetScope = {
|
||||
target: { runtime: 'host' as const, wslDistro: null },
|
||||
accountId: 'codex-account',
|
||||
accountRevision: 42,
|
||||
offerRevision: 'v1:offer'
|
||||
}
|
||||
const consumeCodexRateLimitResetCredit = vi.fn().mockResolvedValue({
|
||||
outcome: 'reset',
|
||||
scope: expectedCodexResetScope,
|
||||
snapshot: { claude: null, codex: null }
|
||||
})
|
||||
const removeClaudeAccount = vi.fn().mockResolvedValue({ ok: true })
|
||||
const readTerminal = vi.fn().mockResolvedValue({ tail: ['ok'] })
|
||||
const getRuntimeGitStatus = vi
|
||||
@@ -1496,6 +1507,7 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
pushRuntimeGit,
|
||||
selectClaudeAccount,
|
||||
selectCodexAccount,
|
||||
consumeCodexRateLimitResetCredit,
|
||||
removeClaudeAccount,
|
||||
readTerminal,
|
||||
getRuntimeGitStatus,
|
||||
@@ -2129,6 +2141,19 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_consume_codex_reset',
|
||||
method: 'accounts.consumeCodexResetCredit',
|
||||
deviceToken: mobile.token,
|
||||
params: {
|
||||
idempotencyKey: '11111111-1111-4111-8111-111111111111',
|
||||
expectedScope: expectedCodexResetScope
|
||||
}
|
||||
}),
|
||||
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
|
||||
() => {}
|
||||
)
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req_remove_claude',
|
||||
@@ -2334,6 +2359,9 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
)
|
||||
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_select_claude', ok: true }))
|
||||
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_select_codex', ok: true }))
|
||||
expect(replies).toContainEqual(
|
||||
expect.objectContaining({ id: 'req_consume_codex_reset', ok: true })
|
||||
)
|
||||
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_terminal_read', ok: true }))
|
||||
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_files_open_diff', ok: true }))
|
||||
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_diff', ok: true }))
|
||||
@@ -2365,6 +2393,10 @@ describe('OrcaRuntimeRpcServer', () => {
|
||||
)
|
||||
expect(selectClaudeAccount).toHaveBeenCalledWith('claude-account')
|
||||
expect(selectCodexAccount).toHaveBeenCalledWith(null)
|
||||
expect(consumeCodexRateLimitResetCredit).toHaveBeenCalledWith(
|
||||
'11111111-1111-4111-8111-111111111111',
|
||||
expectedCodexResetScope
|
||||
)
|
||||
expect(readTerminal).toHaveBeenCalledWith('term-1', { cursor: undefined })
|
||||
expect(getRuntimeGitStatus).toHaveBeenCalledWith('id:wt-1')
|
||||
expect(pushRuntimeGit).toHaveBeenCalledWith('id:wt-1', true, undefined, undefined)
|
||||
|
||||
@@ -134,8 +134,10 @@ function webClientPathForEndpoint(pathname: string): string {
|
||||
|
||||
const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
||||
'accounts.list',
|
||||
'accounts.consumeCodexResetCredit',
|
||||
'accounts.selectClaude',
|
||||
'accounts.selectCodex',
|
||||
'accounts.selectCodexForTarget',
|
||||
'accounts.subscribe',
|
||||
'accounts.unsubscribe',
|
||||
'aiVault.listSessions',
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
EMPTY_CODEX_RESET_CREDIT_ATTEMPT_LEDGER,
|
||||
parseCodexResetCreditAttemptLedger
|
||||
} from './codex-reset-credit-attempt-ledger'
|
||||
|
||||
const hostScope = {
|
||||
target: { runtime: 'host' as const, wslDistro: null },
|
||||
accountId: 'account-host',
|
||||
accountRevision: 42,
|
||||
offerRevision: 'v1:offer-host'
|
||||
}
|
||||
|
||||
describe('Codex reset credit attempt ledger', () => {
|
||||
it('strictly accepts pending and settled durable attempts', () => {
|
||||
expect(
|
||||
parseCodexResetCreditAttemptLedger({
|
||||
version: 1,
|
||||
attempts: [
|
||||
{
|
||||
idempotencyKey: '11111111-1111-4111-8111-111111111111',
|
||||
expectedScope: hostScope,
|
||||
state: 'providerPending'
|
||||
},
|
||||
{
|
||||
idempotencyKey: '22222222-2222-4222-8222-222222222222',
|
||||
expectedScope: {
|
||||
...hostScope,
|
||||
offerRevision: 'v1:offer-settled'
|
||||
},
|
||||
state: 'settled',
|
||||
outcome: 'alreadyRedeemed'
|
||||
}
|
||||
]
|
||||
})
|
||||
).toMatchObject({ version: 1, attempts: [{ state: 'providerPending' }, { state: 'settled' }] })
|
||||
expect(parseCodexResetCreditAttemptLedger(undefined)).toEqual(
|
||||
EMPTY_CODEX_RESET_CREDIT_ATTEMPT_LEDGER
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'unknown field',
|
||||
value: { version: 1, attempts: [], extra: true }
|
||||
},
|
||||
{
|
||||
name: 'duplicate idempotency key',
|
||||
value: {
|
||||
version: 1,
|
||||
attempts: [
|
||||
{
|
||||
idempotencyKey: '11111111-1111-4111-8111-111111111111',
|
||||
expectedScope: hostScope,
|
||||
state: 'providerPending'
|
||||
},
|
||||
{
|
||||
idempotencyKey: '11111111-1111-4111-8111-111111111111',
|
||||
expectedScope: { ...hostScope, offerRevision: 'v1:other' },
|
||||
state: 'settled',
|
||||
outcome: 'reset'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'duplicate claimed offer',
|
||||
value: {
|
||||
version: 1,
|
||||
attempts: [
|
||||
{
|
||||
idempotencyKey: '11111111-1111-4111-8111-111111111111',
|
||||
expectedScope: hostScope,
|
||||
state: 'settled',
|
||||
outcome: 'reset'
|
||||
},
|
||||
{
|
||||
idempotencyKey: '22222222-2222-4222-8222-222222222222',
|
||||
expectedScope: hostScope,
|
||||
state: 'settled',
|
||||
outcome: 'alreadyRedeemed'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'duplicate pending account scope',
|
||||
value: {
|
||||
version: 1,
|
||||
attempts: [
|
||||
{
|
||||
idempotencyKey: '11111111-1111-4111-8111-111111111111',
|
||||
expectedScope: hostScope,
|
||||
state: 'providerPending'
|
||||
},
|
||||
{
|
||||
idempotencyKey: '22222222-2222-4222-8222-222222222222',
|
||||
expectedScope: { ...hostScope, offerRevision: 'v1:other' },
|
||||
state: 'providerPending'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'invalid WSL target',
|
||||
value: {
|
||||
version: 1,
|
||||
attempts: [
|
||||
{
|
||||
idempotencyKey: '11111111-1111-4111-8111-111111111111',
|
||||
expectedScope: {
|
||||
...hostScope,
|
||||
target: { runtime: 'wsl', wslDistro: null }
|
||||
},
|
||||
state: 'providerPending'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
])('rejects $name as a corrupt ledger', ({ value }) => {
|
||||
expect(() => parseCodexResetCreditAttemptLedger(value)).toThrow(
|
||||
'Codex reset-credit attempt ledger is corrupt'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { z } from 'zod'
|
||||
import type { CodexRateLimitResetOutcome } from './rate-limit-types'
|
||||
import type { CodexResetCreditExpectedScope } from './codex-reset-credit-scope'
|
||||
|
||||
const CodexResetCreditTargetSchema = z.discriminatedUnion('runtime', [
|
||||
z.object({ runtime: z.literal('host'), wslDistro: z.null() }).strict(),
|
||||
z.object({ runtime: z.literal('wsl'), wslDistro: z.string().trim().min(1).max(255) }).strict()
|
||||
])
|
||||
|
||||
const CodexResetCreditExpectedScopeSchema = z
|
||||
.object({
|
||||
target: CodexResetCreditTargetSchema,
|
||||
accountId: z.string().min(1).max(512),
|
||||
accountRevision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
||||
offerRevision: z.string().startsWith('v1:').max(4_096)
|
||||
})
|
||||
.strict()
|
||||
|
||||
const DurableCodexResetCreditAttemptSchema = z.discriminatedUnion('state', [
|
||||
z
|
||||
.object({
|
||||
idempotencyKey: z.uuid(),
|
||||
expectedScope: CodexResetCreditExpectedScopeSchema,
|
||||
state: z.literal('providerPending')
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
idempotencyKey: z.uuid(),
|
||||
expectedScope: CodexResetCreditExpectedScopeSchema,
|
||||
state: z.literal('settled'),
|
||||
outcome: z.enum(['reset', 'nothingToReset', 'noCredit', 'alreadyRedeemed'])
|
||||
})
|
||||
.strict()
|
||||
])
|
||||
|
||||
const CodexResetCreditAttemptLedgerSchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
attempts: z.array(DurableCodexResetCreditAttemptSchema).max(10_000)
|
||||
})
|
||||
.strict()
|
||||
.superRefine((ledger, context) => {
|
||||
const keys = new Set<string>()
|
||||
const offers = new Set<string>()
|
||||
const pendingAccountScopes = new Set<string>()
|
||||
for (const [index, attempt] of ledger.attempts.entries()) {
|
||||
const offerScope = JSON.stringify([
|
||||
attempt.expectedScope.target.runtime,
|
||||
attempt.expectedScope.target.wslDistro,
|
||||
attempt.expectedScope.accountId,
|
||||
attempt.expectedScope.accountRevision,
|
||||
attempt.expectedScope.offerRevision
|
||||
])
|
||||
const accountScope = JSON.stringify([
|
||||
attempt.expectedScope.target.runtime,
|
||||
attempt.expectedScope.target.wslDistro,
|
||||
attempt.expectedScope.accountId,
|
||||
attempt.expectedScope.accountRevision
|
||||
])
|
||||
if (keys.has(attempt.idempotencyKey)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Duplicate idempotency key',
|
||||
path: ['attempts', index, 'idempotencyKey']
|
||||
})
|
||||
}
|
||||
if (offers.has(offerScope)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Duplicate claimed offer',
|
||||
path: ['attempts', index, 'expectedScope']
|
||||
})
|
||||
}
|
||||
if (attempt.state === 'providerPending' && pendingAccountScopes.has(accountScope)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Duplicate pending account scope',
|
||||
path: ['attempts', index, 'expectedScope']
|
||||
})
|
||||
}
|
||||
keys.add(attempt.idempotencyKey)
|
||||
offers.add(offerScope)
|
||||
if (attempt.state === 'providerPending') {
|
||||
pendingAccountScopes.add(accountScope)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export type DurableCodexResetCreditAttempt =
|
||||
| {
|
||||
idempotencyKey: string
|
||||
expectedScope: CodexResetCreditExpectedScope
|
||||
state: 'providerPending'
|
||||
}
|
||||
| {
|
||||
idempotencyKey: string
|
||||
expectedScope: CodexResetCreditExpectedScope
|
||||
state: 'settled'
|
||||
outcome: CodexRateLimitResetOutcome
|
||||
}
|
||||
|
||||
export type CodexResetCreditAttemptLedger = {
|
||||
version: 1
|
||||
attempts: DurableCodexResetCreditAttempt[]
|
||||
}
|
||||
|
||||
export const EMPTY_CODEX_RESET_CREDIT_ATTEMPT_LEDGER: CodexResetCreditAttemptLedger = {
|
||||
version: 1,
|
||||
attempts: []
|
||||
}
|
||||
|
||||
export function parseCodexResetCreditAttemptLedger(value: unknown): CodexResetCreditAttemptLedger {
|
||||
if (value === undefined) {
|
||||
return { version: 1, attempts: [] }
|
||||
}
|
||||
const parsed = CodexResetCreditAttemptLedgerSchema.safeParse(value)
|
||||
if (!parsed.success) {
|
||||
throw new Error('Codex reset-credit attempt ledger is corrupt')
|
||||
}
|
||||
return structuredClone(parsed.data) as CodexResetCreditAttemptLedger
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ProviderRateLimits } from './rate-limit-types'
|
||||
import type { CodexManagedAccountSummary } from './types'
|
||||
import { buildCodexResetCreditExpectedScope } from './codex-reset-credit-scope'
|
||||
|
||||
const account: CodexManagedAccountSummary = {
|
||||
id: 'account-host',
|
||||
email: 'dev@example.com',
|
||||
managedHomeRuntime: 'host',
|
||||
wslDistro: null,
|
||||
createdAt: 10,
|
||||
updatedAt: 20,
|
||||
lastAuthenticatedAt: 20
|
||||
}
|
||||
|
||||
const limits: ProviderRateLimits = {
|
||||
provider: 'codex',
|
||||
session: {
|
||||
usedPercent: 100,
|
||||
windowMinutes: 300,
|
||||
resetsAt: 1_000,
|
||||
resetDescription: 'soon'
|
||||
},
|
||||
weekly: null,
|
||||
rateLimitResetCredits: {
|
||||
availableCount: 1,
|
||||
totalEarnedCount: 2,
|
||||
nextExpiresAt: 2_000,
|
||||
credits: [{ status: 'available', expiresAt: 2_000, grantedAt: 500 }]
|
||||
},
|
||||
updatedAt: 30,
|
||||
error: null,
|
||||
status: 'ok'
|
||||
}
|
||||
|
||||
describe('buildCodexResetCreditExpectedScope', () => {
|
||||
it('builds a deterministic exact host scope', () => {
|
||||
const first = buildCodexResetCreditExpectedScope({
|
||||
target: { runtime: 'host', wslDistro: null },
|
||||
account,
|
||||
limits
|
||||
})
|
||||
const second = buildCodexResetCreditExpectedScope({
|
||||
target: { runtime: 'host', wslDistro: null },
|
||||
account,
|
||||
limits: {
|
||||
...limits,
|
||||
rateLimitResetCredits: {
|
||||
...limits.rateLimitResetCredits!,
|
||||
credits: [
|
||||
{ status: 'available', expiresAt: 3_000, grantedAt: 600 },
|
||||
...limits.rateLimitResetCredits!.credits!
|
||||
].toReversed()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const firstWithBothRows = buildCodexResetCreditExpectedScope({
|
||||
target: { runtime: 'host', wslDistro: null },
|
||||
account,
|
||||
limits: {
|
||||
...limits,
|
||||
rateLimitResetCredits: {
|
||||
...limits.rateLimitResetCredits!,
|
||||
credits: [
|
||||
...limits.rateLimitResetCredits!.credits!,
|
||||
{ status: 'available', expiresAt: 3_000, grantedAt: 600 }
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(firstWithBothRows).toEqual(second)
|
||||
expect(first).toMatchObject({
|
||||
target: { runtime: 'host', wslDistro: null },
|
||||
accountId: account.id,
|
||||
accountRevision: account.updatedAt,
|
||||
offerRevision: expect.stringMatching(/^v1:/)
|
||||
})
|
||||
})
|
||||
|
||||
it('changes the offer revision when fetched credit or window data changes', () => {
|
||||
const original = buildCodexResetCreditExpectedScope({
|
||||
target: { runtime: 'host', wslDistro: null },
|
||||
account,
|
||||
limits
|
||||
})
|
||||
const refreshed = buildCodexResetCreditExpectedScope({
|
||||
target: { runtime: 'host', wslDistro: null },
|
||||
account,
|
||||
limits: { ...limits, updatedAt: limits.updatedAt + 1 }
|
||||
})
|
||||
|
||||
expect(refreshed?.offerRevision).not.toBe(original?.offerRevision)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'system default account',
|
||||
target: { runtime: 'host', wslDistro: null } as const,
|
||||
candidate: null,
|
||||
candidateLimits: limits
|
||||
},
|
||||
{
|
||||
name: 'no available credit',
|
||||
target: { runtime: 'host', wslDistro: null } as const,
|
||||
candidate: account,
|
||||
candidateLimits: {
|
||||
...limits,
|
||||
rateLimitResetCredits: { ...limits.rateLimitResetCredits!, availableCount: 0 }
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'unknown WSL distro',
|
||||
target: { runtime: 'wsl', wslDistro: null } as const,
|
||||
candidate: { ...account, managedHomeRuntime: 'wsl' as const, wslDistro: 'Ubuntu' },
|
||||
candidateLimits: limits
|
||||
},
|
||||
{
|
||||
name: 'runtime mismatch',
|
||||
target: { runtime: 'wsl', wslDistro: 'Ubuntu' } as const,
|
||||
candidate: account,
|
||||
candidateLimits: limits
|
||||
},
|
||||
{
|
||||
name: 'WSL distro mismatch',
|
||||
target: { runtime: 'wsl', wslDistro: 'Ubuntu' } as const,
|
||||
candidate: { ...account, managedHomeRuntime: 'wsl' as const, wslDistro: 'Debian' },
|
||||
candidateLimits: limits
|
||||
}
|
||||
])('fails closed for $name', ({ target, candidate, candidateLimits }) => {
|
||||
expect(
|
||||
buildCodexResetCreditExpectedScope({
|
||||
target,
|
||||
account: candidate,
|
||||
limits: candidateLimits
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import type {
|
||||
ProviderRateLimits,
|
||||
RateLimitRuntimeTarget,
|
||||
RateLimitWindow
|
||||
} from './rate-limit-types'
|
||||
import type { CodexManagedAccountSummary } from './types'
|
||||
|
||||
export type CodexResetCreditExpectedScope = {
|
||||
target: RateLimitRuntimeTarget
|
||||
accountId: string
|
||||
accountRevision: number
|
||||
offerRevision: string
|
||||
}
|
||||
|
||||
type BuildCodexResetCreditExpectedScopeOptions = {
|
||||
target: RateLimitRuntimeTarget
|
||||
account: Pick<
|
||||
CodexManagedAccountSummary,
|
||||
'id' | 'managedHomeRuntime' | 'wslDistro' | 'updatedAt'
|
||||
> | null
|
||||
limits: ProviderRateLimits | null
|
||||
}
|
||||
|
||||
function windowRevision(window: RateLimitWindow | null): readonly unknown[] | null {
|
||||
if (!window) {
|
||||
return null
|
||||
}
|
||||
return [window.usedPercent, window.windowMinutes, window.resetsAt]
|
||||
}
|
||||
|
||||
function buildOfferRevision(limits: ProviderRateLimits): string {
|
||||
const credits = limits.rateLimitResetCredits
|
||||
const creditRows = [...(credits?.credits ?? [])]
|
||||
.map((credit) => [credit.status, credit.expiresAt, credit.grantedAt] as const)
|
||||
.sort((left, right) => {
|
||||
const leftKey = JSON.stringify(left)
|
||||
const rightKey = JSON.stringify(right)
|
||||
return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0
|
||||
})
|
||||
|
||||
// Why: clients treat this as an opaque compare-and-swap token. Including the
|
||||
// fetched-at revision makes a refresh invalidate a confirmation based on old quota data.
|
||||
return `v1:${JSON.stringify([
|
||||
credits?.availableCount ?? 0,
|
||||
credits?.totalEarnedCount ?? null,
|
||||
credits?.nextExpiresAt ?? null,
|
||||
creditRows,
|
||||
windowRevision(limits.session),
|
||||
windowRevision(limits.weekly),
|
||||
limits.updatedAt
|
||||
])}`
|
||||
}
|
||||
|
||||
export function buildCodexResetCreditExpectedScope({
|
||||
target,
|
||||
account,
|
||||
limits
|
||||
}: BuildCodexResetCreditExpectedScopeOptions): CodexResetCreditExpectedScope | null {
|
||||
if (!account || limits?.provider !== 'codex') {
|
||||
return null
|
||||
}
|
||||
if ((limits.rateLimitResetCredits?.availableCount ?? 0) <= 0) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
!account.id.trim() ||
|
||||
account.id.length > 512 ||
|
||||
!Number.isSafeInteger(account.updatedAt) ||
|
||||
account.updatedAt < 0 ||
|
||||
!Number.isSafeInteger(limits.updatedAt) ||
|
||||
limits.updatedAt < 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const accountRuntime = account.managedHomeRuntime ?? 'host'
|
||||
if (target.runtime === 'host') {
|
||||
if (target.wslDistro !== null || accountRuntime !== 'host') {
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
const targetDistro = target.wslDistro?.trim()
|
||||
const accountDistro = account.wslDistro?.trim()
|
||||
if (
|
||||
!targetDistro ||
|
||||
targetDistro.length > 255 ||
|
||||
accountRuntime !== 'wsl' ||
|
||||
accountDistro !== targetDistro
|
||||
) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const offerRevision = buildOfferRevision(limits)
|
||||
if (offerRevision.length > 4_096) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
target: {
|
||||
runtime: target.runtime,
|
||||
wslDistro: target.runtime === 'wsl' ? target.wslDistro!.trim() : null
|
||||
},
|
||||
accountId: account.id,
|
||||
accountRevision: account.updatedAt,
|
||||
offerRevision
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
WorkspaceSessionState,
|
||||
AgentActivityDisplayMode
|
||||
} from './types'
|
||||
import { EMPTY_CODEX_RESET_CREDIT_ATTEMPT_LEDGER } from './codex-reset-credit-attempt-ledger'
|
||||
import { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults'
|
||||
import { DEFAULT_TERMINAL_FONT_WEIGHT } from './terminal-fonts'
|
||||
import { getDefaultTerminalQuickCommands } from './terminal-quick-commands'
|
||||
@@ -425,7 +426,8 @@ export function getDefaultPersistedState(homedir: string): PersistedState {
|
||||
automations: [],
|
||||
automationRuns: [],
|
||||
onboarding: getDefaultOnboardingState(),
|
||||
featureInteractionTelemetryBuckets: {}
|
||||
featureInteractionTelemetryBuckets: {},
|
||||
codexResetCreditAttemptLedger: structuredClone(EMPTY_CODEX_RESET_CREDIT_ATTEMPT_LEDGER)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ export const TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY = 'terminal.quick-comman
|
||||
// replay ambiguous cutovers when the host advertises idempotent create support.
|
||||
export const WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY =
|
||||
'worktree.create-idempotency.v1' as const
|
||||
export const CODEX_RESET_CREDIT_RUNTIME_CAPABILITY = 'accounts.codex-reset-credit.v1' as const
|
||||
// Why: older hosts cannot reconcile terminal.create's mutation after losing the reply, so clients may only retry unknown outcomes when advertised.
|
||||
export const TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY =
|
||||
'terminal.create-idempotency.v2' as const
|
||||
@@ -88,7 +89,8 @@ export const RUNTIME_CAPABILITIES = [
|
||||
REMOTE_SERVER_UPDATE_CAPABILITY,
|
||||
AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY,
|
||||
FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY
|
||||
FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY,
|
||||
CODEX_RESET_CREDIT_RUNTIME_CAPABILITY
|
||||
] as const
|
||||
|
||||
export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {})
|
||||
|
||||
@@ -43,6 +43,7 @@ import type {
|
||||
import type { UsagePercentageDisplay } from './usage-percentage-display'
|
||||
import type { StatusBarUsageMode } from './status-bar-usage-mode'
|
||||
import type { PersistedNativeChatSessionOptions } from './native-chat-session-options'
|
||||
import type { CodexResetCreditAttemptLedger } from './codex-reset-credit-attempt-ledger'
|
||||
|
||||
// Re-exported for backward compat with renderer call sites that import
|
||||
// `WorkspaceCreateTelemetrySource` from '../../../shared/types'.
|
||||
@@ -3486,6 +3487,8 @@ export type PersistedState = {
|
||||
onboarding: OnboardingState
|
||||
/** Main-owned telemetry de-dupe marker; never exposed through PersistedUIState. */
|
||||
featureInteractionTelemetryBuckets?: FeatureInteractionTelemetryBucketState
|
||||
/** Main-owned reset mutation journal. Never expose this through renderer settings APIs. */
|
||||
codexResetCreditAttemptLedger?: CodexResetCreditAttemptLedger
|
||||
}
|
||||
|
||||
// ─── Filesystem ─────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user