@cortexkit/opencode-openai-auth 0.4.3 → 0.5.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 +8 -4
- package/dist/cli.js +1 -1
- package/dist/codex-http.d.ts +1 -1
- package/dist/commands.d.ts +6 -0
- package/dist/core/accounts.d.ts +6 -1
- package/dist/core/background-quota-refresh.d.ts +46 -0
- package/dist/core/cachekeep.d.ts +4 -0
- package/dist/core/oauth.d.ts +1 -1
- package/dist/core/quota-manager.d.ts +11 -4
- package/dist/core/refresh-all-quota.d.ts +12 -1
- package/dist/core/sticky-routing.d.ts +43 -0
- package/dist/dump.d.ts +1 -0
- package/dist/{index-xcf8tm40.js → index-6rnw2cgs.js} +1 -1
- package/dist/index-j1364eag.js +89 -0
- package/dist/index-rfgyxxxq.js +1 -0
- package/dist/index-s5fbqqwx.js +3 -0
- package/dist/index-vrdc90yd.js +6 -0
- package/dist/index-wnxfzjfr.js +2 -0
- package/dist/{index-xbq3aq6f.js → index-wpd7mg3p.js} +1 -1
- package/dist/index-zkzb0twr.js +2 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +99 -86
- package/dist/{quota-normalize-754rewnb.js → quota-normalize-n02dfdx0.js} +1 -1
- package/dist/rpc/port-file.d.ts +4 -0
- package/dist/rpc/port-file.js +1 -1
- package/dist/rpc/protocol.d.ts +1 -0
- package/dist/rpc/rpc-client.js +1 -1
- package/dist/rpc/rpc-dir.d.ts +6 -0
- package/dist/rpc/rpc-dir.js +1 -1
- package/dist/rpc/rpc-server.d.ts +2 -0
- package/dist/sidebar-state.d.ts +44 -5
- package/dist/sidebar-state.js +1 -1
- package/dist/{token-xsc39hv8.js → token-5hdnazxp.js} +1 -1
- package/dist/token-util-3d2nj45d.js +1 -0
- package/dist/tui-preferences.js +1 -1
- package/dist/tui.d.ts +9 -0
- package/dist/version.d.ts +1 -1
- package/package.json +2 -2
- package/src/rpc/port-file.ts +115 -8
- package/src/rpc/protocol.ts +1 -0
- package/src/rpc/rpc-dir.ts +31 -7
- package/src/rpc/rpc-server.ts +18 -2
- package/src/sidebar-state.ts +744 -27
- package/src/tui/command-dialogs.tsx +25 -3
- package/src/tui-compiled/rpc/port-file.ts +115 -8
- package/src/tui-compiled/rpc/protocol.ts +1 -0
- package/src/tui-compiled/rpc/rpc-dir.ts +31 -7
- package/src/tui-compiled/sidebar-state.ts +744 -27
- package/src/tui-compiled/tui/command-dialogs.tsx +17 -4
- package/src/tui-compiled/tui.tsx +50 -15
- package/src/tui.tsx +57 -19
- package/dist/index-9k2gxqc1.js +0 -89
- package/dist/index-ptsh66z7.js +0 -2
- package/dist/index-rckqt97a.js +0 -1
- package/dist/index-reagbdg3.js +0 -2
- package/dist/index-ya6hw803.js +0 -7
- package/dist/token-util-xzavn760.js +0 -1
package/src/sidebar-state.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
export interface QuotaWindow {
|
|
2
2
|
usedPercent: number
|
|
3
3
|
remainingPercent: number
|
|
4
|
+
checkedAt?: number
|
|
4
5
|
resetsAt?: string
|
|
5
6
|
windowMinutes?: number
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
export interface AccountQuota {
|
|
10
|
+
checkedAt?: number
|
|
9
11
|
primary?: QuotaWindow
|
|
10
12
|
secondary?: QuotaWindow
|
|
11
13
|
resetCreditsAvailable?: number
|
|
@@ -80,6 +82,8 @@ export function getPresentQuotaWindows(
|
|
|
80
82
|
export interface SidebarAccountState {
|
|
81
83
|
id: string
|
|
82
84
|
label: string | undefined
|
|
85
|
+
/** ChatGPT identity of the account this quota belongs to. */
|
|
86
|
+
accountId?: string
|
|
83
87
|
quota: AccountQuota | null
|
|
84
88
|
killed: boolean
|
|
85
89
|
enabled: boolean
|
|
@@ -94,9 +98,38 @@ export interface ActiveRoutingEntry {
|
|
|
94
98
|
|
|
95
99
|
export type ActiveRoutingMap = Record<string, ActiveRoutingEntry>
|
|
96
100
|
|
|
101
|
+
export interface StickyAssignment {
|
|
102
|
+
accountId: string
|
|
103
|
+
assignedAt: number
|
|
104
|
+
lastSeenAt: number
|
|
105
|
+
inputBytes: number
|
|
106
|
+
quotaCheckedAt?: number
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type StickyAssignmentMap = Record<string, StickyAssignment>
|
|
110
|
+
|
|
111
|
+
export interface StickyAssignmentChoice {
|
|
112
|
+
accountId: string
|
|
113
|
+
quotaCheckedAt?: number
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface ResolveStickyAssignmentInput {
|
|
117
|
+
sessionId: string
|
|
118
|
+
requestBytes: number
|
|
119
|
+
now: number
|
|
120
|
+
validPinnedAccountIds: readonly string[]
|
|
121
|
+
excludeAccountIds?: readonly string[]
|
|
122
|
+
quotaCheckedAtByAccount: Readonly<Record<string, number | undefined>>
|
|
123
|
+
choose: (
|
|
124
|
+
pendingBytes: ReadonlyMap<string, number>,
|
|
125
|
+
) => StickyAssignmentChoice | undefined
|
|
126
|
+
}
|
|
127
|
+
|
|
97
128
|
export interface SidebarState {
|
|
98
129
|
main: {
|
|
99
130
|
quota: AccountQuota | null
|
|
131
|
+
/** ChatGPT identity of the main account this quota belongs to. */
|
|
132
|
+
mainAccountId?: string
|
|
100
133
|
killed: boolean
|
|
101
134
|
quotaBackedOff?: boolean
|
|
102
135
|
quotaBackoffUntil?: number
|
|
@@ -110,13 +143,14 @@ export interface SidebarState {
|
|
|
110
143
|
/** Machine-global routing mode and compatibility value for older readers. */
|
|
111
144
|
route: string
|
|
112
145
|
activeRouting?: ActiveRoutingMap
|
|
146
|
+
stickyAssignments?: StickyAssignmentMap
|
|
113
147
|
planType?: string
|
|
114
148
|
credits?: number
|
|
115
149
|
lastUpdated: number
|
|
116
150
|
}
|
|
117
151
|
|
|
118
|
-
import { randomUUID } from 'node:crypto'
|
|
119
|
-
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
152
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
153
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
120
154
|
import { tmpdir } from 'node:os'
|
|
121
155
|
import { dirname, join } from 'node:path'
|
|
122
156
|
import { acquireRefreshFileLock } from './core/refresh-file-lock'
|
|
@@ -127,6 +161,14 @@ const logSb = createLogger('sidebar')
|
|
|
127
161
|
const STATE_FILE_ENV = 'OPENCODE_OPENAI_AUTH_SIDEBAR_STATE_FILE'
|
|
128
162
|
const DEFAULT_STATE_DIR = join(tmpdir(), 'opencode-openai-auth')
|
|
129
163
|
const DEFAULT_STATE_FILE = join(DEFAULT_STATE_DIR, 'sidebar-state.json')
|
|
164
|
+
const SESSION_HASH_PATTERN = /^[a-f0-9]{64}$/
|
|
165
|
+
export const STICKY_ASSIGNMENT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
|
166
|
+
export const STICKY_ASSIGNMENT_MAX_ENTRIES = 256
|
|
167
|
+
const STICKY_ASSIGNMENT_LAST_SEEN_TOUCH_MS = 60 * 60 * 1000
|
|
168
|
+
|
|
169
|
+
export function hashSidebarSessionId(sessionId: string): string {
|
|
170
|
+
return createHash('sha256').update(sessionId).digest('hex')
|
|
171
|
+
}
|
|
130
172
|
|
|
131
173
|
function normalizeResetCredits(value: unknown): number | undefined {
|
|
132
174
|
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
@@ -172,6 +214,60 @@ function normalizeActiveRouting(value: unknown): ActiveRoutingMap | undefined {
|
|
|
172
214
|
return Object.keys(normalized).length > 0 ? normalized : undefined
|
|
173
215
|
}
|
|
174
216
|
|
|
217
|
+
function normalizeStickyAssignments(
|
|
218
|
+
value: unknown,
|
|
219
|
+
): StickyAssignmentMap | undefined {
|
|
220
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
221
|
+
return undefined
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const normalized: StickyAssignmentMap = {}
|
|
225
|
+
for (const [sessionHash, rawAssignment] of Object.entries(value)) {
|
|
226
|
+
if (
|
|
227
|
+
!SESSION_HASH_PATTERN.test(sessionHash) ||
|
|
228
|
+
rawAssignment === null ||
|
|
229
|
+
typeof rawAssignment !== 'object' ||
|
|
230
|
+
Array.isArray(rawAssignment)
|
|
231
|
+
) {
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
const assignment = rawAssignment as Record<string, unknown>
|
|
235
|
+
if (
|
|
236
|
+
typeof assignment.accountId !== 'string' ||
|
|
237
|
+
assignment.accountId.length === 0 ||
|
|
238
|
+
typeof assignment.assignedAt !== 'number' ||
|
|
239
|
+
!Number.isFinite(assignment.assignedAt) ||
|
|
240
|
+
assignment.assignedAt < 0 ||
|
|
241
|
+
typeof assignment.lastSeenAt !== 'number' ||
|
|
242
|
+
!Number.isFinite(assignment.lastSeenAt) ||
|
|
243
|
+
assignment.lastSeenAt < 0 ||
|
|
244
|
+
typeof assignment.inputBytes !== 'number' ||
|
|
245
|
+
!Number.isFinite(assignment.inputBytes) ||
|
|
246
|
+
assignment.inputBytes < 0
|
|
247
|
+
) {
|
|
248
|
+
continue
|
|
249
|
+
}
|
|
250
|
+
const quotaCheckedAt = assignment.quotaCheckedAt
|
|
251
|
+
if (
|
|
252
|
+
quotaCheckedAt !== undefined &&
|
|
253
|
+
(typeof quotaCheckedAt !== 'number' ||
|
|
254
|
+
!Number.isFinite(quotaCheckedAt) ||
|
|
255
|
+
quotaCheckedAt < 0)
|
|
256
|
+
) {
|
|
257
|
+
continue
|
|
258
|
+
}
|
|
259
|
+
normalized[sessionHash] = {
|
|
260
|
+
accountId: assignment.accountId,
|
|
261
|
+
assignedAt: assignment.assignedAt,
|
|
262
|
+
lastSeenAt: assignment.lastSeenAt,
|
|
263
|
+
inputBytes: assignment.inputBytes,
|
|
264
|
+
...(quotaCheckedAt === undefined ? {} : { quotaCheckedAt }),
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return Object.keys(normalized).length > 0 ? normalized : undefined
|
|
269
|
+
}
|
|
270
|
+
|
|
175
271
|
export function getSidebarStateFile(): string {
|
|
176
272
|
return process.env[STATE_FILE_ENV] || DEFAULT_STATE_FILE
|
|
177
273
|
}
|
|
@@ -212,6 +308,9 @@ export function normalizeSidebarState(raw: unknown): SidebarState {
|
|
|
212
308
|
main = {
|
|
213
309
|
quota: ('quota' in m ? m.quota : null) as AccountQuota | null,
|
|
214
310
|
killed: typeof m.killed === 'boolean' ? m.killed : false,
|
|
311
|
+
...(typeof m.mainAccountId === 'string'
|
|
312
|
+
? { mainAccountId: m.mainAccountId }
|
|
313
|
+
: {}),
|
|
215
314
|
// Preserve optional backoff fields if present
|
|
216
315
|
...(typeof m.quotaBackedOff === 'boolean'
|
|
217
316
|
? { quotaBackedOff: m.quotaBackedOff }
|
|
@@ -247,6 +346,9 @@ export function normalizeSidebarState(raw: unknown): SidebarState {
|
|
|
247
346
|
.map((e) => ({
|
|
248
347
|
id: e.id as string,
|
|
249
348
|
label: typeof e.label === 'string' ? e.label : undefined,
|
|
349
|
+
...(typeof e.accountId === 'string'
|
|
350
|
+
? { accountId: e.accountId }
|
|
351
|
+
: {}),
|
|
250
352
|
quota: ('quota' in e ? e.quota : null) as AccountQuota | null,
|
|
251
353
|
killed: typeof e.killed === 'boolean' ? e.killed : false,
|
|
252
354
|
enabled: typeof e.enabled === 'boolean' ? e.enabled : true,
|
|
@@ -268,6 +370,7 @@ export function normalizeSidebarState(raw: unknown): SidebarState {
|
|
|
268
370
|
const planType = typeof r.planType === 'string' ? r.planType : undefined
|
|
269
371
|
const credits = typeof r.credits === 'number' ? r.credits : undefined
|
|
270
372
|
const activeRouting = normalizeActiveRouting(r.activeRouting)
|
|
373
|
+
const stickyAssignments = normalizeStickyAssignments(r.stickyAssignments)
|
|
271
374
|
return {
|
|
272
375
|
main,
|
|
273
376
|
fallbacks,
|
|
@@ -275,14 +378,17 @@ export function normalizeSidebarState(raw: unknown): SidebarState {
|
|
|
275
378
|
route,
|
|
276
379
|
lastUpdated,
|
|
277
380
|
...(activeRouting !== undefined ? { activeRouting } : {}),
|
|
381
|
+
...(stickyAssignments !== undefined ? { stickyAssignments } : {}),
|
|
278
382
|
...(planType !== undefined ? { planType } : {}),
|
|
279
383
|
...(credits !== undefined ? { credits } : {}),
|
|
280
384
|
}
|
|
281
385
|
}
|
|
282
386
|
|
|
283
|
-
export async function getSidebarState(
|
|
387
|
+
export async function getSidebarState(
|
|
388
|
+
stateFile = getSidebarStateFile(),
|
|
389
|
+
): Promise<SidebarState> {
|
|
284
390
|
try {
|
|
285
|
-
const raw = await readFile(
|
|
391
|
+
const raw = await readFile(stateFile, 'utf8')
|
|
286
392
|
return normalizeSidebarState(JSON.parse(raw))
|
|
287
393
|
} catch {
|
|
288
394
|
return DEFAULT_SIDEBAR_STATE
|
|
@@ -300,12 +406,13 @@ export type SidebarRoutingAccount = {
|
|
|
300
406
|
|
|
301
407
|
export function isUsableRoutingEntry(
|
|
302
408
|
entry: ActiveRoutingEntry,
|
|
303
|
-
accounts: readonly SidebarRoutingAccount[],
|
|
409
|
+
accounts: readonly SidebarRoutingAccount[] | undefined,
|
|
304
410
|
now = Date.now(),
|
|
305
411
|
): boolean {
|
|
306
412
|
const fresh =
|
|
307
413
|
entry.updatedAt >= now - ACTIVE_ROUTING_MAX_AGE_MS && entry.updatedAt <= now
|
|
308
414
|
if (!fresh) return false
|
|
415
|
+
if (accounts === undefined) return true
|
|
309
416
|
return (
|
|
310
417
|
entry.activeId === 'main' ||
|
|
311
418
|
accounts.some(
|
|
@@ -317,20 +424,72 @@ export function isUsableRoutingEntry(
|
|
|
317
424
|
)
|
|
318
425
|
}
|
|
319
426
|
|
|
427
|
+
// Earliest future reset among the quota's exhausted windows, or undefined when
|
|
428
|
+
// no present window is exhausted. Every present window is evaluated — matching
|
|
429
|
+
// the admission policy, which rejects an account when ANY live window is below
|
|
430
|
+
// its threshold — and each check fails open: a missing/malformed usage, a
|
|
431
|
+
// missing/unparsable reset, or a reset already in the past never counts as
|
|
432
|
+
// exhausted, so a stale or corrupt snapshot can never block routing.
|
|
433
|
+
export function exhaustedQuotaResetAt(
|
|
434
|
+
quota: AccountQuota | null | undefined,
|
|
435
|
+
now = Date.now(),
|
|
436
|
+
): { resetsAt: string; resetAtMs: number } | undefined {
|
|
437
|
+
let earliest: { resetsAt: string; resetAtMs: number } | undefined
|
|
438
|
+
for (const key of QUOTA_WINDOW_KEYS) {
|
|
439
|
+
const window = quota?.[key]
|
|
440
|
+
if (
|
|
441
|
+
typeof window?.resetsAt !== 'string' ||
|
|
442
|
+
!Number.isFinite(window.usedPercent) ||
|
|
443
|
+
window.usedPercent < 100
|
|
444
|
+
) {
|
|
445
|
+
continue
|
|
446
|
+
}
|
|
447
|
+
const resetAtMs = Date.parse(window.resetsAt)
|
|
448
|
+
if (!Number.isFinite(resetAtMs) || resetAtMs <= now) continue
|
|
449
|
+
if (!earliest || resetAtMs < earliest.resetAtMs) {
|
|
450
|
+
earliest = { resetsAt: window.resetsAt, resetAtMs }
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return earliest
|
|
454
|
+
}
|
|
455
|
+
|
|
320
456
|
export function isQuotaExhausted(
|
|
321
457
|
quota: AccountQuota | null | undefined,
|
|
322
458
|
now = Date.now(),
|
|
323
459
|
): boolean {
|
|
324
|
-
|
|
460
|
+
return exhaustedQuotaResetAt(quota, now) !== undefined
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export function resolveSessionStickyAccount(
|
|
464
|
+
state: SidebarState,
|
|
465
|
+
sessionId: string | undefined,
|
|
466
|
+
now = Date.now(),
|
|
467
|
+
): string | undefined {
|
|
468
|
+
if (!sessionId || state.route !== 'sticky-balanced') return undefined
|
|
469
|
+
const assignment = state.stickyAssignments?.[hashSidebarSessionId(sessionId)]
|
|
325
470
|
if (
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
primary.usedPercent < 100
|
|
471
|
+
!assignment ||
|
|
472
|
+
assignment.lastSeenAt < now - STICKY_ASSIGNMENT_MAX_AGE_MS
|
|
329
473
|
) {
|
|
330
|
-
return
|
|
474
|
+
return undefined
|
|
331
475
|
}
|
|
332
|
-
|
|
333
|
-
|
|
476
|
+
if (assignment.accountId === 'main') {
|
|
477
|
+
return state.main?.killed || isQuotaExhausted(state.main?.quota, now)
|
|
478
|
+
? undefined
|
|
479
|
+
: 'main'
|
|
480
|
+
}
|
|
481
|
+
const fallback = state.fallbacks?.find(
|
|
482
|
+
(account) => account.id === assignment.accountId,
|
|
483
|
+
)
|
|
484
|
+
if (
|
|
485
|
+
!fallback ||
|
|
486
|
+
fallback.enabled === false ||
|
|
487
|
+
fallback.killed === true ||
|
|
488
|
+
isQuotaExhausted(fallback.quota, now)
|
|
489
|
+
) {
|
|
490
|
+
return undefined
|
|
491
|
+
}
|
|
492
|
+
return fallback.id
|
|
334
493
|
}
|
|
335
494
|
|
|
336
495
|
export function resolveSessionSidebarRouting(
|
|
@@ -354,6 +513,11 @@ export function resolveSessionSidebarRouting(
|
|
|
354
513
|
return { activeId: own.activeId, route: own.route }
|
|
355
514
|
}
|
|
356
515
|
|
|
516
|
+
const stickyAccountId = resolveSessionStickyAccount(state, sessionId, now)
|
|
517
|
+
if (stickyAccountId) {
|
|
518
|
+
return { activeId: stickyAccountId, route: state.route }
|
|
519
|
+
}
|
|
520
|
+
|
|
357
521
|
const enabledFallbacks = state.fallbacks.filter(
|
|
358
522
|
(account) => account.enabled && !account.killed,
|
|
359
523
|
)
|
|
@@ -369,7 +533,7 @@ export function resolveSessionSidebarRouting(
|
|
|
369
533
|
|
|
370
534
|
export function pruneActiveRouting(
|
|
371
535
|
activeRouting: ActiveRoutingMap | undefined,
|
|
372
|
-
accounts: readonly SidebarRoutingAccount[],
|
|
536
|
+
accounts: readonly SidebarRoutingAccount[] | undefined,
|
|
373
537
|
now = Date.now(),
|
|
374
538
|
removedSessionId?: string,
|
|
375
539
|
): ActiveRoutingMap | undefined {
|
|
@@ -388,6 +552,175 @@ export function pruneActiveRouting(
|
|
|
388
552
|
return bounded.length > 0 ? Object.fromEntries(bounded) : undefined
|
|
389
553
|
}
|
|
390
554
|
|
|
555
|
+
export function pruneStickyAssignments(
|
|
556
|
+
assignments: StickyAssignmentMap | undefined,
|
|
557
|
+
validAccountIds: ReadonlySet<string> | undefined,
|
|
558
|
+
now = Date.now(),
|
|
559
|
+
removedSessionHash?: string,
|
|
560
|
+
): StickyAssignmentMap | undefined {
|
|
561
|
+
if (!assignments) return undefined
|
|
562
|
+
let accountNotInRoster = 0
|
|
563
|
+
let expired = 0
|
|
564
|
+
let explicitRemoval = 0
|
|
565
|
+
const kept: [string, StickyAssignment][] = []
|
|
566
|
+
for (const [sessionHash, assignment] of Object.entries(assignments)) {
|
|
567
|
+
if (sessionHash === removedSessionHash) {
|
|
568
|
+
explicitRemoval += 1
|
|
569
|
+
continue
|
|
570
|
+
}
|
|
571
|
+
if (assignment.lastSeenAt < now - STICKY_ASSIGNMENT_MAX_AGE_MS) {
|
|
572
|
+
expired += 1
|
|
573
|
+
continue
|
|
574
|
+
}
|
|
575
|
+
if (
|
|
576
|
+
validAccountIds !== undefined &&
|
|
577
|
+
!validAccountIds.has(assignment.accountId)
|
|
578
|
+
) {
|
|
579
|
+
accountNotInRoster += 1
|
|
580
|
+
continue
|
|
581
|
+
}
|
|
582
|
+
kept.push([sessionHash, assignment])
|
|
583
|
+
}
|
|
584
|
+
const removed = accountNotInRoster + expired + explicitRemoval
|
|
585
|
+
if (removed > 0) {
|
|
586
|
+
logSb.debug('pruned sticky assignments', {
|
|
587
|
+
pid: process.pid,
|
|
588
|
+
removed,
|
|
589
|
+
reasons: {
|
|
590
|
+
'account-not-in-roster': accountNotInRoster,
|
|
591
|
+
expired,
|
|
592
|
+
'explicit-removal': explicitRemoval,
|
|
593
|
+
},
|
|
594
|
+
})
|
|
595
|
+
}
|
|
596
|
+
return kept.length > 0 ? Object.fromEntries(kept) : undefined
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function limitStickyAssignments(
|
|
600
|
+
assignments: StickyAssignmentMap,
|
|
601
|
+
protectedSessionHash: string,
|
|
602
|
+
): StickyAssignmentMap {
|
|
603
|
+
const overflow =
|
|
604
|
+
Object.keys(assignments).length - STICKY_ASSIGNMENT_MAX_ENTRIES
|
|
605
|
+
if (overflow <= 0) return assignments
|
|
606
|
+
const evicted = new Set(
|
|
607
|
+
Object.entries(assignments)
|
|
608
|
+
.filter(([sessionHash]) => sessionHash !== protectedSessionHash)
|
|
609
|
+
.sort(
|
|
610
|
+
([leftHash, left], [rightHash, right]) =>
|
|
611
|
+
left.lastSeenAt - right.lastSeenAt ||
|
|
612
|
+
leftHash.localeCompare(rightHash),
|
|
613
|
+
)
|
|
614
|
+
.slice(0, overflow)
|
|
615
|
+
.map(([sessionHash]) => sessionHash),
|
|
616
|
+
)
|
|
617
|
+
return Object.fromEntries(
|
|
618
|
+
Object.entries(assignments).filter(
|
|
619
|
+
([sessionHash]) => !evicted.has(sessionHash),
|
|
620
|
+
),
|
|
621
|
+
)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function stickyAssignmentsEqual(
|
|
625
|
+
left: StickyAssignmentMap | undefined,
|
|
626
|
+
right: StickyAssignmentMap | undefined,
|
|
627
|
+
): boolean {
|
|
628
|
+
if (left === right) return true
|
|
629
|
+
if (!left || !right) return false
|
|
630
|
+
const leftEntries = Object.entries(left)
|
|
631
|
+
if (leftEntries.length !== Object.keys(right).length) return false
|
|
632
|
+
return leftEntries.every(([sessionHash, assignment]) =>
|
|
633
|
+
Object.is(right[sessionHash], assignment),
|
|
634
|
+
)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function isValidStickyAssignment(
|
|
638
|
+
assignment: StickyAssignment | undefined,
|
|
639
|
+
validPinnedAccountIds: ReadonlySet<string>,
|
|
640
|
+
excludedAccountIds: ReadonlySet<string>,
|
|
641
|
+
now: number,
|
|
642
|
+
): assignment is StickyAssignment {
|
|
643
|
+
return (
|
|
644
|
+
assignment !== undefined &&
|
|
645
|
+
validPinnedAccountIds.has(assignment.accountId) &&
|
|
646
|
+
!excludedAccountIds.has(assignment.accountId) &&
|
|
647
|
+
assignment.lastSeenAt >= now - STICKY_ASSIGNMENT_MAX_AGE_MS
|
|
648
|
+
)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function stickyAssignmentNeedsMetadataUpdate(
|
|
652
|
+
assignment: StickyAssignment,
|
|
653
|
+
requestBytes: number,
|
|
654
|
+
now: number,
|
|
655
|
+
): boolean {
|
|
656
|
+
return (
|
|
657
|
+
requestBytes > assignment.inputBytes ||
|
|
658
|
+
now - assignment.lastSeenAt >= STICKY_ASSIGNMENT_LAST_SEEN_TOUCH_MS
|
|
659
|
+
)
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function readonlyPendingBytes(
|
|
663
|
+
pendingBytes: Map<string, number>,
|
|
664
|
+
): ReadonlyMap<string, number> {
|
|
665
|
+
const snapshot = new Map(pendingBytes)
|
|
666
|
+
const view: ReadonlyMap<string, number> = {
|
|
667
|
+
get size() {
|
|
668
|
+
return snapshot.size
|
|
669
|
+
},
|
|
670
|
+
has: (key: string) => snapshot.has(key),
|
|
671
|
+
get: (key: string) => snapshot.get(key),
|
|
672
|
+
entries: () => snapshot.entries(),
|
|
673
|
+
keys: () => snapshot.keys(),
|
|
674
|
+
values: () => snapshot.values(),
|
|
675
|
+
forEach: (
|
|
676
|
+
callback: (
|
|
677
|
+
value: number,
|
|
678
|
+
key: string,
|
|
679
|
+
map: ReadonlyMap<string, number>,
|
|
680
|
+
) => void,
|
|
681
|
+
thisArg?: unknown,
|
|
682
|
+
) => {
|
|
683
|
+
snapshot.forEach((value, key) => {
|
|
684
|
+
callback.call(thisArg, value, key, view)
|
|
685
|
+
})
|
|
686
|
+
},
|
|
687
|
+
[Symbol.iterator]: () => snapshot[Symbol.iterator](),
|
|
688
|
+
}
|
|
689
|
+
return Object.freeze(view)
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function pendingBytesForAssignments(
|
|
693
|
+
assignments: StickyAssignmentMap | undefined,
|
|
694
|
+
quotaCheckedAtByAccount: Readonly<Record<string, number | undefined>>,
|
|
695
|
+
): ReadonlyMap<string, number> {
|
|
696
|
+
const pendingBytes = new Map<string, number>()
|
|
697
|
+
for (const assignment of Object.values(assignments ?? {})) {
|
|
698
|
+
if (
|
|
699
|
+
assignment.quotaCheckedAt !==
|
|
700
|
+
quotaCheckedAtByAccount[assignment.accountId]
|
|
701
|
+
) {
|
|
702
|
+
continue
|
|
703
|
+
}
|
|
704
|
+
pendingBytes.set(
|
|
705
|
+
assignment.accountId,
|
|
706
|
+
(pendingBytes.get(assignment.accountId) ?? 0) + assignment.inputBytes,
|
|
707
|
+
)
|
|
708
|
+
}
|
|
709
|
+
return readonlyPendingBytes(pendingBytes)
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function usableRoutingAccountIds(
|
|
713
|
+
accounts: readonly SidebarRoutingAccount[] | undefined,
|
|
714
|
+
): ReadonlySet<string> | undefined {
|
|
715
|
+
if (accounts === undefined) return undefined
|
|
716
|
+
return new Set([
|
|
717
|
+
'main',
|
|
718
|
+
...accounts
|
|
719
|
+
.filter((account) => account.enabled !== false && account.killed !== true)
|
|
720
|
+
.map((account) => account.id),
|
|
721
|
+
])
|
|
722
|
+
}
|
|
723
|
+
|
|
391
724
|
// Serialization chain: concurrent calls are queued so a stale background
|
|
392
725
|
// write cannot land after a newer one and corrupt the file.
|
|
393
726
|
let sidebarWriteChain: Promise<void> = Promise.resolve()
|
|
@@ -450,7 +783,7 @@ function parseSidebarState(raw: string): SidebarState {
|
|
|
450
783
|
}
|
|
451
784
|
|
|
452
785
|
async function acquireSidebarWriteLock(file: string) {
|
|
453
|
-
await
|
|
786
|
+
await ensureSidebarStateDirectory(file)
|
|
454
787
|
const deadline = Date.now() + SIDEBAR_WRITE_LOCK_WAIT_MS
|
|
455
788
|
while (Date.now() <= deadline) {
|
|
456
789
|
const lock = await acquireRefreshFileLock({
|
|
@@ -465,9 +798,21 @@ async function acquireSidebarWriteLock(file: string) {
|
|
|
465
798
|
throw new Error('Timed out waiting for the sidebar state write lock')
|
|
466
799
|
}
|
|
467
800
|
|
|
801
|
+
async function ensureSidebarStateDirectory(file: string): Promise<void> {
|
|
802
|
+
const dir = dirname(file)
|
|
803
|
+
await mkdir(dir, { recursive: true, mode: 0o700 })
|
|
804
|
+
if (file !== DEFAULT_STATE_FILE) return
|
|
805
|
+
await chmod(dir, 0o700).catch((error) => {
|
|
806
|
+
logSb.warn('sidebar directory permission remediation failed', {
|
|
807
|
+
pid: process.pid,
|
|
808
|
+
error: error instanceof Error ? error.message : String(error),
|
|
809
|
+
})
|
|
810
|
+
})
|
|
811
|
+
}
|
|
812
|
+
|
|
468
813
|
async function writeMergedSidebarState(
|
|
469
814
|
file: string,
|
|
470
|
-
merge: (latest: SidebarState) => SidebarState,
|
|
815
|
+
merge: (latest: SidebarState) => SidebarState | undefined,
|
|
471
816
|
hooks?: SidebarMergeHooks,
|
|
472
817
|
): Promise<void> {
|
|
473
818
|
const lock = await acquireSidebarWriteLock(file)
|
|
@@ -477,6 +822,7 @@ async function writeMergedSidebarState(
|
|
|
477
822
|
for (let attempt = 0; attempt < MAX_MERGE_ATTEMPTS; attempt += 1) {
|
|
478
823
|
const rawBefore = await readRawSidebar(file)
|
|
479
824
|
const next = merge(parseSidebarState(rawBefore))
|
|
825
|
+
if (!next) return
|
|
480
826
|
if (attempt === 0) await hooks?.beforeRecheck?.()
|
|
481
827
|
const rawRecheck = await readRawSidebar(file)
|
|
482
828
|
if (rawRecheck !== rawBefore) continue
|
|
@@ -485,7 +831,8 @@ async function writeMergedSidebarState(
|
|
|
485
831
|
}
|
|
486
832
|
|
|
487
833
|
const latest = await readSidebarState(file)
|
|
488
|
-
|
|
834
|
+
const next = merge(latest)
|
|
835
|
+
if (next) await doWriteSidebarState(next, file)
|
|
489
836
|
} finally {
|
|
490
837
|
await lock.release()
|
|
491
838
|
}
|
|
@@ -496,6 +843,156 @@ export type SidebarMachineState = Pick<
|
|
|
496
843
|
'main' | 'fallbacks' | 'planType' | 'credits' | 'lastUpdated'
|
|
497
844
|
> & { route: string }
|
|
498
845
|
|
|
846
|
+
// The freshest signal across every timestamp a snapshot carries: either window
|
|
847
|
+
// (primary/secondary) or the legacy top-level stamp. A retired primary window
|
|
848
|
+
// (null) with a fresh secondary must still outrank an older incoming primary, so
|
|
849
|
+
// the comparison takes the max rather than the first present value.
|
|
850
|
+
function latestQuotaCheckedAt(quota: AccountQuota | null): number | undefined {
|
|
851
|
+
let latest: number | undefined
|
|
852
|
+
for (const checkedAt of [
|
|
853
|
+
quota?.primary?.checkedAt,
|
|
854
|
+
quota?.secondary?.checkedAt,
|
|
855
|
+
quota?.checkedAt,
|
|
856
|
+
]) {
|
|
857
|
+
if (typeof checkedAt === 'number' && Number.isFinite(checkedAt)) {
|
|
858
|
+
latest = latest === undefined ? checkedAt : Math.max(latest, checkedAt)
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
return latest
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function freshestQuota(
|
|
865
|
+
incoming: AccountQuota | null,
|
|
866
|
+
existing: AccountQuota | null,
|
|
867
|
+
): AccountQuota | null {
|
|
868
|
+
const incomingCheckedAt = latestQuotaCheckedAt(incoming)
|
|
869
|
+
const existingCheckedAt = latestQuotaCheckedAt(existing)
|
|
870
|
+
if (
|
|
871
|
+
existingCheckedAt !== undefined &&
|
|
872
|
+
(incomingCheckedAt === undefined || existingCheckedAt > incomingCheckedAt)
|
|
873
|
+
) {
|
|
874
|
+
return existing
|
|
875
|
+
}
|
|
876
|
+
return incoming
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// True when both sides assert the same stable account identity. An unknown
|
|
880
|
+
// identity on either side is NOT a confirmed match — merging windows across an
|
|
881
|
+
// unconfirmed identity could combine two accounts' quota, so the caller
|
|
882
|
+
// whole-picks instead.
|
|
883
|
+
function sameAccountIdentity(
|
|
884
|
+
incoming: string | undefined,
|
|
885
|
+
existing: string | undefined,
|
|
886
|
+
): boolean {
|
|
887
|
+
return (
|
|
888
|
+
incoming !== undefined && existing !== undefined && incoming === existing
|
|
889
|
+
)
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// A window's checkedAt when it is a usable timestamp, else undefined — so an
|
|
893
|
+
// absent or invalid stamp sorts oldest and a timestamped window always wins
|
|
894
|
+
// over an untimestamped one. The optional fallback is the enclosing snapshot's
|
|
895
|
+
// checkedAt, used when the window itself carries no usable stamp (files written
|
|
896
|
+
// by versions that did not propagate the entry timestamp onto each present
|
|
897
|
+
// window): a present window with no stamp is still "live", so it must sort by
|
|
898
|
+
// SOME timestamp — the snapshot's is the next-best signal.
|
|
899
|
+
function finiteWindowCheckedAt(
|
|
900
|
+
window: QuotaWindow | undefined,
|
|
901
|
+
fallback?: number,
|
|
902
|
+
): number | undefined {
|
|
903
|
+
const checkedAt = window?.checkedAt
|
|
904
|
+
if (typeof checkedAt === 'number' && Number.isFinite(checkedAt)) {
|
|
905
|
+
return checkedAt
|
|
906
|
+
}
|
|
907
|
+
if (typeof fallback === 'number' && Number.isFinite(fallback)) {
|
|
908
|
+
return fallback
|
|
909
|
+
}
|
|
910
|
+
return undefined
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Fresher of two same-slot windows. When both sides report the window, the
|
|
914
|
+
// window's own stamp decides (falling back to each side's snapshot stamp when
|
|
915
|
+
// the window itself has none). When the slots disagree on presence, the FRESHER
|
|
916
|
+
// snapshot's slot is authoritative — a quota snapshot reports every live window,
|
|
917
|
+
// so an absent slot there means the wire retired it, which a stale window on the
|
|
918
|
+
// other side must not resurrect (and a fresher snapshot's present window must
|
|
919
|
+
// not be dropped by a stale window-less write).
|
|
920
|
+
function freshestWindow(
|
|
921
|
+
incoming: QuotaWindow | undefined,
|
|
922
|
+
existing: QuotaWindow | undefined,
|
|
923
|
+
existingSnapshotIsFresher: boolean,
|
|
924
|
+
incomingSnapshotCheckedAt: number | undefined,
|
|
925
|
+
existingSnapshotCheckedAt: number | undefined,
|
|
926
|
+
): QuotaWindow | undefined {
|
|
927
|
+
if (incoming && existing) {
|
|
928
|
+
const incomingAt = finiteWindowCheckedAt(
|
|
929
|
+
incoming,
|
|
930
|
+
incomingSnapshotCheckedAt,
|
|
931
|
+
)
|
|
932
|
+
const existingAt = finiteWindowCheckedAt(
|
|
933
|
+
existing,
|
|
934
|
+
existingSnapshotCheckedAt,
|
|
935
|
+
)
|
|
936
|
+
if (
|
|
937
|
+
existingAt !== undefined &&
|
|
938
|
+
(incomingAt === undefined || existingAt > incomingAt)
|
|
939
|
+
) {
|
|
940
|
+
return existing
|
|
941
|
+
}
|
|
942
|
+
return incoming
|
|
943
|
+
}
|
|
944
|
+
return existingSnapshotIsFresher ? existing : incoming
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Merge two same-account snapshots window-by-window: each slot keeps the
|
|
948
|
+
// fresher of the two sides, so a newer primary on one side and a newer
|
|
949
|
+
// secondary on the other both survive instead of one side's whole snapshot
|
|
950
|
+
// replacing the other's. The snapshot stamp becomes the freshest window stamp
|
|
951
|
+
// of the merged result. Only safe when both snapshots share an account identity
|
|
952
|
+
// (sameAccountIdentity) — an identity switch must whole-pick (freshestQuota) so
|
|
953
|
+
// windows from two accounts are never combined.
|
|
954
|
+
function mergeQuotaByWindow(
|
|
955
|
+
incoming: AccountQuota | null,
|
|
956
|
+
existing: AccountQuota | null,
|
|
957
|
+
): AccountQuota | null {
|
|
958
|
+
if (!incoming) return existing
|
|
959
|
+
if (!existing) return incoming
|
|
960
|
+
const incomingAt = latestQuotaCheckedAt(incoming)
|
|
961
|
+
const existingAt = latestQuotaCheckedAt(existing)
|
|
962
|
+
const existingSnapshotIsFresher =
|
|
963
|
+
existingAt !== undefined &&
|
|
964
|
+
(incomingAt === undefined || existingAt > incomingAt)
|
|
965
|
+
const primary = freshestWindow(
|
|
966
|
+
incoming.primary,
|
|
967
|
+
existing.primary,
|
|
968
|
+
existingSnapshotIsFresher,
|
|
969
|
+
incoming.checkedAt,
|
|
970
|
+
existing.checkedAt,
|
|
971
|
+
)
|
|
972
|
+
const secondary = freshestWindow(
|
|
973
|
+
incoming.secondary,
|
|
974
|
+
existing.secondary,
|
|
975
|
+
existingSnapshotIsFresher,
|
|
976
|
+
incoming.checkedAt,
|
|
977
|
+
existing.checkedAt,
|
|
978
|
+
)
|
|
979
|
+
let checkedAt: number | undefined
|
|
980
|
+
for (const stamp of [
|
|
981
|
+
finiteWindowCheckedAt(primary),
|
|
982
|
+
finiteWindowCheckedAt(secondary),
|
|
983
|
+
]) {
|
|
984
|
+
if (stamp !== undefined) {
|
|
985
|
+
checkedAt = checkedAt === undefined ? stamp : Math.max(checkedAt, stamp)
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
return {
|
|
989
|
+
...incoming,
|
|
990
|
+
primary,
|
|
991
|
+
secondary,
|
|
992
|
+
checkedAt: checkedAt ?? incoming.checkedAt,
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
499
996
|
export function setSidebarMachineState(
|
|
500
997
|
machineState: SidebarMachineState,
|
|
501
998
|
file = getSidebarStateFile(),
|
|
@@ -504,13 +1001,70 @@ export function setSidebarMachineState(
|
|
|
504
1001
|
return enqueueSidebarWrite(async () => {
|
|
505
1002
|
await writeMergedSidebarState(
|
|
506
1003
|
file,
|
|
507
|
-
(latest) =>
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
1004
|
+
(latest) => {
|
|
1005
|
+
const latestFallbacks = new Map(
|
|
1006
|
+
latest.fallbacks.map((account) => [account.id, account]),
|
|
1007
|
+
)
|
|
1008
|
+
// A same-identity merge combines the freshest of each window across the
|
|
1009
|
+
// two sides; a differing or unknown identity whole-picks the fresher
|
|
1010
|
+
// snapshot so windows from two accounts are never combined.
|
|
1011
|
+
const mainSameIdentity = sameAccountIdentity(
|
|
1012
|
+
machineState.main.mainAccountId,
|
|
1013
|
+
latest.main.mainAccountId,
|
|
1014
|
+
)
|
|
1015
|
+
const mergedMainQuota = mainSameIdentity
|
|
1016
|
+
? mergeQuotaByWindow(machineState.main.quota, latest.main.quota)
|
|
1017
|
+
: freshestQuota(machineState.main.quota, latest.main.quota)
|
|
1018
|
+
const now = Date.now()
|
|
1019
|
+
const stickyAssignments = pruneStickyAssignments(
|
|
1020
|
+
latest.stickyAssignments,
|
|
1021
|
+
usableRoutingAccountIds(machineState.fallbacks),
|
|
1022
|
+
now,
|
|
1023
|
+
)
|
|
1024
|
+
return {
|
|
1025
|
+
...latest,
|
|
1026
|
+
...machineState,
|
|
1027
|
+
main: {
|
|
1028
|
+
...machineState.main,
|
|
1029
|
+
quota: mergedMainQuota,
|
|
1030
|
+
// On a whole-pick the identity follows the winning snapshot, so a
|
|
1031
|
+
// reader never pairs one account's id with another account's quota
|
|
1032
|
+
// (a re-login race would otherwise resurrect the stale-account bug).
|
|
1033
|
+
// On a same-identity merge both sides already agree, so the incoming
|
|
1034
|
+
// id is the shared one.
|
|
1035
|
+
mainAccountId: mainSameIdentity
|
|
1036
|
+
? machineState.main.mainAccountId
|
|
1037
|
+
: mergedMainQuota === latest.main.quota &&
|
|
1038
|
+
mergedMainQuota !== machineState.main.quota
|
|
1039
|
+
? latest.main.mainAccountId
|
|
1040
|
+
: machineState.main.mainAccountId,
|
|
1041
|
+
},
|
|
1042
|
+
fallbacks: machineState.fallbacks.map((account) => {
|
|
1043
|
+
const existing = latestFallbacks.get(account.id)
|
|
1044
|
+
const fallbackSameIdentity = sameAccountIdentity(
|
|
1045
|
+
account.accountId,
|
|
1046
|
+
existing?.accountId,
|
|
1047
|
+
)
|
|
1048
|
+
const mergedQuota = fallbackSameIdentity
|
|
1049
|
+
? mergeQuotaByWindow(account.quota, existing?.quota ?? null)
|
|
1050
|
+
: freshestQuota(account.quota, existing?.quota ?? null)
|
|
1051
|
+
return {
|
|
1052
|
+
...account,
|
|
1053
|
+
quota: mergedQuota,
|
|
1054
|
+
accountId: fallbackSameIdentity
|
|
1055
|
+
? account.accountId
|
|
1056
|
+
: mergedQuota === existing?.quota &&
|
|
1057
|
+
mergedQuota !== account.quota
|
|
1058
|
+
? existing?.accountId
|
|
1059
|
+
: account.accountId,
|
|
1060
|
+
}
|
|
1061
|
+
}),
|
|
1062
|
+
activeId: latest.activeId,
|
|
1063
|
+
activeRouting: latest.activeRouting,
|
|
1064
|
+
stickyAssignments,
|
|
1065
|
+
lastUpdated: Math.max(now, latest.lastUpdated + 1),
|
|
1066
|
+
}
|
|
1067
|
+
},
|
|
514
1068
|
hooks,
|
|
515
1069
|
)
|
|
516
1070
|
})
|
|
@@ -518,7 +1072,7 @@ export function setSidebarMachineState(
|
|
|
518
1072
|
|
|
519
1073
|
export function upsertSidebarActiveRouting(
|
|
520
1074
|
input: { sessionId: string } & ActiveRoutingEntry,
|
|
521
|
-
accounts: readonly SidebarRoutingAccount[],
|
|
1075
|
+
accounts: readonly SidebarRoutingAccount[] | undefined,
|
|
522
1076
|
file = getSidebarStateFile(),
|
|
523
1077
|
hooks?: SidebarMergeHooks,
|
|
524
1078
|
): Promise<void> {
|
|
@@ -538,11 +1092,17 @@ export function upsertSidebarActiveRouting(
|
|
|
538
1092
|
accounts,
|
|
539
1093
|
Date.now(),
|
|
540
1094
|
)
|
|
1095
|
+
const stickyAssignments = pruneStickyAssignments(
|
|
1096
|
+
latest.stickyAssignments,
|
|
1097
|
+
usableRoutingAccountIds(accounts),
|
|
1098
|
+
Date.now(),
|
|
1099
|
+
)
|
|
541
1100
|
return {
|
|
542
1101
|
...latest,
|
|
543
1102
|
activeId: input.activeId,
|
|
544
1103
|
route: input.route,
|
|
545
1104
|
activeRouting,
|
|
1105
|
+
stickyAssignments,
|
|
546
1106
|
lastUpdated: Math.max(Date.now(), latest.lastUpdated + 1),
|
|
547
1107
|
}
|
|
548
1108
|
},
|
|
@@ -567,7 +1127,7 @@ export function setSidebarLegacyRouting(
|
|
|
567
1127
|
|
|
568
1128
|
export function removeSidebarActiveRouting(
|
|
569
1129
|
sessionId: string,
|
|
570
|
-
accounts: readonly SidebarRoutingAccount[],
|
|
1130
|
+
accounts: readonly SidebarRoutingAccount[] | undefined,
|
|
571
1131
|
file = getSidebarStateFile(),
|
|
572
1132
|
hooks?: SidebarMergeHooks,
|
|
573
1133
|
): Promise<void> {
|
|
@@ -582,9 +1142,16 @@ export function removeSidebarActiveRouting(
|
|
|
582
1142
|
now,
|
|
583
1143
|
sessionId,
|
|
584
1144
|
)
|
|
1145
|
+
const stickyAssignments = pruneStickyAssignments(
|
|
1146
|
+
latest.stickyAssignments,
|
|
1147
|
+
usableRoutingAccountIds(accounts),
|
|
1148
|
+
now,
|
|
1149
|
+
hashSidebarSessionId(sessionId),
|
|
1150
|
+
)
|
|
585
1151
|
return {
|
|
586
1152
|
...latest,
|
|
587
1153
|
activeRouting,
|
|
1154
|
+
stickyAssignments,
|
|
588
1155
|
lastUpdated: Math.max(Date.now(), latest.lastUpdated + 1),
|
|
589
1156
|
}
|
|
590
1157
|
},
|
|
@@ -593,14 +1160,164 @@ export function removeSidebarActiveRouting(
|
|
|
593
1160
|
})
|
|
594
1161
|
}
|
|
595
1162
|
|
|
1163
|
+
export async function resolveSidebarStickyAssignment(
|
|
1164
|
+
input: ResolveStickyAssignmentInput,
|
|
1165
|
+
file = getSidebarStateFile(),
|
|
1166
|
+
hooks?: SidebarMergeHooks,
|
|
1167
|
+
): Promise<StickyAssignment | undefined> {
|
|
1168
|
+
const sessionHash = hashSidebarSessionId(input.sessionId)
|
|
1169
|
+
const validPinnedAccountIds = new Set(input.validPinnedAccountIds)
|
|
1170
|
+
const excludedAccountIds = new Set(input.excludeAccountIds)
|
|
1171
|
+
const existing = (await readSidebarState(file)).stickyAssignments?.[
|
|
1172
|
+
sessionHash
|
|
1173
|
+
]
|
|
1174
|
+
if (
|
|
1175
|
+
isValidStickyAssignment(
|
|
1176
|
+
existing,
|
|
1177
|
+
validPinnedAccountIds,
|
|
1178
|
+
excludedAccountIds,
|
|
1179
|
+
input.now,
|
|
1180
|
+
) &&
|
|
1181
|
+
!stickyAssignmentNeedsMetadataUpdate(
|
|
1182
|
+
existing,
|
|
1183
|
+
input.requestBytes,
|
|
1184
|
+
input.now,
|
|
1185
|
+
)
|
|
1186
|
+
) {
|
|
1187
|
+
return existing
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
let resolved: StickyAssignment | undefined
|
|
1191
|
+
await enqueueSidebarWrite(async () => {
|
|
1192
|
+
await writeMergedSidebarState(
|
|
1193
|
+
file,
|
|
1194
|
+
(latest) => {
|
|
1195
|
+
const stickyAssignments = pruneStickyAssignments(
|
|
1196
|
+
latest.stickyAssignments,
|
|
1197
|
+
validPinnedAccountIds,
|
|
1198
|
+
input.now,
|
|
1199
|
+
)
|
|
1200
|
+
const assignmentsPruned = !stickyAssignmentsEqual(
|
|
1201
|
+
latest.stickyAssignments,
|
|
1202
|
+
stickyAssignments,
|
|
1203
|
+
)
|
|
1204
|
+
const current = stickyAssignments?.[sessionHash]
|
|
1205
|
+
if (
|
|
1206
|
+
isValidStickyAssignment(
|
|
1207
|
+
current,
|
|
1208
|
+
validPinnedAccountIds,
|
|
1209
|
+
excludedAccountIds,
|
|
1210
|
+
input.now,
|
|
1211
|
+
)
|
|
1212
|
+
) {
|
|
1213
|
+
const metadataNeedsUpdate = stickyAssignmentNeedsMetadataUpdate(
|
|
1214
|
+
current,
|
|
1215
|
+
input.requestBytes,
|
|
1216
|
+
input.now,
|
|
1217
|
+
)
|
|
1218
|
+
const assignment = metadataNeedsUpdate
|
|
1219
|
+
? {
|
|
1220
|
+
...current,
|
|
1221
|
+
inputBytes: Math.max(current.inputBytes, input.requestBytes),
|
|
1222
|
+
...(input.now - current.lastSeenAt >=
|
|
1223
|
+
STICKY_ASSIGNMENT_LAST_SEEN_TOUCH_MS
|
|
1224
|
+
? { lastSeenAt: input.now }
|
|
1225
|
+
: {}),
|
|
1226
|
+
}
|
|
1227
|
+
: current
|
|
1228
|
+
resolved = assignment
|
|
1229
|
+
if (!assignmentsPruned && !metadataNeedsUpdate) return undefined
|
|
1230
|
+
return {
|
|
1231
|
+
...latest,
|
|
1232
|
+
stickyAssignments: {
|
|
1233
|
+
...stickyAssignments,
|
|
1234
|
+
[sessionHash]: assignment,
|
|
1235
|
+
},
|
|
1236
|
+
lastUpdated: Math.max(input.now, latest.lastUpdated + 1),
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
const choice = input.choose(
|
|
1241
|
+
pendingBytesForAssignments(
|
|
1242
|
+
stickyAssignments,
|
|
1243
|
+
input.quotaCheckedAtByAccount,
|
|
1244
|
+
),
|
|
1245
|
+
)
|
|
1246
|
+
if (!choice) {
|
|
1247
|
+
resolved = undefined
|
|
1248
|
+
if (!assignmentsPruned) return undefined
|
|
1249
|
+
return {
|
|
1250
|
+
...latest,
|
|
1251
|
+
stickyAssignments,
|
|
1252
|
+
lastUpdated: Math.max(input.now, latest.lastUpdated + 1),
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
resolved = {
|
|
1257
|
+
accountId: choice.accountId,
|
|
1258
|
+
assignedAt: input.now,
|
|
1259
|
+
lastSeenAt: input.now,
|
|
1260
|
+
inputBytes: input.requestBytes,
|
|
1261
|
+
...(choice.quotaCheckedAt === undefined
|
|
1262
|
+
? {}
|
|
1263
|
+
: { quotaCheckedAt: choice.quotaCheckedAt }),
|
|
1264
|
+
}
|
|
1265
|
+
return {
|
|
1266
|
+
...latest,
|
|
1267
|
+
stickyAssignments: limitStickyAssignments(
|
|
1268
|
+
{
|
|
1269
|
+
...stickyAssignments,
|
|
1270
|
+
[sessionHash]: resolved,
|
|
1271
|
+
},
|
|
1272
|
+
sessionHash,
|
|
1273
|
+
),
|
|
1274
|
+
lastUpdated: Math.max(input.now, latest.lastUpdated + 1),
|
|
1275
|
+
}
|
|
1276
|
+
},
|
|
1277
|
+
hooks,
|
|
1278
|
+
)
|
|
1279
|
+
})
|
|
1280
|
+
return resolved
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
export async function clearSidebarStickyAssignment(
|
|
1284
|
+
sessionId: string,
|
|
1285
|
+
file = getSidebarStateFile(),
|
|
1286
|
+
): Promise<boolean> {
|
|
1287
|
+
const sessionHash = hashSidebarSessionId(sessionId)
|
|
1288
|
+
let removed = false
|
|
1289
|
+
await enqueueSidebarWrite(async () => {
|
|
1290
|
+
await writeMergedSidebarState(file, (latest) => {
|
|
1291
|
+
const assignments = latest.stickyAssignments
|
|
1292
|
+
if (assignments?.[sessionHash] === undefined) {
|
|
1293
|
+
removed = false
|
|
1294
|
+
return undefined
|
|
1295
|
+
}
|
|
1296
|
+
removed = true
|
|
1297
|
+
const { [sessionHash]: _removed, ...remaining } = assignments
|
|
1298
|
+
return {
|
|
1299
|
+
...latest,
|
|
1300
|
+
...(Object.keys(remaining).length > 0
|
|
1301
|
+
? { stickyAssignments: remaining }
|
|
1302
|
+
: { stickyAssignments: undefined }),
|
|
1303
|
+
lastUpdated: Math.max(Date.now(), latest.lastUpdated + 1),
|
|
1304
|
+
}
|
|
1305
|
+
})
|
|
1306
|
+
})
|
|
1307
|
+
return removed
|
|
1308
|
+
}
|
|
1309
|
+
|
|
596
1310
|
async function doWriteSidebarState(
|
|
597
1311
|
state: SidebarState,
|
|
598
1312
|
file: string,
|
|
599
1313
|
): Promise<void> {
|
|
600
1314
|
const tempPath = `${file}.${randomUUID()}.tmp`
|
|
601
1315
|
try {
|
|
602
|
-
await
|
|
603
|
-
await writeFile(tempPath, JSON.stringify(state),
|
|
1316
|
+
await ensureSidebarStateDirectory(file)
|
|
1317
|
+
await writeFile(tempPath, JSON.stringify(normalizeSidebarState(state)), {
|
|
1318
|
+
encoding: 'utf8',
|
|
1319
|
+
mode: 0o600,
|
|
1320
|
+
})
|
|
604
1321
|
await rename(tempPath, file)
|
|
605
1322
|
} catch (e) {
|
|
606
1323
|
await rm(tempPath, { force: true }).catch(() => {})
|