@cortexkit/opencode-openai-auth 0.4.2 → 0.5.0
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 +18 -15
- 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-x1c39tzz.js → index-6rnw2cgs.js} +1 -1
- package/dist/index-89mh4ahn.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-jpx7h5gc.js → index-wpd7mg3p.js} +1 -1
- package/dist/index-zkzb0twr.js +2 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +101 -83
- package/dist/{quota-normalize-02mn21p4.js → quota-normalize-n02dfdx0.js} +1 -1
- package/dist/raw-ws-bun.d.ts +2 -0
- package/dist/raw-ws-node.d.ts +2 -0
- package/dist/raw-ws-upgrade.d.ts +2 -0
- 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 +46 -5
- package/dist/sidebar-state.js +1 -1
- package/dist/{token-dab1ama0.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/dist/ws-pool.d.ts +2 -11
- package/dist/ws.d.ts +7 -2
- 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 +771 -23
- 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 +771 -23
- 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-72cas5ht.js +0 -7
- package/dist/index-dc7za61x.js +0 -89
- package/dist/index-hjgzqz1c.js +0 -1
- package/dist/index-qnd9x5jh.js +0 -2
- package/dist/index-wpk440dz.js +0 -2
- package/dist/token-util-41n40m0y.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
|
|
@@ -295,24 +401,97 @@ export const ACTIVE_ROUTING_MAX_ENTRIES = 128
|
|
|
295
401
|
export type SidebarRoutingAccount = {
|
|
296
402
|
id: string
|
|
297
403
|
enabled?: boolean
|
|
404
|
+
killed?: boolean
|
|
298
405
|
}
|
|
299
406
|
|
|
300
407
|
export function isUsableRoutingEntry(
|
|
301
408
|
entry: ActiveRoutingEntry,
|
|
302
|
-
accounts: readonly SidebarRoutingAccount[],
|
|
409
|
+
accounts: readonly SidebarRoutingAccount[] | undefined,
|
|
303
410
|
now = Date.now(),
|
|
304
411
|
): boolean {
|
|
305
412
|
const fresh =
|
|
306
413
|
entry.updatedAt >= now - ACTIVE_ROUTING_MAX_AGE_MS && entry.updatedAt <= now
|
|
307
414
|
if (!fresh) return false
|
|
415
|
+
if (accounts === undefined) return true
|
|
308
416
|
return (
|
|
309
417
|
entry.activeId === 'main' ||
|
|
310
418
|
accounts.some(
|
|
311
|
-
(account) =>
|
|
419
|
+
(account) =>
|
|
420
|
+
account.enabled !== false &&
|
|
421
|
+
account.killed !== true &&
|
|
422
|
+
account.id === entry.activeId,
|
|
312
423
|
)
|
|
313
424
|
)
|
|
314
425
|
}
|
|
315
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
|
+
|
|
456
|
+
export function isQuotaExhausted(
|
|
457
|
+
quota: AccountQuota | null | undefined,
|
|
458
|
+
now = Date.now(),
|
|
459
|
+
): boolean {
|
|
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)]
|
|
470
|
+
if (
|
|
471
|
+
!assignment ||
|
|
472
|
+
assignment.lastSeenAt < now - STICKY_ASSIGNMENT_MAX_AGE_MS
|
|
473
|
+
) {
|
|
474
|
+
return undefined
|
|
475
|
+
}
|
|
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
|
|
493
|
+
}
|
|
494
|
+
|
|
316
495
|
export function resolveSessionSidebarRouting(
|
|
317
496
|
state: SidebarState,
|
|
318
497
|
sessionId?: string,
|
|
@@ -322,13 +501,29 @@ export function resolveSessionSidebarRouting(
|
|
|
322
501
|
return { activeId: state.activeId ?? 'main', route: state.route }
|
|
323
502
|
}
|
|
324
503
|
const own = sessionId ? state.activeRouting?.[sessionId] : undefined
|
|
325
|
-
|
|
504
|
+
const ownQuota =
|
|
505
|
+
own?.activeId === 'main'
|
|
506
|
+
? state.main.quota
|
|
507
|
+
: state.fallbacks.find((account) => account.id === own?.activeId)?.quota
|
|
508
|
+
if (
|
|
509
|
+
own &&
|
|
510
|
+
isUsableRoutingEntry(own, state.fallbacks, now) &&
|
|
511
|
+
!isQuotaExhausted(ownQuota, now)
|
|
512
|
+
) {
|
|
326
513
|
return { activeId: own.activeId, route: own.route }
|
|
327
514
|
}
|
|
328
515
|
|
|
329
|
-
const
|
|
516
|
+
const stickyAccountId = resolveSessionStickyAccount(state, sessionId, now)
|
|
517
|
+
if (stickyAccountId) {
|
|
518
|
+
return { activeId: stickyAccountId, route: state.route }
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const enabledFallbacks = state.fallbacks.filter(
|
|
330
522
|
(account) => account.enabled && !account.killed,
|
|
331
523
|
)
|
|
524
|
+
const fallback =
|
|
525
|
+
enabledFallbacks.find((account) => !isQuotaExhausted(account.quota, now)) ??
|
|
526
|
+
enabledFallbacks[0]
|
|
332
527
|
return {
|
|
333
528
|
activeId:
|
|
334
529
|
state.route === 'fallback-first' && fallback ? fallback.id : 'main',
|
|
@@ -338,7 +533,7 @@ export function resolveSessionSidebarRouting(
|
|
|
338
533
|
|
|
339
534
|
export function pruneActiveRouting(
|
|
340
535
|
activeRouting: ActiveRoutingMap | undefined,
|
|
341
|
-
accounts: readonly SidebarRoutingAccount[],
|
|
536
|
+
accounts: readonly SidebarRoutingAccount[] | undefined,
|
|
342
537
|
now = Date.now(),
|
|
343
538
|
removedSessionId?: string,
|
|
344
539
|
): ActiveRoutingMap | undefined {
|
|
@@ -357,6 +552,175 @@ export function pruneActiveRouting(
|
|
|
357
552
|
return bounded.length > 0 ? Object.fromEntries(bounded) : undefined
|
|
358
553
|
}
|
|
359
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
|
+
|
|
360
724
|
// Serialization chain: concurrent calls are queued so a stale background
|
|
361
725
|
// write cannot land after a newer one and corrupt the file.
|
|
362
726
|
let sidebarWriteChain: Promise<void> = Promise.resolve()
|
|
@@ -419,7 +783,7 @@ function parseSidebarState(raw: string): SidebarState {
|
|
|
419
783
|
}
|
|
420
784
|
|
|
421
785
|
async function acquireSidebarWriteLock(file: string) {
|
|
422
|
-
await
|
|
786
|
+
await ensureSidebarStateDirectory(file)
|
|
423
787
|
const deadline = Date.now() + SIDEBAR_WRITE_LOCK_WAIT_MS
|
|
424
788
|
while (Date.now() <= deadline) {
|
|
425
789
|
const lock = await acquireRefreshFileLock({
|
|
@@ -434,9 +798,21 @@ async function acquireSidebarWriteLock(file: string) {
|
|
|
434
798
|
throw new Error('Timed out waiting for the sidebar state write lock')
|
|
435
799
|
}
|
|
436
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
|
+
|
|
437
813
|
async function writeMergedSidebarState(
|
|
438
814
|
file: string,
|
|
439
|
-
merge: (latest: SidebarState) => SidebarState,
|
|
815
|
+
merge: (latest: SidebarState) => SidebarState | undefined,
|
|
440
816
|
hooks?: SidebarMergeHooks,
|
|
441
817
|
): Promise<void> {
|
|
442
818
|
const lock = await acquireSidebarWriteLock(file)
|
|
@@ -446,6 +822,7 @@ async function writeMergedSidebarState(
|
|
|
446
822
|
for (let attempt = 0; attempt < MAX_MERGE_ATTEMPTS; attempt += 1) {
|
|
447
823
|
const rawBefore = await readRawSidebar(file)
|
|
448
824
|
const next = merge(parseSidebarState(rawBefore))
|
|
825
|
+
if (!next) return
|
|
449
826
|
if (attempt === 0) await hooks?.beforeRecheck?.()
|
|
450
827
|
const rawRecheck = await readRawSidebar(file)
|
|
451
828
|
if (rawRecheck !== rawBefore) continue
|
|
@@ -454,7 +831,8 @@ async function writeMergedSidebarState(
|
|
|
454
831
|
}
|
|
455
832
|
|
|
456
833
|
const latest = await readSidebarState(file)
|
|
457
|
-
|
|
834
|
+
const next = merge(latest)
|
|
835
|
+
if (next) await doWriteSidebarState(next, file)
|
|
458
836
|
} finally {
|
|
459
837
|
await lock.release()
|
|
460
838
|
}
|
|
@@ -465,6 +843,156 @@ export type SidebarMachineState = Pick<
|
|
|
465
843
|
'main' | 'fallbacks' | 'planType' | 'credits' | 'lastUpdated'
|
|
466
844
|
> & { route: string }
|
|
467
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
|
+
|
|
468
996
|
export function setSidebarMachineState(
|
|
469
997
|
machineState: SidebarMachineState,
|
|
470
998
|
file = getSidebarStateFile(),
|
|
@@ -473,13 +1001,70 @@ export function setSidebarMachineState(
|
|
|
473
1001
|
return enqueueSidebarWrite(async () => {
|
|
474
1002
|
await writeMergedSidebarState(
|
|
475
1003
|
file,
|
|
476
|
-
(latest) =>
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
+
},
|
|
483
1068
|
hooks,
|
|
484
1069
|
)
|
|
485
1070
|
})
|
|
@@ -487,7 +1072,7 @@ export function setSidebarMachineState(
|
|
|
487
1072
|
|
|
488
1073
|
export function upsertSidebarActiveRouting(
|
|
489
1074
|
input: { sessionId: string } & ActiveRoutingEntry,
|
|
490
|
-
accounts: readonly SidebarRoutingAccount[],
|
|
1075
|
+
accounts: readonly SidebarRoutingAccount[] | undefined,
|
|
491
1076
|
file = getSidebarStateFile(),
|
|
492
1077
|
hooks?: SidebarMergeHooks,
|
|
493
1078
|
): Promise<void> {
|
|
@@ -507,11 +1092,17 @@ export function upsertSidebarActiveRouting(
|
|
|
507
1092
|
accounts,
|
|
508
1093
|
Date.now(),
|
|
509
1094
|
)
|
|
1095
|
+
const stickyAssignments = pruneStickyAssignments(
|
|
1096
|
+
latest.stickyAssignments,
|
|
1097
|
+
usableRoutingAccountIds(accounts),
|
|
1098
|
+
Date.now(),
|
|
1099
|
+
)
|
|
510
1100
|
return {
|
|
511
1101
|
...latest,
|
|
512
1102
|
activeId: input.activeId,
|
|
513
1103
|
route: input.route,
|
|
514
1104
|
activeRouting,
|
|
1105
|
+
stickyAssignments,
|
|
515
1106
|
lastUpdated: Math.max(Date.now(), latest.lastUpdated + 1),
|
|
516
1107
|
}
|
|
517
1108
|
},
|
|
@@ -536,7 +1127,7 @@ export function setSidebarLegacyRouting(
|
|
|
536
1127
|
|
|
537
1128
|
export function removeSidebarActiveRouting(
|
|
538
1129
|
sessionId: string,
|
|
539
|
-
accounts: readonly SidebarRoutingAccount[],
|
|
1130
|
+
accounts: readonly SidebarRoutingAccount[] | undefined,
|
|
540
1131
|
file = getSidebarStateFile(),
|
|
541
1132
|
hooks?: SidebarMergeHooks,
|
|
542
1133
|
): Promise<void> {
|
|
@@ -551,9 +1142,16 @@ export function removeSidebarActiveRouting(
|
|
|
551
1142
|
now,
|
|
552
1143
|
sessionId,
|
|
553
1144
|
)
|
|
1145
|
+
const stickyAssignments = pruneStickyAssignments(
|
|
1146
|
+
latest.stickyAssignments,
|
|
1147
|
+
usableRoutingAccountIds(accounts),
|
|
1148
|
+
now,
|
|
1149
|
+
hashSidebarSessionId(sessionId),
|
|
1150
|
+
)
|
|
554
1151
|
return {
|
|
555
1152
|
...latest,
|
|
556
1153
|
activeRouting,
|
|
1154
|
+
stickyAssignments,
|
|
557
1155
|
lastUpdated: Math.max(Date.now(), latest.lastUpdated + 1),
|
|
558
1156
|
}
|
|
559
1157
|
},
|
|
@@ -562,14 +1160,164 @@ export function removeSidebarActiveRouting(
|
|
|
562
1160
|
})
|
|
563
1161
|
}
|
|
564
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
|
+
|
|
565
1310
|
async function doWriteSidebarState(
|
|
566
1311
|
state: SidebarState,
|
|
567
1312
|
file: string,
|
|
568
1313
|
): Promise<void> {
|
|
569
1314
|
const tempPath = `${file}.${randomUUID()}.tmp`
|
|
570
1315
|
try {
|
|
571
|
-
await
|
|
572
|
-
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
|
+
})
|
|
573
1321
|
await rename(tempPath, file)
|
|
574
1322
|
} catch (e) {
|
|
575
1323
|
await rm(tempPath, { force: true }).catch(() => {})
|