@cortexkit/opencode-openai-auth 0.11.1 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -26
- package/dist/index-766sfrh5.js +1 -0
- package/dist/index-8kj7sa1p.js +12 -0
- package/dist/index-b3q3j39x.js +2 -0
- package/dist/index-bmd7x5aq.js +2 -0
- package/dist/index-eqh53k8v.js +2 -0
- package/dist/{index-sdt2d2ra.js → index-hy9030jh.js} +1 -1
- package/dist/index-jyr8y8k4.js +7 -0
- package/dist/index-rv08jmd4.js +2 -0
- package/dist/index-ww91hyzr.js +221 -0
- package/dist/index.d.ts +578 -145
- package/dist/index.js +1 -110
- package/dist/pool-lifecycle-DbDmgabd.d.ts +2430 -0
- package/dist/port-file-Do3lQIEk.d.ts +29 -0
- package/dist/protocol-BOBdkqqm.d.ts +198 -0
- package/dist/rpc/port-file.d.ts +4 -17
- package/dist/rpc/port-file.js +1 -1
- package/dist/rpc/protocol.d.ts +2 -2
- package/dist/rpc/protocol.js +1 -0
- package/dist/rpc/rpc-client.d.ts +5 -4
- package/dist/rpc/rpc-client.js +1 -1
- package/dist/rpc/rpc-dir.d.ts +2 -0
- package/dist/rpc/rpc-dir.js +1 -1
- package/dist/sidebar-state-D6cUKgkm.d.ts +385 -0
- package/dist/sidebar-state.d.ts +2 -2
- package/dist/sidebar-state.js +1 -1
- package/dist/token-util-wdza526f.js +1 -0
- package/dist/{token-9823y0pc.js → token-ypk61xj0.js} +1 -1
- package/dist/tui-preferences.js +5 -2
- package/dist/tui.d.ts +9 -4
- package/dist/v2/server.d.ts +54 -0
- package/dist/v2/server.js +4 -0
- package/package.json +21 -9
- package/src/tui/entry.mjs +26 -25
- package/src/tui-compiled/raw/logger.js +24 -0
- package/src/tui-compiled/raw/rpc/protocol.js +3 -0
- package/src/tui-compiled/raw/rpc/rpc-client.js +7 -0
- package/src/tui-compiled/raw/rpc/rpc-dir.js +27 -0
- package/src/tui-compiled/raw/selector.js +21 -0
- package/src/tui-compiled/raw/shared/0332701351b3eeec-format.js +138 -0
- package/src/tui-compiled/raw/shared/04a6357627de5ed0-errors.js +38 -0
- package/src/tui-compiled/raw/shared/053cc17b5419261e-codec.js +12 -0
- package/src/tui-compiled/raw/shared/0766d78c6f481f18-watcher.js +157 -0
- package/src/tui-compiled/raw/shared/08eef45626a94992-reserved.js +13 -0
- package/src/tui-compiled/raw/shared/39c3cb05738fcf6d-projection.js +134 -0
- package/src/tui-compiled/raw/shared/3c2d4169d00f907e-tui-support.js +5 -0
- package/src/tui-compiled/raw/shared/3e7d45a77c6ad4d5-index.js +4 -0
- package/src/tui-compiled/raw/shared/415792e85d80819e-capture-sink.js +13 -0
- package/src/tui-compiled/raw/shared/42ae93ab554fcec2-open-url.js +28 -0
- package/src/tui-compiled/raw/shared/43fc9e6b994519c7-refresh-lock.js +136 -0
- package/src/tui-compiled/raw/shared/458b934e92490331-mutate.js +500 -0
- package/src/tui-compiled/raw/shared/45bf5343bbbf9add-admission.js +142 -0
- package/src/tui-compiled/raw/shared/46f3dff1266160e8-client.js +2 -0
- package/src/tui-compiled/raw/shared/4825e03209d6ae3e-with-lock.js +48 -0
- package/src/tui-compiled/raw/shared/4f36d333d081d040-merge.js +162 -0
- package/src/tui-compiled/raw/shared/54eba94fd64c8c3c-index.js +7 -0
- package/src/tui-compiled/raw/shared/7211cfec06a298bc-atomic-write.js +80 -0
- package/src/tui-compiled/raw/shared/754d6017f8e419cf-builtins.js +534 -0
- package/src/tui-compiled/raw/shared/75bd8c3bd63b315f-identity.js +79 -0
- package/src/tui-compiled/raw/shared/7a91bb630e6f4804-index.js +1 -0
- package/src/tui-compiled/raw/shared/826342a4b6a8b89b-model.js +16 -0
- package/src/tui-compiled/raw/shared/83068c0dbe8a6f13-publication.js +304 -0
- package/src/tui-compiled/raw/shared/836fc615de6afbd9-settings.js +120 -0
- package/src/tui-compiled/raw/shared/83bc3ba9d0fe2761-runtime.js +68 -0
- package/src/tui-compiled/raw/shared/8a18abe4fa2977c8-menu.js +300 -0
- package/src/tui-compiled/raw/shared/901e149c5a0dbbff-sticky.js +339 -0
- package/src/tui-compiled/raw/shared/9020c407ce64f672-ordered.js +58 -0
- package/src/tui-compiled/raw/shared/905bffc0220f43d0-map.js +105 -0
- package/src/tui-compiled/raw/shared/94820e324f2b4c09-seam.js +301 -0
- package/src/tui-compiled/raw/shared/97484d9bb98c705b-provider-state.js +226 -0
- package/src/tui-compiled/raw/shared/9968e3cade6969a1-index.js +2 -0
- package/src/tui-compiled/raw/shared/9b4da614c45f9cd0-lock-constants.js +14 -0
- package/src/tui-compiled/raw/shared/9d3fc9e37456504e-refresh.js +225 -0
- package/src/tui-compiled/raw/shared/9deae3c4da4fd63b-hooks.js +34 -0
- package/src/tui-compiled/raw/shared/a09b6a533c11858f-tui-preferences.js +77 -0
- package/src/tui-compiled/raw/shared/a62a27a204266acd-attribution.js +60 -0
- package/src/tui-compiled/raw/shared/a910277ac43a8626-pull.js +143 -0
- package/src/tui-compiled/raw/shared/b85c5301fddcb98a-paths.js +5 -0
- package/src/tui-compiled/raw/shared/c14988621dc60a75-port-file.js +217 -0
- package/src/tui-compiled/raw/shared/c64d7afbc14ca125-sidebar-file.js +157 -0
- package/src/tui-compiled/raw/shared/c6b176708c446d6d-pins.js +34 -0
- package/src/tui-compiled/raw/shared/c7d252bedee01600-pi.js +206 -0
- package/src/tui-compiled/raw/shared/c852a15cfe489d12-pool.js +120 -0
- package/src/tui-compiled/raw/shared/cc9e0702a752cea7-index.js +3 -0
- package/src/tui-compiled/raw/shared/cd94316a888b0a8e-schema.js +754 -0
- package/src/tui-compiled/raw/shared/d319517e3ea6ad70-redact.js +83 -0
- package/src/tui-compiled/raw/shared/d5a8f1061ee2f114-index.js +5 -0
- package/src/tui-compiled/raw/shared/d669d72c40e9e116-logger.js +19 -0
- package/src/tui-compiled/raw/shared/d7dcba96fea1685c-engine.js +246 -0
- package/src/tui-compiled/raw/shared/dbef697d1df1cd84-index.js +5 -0
- package/src/tui-compiled/raw/shared/e4acc4cb713f300c-protocol.js +3 -0
- package/src/tui-compiled/raw/shared/e8cd4929507508d8-refresh-file-lock.js +501 -0
- package/src/tui-compiled/raw/shared/ec971142548df90e-index.js +6 -0
- package/src/tui-compiled/raw/shared/f37fcefc7aedbf25-error.js +11 -0
- package/src/tui-compiled/raw/shared/fbcdcd0f925e74c0-rpc-client.js +253 -0
- package/src/tui-compiled/raw/shared/fd45e82f2f9c0a0d-torn.js +294 -0
- package/src/tui-compiled/raw/shared/fe02abeadc535f77-rows.js +1258 -0
- package/src/tui-compiled/raw/sidebar-state.js +960 -0
- package/src/tui-compiled/raw/tui/command-dialogs.tsx +424 -0
- package/src/tui-compiled/raw/tui-preferences.js +135 -0
- package/src/tui-compiled/raw/tui.tsx +135 -143
- package/src/tui-compiled/runtime/logger.js +24 -0
- package/src/tui-compiled/runtime/rpc/protocol.js +3 -0
- package/src/tui-compiled/runtime/rpc/rpc-client.js +7 -0
- package/src/tui-compiled/runtime/rpc/rpc-dir.js +27 -0
- package/src/tui-compiled/runtime/selector.js +21 -0
- package/src/tui-compiled/runtime/shared/0332701351b3eeec-format.js +138 -0
- package/src/tui-compiled/runtime/shared/04a6357627de5ed0-errors.js +38 -0
- package/src/tui-compiled/runtime/shared/053cc17b5419261e-codec.js +12 -0
- package/src/tui-compiled/runtime/shared/0766d78c6f481f18-watcher.js +157 -0
- package/src/tui-compiled/runtime/shared/08eef45626a94992-reserved.js +13 -0
- package/src/tui-compiled/runtime/shared/39c3cb05738fcf6d-projection.js +134 -0
- package/src/tui-compiled/runtime/shared/3c2d4169d00f907e-tui-support.js +5 -0
- package/src/tui-compiled/runtime/shared/3e7d45a77c6ad4d5-index.js +4 -0
- package/src/tui-compiled/runtime/shared/415792e85d80819e-capture-sink.js +13 -0
- package/src/tui-compiled/runtime/shared/42ae93ab554fcec2-open-url.js +28 -0
- package/src/tui-compiled/runtime/shared/43fc9e6b994519c7-refresh-lock.js +136 -0
- package/src/tui-compiled/runtime/shared/458b934e92490331-mutate.js +500 -0
- package/src/tui-compiled/runtime/shared/45bf5343bbbf9add-admission.js +142 -0
- package/src/tui-compiled/runtime/shared/46f3dff1266160e8-client.js +2 -0
- package/src/tui-compiled/runtime/shared/4825e03209d6ae3e-with-lock.js +48 -0
- package/src/tui-compiled/runtime/shared/4f36d333d081d040-merge.js +162 -0
- package/src/tui-compiled/runtime/shared/54eba94fd64c8c3c-index.js +7 -0
- package/src/tui-compiled/runtime/shared/7211cfec06a298bc-atomic-write.js +80 -0
- package/src/tui-compiled/runtime/shared/754d6017f8e419cf-builtins.js +534 -0
- package/src/tui-compiled/runtime/shared/75bd8c3bd63b315f-identity.js +79 -0
- package/src/tui-compiled/runtime/shared/7a91bb630e6f4804-index.js +1 -0
- package/src/tui-compiled/runtime/shared/826342a4b6a8b89b-model.js +16 -0
- package/src/tui-compiled/runtime/shared/83068c0dbe8a6f13-publication.js +304 -0
- package/src/tui-compiled/runtime/shared/836fc615de6afbd9-settings.js +120 -0
- package/src/tui-compiled/runtime/shared/83bc3ba9d0fe2761-runtime.js +68 -0
- package/src/tui-compiled/runtime/shared/8a18abe4fa2977c8-menu.js +300 -0
- package/src/tui-compiled/runtime/shared/901e149c5a0dbbff-sticky.js +339 -0
- package/src/tui-compiled/runtime/shared/9020c407ce64f672-ordered.js +58 -0
- package/src/tui-compiled/runtime/shared/905bffc0220f43d0-map.js +105 -0
- package/src/tui-compiled/runtime/shared/94820e324f2b4c09-seam.js +301 -0
- package/src/tui-compiled/runtime/shared/97484d9bb98c705b-provider-state.js +226 -0
- package/src/tui-compiled/runtime/shared/9968e3cade6969a1-index.js +2 -0
- package/src/tui-compiled/runtime/shared/9b4da614c45f9cd0-lock-constants.js +14 -0
- package/src/tui-compiled/runtime/shared/9d3fc9e37456504e-refresh.js +225 -0
- package/src/tui-compiled/runtime/shared/9deae3c4da4fd63b-hooks.js +34 -0
- package/src/tui-compiled/runtime/shared/a09b6a533c11858f-tui-preferences.js +77 -0
- package/src/tui-compiled/runtime/shared/a62a27a204266acd-attribution.js +60 -0
- package/src/tui-compiled/runtime/shared/a910277ac43a8626-pull.js +143 -0
- package/src/tui-compiled/runtime/shared/b85c5301fddcb98a-paths.js +5 -0
- package/src/tui-compiled/runtime/shared/c14988621dc60a75-port-file.js +217 -0
- package/src/tui-compiled/runtime/shared/c64d7afbc14ca125-sidebar-file.js +157 -0
- package/src/tui-compiled/runtime/shared/c6b176708c446d6d-pins.js +34 -0
- package/src/tui-compiled/runtime/shared/c7d252bedee01600-pi.js +206 -0
- package/src/tui-compiled/runtime/shared/c852a15cfe489d12-pool.js +120 -0
- package/src/tui-compiled/runtime/shared/cc9e0702a752cea7-index.js +3 -0
- package/src/tui-compiled/runtime/shared/cd94316a888b0a8e-schema.js +754 -0
- package/src/tui-compiled/runtime/shared/d319517e3ea6ad70-redact.js +83 -0
- package/src/tui-compiled/runtime/shared/d5a8f1061ee2f114-index.js +5 -0
- package/src/tui-compiled/runtime/shared/d669d72c40e9e116-logger.js +19 -0
- package/src/tui-compiled/runtime/shared/d7dcba96fea1685c-engine.js +246 -0
- package/src/tui-compiled/runtime/shared/dbef697d1df1cd84-index.js +5 -0
- package/src/tui-compiled/runtime/shared/e4acc4cb713f300c-protocol.js +3 -0
- package/src/tui-compiled/runtime/shared/e8cd4929507508d8-refresh-file-lock.js +501 -0
- package/src/tui-compiled/runtime/shared/ec971142548df90e-index.js +6 -0
- package/src/tui-compiled/runtime/shared/f37fcefc7aedbf25-error.js +11 -0
- package/src/tui-compiled/runtime/shared/fbcdcd0f925e74c0-rpc-client.js +253 -0
- package/src/tui-compiled/runtime/shared/fd45e82f2f9c0a0d-torn.js +294 -0
- package/src/tui-compiled/runtime/shared/fe02abeadc535f77-rows.js +1258 -0
- package/src/tui-compiled/runtime/sidebar-state.js +960 -0
- package/src/tui-compiled/runtime/tui/command-dialogs.js +378 -0
- package/src/tui-compiled/runtime/tui-preferences.js +135 -0
- package/src/tui-compiled/runtime/{tui.tsx → tui.js} +288 -316
- package/dist/index-4effjmr6.js +0 -2
- package/dist/index-9ehhb051.js +0 -2
- package/dist/index-g3hsdjh4.js +0 -2
- package/dist/index-hh52v8sb.js +0 -2
- package/dist/index-jwn4r3sw.js +0 -1
- package/dist/index-sq36b120.js +0 -211
- package/dist/internal-BTndPmW2.d.ts +0 -914
- package/dist/quota-normalize-08hpp51v.js +0 -1
- package/dist/sidebar-state-CoKYKcLn.d.ts +0 -220
- package/dist/token-util-ypgz7rge.js +0 -1
- package/src/tui-compiled/raw/core-account-paths.ts +0 -114
- package/src/tui-compiled/raw/core-custody-state.ts +0 -243
- package/src/tui-compiled/raw/logger.ts +0 -53
- package/src/tui-compiled/raw/rpc-port-file.ts +0 -177
- package/src/tui-compiled/raw/rpc-protocol.ts +0 -12
- package/src/tui-compiled/raw/rpc-rpc-client.ts +0 -82
- package/src/tui-compiled/raw/rpc-rpc-dir.ts +0 -46
- package/src/tui-compiled/raw/sidebar-state.ts +0 -1659
- package/src/tui-compiled/raw/tui-command-dialogs.tsx +0 -1096
- package/src/tui-compiled/raw/tui-preferences.ts +0 -328
- package/src/tui-compiled/runtime/core-account-paths.ts +0 -114
- package/src/tui-compiled/runtime/core-custody-state.ts +0 -243
- package/src/tui-compiled/runtime/logger.ts +0 -53
- package/src/tui-compiled/runtime/rpc-port-file.ts +0 -177
- package/src/tui-compiled/runtime/rpc-protocol.ts +0 -12
- package/src/tui-compiled/runtime/rpc-rpc-client.ts +0 -82
- package/src/tui-compiled/runtime/rpc-rpc-dir.ts +0 -46
- package/src/tui-compiled/runtime/sidebar-state.ts +0 -1659
- package/src/tui-compiled/runtime/tui-command-dialogs.tsx +0 -855
- package/src/tui-compiled/runtime/tui-preferences.ts +0 -328
- package/src/tui-compiled/shared/internal.ts +0 -6
- package/src/tui-compiled/shared/logger.ts +0 -264
- package/src/tui-compiled/shared/paths.ts +0 -33
- package/src/tui-compiled/shared/refresh-file-lock.ts +0 -384
- package/src/tui-compiled/shared/util/error.ts +0 -9
- package/src/tui-compiled/shared/util/open-url.ts +0 -30
|
@@ -1,1659 +0,0 @@
|
|
|
1
|
-
export interface QuotaWindow {
|
|
2
|
-
usedPercent: number
|
|
3
|
-
remainingPercent: number
|
|
4
|
-
checkedAt?: number
|
|
5
|
-
resetsAt?: string
|
|
6
|
-
windowMinutes?: number
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export interface SpendControlReading {
|
|
10
|
-
limit: number
|
|
11
|
-
used: number
|
|
12
|
-
remaining: number
|
|
13
|
-
usedPercent: number
|
|
14
|
-
remainingPercent: number
|
|
15
|
-
resetsAt?: string
|
|
16
|
-
unit?: string
|
|
17
|
-
source?: string
|
|
18
|
-
reached: boolean
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface AccountQuota {
|
|
22
|
-
checkedAt?: number
|
|
23
|
-
primary?: QuotaWindow
|
|
24
|
-
secondary?: QuotaWindow
|
|
25
|
-
spendControl?: SpendControlReading
|
|
26
|
-
// The source reported that no credit budget exists; see OAuthQuotaSnapshot.
|
|
27
|
-
spendControlCleared?: true
|
|
28
|
-
resetCreditsAvailable?: number
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export type QuotaWindowKey = 'primary' | 'secondary'
|
|
32
|
-
|
|
33
|
-
const QUOTA_WINDOW_KEYS: readonly QuotaWindowKey[] = ['primary', 'secondary']
|
|
34
|
-
const LEGACY_WINDOW_MINUTES: Record<QuotaWindowKey, number> = {
|
|
35
|
-
primary: 300,
|
|
36
|
-
secondary: 10_080,
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function compactUnit(value: number): string {
|
|
40
|
-
return Number.isInteger(value)
|
|
41
|
-
? String(value)
|
|
42
|
-
: String(Math.round(value * 10) / 10)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Derives a short human label ("5h", "1d", "7d") from a window length in
|
|
46
|
-
// minutes. Snapshots written before dynamic windows carry no length, so retain
|
|
47
|
-
// their historical primary=5h and secondary=7d meanings.
|
|
48
|
-
export function formatWindowLabel(
|
|
49
|
-
windowMinutes: number | undefined,
|
|
50
|
-
fallbackKey: QuotaWindowKey,
|
|
51
|
-
): string {
|
|
52
|
-
const minutes =
|
|
53
|
-
windowMinutes !== undefined &&
|
|
54
|
-
Number.isFinite(windowMinutes) &&
|
|
55
|
-
windowMinutes > 0
|
|
56
|
-
? windowMinutes
|
|
57
|
-
: LEGACY_WINDOW_MINUTES[fallbackKey]
|
|
58
|
-
if (minutes < 60) return `${compactUnit(minutes)}m`
|
|
59
|
-
if (minutes < 1_440) return `${compactUnit(minutes / 60)}h`
|
|
60
|
-
return `${compactUnit(minutes / 1_440)}d`
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export interface PresentQuotaWindow {
|
|
64
|
-
key: QuotaWindowKey
|
|
65
|
-
label: string
|
|
66
|
-
window: QuotaWindow
|
|
67
|
-
windowMs: number | null
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Present windows only — an absent slot means "not applicable", not
|
|
71
|
-
// "unknown", so it must never synthesize a placeholder row here.
|
|
72
|
-
export function getPresentQuotaWindows(
|
|
73
|
-
quota: AccountQuota | null,
|
|
74
|
-
): PresentQuotaWindow[] {
|
|
75
|
-
if (!quota) return []
|
|
76
|
-
const rows: PresentQuotaWindow[] = []
|
|
77
|
-
for (const key of QUOTA_WINDOW_KEYS) {
|
|
78
|
-
const window = quota[key]
|
|
79
|
-
if (!window) continue
|
|
80
|
-
const configuredMinutes = window.windowMinutes
|
|
81
|
-
const windowMinutes =
|
|
82
|
-
configuredMinutes !== undefined &&
|
|
83
|
-
Number.isFinite(configuredMinutes) &&
|
|
84
|
-
configuredMinutes > 0
|
|
85
|
-
? configuredMinutes
|
|
86
|
-
: LEGACY_WINDOW_MINUTES[key]
|
|
87
|
-
rows.push({
|
|
88
|
-
key,
|
|
89
|
-
label: formatWindowLabel(windowMinutes, key),
|
|
90
|
-
window,
|
|
91
|
-
windowMs: windowMinutes * 60_000,
|
|
92
|
-
})
|
|
93
|
-
}
|
|
94
|
-
return rows
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export interface SidebarAccountState {
|
|
98
|
-
id: string
|
|
99
|
-
label: string | undefined
|
|
100
|
-
/** ChatGPT identity of the account this quota belongs to. */
|
|
101
|
-
accountId?: string
|
|
102
|
-
quota: AccountQuota | null
|
|
103
|
-
killed: boolean
|
|
104
|
-
enabled: boolean
|
|
105
|
-
resetCredits?: number
|
|
106
|
-
custody?: SidebarAccountCustody
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export type SidebarCustodyState = 'vault' | 'needsLogin' | 'local' | 'inert'
|
|
110
|
-
|
|
111
|
-
export type SidebarCustodyReason = CustodyInertReason | 'corrupt'
|
|
112
|
-
|
|
113
|
-
export interface SidebarAccountCustody {
|
|
114
|
-
state: SidebarCustodyState
|
|
115
|
-
reason?: SidebarCustodyReason
|
|
116
|
-
recordVersion?: number
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export interface ActiveRoutingEntry {
|
|
120
|
-
activeId: string
|
|
121
|
-
route: string
|
|
122
|
-
updatedAt: number
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export type ActiveRoutingMap = Record<string, ActiveRoutingEntry>
|
|
126
|
-
|
|
127
|
-
export interface StickyAssignment {
|
|
128
|
-
accountId: string
|
|
129
|
-
wireAccountId?: string
|
|
130
|
-
assignedAt: number
|
|
131
|
-
lastSeenAt: number
|
|
132
|
-
inputBytes: number
|
|
133
|
-
quotaCheckedAt?: number
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export type StickyAssignmentMap = Record<string, StickyAssignment>
|
|
137
|
-
|
|
138
|
-
export interface StickyAssignmentChoice {
|
|
139
|
-
accountId: string
|
|
140
|
-
quotaCheckedAt?: number
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
export interface ResolveStickyAssignmentInput {
|
|
144
|
-
sessionId: string
|
|
145
|
-
requestBytes: number
|
|
146
|
-
now: number
|
|
147
|
-
validPinnedAccountIds: readonly string[]
|
|
148
|
-
excludeAccountIds?: readonly string[]
|
|
149
|
-
quotaCheckedAtByAccount: Readonly<Record<string, number | undefined>>
|
|
150
|
-
wireAccountIdByAccount?: Readonly<Record<string, string | undefined>>
|
|
151
|
-
choose: (
|
|
152
|
-
pendingBytes: ReadonlyMap<string, number>,
|
|
153
|
-
) => StickyAssignmentChoice | undefined
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export interface SidebarState {
|
|
157
|
-
main: {
|
|
158
|
-
quota: AccountQuota | null
|
|
159
|
-
/** ChatGPT identity of the main account this quota belongs to. */
|
|
160
|
-
mainAccountId?: string
|
|
161
|
-
custody?: SidebarAccountCustody
|
|
162
|
-
killed: boolean
|
|
163
|
-
quotaBackedOff?: boolean
|
|
164
|
-
quotaBackoffUntil?: number
|
|
165
|
-
refreshBackedOff?: boolean
|
|
166
|
-
refreshBackoffUntil?: number
|
|
167
|
-
resetCredits?: number
|
|
168
|
-
}
|
|
169
|
-
fallbacks: SidebarAccountState[]
|
|
170
|
-
/** @deprecated Compatibility field for readers that do not consume activeRouting. */
|
|
171
|
-
activeId: string | undefined
|
|
172
|
-
/** Machine-global routing mode and compatibility value for older readers. */
|
|
173
|
-
route: string
|
|
174
|
-
activeRouting?: ActiveRoutingMap
|
|
175
|
-
stickyAssignments?: StickyAssignmentMap
|
|
176
|
-
planType?: string
|
|
177
|
-
credits?: number
|
|
178
|
-
lastUpdated: number
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
import { createHash, randomUUID } from 'node:crypto'
|
|
182
|
-
import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
183
|
-
import { tmpdir } from 'node:os'
|
|
184
|
-
import { dirname, join } from 'node:path'
|
|
185
|
-
import { acquireRefreshFileLock } from '../shared/internal.ts'
|
|
186
|
-
import {
|
|
187
|
-
CUSTODY_INERT_REASONS,
|
|
188
|
-
type CustodyInertReason,
|
|
189
|
-
type CustodyVerdict,
|
|
190
|
-
} from './core-custody-state.ts'
|
|
191
|
-
import { createLogger } from './logger.ts'
|
|
192
|
-
|
|
193
|
-
const logSb = createLogger('sidebar')
|
|
194
|
-
|
|
195
|
-
const STATE_FILE_ENV = 'OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE'
|
|
196
|
-
const DEFAULT_STATE_DIR = join(tmpdir(), 'opencode-openai-auth')
|
|
197
|
-
const DEFAULT_STATE_FILE = join(DEFAULT_STATE_DIR, 'sidebar-state.json')
|
|
198
|
-
const SESSION_HASH_PATTERN = /^[a-f0-9]{64}$/
|
|
199
|
-
export const STICKY_ASSIGNMENT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
|
200
|
-
export const STICKY_ASSIGNMENT_MAX_ENTRIES = 256
|
|
201
|
-
const STICKY_ASSIGNMENT_LAST_SEEN_TOUCH_MS = 60 * 60 * 1000
|
|
202
|
-
|
|
203
|
-
export function hashSidebarSessionId(sessionId: string): string {
|
|
204
|
-
return createHash('sha256').update(sessionId).digest('hex')
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function normalizeResetCredits(value: unknown): number | undefined {
|
|
208
|
-
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
209
|
-
? value
|
|
210
|
-
: undefined
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function resetCreditsField(value: unknown): { resetCredits?: number } {
|
|
214
|
-
const credits = normalizeResetCredits(value)
|
|
215
|
-
return credits !== undefined ? { resetCredits: credits } : {}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const CUSTODY_STATES = new Set<SidebarCustodyState>([
|
|
219
|
-
'vault',
|
|
220
|
-
'needsLogin',
|
|
221
|
-
'local',
|
|
222
|
-
'inert',
|
|
223
|
-
])
|
|
224
|
-
|
|
225
|
-
const CUSTODY_REASONS = new Set<SidebarCustodyReason>([
|
|
226
|
-
...CUSTODY_INERT_REASONS,
|
|
227
|
-
'corrupt',
|
|
228
|
-
])
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* Tolerant reader for the per-fallback `custody` projection. Unknown state
|
|
232
|
-
* or reason values are dropped (NOT replaced with a default — a stale state
|
|
233
|
-
* file with an experimental `vaultHealing` value must not silently render
|
|
234
|
-
* as `local`, it must render as no-projection-at-all). Valid values round-
|
|
235
|
-
* trip byte-identical. The output contains only `state` and `reason`.
|
|
236
|
-
*/
|
|
237
|
-
function normalizeSidebarCustody(
|
|
238
|
-
value: unknown,
|
|
239
|
-
): SidebarAccountCustody | undefined {
|
|
240
|
-
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
241
|
-
return undefined
|
|
242
|
-
}
|
|
243
|
-
const c = value as Record<string, unknown>
|
|
244
|
-
if (
|
|
245
|
-
typeof c.state !== 'string' ||
|
|
246
|
-
!CUSTODY_STATES.has(c.state as SidebarCustodyState)
|
|
247
|
-
) {
|
|
248
|
-
return undefined
|
|
249
|
-
}
|
|
250
|
-
const state = c.state as SidebarCustodyState
|
|
251
|
-
const out: SidebarAccountCustody = { state }
|
|
252
|
-
if (
|
|
253
|
-
typeof c.reason === 'string' &&
|
|
254
|
-
CUSTODY_REASONS.has(c.reason as SidebarCustodyReason)
|
|
255
|
-
) {
|
|
256
|
-
out.reason = c.reason as SidebarCustodyReason
|
|
257
|
-
}
|
|
258
|
-
return out
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function normalizeActiveRouting(value: unknown): ActiveRoutingMap | undefined {
|
|
262
|
-
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
263
|
-
return undefined
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
const normalized: ActiveRoutingMap = {}
|
|
267
|
-
for (const [sessionId, rawEntry] of Object.entries(value)) {
|
|
268
|
-
if (
|
|
269
|
-
rawEntry === null ||
|
|
270
|
-
typeof rawEntry !== 'object' ||
|
|
271
|
-
Array.isArray(rawEntry)
|
|
272
|
-
) {
|
|
273
|
-
continue
|
|
274
|
-
}
|
|
275
|
-
const entry = rawEntry as Record<string, unknown>
|
|
276
|
-
if (
|
|
277
|
-
typeof entry.activeId !== 'string' ||
|
|
278
|
-
typeof entry.route !== 'string' ||
|
|
279
|
-
typeof entry.updatedAt !== 'number' ||
|
|
280
|
-
!Number.isFinite(entry.updatedAt)
|
|
281
|
-
) {
|
|
282
|
-
continue
|
|
283
|
-
}
|
|
284
|
-
normalized[sessionId] = {
|
|
285
|
-
activeId: entry.activeId,
|
|
286
|
-
route: entry.route,
|
|
287
|
-
updatedAt: entry.updatedAt,
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
return Object.keys(normalized).length > 0 ? normalized : undefined
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function normalizeStickyAssignments(
|
|
295
|
-
value: unknown,
|
|
296
|
-
): StickyAssignmentMap | undefined {
|
|
297
|
-
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
298
|
-
return undefined
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
const normalized: StickyAssignmentMap = {}
|
|
302
|
-
for (const [sessionHash, rawAssignment] of Object.entries(value)) {
|
|
303
|
-
if (
|
|
304
|
-
!SESSION_HASH_PATTERN.test(sessionHash) ||
|
|
305
|
-
rawAssignment === null ||
|
|
306
|
-
typeof rawAssignment !== 'object' ||
|
|
307
|
-
Array.isArray(rawAssignment)
|
|
308
|
-
) {
|
|
309
|
-
continue
|
|
310
|
-
}
|
|
311
|
-
const assignment = rawAssignment as Record<string, unknown>
|
|
312
|
-
if (
|
|
313
|
-
typeof assignment.accountId !== 'string' ||
|
|
314
|
-
assignment.accountId.length === 0 ||
|
|
315
|
-
typeof assignment.assignedAt !== 'number' ||
|
|
316
|
-
!Number.isFinite(assignment.assignedAt) ||
|
|
317
|
-
assignment.assignedAt < 0 ||
|
|
318
|
-
typeof assignment.lastSeenAt !== 'number' ||
|
|
319
|
-
!Number.isFinite(assignment.lastSeenAt) ||
|
|
320
|
-
assignment.lastSeenAt < 0 ||
|
|
321
|
-
typeof assignment.inputBytes !== 'number' ||
|
|
322
|
-
!Number.isFinite(assignment.inputBytes) ||
|
|
323
|
-
assignment.inputBytes < 0
|
|
324
|
-
) {
|
|
325
|
-
continue
|
|
326
|
-
}
|
|
327
|
-
const quotaCheckedAt = assignment.quotaCheckedAt
|
|
328
|
-
if (
|
|
329
|
-
quotaCheckedAt !== undefined &&
|
|
330
|
-
(typeof quotaCheckedAt !== 'number' ||
|
|
331
|
-
!Number.isFinite(quotaCheckedAt) ||
|
|
332
|
-
quotaCheckedAt < 0)
|
|
333
|
-
) {
|
|
334
|
-
continue
|
|
335
|
-
}
|
|
336
|
-
const wireAccountId = assignment.wireAccountId
|
|
337
|
-
normalized[sessionHash] = {
|
|
338
|
-
accountId: assignment.accountId,
|
|
339
|
-
assignedAt: assignment.assignedAt,
|
|
340
|
-
lastSeenAt: assignment.lastSeenAt,
|
|
341
|
-
inputBytes: assignment.inputBytes,
|
|
342
|
-
...(quotaCheckedAt === undefined ? {} : { quotaCheckedAt }),
|
|
343
|
-
...(typeof wireAccountId === 'string' ? { wireAccountId } : {}),
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
return Object.keys(normalized).length > 0 ? normalized : undefined
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
export function getSidebarStateFile(): string {
|
|
351
|
-
return process.env[STATE_FILE_ENV] || DEFAULT_STATE_FILE
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
export function projectCustodyForSidebar(
|
|
355
|
-
verdict: CustodyVerdict,
|
|
356
|
-
): SidebarAccountCustody {
|
|
357
|
-
switch (verdict.kind) {
|
|
358
|
-
case 'LOCAL':
|
|
359
|
-
return { state: 'local' }
|
|
360
|
-
case 'VAULT':
|
|
361
|
-
return { state: 'vault' }
|
|
362
|
-
case 'INERT':
|
|
363
|
-
return { state: 'inert', reason: verdict.reason }
|
|
364
|
-
case 'NEEDS_LOGIN':
|
|
365
|
-
return verdict.reason === 'corrupt'
|
|
366
|
-
? { state: 'needsLogin', reason: 'corrupt' }
|
|
367
|
-
: { state: 'needsLogin' }
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
export const DEFAULT_SIDEBAR_STATE: SidebarState = {
|
|
372
|
-
main: { quota: null, killed: false },
|
|
373
|
-
fallbacks: [],
|
|
374
|
-
activeId: undefined,
|
|
375
|
-
route: 'main',
|
|
376
|
-
lastUpdated: 0,
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/**
|
|
380
|
-
* Normalize an arbitrary parsed value into a well-formed SidebarState.
|
|
381
|
-
*
|
|
382
|
-
* JSON.parse + `as SidebarState` is an unchecked cast — a partial, old, or
|
|
383
|
-
* malformed state file passes through and the TUI's `state().main.quota` /
|
|
384
|
-
* `state().fallbacks.filter(...)` throw at runtime. This helper guarantees
|
|
385
|
-
* every required field is present and correctly typed before the value leaves
|
|
386
|
-
* the I/O boundary, so a bad file can never crash the host TUI.
|
|
387
|
-
*/
|
|
388
|
-
export function normalizeSidebarState(raw: unknown): SidebarState {
|
|
389
|
-
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
390
|
-
return DEFAULT_SIDEBAR_STATE
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
const r = raw as Record<string, unknown>
|
|
394
|
-
|
|
395
|
-
// main — must be an object with at least quota and killed
|
|
396
|
-
const rawMain = r.main
|
|
397
|
-
let main: SidebarState['main']
|
|
398
|
-
if (
|
|
399
|
-
rawMain !== null &&
|
|
400
|
-
typeof rawMain === 'object' &&
|
|
401
|
-
!Array.isArray(rawMain)
|
|
402
|
-
) {
|
|
403
|
-
const m = rawMain as Record<string, unknown>
|
|
404
|
-
main = {
|
|
405
|
-
quota: ('quota' in m ? m.quota : null) as AccountQuota | null,
|
|
406
|
-
killed: typeof m.killed === 'boolean' ? m.killed : false,
|
|
407
|
-
...(typeof m.mainAccountId === 'string'
|
|
408
|
-
? { mainAccountId: m.mainAccountId }
|
|
409
|
-
: {}),
|
|
410
|
-
...(() => {
|
|
411
|
-
const custody = normalizeSidebarCustody(m.custody)
|
|
412
|
-
return custody ? { custody } : {}
|
|
413
|
-
})(),
|
|
414
|
-
// Preserve optional backoff fields if present
|
|
415
|
-
...(typeof m.quotaBackedOff === 'boolean'
|
|
416
|
-
? { quotaBackedOff: m.quotaBackedOff }
|
|
417
|
-
: {}),
|
|
418
|
-
...(typeof m.quotaBackoffUntil === 'number'
|
|
419
|
-
? { quotaBackoffUntil: m.quotaBackoffUntil }
|
|
420
|
-
: {}),
|
|
421
|
-
...(typeof m.refreshBackedOff === 'boolean'
|
|
422
|
-
? { refreshBackedOff: m.refreshBackedOff }
|
|
423
|
-
: {}),
|
|
424
|
-
...(typeof m.refreshBackoffUntil === 'number'
|
|
425
|
-
? { refreshBackoffUntil: m.refreshBackoffUntil }
|
|
426
|
-
: {}),
|
|
427
|
-
...resetCreditsField(m.resetCredits),
|
|
428
|
-
}
|
|
429
|
-
} else {
|
|
430
|
-
main = { quota: null, killed: false }
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// fallbacks — must be an array; keep entries that are objects with a string
|
|
434
|
-
// id, and normalize each entry's inner fields so the TUI never reads a
|
|
435
|
-
// wrong-typed value (e.g. a string `enabled`) off a malformed file.
|
|
436
|
-
const rawFallbacks = r.fallbacks
|
|
437
|
-
const fallbacks: SidebarAccountState[] = Array.isArray(rawFallbacks)
|
|
438
|
-
? rawFallbacks
|
|
439
|
-
.filter(
|
|
440
|
-
(entry): entry is Record<string, unknown> =>
|
|
441
|
-
entry !== null &&
|
|
442
|
-
typeof entry === 'object' &&
|
|
443
|
-
!Array.isArray(entry) &&
|
|
444
|
-
typeof (entry as Record<string, unknown>).id === 'string',
|
|
445
|
-
)
|
|
446
|
-
.map((e) => {
|
|
447
|
-
const custody = normalizeSidebarCustody(e.custody)
|
|
448
|
-
return {
|
|
449
|
-
id: e.id as string,
|
|
450
|
-
label: typeof e.label === 'string' ? e.label : undefined,
|
|
451
|
-
...(typeof e.accountId === 'string'
|
|
452
|
-
? { accountId: e.accountId }
|
|
453
|
-
: {}),
|
|
454
|
-
quota: ('quota' in e ? e.quota : null) as AccountQuota | null,
|
|
455
|
-
killed: typeof e.killed === 'boolean' ? e.killed : false,
|
|
456
|
-
enabled: typeof e.enabled === 'boolean' ? e.enabled : true,
|
|
457
|
-
...resetCreditsField(e.resetCredits),
|
|
458
|
-
...(custody ? { custody } : {}),
|
|
459
|
-
}
|
|
460
|
-
})
|
|
461
|
-
: []
|
|
462
|
-
|
|
463
|
-
// activeId — string or undefined
|
|
464
|
-
const activeId = typeof r.activeId === 'string' ? r.activeId : undefined
|
|
465
|
-
|
|
466
|
-
// route — string, default 'main'
|
|
467
|
-
const route =
|
|
468
|
-
typeof r.route === 'string' ? r.route : DEFAULT_SIDEBAR_STATE.route
|
|
469
|
-
|
|
470
|
-
// lastUpdated — number, default 0
|
|
471
|
-
const lastUpdated = typeof r.lastUpdated === 'number' ? r.lastUpdated : 0
|
|
472
|
-
|
|
473
|
-
// Optional top-level fields
|
|
474
|
-
const planType = typeof r.planType === 'string' ? r.planType : undefined
|
|
475
|
-
const credits = typeof r.credits === 'number' ? r.credits : undefined
|
|
476
|
-
const activeRouting = normalizeActiveRouting(r.activeRouting)
|
|
477
|
-
const stickyAssignments = normalizeStickyAssignments(r.stickyAssignments)
|
|
478
|
-
return {
|
|
479
|
-
main,
|
|
480
|
-
fallbacks,
|
|
481
|
-
activeId,
|
|
482
|
-
route,
|
|
483
|
-
lastUpdated,
|
|
484
|
-
...(activeRouting !== undefined ? { activeRouting } : {}),
|
|
485
|
-
...(stickyAssignments !== undefined ? { stickyAssignments } : {}),
|
|
486
|
-
...(planType !== undefined ? { planType } : {}),
|
|
487
|
-
...(credits !== undefined ? { credits } : {}),
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
export async function getSidebarState(
|
|
492
|
-
stateFile = getSidebarStateFile(),
|
|
493
|
-
): Promise<SidebarState> {
|
|
494
|
-
try {
|
|
495
|
-
const raw = await readFile(stateFile, 'utf8')
|
|
496
|
-
return normalizeSidebarState(JSON.parse(raw))
|
|
497
|
-
} catch {
|
|
498
|
-
return DEFAULT_SIDEBAR_STATE
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
export const ACTIVE_ROUTING_MAX_AGE_MS = 60 * 60 * 1000
|
|
503
|
-
export const ACTIVE_ROUTING_MAX_ENTRIES = 128
|
|
504
|
-
|
|
505
|
-
export type SidebarRoutingAccount = {
|
|
506
|
-
id: string
|
|
507
|
-
enabled?: boolean
|
|
508
|
-
killed?: boolean
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
export function isUsableRoutingEntry(
|
|
512
|
-
entry: ActiveRoutingEntry,
|
|
513
|
-
accounts: readonly SidebarRoutingAccount[] | undefined,
|
|
514
|
-
now = Date.now(),
|
|
515
|
-
): boolean {
|
|
516
|
-
const fresh =
|
|
517
|
-
entry.updatedAt >= now - ACTIVE_ROUTING_MAX_AGE_MS && entry.updatedAt <= now
|
|
518
|
-
if (!fresh) return false
|
|
519
|
-
if (accounts === undefined) return true
|
|
520
|
-
return (
|
|
521
|
-
entry.activeId === 'main' ||
|
|
522
|
-
accounts.some(
|
|
523
|
-
(account) =>
|
|
524
|
-
account.enabled !== false &&
|
|
525
|
-
account.killed !== true &&
|
|
526
|
-
account.id === entry.activeId,
|
|
527
|
-
)
|
|
528
|
-
)
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
// The credit budget's own exhaustion signal, shared by admission and sticky
|
|
532
|
-
// migration so both agree on what "spent" means. `reached` is the provider's
|
|
533
|
-
// authoritative boolean — the percentage is only a display approximation — and
|
|
534
|
-
// the check fails open on a missing or lapsed reset exactly like a rate-limit
|
|
535
|
-
// window, so a stale or corrupt reading never blocks.
|
|
536
|
-
export function spendControlExhaustedResetAt(
|
|
537
|
-
quota: AccountQuota | null | undefined,
|
|
538
|
-
now = Date.now(),
|
|
539
|
-
): { resetsAt: string; resetAtMs: number } | undefined {
|
|
540
|
-
const spendControl = quota?.spendControl
|
|
541
|
-
if (
|
|
542
|
-
spendControl?.reached !== true ||
|
|
543
|
-
typeof spendControl.resetsAt !== 'string'
|
|
544
|
-
) {
|
|
545
|
-
return undefined
|
|
546
|
-
}
|
|
547
|
-
const resetAtMs = Date.parse(spendControl.resetsAt)
|
|
548
|
-
if (!Number.isFinite(resetAtMs) || resetAtMs <= now) return undefined
|
|
549
|
-
return { resetsAt: spendControl.resetsAt, resetAtMs }
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
// Earliest future reset among the quota's exhausted windows, or undefined when
|
|
553
|
-
// no present window is exhausted. Every present window is evaluated — matching
|
|
554
|
-
// the admission policy, which rejects an account when ANY live window is below
|
|
555
|
-
// its threshold — and each check fails open: a missing/malformed usage, a
|
|
556
|
-
// missing/unparsable reset, or a reset already in the past never counts as
|
|
557
|
-
// exhausted, so a stale or corrupt snapshot can never block routing.
|
|
558
|
-
export function exhaustedQuotaResetAt(
|
|
559
|
-
quota: AccountQuota | null | undefined,
|
|
560
|
-
now = Date.now(),
|
|
561
|
-
): { resetsAt: string; resetAtMs: number } | undefined {
|
|
562
|
-
let earliest: { resetsAt: string; resetAtMs: number } | undefined
|
|
563
|
-
for (const key of QUOTA_WINDOW_KEYS) {
|
|
564
|
-
const window = quota?.[key]
|
|
565
|
-
if (
|
|
566
|
-
typeof window?.resetsAt !== 'string' ||
|
|
567
|
-
!Number.isFinite(window.usedPercent) ||
|
|
568
|
-
window.usedPercent < 100
|
|
569
|
-
) {
|
|
570
|
-
continue
|
|
571
|
-
}
|
|
572
|
-
const resetAtMs = Date.parse(window.resetsAt)
|
|
573
|
-
if (!Number.isFinite(resetAtMs) || resetAtMs <= now) continue
|
|
574
|
-
if (!earliest || resetAtMs < earliest.resetAtMs) {
|
|
575
|
-
earliest = { resetsAt: window.resetsAt, resetAtMs }
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
// The credit budget is a third axis on its own reset clock (a month, not
|
|
579
|
-
// 5h/7d), judged by the same shared signal sticky migration uses.
|
|
580
|
-
const spendReset = spendControlExhaustedResetAt(quota, now)
|
|
581
|
-
if (spendReset && (!earliest || spendReset.resetAtMs < earliest.resetAtMs)) {
|
|
582
|
-
earliest = spendReset
|
|
583
|
-
}
|
|
584
|
-
return earliest
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
export function isQuotaExhausted(
|
|
588
|
-
quota: AccountQuota | null | undefined,
|
|
589
|
-
now = Date.now(),
|
|
590
|
-
): boolean {
|
|
591
|
-
return exhaustedQuotaResetAt(quota, now) !== undefined
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
export function resolveSessionStickyAccount(
|
|
595
|
-
state: SidebarState,
|
|
596
|
-
sessionId: string | undefined,
|
|
597
|
-
now = Date.now(),
|
|
598
|
-
): string | undefined {
|
|
599
|
-
if (!sessionId || state.route !== 'sticky-balanced') return undefined
|
|
600
|
-
const assignment = state.stickyAssignments?.[hashSidebarSessionId(sessionId)]
|
|
601
|
-
if (
|
|
602
|
-
!assignment ||
|
|
603
|
-
assignment.lastSeenAt < now - STICKY_ASSIGNMENT_MAX_AGE_MS
|
|
604
|
-
) {
|
|
605
|
-
return undefined
|
|
606
|
-
}
|
|
607
|
-
if (assignment.accountId === 'main') {
|
|
608
|
-
return state.main?.killed || isQuotaExhausted(state.main?.quota, now)
|
|
609
|
-
? undefined
|
|
610
|
-
: 'main'
|
|
611
|
-
}
|
|
612
|
-
const fallback = state.fallbacks?.find(
|
|
613
|
-
(account) => account.id === assignment.accountId,
|
|
614
|
-
)
|
|
615
|
-
if (
|
|
616
|
-
!fallback ||
|
|
617
|
-
fallback.enabled === false ||
|
|
618
|
-
fallback.killed === true ||
|
|
619
|
-
isQuotaExhausted(fallback.quota, now)
|
|
620
|
-
) {
|
|
621
|
-
return undefined
|
|
622
|
-
}
|
|
623
|
-
return fallback.id
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
export function resolveSessionSidebarRouting(
|
|
627
|
-
state: SidebarState,
|
|
628
|
-
sessionId?: string,
|
|
629
|
-
now = Date.now(),
|
|
630
|
-
): { activeId: string; route: string } {
|
|
631
|
-
if (!sessionId) {
|
|
632
|
-
return { activeId: state.activeId ?? 'main', route: state.route }
|
|
633
|
-
}
|
|
634
|
-
const own = sessionId ? state.activeRouting?.[sessionId] : undefined
|
|
635
|
-
const ownQuota =
|
|
636
|
-
own?.activeId === 'main'
|
|
637
|
-
? state.main.quota
|
|
638
|
-
: state.fallbacks.find((account) => account.id === own?.activeId)?.quota
|
|
639
|
-
if (
|
|
640
|
-
own &&
|
|
641
|
-
isUsableRoutingEntry(own, state.fallbacks, now) &&
|
|
642
|
-
!isQuotaExhausted(ownQuota, now)
|
|
643
|
-
) {
|
|
644
|
-
return { activeId: own.activeId, route: own.route }
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
const stickyAccountId = resolveSessionStickyAccount(state, sessionId, now)
|
|
648
|
-
if (stickyAccountId) {
|
|
649
|
-
return { activeId: stickyAccountId, route: state.route }
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
const enabledFallbacks = state.fallbacks.filter(
|
|
653
|
-
(account) => account.enabled && !account.killed,
|
|
654
|
-
)
|
|
655
|
-
const fallback =
|
|
656
|
-
enabledFallbacks.find((account) => !isQuotaExhausted(account.quota, now)) ??
|
|
657
|
-
enabledFallbacks[0]
|
|
658
|
-
return {
|
|
659
|
-
activeId:
|
|
660
|
-
state.route === 'fallback-first' && fallback ? fallback.id : 'main',
|
|
661
|
-
route: state.route,
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
export function pruneActiveRouting(
|
|
666
|
-
activeRouting: ActiveRoutingMap | undefined,
|
|
667
|
-
accounts: readonly SidebarRoutingAccount[] | undefined,
|
|
668
|
-
now = Date.now(),
|
|
669
|
-
removedSessionId?: string,
|
|
670
|
-
): ActiveRoutingMap | undefined {
|
|
671
|
-
if (!activeRouting) return undefined
|
|
672
|
-
const kept = Object.entries(activeRouting).filter(
|
|
673
|
-
([sessionId, entry]) =>
|
|
674
|
-
sessionId !== removedSessionId &&
|
|
675
|
-
isUsableRoutingEntry(entry, accounts, now),
|
|
676
|
-
)
|
|
677
|
-
const bounded =
|
|
678
|
-
kept.length <= ACTIVE_ROUTING_MAX_ENTRIES
|
|
679
|
-
? kept
|
|
680
|
-
: kept
|
|
681
|
-
.sort((left, right) => right[1].updatedAt - left[1].updatedAt)
|
|
682
|
-
.slice(0, ACTIVE_ROUTING_MAX_ENTRIES)
|
|
683
|
-
return bounded.length > 0 ? Object.fromEntries(bounded) : undefined
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
export function pruneStickyAssignments(
|
|
687
|
-
assignments: StickyAssignmentMap | undefined,
|
|
688
|
-
validAccountIds: ReadonlySet<string> | undefined,
|
|
689
|
-
now = Date.now(),
|
|
690
|
-
removedSessionHash?: string,
|
|
691
|
-
): StickyAssignmentMap | undefined {
|
|
692
|
-
if (!assignments) return undefined
|
|
693
|
-
let accountNotInRoster = 0
|
|
694
|
-
let expired = 0
|
|
695
|
-
let explicitRemoval = 0
|
|
696
|
-
const kept: [string, StickyAssignment][] = []
|
|
697
|
-
for (const [sessionHash, assignment] of Object.entries(assignments)) {
|
|
698
|
-
if (sessionHash === removedSessionHash) {
|
|
699
|
-
explicitRemoval += 1
|
|
700
|
-
continue
|
|
701
|
-
}
|
|
702
|
-
if (assignment.lastSeenAt < now - STICKY_ASSIGNMENT_MAX_AGE_MS) {
|
|
703
|
-
expired += 1
|
|
704
|
-
continue
|
|
705
|
-
}
|
|
706
|
-
if (
|
|
707
|
-
validAccountIds !== undefined &&
|
|
708
|
-
!validAccountIds.has(assignment.accountId)
|
|
709
|
-
) {
|
|
710
|
-
accountNotInRoster += 1
|
|
711
|
-
continue
|
|
712
|
-
}
|
|
713
|
-
kept.push([sessionHash, assignment])
|
|
714
|
-
}
|
|
715
|
-
const removed = accountNotInRoster + expired + explicitRemoval
|
|
716
|
-
if (removed > 0) {
|
|
717
|
-
logSb.debug('pruned sticky assignments', {
|
|
718
|
-
pid: process.pid,
|
|
719
|
-
removed,
|
|
720
|
-
reasons: {
|
|
721
|
-
'account-not-in-roster': accountNotInRoster,
|
|
722
|
-
expired,
|
|
723
|
-
'explicit-removal': explicitRemoval,
|
|
724
|
-
},
|
|
725
|
-
})
|
|
726
|
-
}
|
|
727
|
-
return kept.length > 0 ? Object.fromEntries(kept) : undefined
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
function limitStickyAssignments(
|
|
731
|
-
assignments: StickyAssignmentMap,
|
|
732
|
-
protectedSessionHash: string,
|
|
733
|
-
): StickyAssignmentMap {
|
|
734
|
-
const overflow =
|
|
735
|
-
Object.keys(assignments).length - STICKY_ASSIGNMENT_MAX_ENTRIES
|
|
736
|
-
if (overflow <= 0) return assignments
|
|
737
|
-
const evicted = new Set(
|
|
738
|
-
Object.entries(assignments)
|
|
739
|
-
.filter(([sessionHash]) => sessionHash !== protectedSessionHash)
|
|
740
|
-
.sort(
|
|
741
|
-
([leftHash, left], [rightHash, right]) =>
|
|
742
|
-
left.lastSeenAt - right.lastSeenAt ||
|
|
743
|
-
leftHash.localeCompare(rightHash),
|
|
744
|
-
)
|
|
745
|
-
.slice(0, overflow)
|
|
746
|
-
.map(([sessionHash]) => sessionHash),
|
|
747
|
-
)
|
|
748
|
-
return Object.fromEntries(
|
|
749
|
-
Object.entries(assignments).filter(
|
|
750
|
-
([sessionHash]) => !evicted.has(sessionHash),
|
|
751
|
-
),
|
|
752
|
-
)
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
function stickyAssignmentsEqual(
|
|
756
|
-
left: StickyAssignmentMap | undefined,
|
|
757
|
-
right: StickyAssignmentMap | undefined,
|
|
758
|
-
): boolean {
|
|
759
|
-
if (left === right) return true
|
|
760
|
-
if (!left || !right) return false
|
|
761
|
-
const leftEntries = Object.entries(left)
|
|
762
|
-
if (leftEntries.length !== Object.keys(right).length) return false
|
|
763
|
-
return leftEntries.every(([sessionHash, assignment]) =>
|
|
764
|
-
Object.is(right[sessionHash], assignment),
|
|
765
|
-
)
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
function isValidStickyAssignment(
|
|
769
|
-
assignment: StickyAssignment | undefined,
|
|
770
|
-
validPinnedAccountIds: ReadonlySet<string>,
|
|
771
|
-
excludedAccountIds: ReadonlySet<string>,
|
|
772
|
-
now: number,
|
|
773
|
-
): assignment is StickyAssignment {
|
|
774
|
-
return (
|
|
775
|
-
assignment !== undefined &&
|
|
776
|
-
validPinnedAccountIds.has(assignment.accountId) &&
|
|
777
|
-
!excludedAccountIds.has(assignment.accountId) &&
|
|
778
|
-
assignment.lastSeenAt >= now - STICKY_ASSIGNMENT_MAX_AGE_MS
|
|
779
|
-
)
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
function stickyAssignmentNeedsMetadataUpdate(
|
|
783
|
-
assignment: StickyAssignment,
|
|
784
|
-
requestBytes: number,
|
|
785
|
-
now: number,
|
|
786
|
-
wireAccountId: string | undefined,
|
|
787
|
-
): boolean {
|
|
788
|
-
return (
|
|
789
|
-
requestBytes > assignment.inputBytes ||
|
|
790
|
-
now - assignment.lastSeenAt >= STICKY_ASSIGNMENT_LAST_SEEN_TOUCH_MS ||
|
|
791
|
-
(assignment.wireAccountId === undefined && wireAccountId !== undefined)
|
|
792
|
-
)
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
function hasStickyIdentityMismatch(
|
|
796
|
-
assignment: StickyAssignment,
|
|
797
|
-
wireAccountId: string | undefined,
|
|
798
|
-
): boolean {
|
|
799
|
-
// True only when both sides know their ChatGPT account and those accounts
|
|
800
|
-
// differ, which means the slot this session was placed on now holds a
|
|
801
|
-
// different account than it did at placement.
|
|
802
|
-
//
|
|
803
|
-
// An unknown on either side deliberately reports no mismatch. A session is
|
|
804
|
-
// pinned to keep its prompt cache warm, and treating missing data as a
|
|
805
|
-
// change would place sessions again for no reason and throw those caches
|
|
806
|
-
// away — worse than leaving a rare wrong pin in place until real evidence
|
|
807
|
-
// arrives.
|
|
808
|
-
return (
|
|
809
|
-
typeof assignment.wireAccountId === 'string' &&
|
|
810
|
-
typeof wireAccountId === 'string' &&
|
|
811
|
-
assignment.wireAccountId !== wireAccountId
|
|
812
|
-
)
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
function readonlyPendingBytes(
|
|
816
|
-
pendingBytes: Map<string, number>,
|
|
817
|
-
): ReadonlyMap<string, number> {
|
|
818
|
-
const snapshot = new Map(pendingBytes)
|
|
819
|
-
const view: ReadonlyMap<string, number> = {
|
|
820
|
-
get size() {
|
|
821
|
-
return snapshot.size
|
|
822
|
-
},
|
|
823
|
-
has: (key: string) => snapshot.has(key),
|
|
824
|
-
get: (key: string) => snapshot.get(key),
|
|
825
|
-
entries: () => snapshot.entries(),
|
|
826
|
-
keys: () => snapshot.keys(),
|
|
827
|
-
values: () => snapshot.values(),
|
|
828
|
-
forEach: (
|
|
829
|
-
callback: (
|
|
830
|
-
value: number,
|
|
831
|
-
key: string,
|
|
832
|
-
map: ReadonlyMap<string, number>,
|
|
833
|
-
) => void,
|
|
834
|
-
thisArg?: unknown,
|
|
835
|
-
) => {
|
|
836
|
-
snapshot.forEach((value, key) => {
|
|
837
|
-
callback.call(thisArg, value, key, view)
|
|
838
|
-
})
|
|
839
|
-
},
|
|
840
|
-
[Symbol.iterator]: () => snapshot[Symbol.iterator](),
|
|
841
|
-
}
|
|
842
|
-
return Object.freeze(view)
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
function pendingBytesForAssignments(
|
|
846
|
-
assignments: StickyAssignmentMap | undefined,
|
|
847
|
-
quotaCheckedAtByAccount: Readonly<Record<string, number | undefined>>,
|
|
848
|
-
excludedSessionHash?: string,
|
|
849
|
-
): ReadonlyMap<string, number> {
|
|
850
|
-
const pendingBytes = new Map<string, number>()
|
|
851
|
-
for (const [sessionHash, assignment] of Object.entries(assignments ?? {})) {
|
|
852
|
-
if (sessionHash === excludedSessionHash) continue
|
|
853
|
-
if (
|
|
854
|
-
assignment.quotaCheckedAt !==
|
|
855
|
-
quotaCheckedAtByAccount[assignment.accountId]
|
|
856
|
-
) {
|
|
857
|
-
continue
|
|
858
|
-
}
|
|
859
|
-
pendingBytes.set(
|
|
860
|
-
assignment.accountId,
|
|
861
|
-
(pendingBytes.get(assignment.accountId) ?? 0) + assignment.inputBytes,
|
|
862
|
-
)
|
|
863
|
-
}
|
|
864
|
-
return readonlyPendingBytes(pendingBytes)
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
function usableRoutingAccountIds(
|
|
868
|
-
accounts: readonly SidebarRoutingAccount[] | undefined,
|
|
869
|
-
): ReadonlySet<string> | undefined {
|
|
870
|
-
if (accounts === undefined) return undefined
|
|
871
|
-
return new Set([
|
|
872
|
-
'main',
|
|
873
|
-
...accounts
|
|
874
|
-
.filter((account) => account.enabled !== false && account.killed !== true)
|
|
875
|
-
.map((account) => account.id),
|
|
876
|
-
])
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
// Serialization chain: concurrent calls are queued so a stale background
|
|
880
|
-
// write cannot land after a newer one and corrupt the file.
|
|
881
|
-
let sidebarWriteChain: Promise<void> = Promise.resolve()
|
|
882
|
-
const MAX_MERGE_ATTEMPTS = 3
|
|
883
|
-
const SIDEBAR_WRITE_LOCK_TTL_MS = 10_000
|
|
884
|
-
const SIDEBAR_WRITE_LOCK_WAIT_MS = 15_000
|
|
885
|
-
|
|
886
|
-
interface SidebarMergeHooks {
|
|
887
|
-
beforeRecheck?: () => void | Promise<void>
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
function enqueueSidebarWrite(operation: () => Promise<void>): Promise<void> {
|
|
891
|
-
const result = sidebarWriteChain.then(operation)
|
|
892
|
-
sidebarWriteChain = result.catch(() => {})
|
|
893
|
-
return result
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
/**
|
|
897
|
-
* Write sidebar state to disk, serialized through a promise chain so
|
|
898
|
-
* concurrent callers never interleave or let a stale write land last.
|
|
899
|
-
*
|
|
900
|
-
* @param state The state to persist.
|
|
901
|
-
* @param file Explicit path override — callers that bind the path at init
|
|
902
|
-
* time (e.g. the index.ts loader) pass this so late callbacks
|
|
903
|
-
* always write to the path that was current when the loader ran,
|
|
904
|
-
* even if the env changes underneath them during tests.
|
|
905
|
-
* Defaults to getSidebarStateFile() (per-call resolution).
|
|
906
|
-
*/
|
|
907
|
-
export function setSidebarState(
|
|
908
|
-
state: SidebarState,
|
|
909
|
-
file = getSidebarStateFile(),
|
|
910
|
-
): Promise<void> {
|
|
911
|
-
return enqueueSidebarWrite(() => doWriteSidebarState(state, file))
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
async function readSidebarState(file: string): Promise<SidebarState> {
|
|
915
|
-
try {
|
|
916
|
-
return parseSidebarState(await readRawSidebar(file))
|
|
917
|
-
} catch {
|
|
918
|
-
return DEFAULT_SIDEBAR_STATE
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
async function readRawSidebar(file: string): Promise<string> {
|
|
923
|
-
try {
|
|
924
|
-
return await readFile(file, 'utf8')
|
|
925
|
-
} catch (error) {
|
|
926
|
-
if ((error as { code?: string }).code === 'ENOENT') return ''
|
|
927
|
-
throw error
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
function parseSidebarState(raw: string): SidebarState {
|
|
932
|
-
if (raw === '') return DEFAULT_SIDEBAR_STATE
|
|
933
|
-
try {
|
|
934
|
-
return normalizeSidebarState(JSON.parse(raw))
|
|
935
|
-
} catch {
|
|
936
|
-
return DEFAULT_SIDEBAR_STATE
|
|
937
|
-
}
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
async function acquireSidebarWriteLock(file: string) {
|
|
941
|
-
await ensureSidebarStateDirectory(file)
|
|
942
|
-
const deadline = Date.now() + SIDEBAR_WRITE_LOCK_WAIT_MS
|
|
943
|
-
while (Date.now() <= deadline) {
|
|
944
|
-
const lock = await acquireRefreshFileLock({
|
|
945
|
-
name: 'sidebar-write',
|
|
946
|
-
path: file,
|
|
947
|
-
ttlMs: SIDEBAR_WRITE_LOCK_TTL_MS,
|
|
948
|
-
renew: true,
|
|
949
|
-
})
|
|
950
|
-
if (lock) return lock
|
|
951
|
-
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
952
|
-
}
|
|
953
|
-
throw new Error('Timed out waiting for the sidebar state write lock')
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
async function ensureSidebarStateDirectory(file: string): Promise<void> {
|
|
957
|
-
const dir = dirname(file)
|
|
958
|
-
await mkdir(dir, { recursive: true, mode: 0o700 })
|
|
959
|
-
if (file !== DEFAULT_STATE_FILE) return
|
|
960
|
-
await chmod(dir, 0o700).catch((error) => {
|
|
961
|
-
logSb.warn('sidebar directory permission remediation failed', {
|
|
962
|
-
pid: process.pid,
|
|
963
|
-
error: error instanceof Error ? error.message : String(error),
|
|
964
|
-
})
|
|
965
|
-
})
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
async function writeMergedSidebarState(
|
|
969
|
-
file: string,
|
|
970
|
-
merge: (latest: SidebarState) => SidebarState | undefined,
|
|
971
|
-
hooks?: SidebarMergeHooks,
|
|
972
|
-
): Promise<void> {
|
|
973
|
-
const lock = await acquireSidebarWriteLock(file)
|
|
974
|
-
try {
|
|
975
|
-
// The file lock serializes current writers. Rechecking also preserves data
|
|
976
|
-
// from older plugin processes that do not participate in this lock.
|
|
977
|
-
for (let attempt = 0; attempt < MAX_MERGE_ATTEMPTS; attempt += 1) {
|
|
978
|
-
const rawBefore = await readRawSidebar(file)
|
|
979
|
-
const next = merge(parseSidebarState(rawBefore))
|
|
980
|
-
if (!next) return
|
|
981
|
-
if (attempt === 0) await hooks?.beforeRecheck?.()
|
|
982
|
-
const rawRecheck = await readRawSidebar(file)
|
|
983
|
-
if (rawRecheck !== rawBefore) continue
|
|
984
|
-
await doWriteSidebarState(next, file)
|
|
985
|
-
return
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
const latest = await readSidebarState(file)
|
|
989
|
-
const next = merge(latest)
|
|
990
|
-
if (next) await doWriteSidebarState(next, file)
|
|
991
|
-
} finally {
|
|
992
|
-
await lock.release()
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
export type SidebarMachineState = Pick<
|
|
997
|
-
SidebarState,
|
|
998
|
-
'main' | 'fallbacks' | 'planType' | 'credits' | 'lastUpdated'
|
|
999
|
-
> & { route: string }
|
|
1000
|
-
|
|
1001
|
-
// The freshest signal across every timestamp a snapshot carries: either window
|
|
1002
|
-
// (primary/secondary) or the legacy top-level stamp. A retired primary window
|
|
1003
|
-
// (null) with a fresh secondary must still outrank an older incoming primary, so
|
|
1004
|
-
// the comparison takes the max rather than the first present value.
|
|
1005
|
-
function latestQuotaCheckedAt(quota: AccountQuota | null): number | undefined {
|
|
1006
|
-
let latest: number | undefined
|
|
1007
|
-
for (const checkedAt of [
|
|
1008
|
-
quota?.primary?.checkedAt,
|
|
1009
|
-
quota?.secondary?.checkedAt,
|
|
1010
|
-
quota?.checkedAt,
|
|
1011
|
-
]) {
|
|
1012
|
-
if (typeof checkedAt === 'number' && Number.isFinite(checkedAt)) {
|
|
1013
|
-
latest = latest === undefined ? checkedAt : Math.max(latest, checkedAt)
|
|
1014
|
-
}
|
|
1015
|
-
}
|
|
1016
|
-
return latest
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
function freshestQuota(
|
|
1020
|
-
incoming: AccountQuota | null,
|
|
1021
|
-
existing: AccountQuota | null,
|
|
1022
|
-
): AccountQuota | null {
|
|
1023
|
-
const incomingCheckedAt = latestQuotaCheckedAt(incoming)
|
|
1024
|
-
const existingCheckedAt = latestQuotaCheckedAt(existing)
|
|
1025
|
-
if (
|
|
1026
|
-
existingCheckedAt !== undefined &&
|
|
1027
|
-
(incomingCheckedAt === undefined || existingCheckedAt > incomingCheckedAt)
|
|
1028
|
-
) {
|
|
1029
|
-
return existing
|
|
1030
|
-
}
|
|
1031
|
-
return incoming
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
// True when both sides assert the same stable account identity. An unknown
|
|
1035
|
-
// identity on either side is NOT a confirmed match — merging windows across an
|
|
1036
|
-
// unconfirmed identity could combine two accounts' quota, so the caller
|
|
1037
|
-
// whole-picks instead.
|
|
1038
|
-
function sameAccountIdentity(
|
|
1039
|
-
incoming: string | undefined,
|
|
1040
|
-
existing: string | undefined,
|
|
1041
|
-
): boolean {
|
|
1042
|
-
return (
|
|
1043
|
-
incoming !== undefined && existing !== undefined && incoming === existing
|
|
1044
|
-
)
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
// A window's checkedAt when it is a usable timestamp, else undefined — so an
|
|
1048
|
-
// absent or invalid stamp sorts oldest and a timestamped window always wins
|
|
1049
|
-
// over an untimestamped one. The optional fallback is the enclosing snapshot's
|
|
1050
|
-
// checkedAt, used when the window itself carries no usable stamp (files written
|
|
1051
|
-
// by versions that did not propagate the entry timestamp onto each present
|
|
1052
|
-
// window): a present window with no stamp is still "live", so it must sort by
|
|
1053
|
-
// SOME timestamp — the snapshot's is the next-best signal.
|
|
1054
|
-
function finiteWindowCheckedAt(
|
|
1055
|
-
window: QuotaWindow | undefined,
|
|
1056
|
-
fallback?: number,
|
|
1057
|
-
): number | undefined {
|
|
1058
|
-
const checkedAt = window?.checkedAt
|
|
1059
|
-
if (typeof checkedAt === 'number' && Number.isFinite(checkedAt)) {
|
|
1060
|
-
return checkedAt
|
|
1061
|
-
}
|
|
1062
|
-
if (typeof fallback === 'number' && Number.isFinite(fallback)) {
|
|
1063
|
-
return fallback
|
|
1064
|
-
}
|
|
1065
|
-
return undefined
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
|
-
// Fresher of two same-slot windows. When both sides report the window, the
|
|
1069
|
-
// window's own stamp decides (falling back to each side's snapshot stamp when
|
|
1070
|
-
// the window itself has none). When the slots disagree on presence, the FRESHER
|
|
1071
|
-
// snapshot's slot is authoritative — a quota snapshot reports every live window,
|
|
1072
|
-
// so an absent slot there means the wire retired it, which a stale window on the
|
|
1073
|
-
// other side must not resurrect (and a fresher snapshot's present window must
|
|
1074
|
-
// not be dropped by a stale window-less write).
|
|
1075
|
-
function freshestWindow(
|
|
1076
|
-
incoming: QuotaWindow | undefined,
|
|
1077
|
-
existing: QuotaWindow | undefined,
|
|
1078
|
-
existingSnapshotIsFresher: boolean,
|
|
1079
|
-
incomingSnapshotCheckedAt: number | undefined,
|
|
1080
|
-
existingSnapshotCheckedAt: number | undefined,
|
|
1081
|
-
): QuotaWindow | undefined {
|
|
1082
|
-
if (incoming && existing) {
|
|
1083
|
-
const incomingAt = finiteWindowCheckedAt(
|
|
1084
|
-
incoming,
|
|
1085
|
-
incomingSnapshotCheckedAt,
|
|
1086
|
-
)
|
|
1087
|
-
const existingAt = finiteWindowCheckedAt(
|
|
1088
|
-
existing,
|
|
1089
|
-
existingSnapshotCheckedAt,
|
|
1090
|
-
)
|
|
1091
|
-
if (
|
|
1092
|
-
existingAt !== undefined &&
|
|
1093
|
-
(incomingAt === undefined || existingAt > incomingAt)
|
|
1094
|
-
) {
|
|
1095
|
-
return existing
|
|
1096
|
-
}
|
|
1097
|
-
return incoming
|
|
1098
|
-
}
|
|
1099
|
-
return existingSnapshotIsFresher ? existing : incoming
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
// Merge two same-account snapshots window-by-window: each slot keeps the
|
|
1103
|
-
// fresher of the two sides, so a newer primary on one side and a newer
|
|
1104
|
-
// secondary on the other both survive instead of one side's whole snapshot
|
|
1105
|
-
// replacing the other's. The snapshot stamp becomes the freshest window stamp
|
|
1106
|
-
// of the merged result. Only safe when both snapshots share an account identity
|
|
1107
|
-
// (sameAccountIdentity) — an identity switch must whole-pick (freshestQuota) so
|
|
1108
|
-
// windows from two accounts are never combined.
|
|
1109
|
-
// The fresher side decides the budget when it knows anything about it: its own
|
|
1110
|
-
// reading, or its report that no budget exists. Only when it says nothing (a
|
|
1111
|
-
// header or WebSocket push, which never carries spend control) does the other
|
|
1112
|
-
// side's reading survive, and even then not if that side reported none.
|
|
1113
|
-
function mergeSpendControl(
|
|
1114
|
-
fresher: AccountQuota,
|
|
1115
|
-
older: AccountQuota,
|
|
1116
|
-
): SpendControlReading | undefined {
|
|
1117
|
-
if (fresher.spendControl !== undefined) return fresher.spendControl
|
|
1118
|
-
if (fresher.spendControlCleared === true) return undefined
|
|
1119
|
-
return older.spendControl
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
function mergeQuotaByWindow(
|
|
1123
|
-
incoming: AccountQuota | null,
|
|
1124
|
-
existing: AccountQuota | null,
|
|
1125
|
-
): AccountQuota | null {
|
|
1126
|
-
if (!incoming) return existing
|
|
1127
|
-
if (!existing) return incoming
|
|
1128
|
-
const incomingAt = latestQuotaCheckedAt(incoming)
|
|
1129
|
-
const existingAt = latestQuotaCheckedAt(existing)
|
|
1130
|
-
const existingSnapshotIsFresher =
|
|
1131
|
-
existingAt !== undefined &&
|
|
1132
|
-
(incomingAt === undefined || existingAt > incomingAt)
|
|
1133
|
-
const primary = freshestWindow(
|
|
1134
|
-
incoming.primary,
|
|
1135
|
-
existing.primary,
|
|
1136
|
-
existingSnapshotIsFresher,
|
|
1137
|
-
incoming.checkedAt,
|
|
1138
|
-
existing.checkedAt,
|
|
1139
|
-
)
|
|
1140
|
-
const secondary = freshestWindow(
|
|
1141
|
-
incoming.secondary,
|
|
1142
|
-
existing.secondary,
|
|
1143
|
-
existingSnapshotIsFresher,
|
|
1144
|
-
incoming.checkedAt,
|
|
1145
|
-
existing.checkedAt,
|
|
1146
|
-
)
|
|
1147
|
-
const spendControl = existingSnapshotIsFresher
|
|
1148
|
-
? mergeSpendControl(existing, incoming)
|
|
1149
|
-
: mergeSpendControl(incoming, existing)
|
|
1150
|
-
const spendControlCleared =
|
|
1151
|
-
spendControl === undefined &&
|
|
1152
|
-
(incoming.spendControlCleared === true ||
|
|
1153
|
-
existing.spendControlCleared === true)
|
|
1154
|
-
let checkedAt: number | undefined
|
|
1155
|
-
for (const stamp of [
|
|
1156
|
-
finiteWindowCheckedAt(primary),
|
|
1157
|
-
finiteWindowCheckedAt(secondary),
|
|
1158
|
-
]) {
|
|
1159
|
-
if (stamp !== undefined) {
|
|
1160
|
-
checkedAt = checkedAt === undefined ? stamp : Math.max(checkedAt, stamp)
|
|
1161
|
-
}
|
|
1162
|
-
}
|
|
1163
|
-
// Both budget fields are decided above, so neither may leak in from the
|
|
1164
|
-
// spread: an older incoming budget must not survive a fresher "no budget".
|
|
1165
|
-
const {
|
|
1166
|
-
spendControl: _incomingSpendControl,
|
|
1167
|
-
spendControlCleared: _incomingCleared,
|
|
1168
|
-
...incomingRest
|
|
1169
|
-
} = incoming
|
|
1170
|
-
return {
|
|
1171
|
-
...incomingRest,
|
|
1172
|
-
primary,
|
|
1173
|
-
secondary,
|
|
1174
|
-
...(spendControl !== undefined ? { spendControl } : {}),
|
|
1175
|
-
...(spendControlCleared ? { spendControlCleared: true as const } : {}),
|
|
1176
|
-
checkedAt: checkedAt ?? incoming.checkedAt,
|
|
1177
|
-
}
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
export function setSidebarMachineState(
|
|
1181
|
-
machineState: SidebarMachineState,
|
|
1182
|
-
file = getSidebarStateFile(),
|
|
1183
|
-
hooks?: SidebarMergeHooks,
|
|
1184
|
-
): Promise<void> {
|
|
1185
|
-
return enqueueSidebarWrite(async () => {
|
|
1186
|
-
await writeMergedSidebarState(
|
|
1187
|
-
file,
|
|
1188
|
-
(latest) => {
|
|
1189
|
-
const latestFallbacks = new Map(
|
|
1190
|
-
latest.fallbacks.map((account) => [account.id, account]),
|
|
1191
|
-
)
|
|
1192
|
-
// A same-identity merge combines the freshest of each window across the
|
|
1193
|
-
// two sides; a differing or unknown identity whole-picks the fresher
|
|
1194
|
-
// snapshot so windows from two accounts are never combined.
|
|
1195
|
-
const mainSameIdentity = sameAccountIdentity(
|
|
1196
|
-
machineState.main.mainAccountId,
|
|
1197
|
-
latest.main.mainAccountId,
|
|
1198
|
-
)
|
|
1199
|
-
const mergedMainQuota = mainSameIdentity
|
|
1200
|
-
? mergeQuotaByWindow(machineState.main.quota, latest.main.quota)
|
|
1201
|
-
: freshestQuota(machineState.main.quota, latest.main.quota)
|
|
1202
|
-
const now = Date.now()
|
|
1203
|
-
const stickyAssignments = pruneStickyAssignments(
|
|
1204
|
-
latest.stickyAssignments,
|
|
1205
|
-
usableRoutingAccountIds(machineState.fallbacks),
|
|
1206
|
-
now,
|
|
1207
|
-
)
|
|
1208
|
-
return {
|
|
1209
|
-
...latest,
|
|
1210
|
-
...machineState,
|
|
1211
|
-
main: {
|
|
1212
|
-
...machineState.main,
|
|
1213
|
-
quota: mergedMainQuota,
|
|
1214
|
-
// On a whole-pick the identity follows the winning snapshot, so a
|
|
1215
|
-
// reader never pairs one account's id with another account's quota
|
|
1216
|
-
// (a re-login race would otherwise resurrect the stale-account bug).
|
|
1217
|
-
// On a same-identity merge both sides already agree, so the incoming
|
|
1218
|
-
// id is the shared one.
|
|
1219
|
-
mainAccountId: mainSameIdentity
|
|
1220
|
-
? machineState.main.mainAccountId
|
|
1221
|
-
: mergedMainQuota === latest.main.quota &&
|
|
1222
|
-
mergedMainQuota !== machineState.main.quota
|
|
1223
|
-
? latest.main.mainAccountId
|
|
1224
|
-
: machineState.main.mainAccountId,
|
|
1225
|
-
},
|
|
1226
|
-
fallbacks: machineState.fallbacks.map((account) => {
|
|
1227
|
-
const existing = latestFallbacks.get(account.id)
|
|
1228
|
-
const fallbackSameIdentity = sameAccountIdentity(
|
|
1229
|
-
account.accountId,
|
|
1230
|
-
existing?.accountId,
|
|
1231
|
-
)
|
|
1232
|
-
const mergedQuota = fallbackSameIdentity
|
|
1233
|
-
? mergeQuotaByWindow(account.quota, existing?.quota ?? null)
|
|
1234
|
-
: freshestQuota(account.quota, existing?.quota ?? null)
|
|
1235
|
-
return {
|
|
1236
|
-
...account,
|
|
1237
|
-
quota: mergedQuota,
|
|
1238
|
-
accountId: fallbackSameIdentity
|
|
1239
|
-
? account.accountId
|
|
1240
|
-
: mergedQuota === existing?.quota &&
|
|
1241
|
-
mergedQuota !== account.quota
|
|
1242
|
-
? existing?.accountId
|
|
1243
|
-
: account.accountId,
|
|
1244
|
-
}
|
|
1245
|
-
}),
|
|
1246
|
-
activeId: latest.activeId,
|
|
1247
|
-
activeRouting: latest.activeRouting,
|
|
1248
|
-
stickyAssignments,
|
|
1249
|
-
lastUpdated: Math.max(now, latest.lastUpdated + 1),
|
|
1250
|
-
}
|
|
1251
|
-
},
|
|
1252
|
-
hooks,
|
|
1253
|
-
)
|
|
1254
|
-
})
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
|
-
export function upsertSidebarActiveRouting(
|
|
1258
|
-
input: { sessionId: string } & ActiveRoutingEntry,
|
|
1259
|
-
accounts: readonly SidebarRoutingAccount[] | undefined,
|
|
1260
|
-
file = getSidebarStateFile(),
|
|
1261
|
-
hooks?: SidebarMergeHooks,
|
|
1262
|
-
): Promise<void> {
|
|
1263
|
-
return enqueueSidebarWrite(async () => {
|
|
1264
|
-
await writeMergedSidebarState(
|
|
1265
|
-
file,
|
|
1266
|
-
(latest) => {
|
|
1267
|
-
const activeRouting = pruneActiveRouting(
|
|
1268
|
-
{
|
|
1269
|
-
...latest.activeRouting,
|
|
1270
|
-
[input.sessionId]: {
|
|
1271
|
-
activeId: input.activeId,
|
|
1272
|
-
route: input.route,
|
|
1273
|
-
updatedAt: input.updatedAt,
|
|
1274
|
-
},
|
|
1275
|
-
},
|
|
1276
|
-
accounts,
|
|
1277
|
-
Date.now(),
|
|
1278
|
-
)
|
|
1279
|
-
const stickyAssignments = pruneStickyAssignments(
|
|
1280
|
-
latest.stickyAssignments,
|
|
1281
|
-
usableRoutingAccountIds(accounts),
|
|
1282
|
-
Date.now(),
|
|
1283
|
-
)
|
|
1284
|
-
return {
|
|
1285
|
-
...latest,
|
|
1286
|
-
activeId: input.activeId,
|
|
1287
|
-
route: input.route,
|
|
1288
|
-
activeRouting,
|
|
1289
|
-
stickyAssignments,
|
|
1290
|
-
lastUpdated: Math.max(Date.now(), latest.lastUpdated + 1),
|
|
1291
|
-
}
|
|
1292
|
-
},
|
|
1293
|
-
hooks,
|
|
1294
|
-
)
|
|
1295
|
-
})
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
export function setSidebarLegacyRouting(
|
|
1299
|
-
input: ActiveRoutingEntry,
|
|
1300
|
-
file = getSidebarStateFile(),
|
|
1301
|
-
): Promise<void> {
|
|
1302
|
-
return enqueueSidebarWrite(async () => {
|
|
1303
|
-
await writeMergedSidebarState(file, (latest) => ({
|
|
1304
|
-
...latest,
|
|
1305
|
-
activeId: input.activeId,
|
|
1306
|
-
route: input.route,
|
|
1307
|
-
lastUpdated: Math.max(Date.now(), latest.lastUpdated + 1),
|
|
1308
|
-
}))
|
|
1309
|
-
})
|
|
1310
|
-
}
|
|
1311
|
-
|
|
1312
|
-
export function removeSidebarActiveRouting(
|
|
1313
|
-
sessionId: string,
|
|
1314
|
-
accounts: readonly SidebarRoutingAccount[] | undefined,
|
|
1315
|
-
file = getSidebarStateFile(),
|
|
1316
|
-
hooks?: SidebarMergeHooks,
|
|
1317
|
-
): Promise<void> {
|
|
1318
|
-
return enqueueSidebarWrite(async () => {
|
|
1319
|
-
await writeMergedSidebarState(
|
|
1320
|
-
file,
|
|
1321
|
-
(latest) => {
|
|
1322
|
-
const now = Date.now()
|
|
1323
|
-
const activeRouting = pruneActiveRouting(
|
|
1324
|
-
latest.activeRouting,
|
|
1325
|
-
accounts,
|
|
1326
|
-
now,
|
|
1327
|
-
sessionId,
|
|
1328
|
-
)
|
|
1329
|
-
const stickyAssignments = pruneStickyAssignments(
|
|
1330
|
-
latest.stickyAssignments,
|
|
1331
|
-
usableRoutingAccountIds(accounts),
|
|
1332
|
-
now,
|
|
1333
|
-
hashSidebarSessionId(sessionId),
|
|
1334
|
-
)
|
|
1335
|
-
return {
|
|
1336
|
-
...latest,
|
|
1337
|
-
activeRouting,
|
|
1338
|
-
stickyAssignments,
|
|
1339
|
-
lastUpdated: Math.max(Date.now(), latest.lastUpdated + 1),
|
|
1340
|
-
}
|
|
1341
|
-
},
|
|
1342
|
-
hooks,
|
|
1343
|
-
)
|
|
1344
|
-
})
|
|
1345
|
-
}
|
|
1346
|
-
|
|
1347
|
-
export async function resolveSidebarStickyAssignment(
|
|
1348
|
-
input: ResolveStickyAssignmentInput,
|
|
1349
|
-
file = getSidebarStateFile(),
|
|
1350
|
-
hooks?: SidebarMergeHooks,
|
|
1351
|
-
): Promise<StickyAssignment | undefined> {
|
|
1352
|
-
const sessionHash = hashSidebarSessionId(input.sessionId)
|
|
1353
|
-
const validPinnedAccountIds = new Set(input.validPinnedAccountIds)
|
|
1354
|
-
const excludedAccountIds = new Set(input.excludeAccountIds)
|
|
1355
|
-
const existing = (await readSidebarState(file)).stickyAssignments?.[
|
|
1356
|
-
sessionHash
|
|
1357
|
-
]
|
|
1358
|
-
if (
|
|
1359
|
-
isValidStickyAssignment(
|
|
1360
|
-
existing,
|
|
1361
|
-
validPinnedAccountIds,
|
|
1362
|
-
excludedAccountIds,
|
|
1363
|
-
input.now,
|
|
1364
|
-
) &&
|
|
1365
|
-
!hasStickyIdentityMismatch(
|
|
1366
|
-
existing,
|
|
1367
|
-
input.wireAccountIdByAccount?.[existing.accountId],
|
|
1368
|
-
) &&
|
|
1369
|
-
!stickyAssignmentNeedsMetadataUpdate(
|
|
1370
|
-
existing,
|
|
1371
|
-
input.requestBytes,
|
|
1372
|
-
input.now,
|
|
1373
|
-
input.wireAccountIdByAccount?.[existing.accountId],
|
|
1374
|
-
)
|
|
1375
|
-
) {
|
|
1376
|
-
return existing
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
let resolved: StickyAssignment | undefined
|
|
1380
|
-
await enqueueSidebarWrite(async () => {
|
|
1381
|
-
await writeMergedSidebarState(
|
|
1382
|
-
file,
|
|
1383
|
-
(latest) => {
|
|
1384
|
-
const stickyAssignments = pruneStickyAssignments(
|
|
1385
|
-
latest.stickyAssignments,
|
|
1386
|
-
validPinnedAccountIds,
|
|
1387
|
-
input.now,
|
|
1388
|
-
)
|
|
1389
|
-
const assignmentsPruned = !stickyAssignmentsEqual(
|
|
1390
|
-
latest.stickyAssignments,
|
|
1391
|
-
stickyAssignments,
|
|
1392
|
-
)
|
|
1393
|
-
const current = stickyAssignments?.[sessionHash]
|
|
1394
|
-
const currentIdentityMismatch =
|
|
1395
|
-
current !== undefined &&
|
|
1396
|
-
hasStickyIdentityMismatch(
|
|
1397
|
-
current,
|
|
1398
|
-
input.wireAccountIdByAccount?.[current.accountId],
|
|
1399
|
-
)
|
|
1400
|
-
if (
|
|
1401
|
-
isValidStickyAssignment(
|
|
1402
|
-
current,
|
|
1403
|
-
validPinnedAccountIds,
|
|
1404
|
-
excludedAccountIds,
|
|
1405
|
-
input.now,
|
|
1406
|
-
) &&
|
|
1407
|
-
!currentIdentityMismatch
|
|
1408
|
-
) {
|
|
1409
|
-
const metadataNeedsUpdate = stickyAssignmentNeedsMetadataUpdate(
|
|
1410
|
-
current,
|
|
1411
|
-
input.requestBytes,
|
|
1412
|
-
input.now,
|
|
1413
|
-
input.wireAccountIdByAccount?.[current.accountId],
|
|
1414
|
-
)
|
|
1415
|
-
const assignment = metadataNeedsUpdate
|
|
1416
|
-
? {
|
|
1417
|
-
...current,
|
|
1418
|
-
inputBytes: Math.max(current.inputBytes, input.requestBytes),
|
|
1419
|
-
...(current.wireAccountId === undefined &&
|
|
1420
|
-
input.wireAccountIdByAccount?.[current.accountId] !== undefined
|
|
1421
|
-
? {
|
|
1422
|
-
wireAccountId:
|
|
1423
|
-
input.wireAccountIdByAccount?.[current.accountId],
|
|
1424
|
-
}
|
|
1425
|
-
: {}),
|
|
1426
|
-
...(input.now - current.lastSeenAt >=
|
|
1427
|
-
STICKY_ASSIGNMENT_LAST_SEEN_TOUCH_MS
|
|
1428
|
-
? { lastSeenAt: input.now }
|
|
1429
|
-
: {}),
|
|
1430
|
-
}
|
|
1431
|
-
: current
|
|
1432
|
-
resolved = assignment
|
|
1433
|
-
if (!assignmentsPruned && !metadataNeedsUpdate) return undefined
|
|
1434
|
-
return {
|
|
1435
|
-
...latest,
|
|
1436
|
-
stickyAssignments: {
|
|
1437
|
-
...stickyAssignments,
|
|
1438
|
-
[sessionHash]: assignment,
|
|
1439
|
-
},
|
|
1440
|
-
lastUpdated: Math.max(input.now, latest.lastUpdated + 1),
|
|
1441
|
-
}
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
|
-
const choice = input.choose(
|
|
1445
|
-
pendingBytesForAssignments(
|
|
1446
|
-
stickyAssignments,
|
|
1447
|
-
input.quotaCheckedAtByAccount,
|
|
1448
|
-
// Pending bytes weigh how much traffic each account is already
|
|
1449
|
-
// committed to, so the session being placed must not weigh its own
|
|
1450
|
-
// stale entry: that entry belongs to the account it is moving off,
|
|
1451
|
-
// and counting it would bias placement away from a perfectly good
|
|
1452
|
-
// destination. Other sessions' entries still count.
|
|
1453
|
-
currentIdentityMismatch ? sessionHash : undefined,
|
|
1454
|
-
),
|
|
1455
|
-
)
|
|
1456
|
-
if (!choice) {
|
|
1457
|
-
resolved = undefined
|
|
1458
|
-
if (!assignmentsPruned) return undefined
|
|
1459
|
-
return {
|
|
1460
|
-
...latest,
|
|
1461
|
-
stickyAssignments,
|
|
1462
|
-
lastUpdated: Math.max(input.now, latest.lastUpdated + 1),
|
|
1463
|
-
}
|
|
1464
|
-
}
|
|
1465
|
-
|
|
1466
|
-
resolved = {
|
|
1467
|
-
accountId: choice.accountId,
|
|
1468
|
-
assignedAt: input.now,
|
|
1469
|
-
lastSeenAt: input.now,
|
|
1470
|
-
inputBytes: input.requestBytes,
|
|
1471
|
-
...(choice.quotaCheckedAt === undefined
|
|
1472
|
-
? {}
|
|
1473
|
-
: { quotaCheckedAt: choice.quotaCheckedAt }),
|
|
1474
|
-
...(input.wireAccountIdByAccount?.[choice.accountId] === undefined
|
|
1475
|
-
? {}
|
|
1476
|
-
: {
|
|
1477
|
-
wireAccountId: input.wireAccountIdByAccount?.[choice.accountId],
|
|
1478
|
-
}),
|
|
1479
|
-
}
|
|
1480
|
-
return {
|
|
1481
|
-
...latest,
|
|
1482
|
-
stickyAssignments: limitStickyAssignments(
|
|
1483
|
-
{
|
|
1484
|
-
...stickyAssignments,
|
|
1485
|
-
[sessionHash]: resolved,
|
|
1486
|
-
},
|
|
1487
|
-
sessionHash,
|
|
1488
|
-
),
|
|
1489
|
-
lastUpdated: Math.max(input.now, latest.lastUpdated + 1),
|
|
1490
|
-
}
|
|
1491
|
-
},
|
|
1492
|
-
hooks,
|
|
1493
|
-
)
|
|
1494
|
-
})
|
|
1495
|
-
return resolved
|
|
1496
|
-
}
|
|
1497
|
-
|
|
1498
|
-
export async function clearSidebarStickyAssignment(
|
|
1499
|
-
sessionId: string,
|
|
1500
|
-
file = getSidebarStateFile(),
|
|
1501
|
-
): Promise<boolean> {
|
|
1502
|
-
const sessionHash = hashSidebarSessionId(sessionId)
|
|
1503
|
-
let removed = false
|
|
1504
|
-
await enqueueSidebarWrite(async () => {
|
|
1505
|
-
await writeMergedSidebarState(file, (latest) => {
|
|
1506
|
-
const assignments = latest.stickyAssignments
|
|
1507
|
-
if (assignments?.[sessionHash] === undefined) {
|
|
1508
|
-
removed = false
|
|
1509
|
-
return undefined
|
|
1510
|
-
}
|
|
1511
|
-
removed = true
|
|
1512
|
-
const { [sessionHash]: _removed, ...remaining } = assignments
|
|
1513
|
-
return {
|
|
1514
|
-
...latest,
|
|
1515
|
-
...(Object.keys(remaining).length > 0
|
|
1516
|
-
? { stickyAssignments: remaining }
|
|
1517
|
-
: { stickyAssignments: undefined }),
|
|
1518
|
-
lastUpdated: Math.max(Date.now(), latest.lastUpdated + 1),
|
|
1519
|
-
}
|
|
1520
|
-
})
|
|
1521
|
-
})
|
|
1522
|
-
return removed
|
|
1523
|
-
}
|
|
1524
|
-
|
|
1525
|
-
async function doWriteSidebarState(
|
|
1526
|
-
state: SidebarState,
|
|
1527
|
-
file: string,
|
|
1528
|
-
): Promise<void> {
|
|
1529
|
-
const tempPath = `${file}.${randomUUID()}.tmp`
|
|
1530
|
-
try {
|
|
1531
|
-
await ensureSidebarStateDirectory(file)
|
|
1532
|
-
await writeFile(tempPath, JSON.stringify(normalizeSidebarState(state)), {
|
|
1533
|
-
encoding: 'utf8',
|
|
1534
|
-
mode: 0o600,
|
|
1535
|
-
})
|
|
1536
|
-
await rename(tempPath, file)
|
|
1537
|
-
} catch (e) {
|
|
1538
|
-
await rm(tempPath, { force: true }).catch(() => {})
|
|
1539
|
-
logSb.warn('sidebar write failed', {
|
|
1540
|
-
pid: process.pid,
|
|
1541
|
-
error: e instanceof Error ? e.message : String(e),
|
|
1542
|
-
})
|
|
1543
|
-
throw e
|
|
1544
|
-
}
|
|
1545
|
-
}
|
|
1546
|
-
|
|
1547
|
-
/**
|
|
1548
|
-
* Await all pending sidebar writes. Tests call this before restoring env
|
|
1549
|
-
* vars in teardown so no in-flight write can re-resolve getSidebarStateFile()
|
|
1550
|
-
* after the env is changed.
|
|
1551
|
-
*/
|
|
1552
|
-
export function drainSidebarWrites(): Promise<void> {
|
|
1553
|
-
return sidebarWriteChain
|
|
1554
|
-
}
|
|
1555
|
-
|
|
1556
|
-
// Resolve the currently-active account from activeId for the collapsed sidebar
|
|
1557
|
-
// view. activeId === 'main' (or undefined/unmatched/disabled) → the main
|
|
1558
|
-
// account; otherwise the enabled fallback whose id matches.
|
|
1559
|
-
export function resolveActiveAccount(state: SidebarState): {
|
|
1560
|
-
id: string
|
|
1561
|
-
name: string
|
|
1562
|
-
quota: AccountQuota | null
|
|
1563
|
-
killed: boolean
|
|
1564
|
-
} {
|
|
1565
|
-
const activeId = state.activeId
|
|
1566
|
-
if (activeId && activeId !== 'main') {
|
|
1567
|
-
const fallback = state.fallbacks.find(
|
|
1568
|
-
(account) => account.enabled && account.id === activeId,
|
|
1569
|
-
)
|
|
1570
|
-
if (fallback) {
|
|
1571
|
-
return {
|
|
1572
|
-
id: fallback.id,
|
|
1573
|
-
name: fallback.label ?? fallback.id,
|
|
1574
|
-
quota: fallback.quota,
|
|
1575
|
-
killed: fallback.killed,
|
|
1576
|
-
}
|
|
1577
|
-
}
|
|
1578
|
-
}
|
|
1579
|
-
return {
|
|
1580
|
-
id: 'main',
|
|
1581
|
-
name: 'main',
|
|
1582
|
-
quota: state.main.quota,
|
|
1583
|
-
killed: state.main.killed,
|
|
1584
|
-
}
|
|
1585
|
-
}
|
|
1586
|
-
|
|
1587
|
-
export function getCollapsedQuotaSummary(quota: AccountQuota | null): {
|
|
1588
|
-
primaryUsedPercent: number | null
|
|
1589
|
-
secondaryUsedPercent: number | null
|
|
1590
|
-
text: string | null
|
|
1591
|
-
} {
|
|
1592
|
-
const primaryUsedPercent = quota?.primary?.usedPercent ?? null
|
|
1593
|
-
const secondaryUsedPercent = quota?.secondary?.usedPercent ?? null
|
|
1594
|
-
const rows = getPresentQuotaWindows(quota)
|
|
1595
|
-
return {
|
|
1596
|
-
primaryUsedPercent,
|
|
1597
|
-
secondaryUsedPercent,
|
|
1598
|
-
text:
|
|
1599
|
-
rows.length === 0
|
|
1600
|
-
? null
|
|
1601
|
-
: rows
|
|
1602
|
-
.map(
|
|
1603
|
-
({ label, window }) =>
|
|
1604
|
-
`${label}: ${Math.round(window.usedPercent)}%`,
|
|
1605
|
-
)
|
|
1606
|
-
.join(' '),
|
|
1607
|
-
}
|
|
1608
|
-
}
|
|
1609
|
-
|
|
1610
|
-
const PACING_MIN_ELAPSED_MS = 5 * 60 * 1000
|
|
1611
|
-
const PACING_MIN_ELAPSED_FRACTION = 0.01
|
|
1612
|
-
const ON_PACE_DELTA = 1
|
|
1613
|
-
|
|
1614
|
-
export interface QuotaPacing {
|
|
1615
|
-
pacePercent: number
|
|
1616
|
-
deltaPercent: number
|
|
1617
|
-
state: 'deficit' | 'reserve' | 'on-pace'
|
|
1618
|
-
runsOutAt: string | null
|
|
1619
|
-
}
|
|
1620
|
-
|
|
1621
|
-
// Even-burn pacing for a quota window. The window start is inferred from the
|
|
1622
|
-
// reset timestamp minus the window length. Two metrics: deltaPercent compares
|
|
1623
|
-
// usage against a uniform burn-down (positive = deficit), and runsOutAt
|
|
1624
|
-
// projects the current average burn rate forward — null means the window
|
|
1625
|
-
// lasts until reset at that rate. Returns null when there is no reset
|
|
1626
|
-
// timestamp or the elapsed time is too small to give a meaningful rate.
|
|
1627
|
-
export function computeQuotaPacing(
|
|
1628
|
-
window: QuotaWindow,
|
|
1629
|
-
windowMs: number,
|
|
1630
|
-
now: number,
|
|
1631
|
-
): QuotaPacing | null {
|
|
1632
|
-
if (!window.resetsAt) return null
|
|
1633
|
-
const resetsAt = new Date(window.resetsAt).getTime()
|
|
1634
|
-
if (!Number.isFinite(resetsAt)) return null
|
|
1635
|
-
const start = resetsAt - windowMs
|
|
1636
|
-
const elapsed = now - start
|
|
1637
|
-
if (elapsed < PACING_MIN_ELAPSED_MS) return null
|
|
1638
|
-
if (elapsed < windowMs * PACING_MIN_ELAPSED_FRACTION) return null
|
|
1639
|
-
if (elapsed >= windowMs) return null
|
|
1640
|
-
|
|
1641
|
-
const used = window.usedPercent
|
|
1642
|
-
const pacePercent = Math.min(Math.max((elapsed / windowMs) * 100, 0), 100)
|
|
1643
|
-
const deltaPercent = used - pacePercent
|
|
1644
|
-
const state =
|
|
1645
|
-
Math.abs(deltaPercent) < ON_PACE_DELTA
|
|
1646
|
-
? 'on-pace'
|
|
1647
|
-
: deltaPercent > 0
|
|
1648
|
-
? 'deficit'
|
|
1649
|
-
: 'reserve'
|
|
1650
|
-
|
|
1651
|
-
let runsOutAt: string | null = null
|
|
1652
|
-
if (used > 0) {
|
|
1653
|
-
const msToFull = (elapsed * 100) / used
|
|
1654
|
-
const runOut = start + msToFull
|
|
1655
|
-
if (runOut < resetsAt) runsOutAt = new Date(runOut).toISOString()
|
|
1656
|
-
}
|
|
1657
|
-
|
|
1658
|
-
return { pacePercent, deltaPercent, state, runsOutAt }
|
|
1659
|
-
}
|