@huai_xin/dsh-workbuddy 0.1.16 → 0.1.18
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/package.json +2 -2
- package/src/adapter.js +8 -5
- package/src/api.js +8 -7
- package/src/auth.js +84 -6
- package/src/availability.js +47 -19
- package/src/catalog-cache.js +4 -2
- package/src/index.js +26 -4
- package/src/model-prefs.js +3 -2
- package/src/official-list.js +8 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huai_xin/dsh-workbuddy",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.18",
|
|
4
4
|
"description": "WorkBuddy model provider for DeepSeek Harness (international + China deployments)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"README.md"
|
|
23
23
|
],
|
|
24
24
|
"scripts": {
|
|
25
|
-
"test": "node test/unit/plugin.mjs && node test/unit/api.mjs && node test/unit/client.mjs && node test/unit/region-order.mjs && node test/unit/catalog.mjs && node test/unit/catalog-cache.mjs && node test/unit/official-list.mjs && node test/unit/model-prefs.mjs && node test/unit/availability.mjs && node test/unit/adapter.mjs",
|
|
25
|
+
"test": "node test/unit/plugin.mjs && node test/unit/api.mjs && node test/unit/client.mjs && node test/unit/region-order.mjs && node test/unit/catalog.mjs && node test/unit/catalog-cache.mjs && node test/unit/official-list.mjs && node test/unit/model-prefs.mjs && node test/unit/availability.mjs && node test/unit/auth.mjs && node test/unit/adapter.mjs",
|
|
26
26
|
"smoke": "node test/live/smoke.mjs"
|
|
27
27
|
},
|
|
28
28
|
"keywords": [
|
package/src/adapter.js
CHANGED
|
@@ -18,7 +18,7 @@ import { writeFileSync } from 'node:fs'
|
|
|
18
18
|
import { tmpdir } from 'node:os'
|
|
19
19
|
import { join } from 'node:path'
|
|
20
20
|
|
|
21
|
-
import { readToken, refreshRegion } from './auth.js'
|
|
21
|
+
import { accountIdOf, readToken, refreshRegion } from './auth.js'
|
|
22
22
|
import { unusableModelIds, readAvailability, writeAvailability } from './availability.js'
|
|
23
23
|
import { fetchModelEntries } from './catalog.js'
|
|
24
24
|
import { readCatalogCache, writeCatalogCache } from './catalog-cache.js'
|
|
@@ -409,6 +409,9 @@ export class WorkBuddyAdapter {
|
|
|
409
409
|
* `resolveModel` still answers for ids the session already knows.
|
|
410
410
|
*/
|
|
411
411
|
async models() {
|
|
412
|
+
// Remember which account these verdicts belong to: the ledger and the imported
|
|
413
|
+
// list are per-account, the catalog itself is shared.
|
|
414
|
+
this.accountId = await accountIdOf(this.ctx, this.region)
|
|
412
415
|
const fresh = this.catalog !== undefined && Date.now() - this.catalogAt < CATALOG_TTL_MS
|
|
413
416
|
if (fresh) return this.catalog
|
|
414
417
|
let loaded
|
|
@@ -419,7 +422,7 @@ export class WorkBuddyAdapter {
|
|
|
419
422
|
}
|
|
420
423
|
// An imported account list wins over whatever the sources claim: it is the
|
|
421
424
|
// one thing that reflects what this account may actually pick.
|
|
422
|
-
const imported = readOfficialList(this.region.id)
|
|
425
|
+
const imported = readOfficialList(this.region.id, this.accountId)
|
|
423
426
|
if (loaded.entries.length > 0) {
|
|
424
427
|
const applied = applyOfficialList(loaded.entries, imported)
|
|
425
428
|
this.catalog = applied.entries
|
|
@@ -474,7 +477,7 @@ export class WorkBuddyAdapter {
|
|
|
474
477
|
* model id. The panel groups by these; the picker hides `dead` ones.
|
|
475
478
|
*/
|
|
476
479
|
availability() {
|
|
477
|
-
return readAvailability(this.region.id).models
|
|
480
|
+
return readAvailability(this.region.id, this.accountId).models
|
|
478
481
|
}
|
|
479
482
|
|
|
480
483
|
/**
|
|
@@ -487,7 +490,7 @@ export class WorkBuddyAdapter {
|
|
|
487
490
|
rememberUnusable(model, code, detail) {
|
|
488
491
|
const text = String(detail)
|
|
489
492
|
if (/11102|service info not found/i.test(text)) {
|
|
490
|
-
writeAvailability(this.region.id, [{ id: model, state: 'dead', detail: '模型不存在(11102 service info not found)', at: Date.now() }])
|
|
493
|
+
writeAvailability(this.region.id, [{ id: model, state: 'dead', detail: '模型不存在(11102 service info not found)', at: Date.now() }], this.accountId)
|
|
491
494
|
this.invalidateCatalog()
|
|
492
495
|
// The verdict feeds the default on/off state, so the picker has to re-read.
|
|
493
496
|
notifyModelTopology(this.ctx)
|
|
@@ -495,7 +498,7 @@ export class WorkBuddyAdapter {
|
|
|
495
498
|
}
|
|
496
499
|
if (/11158|is not allowed for user type/i.test(text)) {
|
|
497
500
|
const type = /user type \[([^\]]{0,40})\]/i.exec(text)?.[1] ?? ''
|
|
498
|
-
writeAvailability(this.region.id, [{ id: model, state: 'denied', detail: `账号无权限(11158${type.length > 0 ? `,用户类型 ${type}` : ''})`, at: Date.now() }])
|
|
501
|
+
writeAvailability(this.region.id, [{ id: model, state: 'denied', detail: `账号无权限(11158${type.length > 0 ? `,用户类型 ${type}` : ''})`, at: Date.now() }], this.accountId)
|
|
499
502
|
this.invalidateCatalog()
|
|
500
503
|
notifyModelTopology(this.ctx)
|
|
501
504
|
}
|
package/src/api.js
CHANGED
|
@@ -14,7 +14,7 @@ import { readFileSync, statSync } from 'node:fs'
|
|
|
14
14
|
import { readFile } from 'node:fs/promises'
|
|
15
15
|
import { fileURLToPath } from 'node:url'
|
|
16
16
|
|
|
17
|
-
import { beginLogin, pendingLogins, readMeta, readToken, refreshRegion, regionStatus } from './auth.js'
|
|
17
|
+
import { accountIdOf, beginLogin, logoutRegion, pendingLogins, readMeta, readToken, refreshRegion, regionStatus } from './auth.js'
|
|
18
18
|
import { invalidateBusy, fetchBusyLevels } from './busy.js'
|
|
19
19
|
import { SWEEP_MIN_INTERVAL_MS, availabilitySummary, readAvailability, runSweep, sweepDue, unusableModelIds } from './availability.js'
|
|
20
20
|
import { claimDailyCheckin, fetchCheckinStatus, fetchCredits } from './credits.js'
|
|
@@ -239,8 +239,9 @@ export function registerWorkbuddyApi(ctx, adapters, options = {}) {
|
|
|
239
239
|
notifyModelTopology(ctx)
|
|
240
240
|
}
|
|
241
241
|
const models = await adapter.models()
|
|
242
|
-
const
|
|
243
|
-
|
|
242
|
+
const account = await accountIdOf(ctx, region)
|
|
243
|
+
const ledger = readAvailability(region.id, account)
|
|
244
|
+
const imported = readOfficialList(region.id, account)
|
|
244
245
|
const prefs = readPrefs(region.id)
|
|
245
246
|
const dead = unusableModelIds(region.id)
|
|
246
247
|
sendJson(res, 200, {
|
|
@@ -463,7 +464,7 @@ export function registerWorkbuddyApi(ctx, adapters, options = {}) {
|
|
|
463
464
|
}
|
|
464
465
|
const adapter = adapters[region.id]
|
|
465
466
|
if (method === 'GET') {
|
|
466
|
-
const list = readOfficialList(region.id)
|
|
467
|
+
const list = readOfficialList(region.id, await accountIdOf(ctx, region))
|
|
467
468
|
sendJson(res, 200, {
|
|
468
469
|
region: region.id,
|
|
469
470
|
ids: list?.ids ?? [],
|
|
@@ -483,7 +484,7 @@ export function registerWorkbuddyApi(ctx, adapters, options = {}) {
|
|
|
483
484
|
return
|
|
484
485
|
}
|
|
485
486
|
const source = typeof body.from === 'string' && body.from.length > 0 ? body.from : 'web-session'
|
|
486
|
-
const result = writeOfficialList(region.id, body.models, { from: source })
|
|
487
|
+
const result = writeOfficialList(region.id, body.models, { from: source, account: await accountIdOf(ctx, region) })
|
|
487
488
|
adapter?.invalidateCatalog()
|
|
488
489
|
const models = adapter === undefined ? [] : await adapter.models()
|
|
489
490
|
notifyModelTopology(ctx)
|
|
@@ -549,9 +550,9 @@ export function registerWorkbuddyApi(ctx, adapters, options = {}) {
|
|
|
549
550
|
return
|
|
550
551
|
}
|
|
551
552
|
if (path.endsWith('/logout')) {
|
|
552
|
-
await ctx
|
|
553
|
-
await ctx.credentials.unset(region.metaRef)
|
|
553
|
+
await logoutRegion(ctx, region)
|
|
554
554
|
adapters[region.id]?.invalidateCatalog()
|
|
555
|
+
notifyModelTopology(ctx)
|
|
555
556
|
sendJson(res, 200, await statePayload(ctx, adapters))
|
|
556
557
|
return
|
|
557
558
|
}
|
package/src/auth.js
CHANGED
|
@@ -44,6 +44,40 @@ const CODE_TOKEN_NOT_READY = 11217
|
|
|
44
44
|
/** One in-flight login per region, so a repeated command reports instead of forking a second flow. */
|
|
45
45
|
export const pendingLogins = new Map()
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Account generations, one per region.
|
|
49
|
+
*
|
|
50
|
+
* Every asynchronous path that later writes credentials or a catalog compares the
|
|
51
|
+
* generation it started under with the current one. Logging out or switching accounts
|
|
52
|
+
* bumps it, so a slow refresh, a background login poll, or a delayed catalog response
|
|
53
|
+
* that started before the switch can no longer resurrect the previous account.
|
|
54
|
+
*/
|
|
55
|
+
const generations = new Map()
|
|
56
|
+
|
|
57
|
+
export function authGeneration(region) {
|
|
58
|
+
return generations.get(region.id) ?? 0
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Invalidate in-flight work for this region and drop any pending login. */
|
|
62
|
+
export function bumpAuthGeneration(region) {
|
|
63
|
+
generations.set(region.id, authGeneration(region) + 1)
|
|
64
|
+
pendingLogins.delete(region.id)
|
|
65
|
+
return authGeneration(region)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Log out: invalidate in-flight work first, then delete the stored credentials.
|
|
70
|
+
*
|
|
71
|
+
* The order matters — a refresh that returns after the deletion would otherwise write
|
|
72
|
+
* the old account straight back, which is exactly the bug this replaces.
|
|
73
|
+
*/
|
|
74
|
+
export async function logoutRegion(ctx, region) {
|
|
75
|
+
bumpAuthGeneration(region)
|
|
76
|
+
await ctx.credentials.unset(region.tokenRef)
|
|
77
|
+
await ctx.credentials.unset(region.metaRef)
|
|
78
|
+
ctx.logger.info(`[workbuddy] ${region.id}: 已退出登录,凭据已删除`)
|
|
79
|
+
}
|
|
80
|
+
|
|
47
81
|
function authHeaders(region, extra = {}) {
|
|
48
82
|
return { 'X-Domain': region.apiDomain, 'User-Agent': region.userAgent, ...extra }
|
|
49
83
|
}
|
|
@@ -169,6 +203,7 @@ export async function readToken(ctx, region) {
|
|
|
169
203
|
* Failures are logged, never thrown: a stale token must not break the plugin.
|
|
170
204
|
*/
|
|
171
205
|
export async function refreshRegion(ctx, region, options = {}) {
|
|
206
|
+
const generation = authGeneration(region)
|
|
172
207
|
const meta = await readMeta(ctx, region)
|
|
173
208
|
if (meta === undefined || typeof meta.refreshToken !== 'string' || meta.refreshToken.length === 0) {
|
|
174
209
|
return { refreshed: false, reason: '未登录' }
|
|
@@ -194,6 +229,12 @@ export async function refreshRegion(ctx, region, options = {}) {
|
|
|
194
229
|
const data = responseData(json) ?? {}
|
|
195
230
|
const nextAccess = readString(data, 'accessToken')
|
|
196
231
|
if (nextAccess.length === 0) return { refreshed: false, reason: '续期响应为空' }
|
|
232
|
+
// Logging out or switching accounts while the refresh was in flight invalidates this
|
|
233
|
+
// result: writing it would put the old account back.
|
|
234
|
+
if (authGeneration(region) !== generation) {
|
|
235
|
+
ctx.logger.info(`[workbuddy] ${region.id}: 续期结果已过期(期间退出或切换账号),不写回`)
|
|
236
|
+
return { refreshed: false, reason: '账号已切换' }
|
|
237
|
+
}
|
|
197
238
|
|
|
198
239
|
const now = Date.now()
|
|
199
240
|
const nextRefresh = readString(data, 'refreshToken')
|
|
@@ -220,25 +261,62 @@ export async function beginLogin(ctx, region, options = {}) {
|
|
|
220
261
|
const existing = pendingLogins.get(region.id)
|
|
221
262
|
if (existing !== undefined) return { started: false, url: existing.url, pending: true }
|
|
222
263
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
264
|
+
// Claimed before the request goes out: two concurrent calls would otherwise both
|
|
265
|
+
// start a flow, and the first to fail would delete the second one's pending entry.
|
|
266
|
+
const generation = authGeneration(region)
|
|
267
|
+
pendingLogins.set(region.id, { url: '', startedAt: Date.now() })
|
|
268
|
+
let authUrl
|
|
269
|
+
let state
|
|
270
|
+
try {
|
|
271
|
+
const fetched = await fetchAuthState(region)
|
|
272
|
+
state = fetched.state
|
|
273
|
+
authUrl = decorateAuthUrl(fetched.authUrl, region)
|
|
274
|
+
pendingLogins.set(region.id, { url: authUrl, startedAt: Date.now() })
|
|
275
|
+
} catch (error) {
|
|
276
|
+
pendingLogins.delete(region.id)
|
|
277
|
+
throw error
|
|
278
|
+
}
|
|
279
|
+
if (options.open !== false) openInBrowser(authUrl)
|
|
227
280
|
|
|
228
281
|
void (async () => {
|
|
229
282
|
try {
|
|
230
283
|
const token = await pollAuthToken(region, state)
|
|
284
|
+
if (authGeneration(region) !== generation) {
|
|
285
|
+
ctx.logger.info(`[workbuddy] ${region.id}: 登录结果已过期(期间退出或切换账号),不写回`)
|
|
286
|
+
return
|
|
287
|
+
}
|
|
231
288
|
const account = await fetchAccount(region, token, state)
|
|
289
|
+
if (authGeneration(region) !== generation) {
|
|
290
|
+
ctx.logger.info(`[workbuddy] ${region.id}: 登录结果已过期(期间退出或切换账号),不写回`)
|
|
291
|
+
return
|
|
292
|
+
}
|
|
232
293
|
const stored = await storeCredential(ctx, region, token, account)
|
|
233
294
|
ctx.logger.info(`[workbuddy] ${region.id}: 已登录 ${stored.nickname || stored.uid || '(未知)'}`)
|
|
234
295
|
await options.onSuccess?.(stored)
|
|
235
296
|
} catch (error) {
|
|
236
297
|
ctx.logger.warn(`[workbuddy] ${region.id}: 登录失败 — ${error instanceof Error ? error.message : String(error)}`)
|
|
237
298
|
} finally {
|
|
238
|
-
|
|
299
|
+
// Only clear the entry this flow created; a newer flow owns its own.
|
|
300
|
+
if (authGeneration(region) === generation) pendingLogins.delete(region.id)
|
|
239
301
|
}
|
|
240
302
|
})()
|
|
241
|
-
return { started: true, url:
|
|
303
|
+
return { started: true, url: authUrl, pending: false }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* A stable identity for the logged-in account.
|
|
308
|
+
*
|
|
309
|
+
* Per-account records (availability verdicts, imported model lists) use it so one
|
|
310
|
+
* account's entitlements do not leak into another's. The catalog itself stays shared:
|
|
311
|
+
* which models a deployment offers is not an account fact.
|
|
312
|
+
*/
|
|
313
|
+
export async function accountIdOf(ctx, region) {
|
|
314
|
+
const meta = await readMeta(ctx, region)
|
|
315
|
+
const uid = meta?.uid
|
|
316
|
+
if (typeof uid === 'string' && uid.length > 0) return uid
|
|
317
|
+
if (typeof uid === 'number') return String(uid)
|
|
318
|
+
const nickname = typeof meta?.nickname === 'string' ? meta.nickname.trim() : ''
|
|
319
|
+
return nickname.length > 0 ? `nick:${nickname}` : undefined
|
|
242
320
|
}
|
|
243
321
|
|
|
244
322
|
/** One-line account summary for the command and the panel. */
|
package/src/availability.js
CHANGED
|
@@ -32,7 +32,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
|
32
32
|
import { homedir } from 'node:os'
|
|
33
33
|
import { join } from 'node:path'
|
|
34
34
|
|
|
35
|
-
import { readToken } from './auth.js'
|
|
35
|
+
import { accountIdOf, readToken } from './auth.js'
|
|
36
36
|
import { chatRoutes } from './regions.js'
|
|
37
37
|
|
|
38
38
|
const LEDGER_VERSION = 1
|
|
@@ -210,16 +210,19 @@ export async function probeModel(ctx, region, id, options = {}) {
|
|
|
210
210
|
export const SWEEP_MIN_INTERVAL_MS = 24 * 3600_000
|
|
211
211
|
|
|
212
212
|
/** Whether a sweep may run now; `manual` callers skip this check. */
|
|
213
|
-
export function sweepDue(regionId, minIntervalMs = SWEEP_MIN_INTERVAL_MS, now = Date.now()) {
|
|
214
|
-
const last = readAvailability(regionId).lastSweepAt
|
|
213
|
+
export function sweepDue(regionId, minIntervalMs = SWEEP_MIN_INTERVAL_MS, now = Date.now(), account) {
|
|
214
|
+
const last = readAvailability(regionId, account).lastSweepAt
|
|
215
215
|
return { due: now - last >= minIntervalMs, lastSweepAt: last }
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
/** Probe a list of ids, a few at a time, and persist every verdict. */
|
|
219
219
|
export async function runSweep(ctx, region, ids, options = {}) {
|
|
220
220
|
const interval = options.minIntervalMs ?? SWEEP_MIN_INTERVAL_MS
|
|
221
|
+
// Verdicts describe one account's entitlements, so the sweep is scoped to whoever is
|
|
222
|
+
// logged in now: a new account starts from an empty ledger and gets its own probes.
|
|
223
|
+
const account = await accountIdOf(ctx, region)
|
|
221
224
|
if (options.manual !== true) {
|
|
222
|
-
const due = sweepDue(region.id, interval)
|
|
225
|
+
const due = sweepDue(region.id, interval, Date.now(), account)
|
|
223
226
|
if (due.due !== true) return { skipped: true, reason: 'interval', lastSweepAt: due.lastSweepAt, results: [] }
|
|
224
227
|
}
|
|
225
228
|
const token = await readToken(ctx, region)
|
|
@@ -236,32 +239,48 @@ export async function runSweep(ctx, region, ids, options = {}) {
|
|
|
236
239
|
}
|
|
237
240
|
}
|
|
238
241
|
await Promise.all(Array.from({ length: Math.min(concurrency, ids.length) }, () => worker()))
|
|
239
|
-
if (results.length > 0) writeAvailability(region.id, results)
|
|
240
|
-
markSwept(region.id)
|
|
241
|
-
return { skipped: false, results }
|
|
242
|
+
if (results.length > 0) writeAvailability(region.id, results, account)
|
|
243
|
+
markSwept(region.id, account)
|
|
244
|
+
return { skipped: false, results, account }
|
|
242
245
|
}
|
|
243
246
|
|
|
244
247
|
/** @returns {{ savedAt: number, lastSweepAt: number, models: Map<string, {state: string, detail: string, at: number}> }} */
|
|
245
|
-
|
|
248
|
+
/** Read the ledger as stored (no account filtering). */
|
|
249
|
+
function readLedger(regionId) {
|
|
246
250
|
try {
|
|
247
251
|
const parsed = JSON.parse(readFileSync(ledgerPath(regionId), 'utf8'))
|
|
248
252
|
if (parsed?.version !== LEDGER_VERSION || typeof parsed.models !== 'object' || parsed.models === null) {
|
|
249
|
-
return { savedAt: 0, lastSweepAt: 0, models: new Map() }
|
|
253
|
+
return { account: undefined, savedAt: 0, lastSweepAt: 0, models: new Map() }
|
|
250
254
|
}
|
|
251
255
|
return {
|
|
256
|
+
account: typeof parsed.account === 'string' && parsed.account.length > 0 ? parsed.account : undefined,
|
|
252
257
|
savedAt: typeof parsed.savedAt === 'number' ? parsed.savedAt : 0,
|
|
253
258
|
lastSweepAt: typeof parsed.lastSweepAt === 'number' ? parsed.lastSweepAt : 0,
|
|
254
259
|
models: new Map(Object.entries(parsed.models))
|
|
255
260
|
}
|
|
256
261
|
} catch {
|
|
262
|
+
return { account: undefined, savedAt: 0, lastSweepAt: 0, models: new Map() }
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* @param regionId deployment id
|
|
268
|
+
* @param account when given, a ledger recorded for another account reads as empty
|
|
269
|
+
* @returns {{ savedAt: number, lastSweepAt: number, models: Map<string, {state: string, detail: string, at: number}> }}
|
|
270
|
+
*/
|
|
271
|
+
export function readAvailability(regionId, account) {
|
|
272
|
+
const ledger = readLedger(regionId)
|
|
273
|
+
if (account !== undefined && ledger.account !== undefined && ledger.account !== account) {
|
|
257
274
|
return { savedAt: 0, lastSweepAt: 0, models: new Map() }
|
|
258
275
|
}
|
|
276
|
+
return { savedAt: ledger.savedAt, lastSweepAt: ledger.lastSweepAt, models: ledger.models }
|
|
259
277
|
}
|
|
260
278
|
|
|
261
279
|
function store(regionId, ledger, now) {
|
|
262
280
|
mkdirSync(ledgerDir(), { recursive: true })
|
|
263
281
|
writeFileSync(ledgerPath(regionId), JSON.stringify({
|
|
264
282
|
version: LEDGER_VERSION,
|
|
283
|
+
...(ledger.account === undefined ? {} : { account: ledger.account }),
|
|
265
284
|
savedAt: now,
|
|
266
285
|
lastSweepAt: ledger.lastSweepAt,
|
|
267
286
|
models: Object.fromEntries(ledger.models)
|
|
@@ -269,10 +288,18 @@ function store(regionId, ledger, now) {
|
|
|
269
288
|
}
|
|
270
289
|
|
|
271
290
|
/** Merge verdicts by id (a fresh verdict always wins) and store them. */
|
|
272
|
-
export function writeAvailability(regionId, results) {
|
|
291
|
+
export function writeAvailability(regionId, results, account) {
|
|
273
292
|
try {
|
|
274
|
-
const ledger =
|
|
293
|
+
const ledger = readLedger(regionId)
|
|
275
294
|
const now = Date.now()
|
|
295
|
+
// A verdict describes one account's entitlements. Writing a different account's
|
|
296
|
+
// results into the same ledger is what made a new login inherit the previous
|
|
297
|
+
// account's `denied` marks for twelve hours, so a different account starts clean.
|
|
298
|
+
if (account !== undefined && ledger.account !== account) {
|
|
299
|
+
ledger.account = account
|
|
300
|
+
ledger.models = new Map()
|
|
301
|
+
ledger.lastSweepAt = 0
|
|
302
|
+
}
|
|
276
303
|
for (const result of results) {
|
|
277
304
|
if (typeof result?.id !== 'string' || result.id.length === 0) continue
|
|
278
305
|
ledger.models.set(result.id, {
|
|
@@ -290,9 +317,10 @@ export function writeAvailability(regionId, results) {
|
|
|
290
317
|
}
|
|
291
318
|
|
|
292
319
|
/** Record that a full sweep just ran, which is what the daily limit reads. */
|
|
293
|
-
function markSwept(regionId) {
|
|
320
|
+
function markSwept(regionId, account) {
|
|
294
321
|
try {
|
|
295
|
-
const ledger =
|
|
322
|
+
const ledger = readLedger(regionId)
|
|
323
|
+
if (account !== undefined && ledger.account !== undefined && ledger.account !== account) return false
|
|
296
324
|
ledger.lastSweepAt = Date.now()
|
|
297
325
|
store(regionId, ledger, Date.now())
|
|
298
326
|
return true
|
|
@@ -302,9 +330,9 @@ function markSwept(regionId) {
|
|
|
302
330
|
}
|
|
303
331
|
|
|
304
332
|
/** Ids the ledger considers unusable, within the verdict's TTL. */
|
|
305
|
-
export function unusableModelIds(regionId, now = Date.now()) {
|
|
333
|
+
export function unusableModelIds(regionId, now = Date.now(), account) {
|
|
306
334
|
const unusable = new Set()
|
|
307
|
-
for (const [id, entry] of readAvailability(regionId).models) {
|
|
335
|
+
for (const [id, entry] of readAvailability(regionId, account).models) {
|
|
308
336
|
if (!UNUSABLE_STATES.has(entry?.state)) continue
|
|
309
337
|
if (typeof entry.at !== 'number' || now - entry.at > AVAILABILITY_TTL_MS) continue
|
|
310
338
|
unusable.add(id)
|
|
@@ -313,9 +341,9 @@ export function unusableModelIds(regionId, now = Date.now()) {
|
|
|
313
341
|
}
|
|
314
342
|
|
|
315
343
|
/** Kept for callers that only care about delisted models. */
|
|
316
|
-
export function deadModelIds(regionId, now = Date.now()) {
|
|
344
|
+
export function deadModelIds(regionId, now = Date.now(), account) {
|
|
317
345
|
const dead = new Set()
|
|
318
|
-
for (const [id, entry] of readAvailability(regionId).models) {
|
|
346
|
+
for (const [id, entry] of readAvailability(regionId, account).models) {
|
|
319
347
|
if (entry?.state !== 'dead') continue
|
|
320
348
|
if (typeof entry.at !== 'number' || now - entry.at > AVAILABILITY_TTL_MS) continue
|
|
321
349
|
dead.add(id)
|
|
@@ -324,8 +352,8 @@ export function deadModelIds(regionId, now = Date.now()) {
|
|
|
324
352
|
}
|
|
325
353
|
|
|
326
354
|
/** One-line summary for the panel and the command. */
|
|
327
|
-
export function availabilitySummary(regionId) {
|
|
328
|
-
const ledger = readAvailability(regionId)
|
|
355
|
+
export function availabilitySummary(regionId, account) {
|
|
356
|
+
const ledger = readAvailability(regionId, account)
|
|
329
357
|
const counts = new Map()
|
|
330
358
|
for (const entry of ledger.models.values()) {
|
|
331
359
|
const state = String(entry?.state ?? 'error')
|
package/src/catalog-cache.js
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
30
|
-
import { homedir
|
|
30
|
+
import { homedir } from 'node:os'
|
|
31
31
|
import { join } from 'node:path'
|
|
32
32
|
|
|
33
33
|
import { isCatalogModelId, keepModel, withConsistentEfforts } from './catalog.js'
|
|
@@ -143,7 +143,9 @@ export function writeCatalogCache(regionId, source, entries) {
|
|
|
143
143
|
|
|
144
144
|
const dir = cacheDir()
|
|
145
145
|
mkdirSync(dir, { recursive: true })
|
|
146
|
-
|
|
146
|
+
// Staged inside the target directory: a temp file on another volume makes the
|
|
147
|
+
// rename below fail with EXDEV, and by then the current file has already been moved.
|
|
148
|
+
const staging = join(dir, `.catalog-${regionId}-${process.pid}.json`)
|
|
147
149
|
writeFileSync(staging, JSON.stringify({
|
|
148
150
|
version: CACHE_VERSION,
|
|
149
151
|
savedAt: now,
|
package/src/index.js
CHANGED
|
@@ -17,7 +17,7 @@ import { randomUUID } from 'node:crypto'
|
|
|
17
17
|
|
|
18
18
|
import { WorkBuddyAdapter } from './adapter.js'
|
|
19
19
|
import { registerWorkbuddyApi } from './api.js'
|
|
20
|
-
import { REFRESH_BOOT_DELAY_MS, REFRESH_INTERVAL_MS, beginLogin, refreshRegion, regionStatus } from './auth.js'
|
|
20
|
+
import { REFRESH_BOOT_DELAY_MS, REFRESH_INTERVAL_MS, beginLogin, logoutRegion, refreshRegion, regionStatus } from './auth.js'
|
|
21
21
|
import { runSweep, sweepDue } from './availability.js'
|
|
22
22
|
import { claimDailyCheckin, runAutoClaim } from './credits.js'
|
|
23
23
|
import { msUntilNextMidnight } from './http.js'
|
|
@@ -53,8 +53,12 @@ async function executeCommand(ctx, adapters, invocation) {
|
|
|
53
53
|
for (const region of regions) {
|
|
54
54
|
try {
|
|
55
55
|
const result = await beginLogin(ctx, region, {
|
|
56
|
-
onSuccess: () => {
|
|
56
|
+
onSuccess: async () => {
|
|
57
57
|
adapters[region.id]?.invalidateCatalog()
|
|
58
|
+
// The availability ledger belongs to the account, so a new login starts with
|
|
59
|
+
// no verdicts: probe this account once, which is how "which models can this
|
|
60
|
+
// account use" gets answered (the catalog itself stays shared).
|
|
61
|
+
void probeAccount(region)
|
|
58
62
|
// A fresh account usually has today's check-in waiting.
|
|
59
63
|
void claimDailyCheckin(ctx, region).then(async (outcome) => {
|
|
60
64
|
if (outcome.claimed) ctx.logger.info(`[workbuddy] ${region.id}: 登录后签到成功,获得 ${outcome.credit ?? 0} 积分`)
|
|
@@ -74,9 +78,9 @@ async function executeCommand(ctx, adapters, invocation) {
|
|
|
74
78
|
if (verb === 'logout') {
|
|
75
79
|
const lines = []
|
|
76
80
|
for (const region of regions) {
|
|
77
|
-
await ctx
|
|
78
|
-
await ctx.credentials.unset(region.metaRef)
|
|
81
|
+
await logoutRegion(ctx, region)
|
|
79
82
|
adapters[region.id]?.invalidateCatalog()
|
|
83
|
+
notifyModelTopology(ctx)
|
|
80
84
|
lines.push(`${region.label}:凭据已清除`)
|
|
81
85
|
}
|
|
82
86
|
return { kind: 'success', text: lines.join('\n') }
|
|
@@ -241,6 +245,24 @@ export function apply(ctx) {
|
|
|
241
245
|
// run every time the panel opens (its probe never generates), the
|
|
242
246
|
// international one once a day (its probe costs a token per model).
|
|
243
247
|
const AVAILABILITY_BOOT_DELAY_MS = 45_000
|
|
248
|
+
/** Probe every model once for the account that just logged in. */
|
|
249
|
+
const probeAccount = async (region) => {
|
|
250
|
+
const adapter = adapters[region.id]
|
|
251
|
+
if (adapter === undefined) return
|
|
252
|
+
try {
|
|
253
|
+
adapter.invalidateCatalog()
|
|
254
|
+
const entries = await adapter.models()
|
|
255
|
+
if (entries.length === 0) return
|
|
256
|
+
const result = await runSweep(ctx, region, entries.map((entry) => entry.id), { manual: true })
|
|
257
|
+
if (result.skipped === true) return
|
|
258
|
+
const counts = new Map()
|
|
259
|
+
for (const verdict of result.results) counts.set(verdict.state, (counts.get(verdict.state) ?? 0) + 1)
|
|
260
|
+
ctx.logger.info(`[workbuddy] ${region.id}: 登录后可用性检测完成 ${result.results.length} 个 — ${[...counts.entries()].map(([state, count]) => `${state} ${count}`).join(' / ')}`)
|
|
261
|
+
notifyModelTopology(ctx)
|
|
262
|
+
} catch (error) {
|
|
263
|
+
ctx.logger.warn(`[workbuddy] ${region.id}: 登录后可用性检测失败 — ${error instanceof Error ? error.message : String(error)}`)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
244
266
|
const availabilityTimer = setTimeout(() => {
|
|
245
267
|
for (const id of REGION_IDS) {
|
|
246
268
|
const region = REGIONS[id]
|
package/src/model-prefs.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
21
|
-
import { homedir
|
|
21
|
+
import { homedir } from 'node:os'
|
|
22
22
|
import { join } from 'node:path'
|
|
23
23
|
|
|
24
24
|
const PREFS_VERSION = 1
|
|
@@ -54,7 +54,8 @@ export function readPrefs(regionId) {
|
|
|
54
54
|
function write(regionId, prefs) {
|
|
55
55
|
try {
|
|
56
56
|
mkdirSync(prefsDir(), { recursive: true })
|
|
57
|
-
|
|
57
|
+
// Staged in the target directory so the rename cannot fail with EXDEV.
|
|
58
|
+
const staging = join(prefsDir(), `.enabled-${regionId}-${process.pid}.json`)
|
|
58
59
|
writeFileSync(staging, JSON.stringify({
|
|
59
60
|
version: PREFS_VERSION,
|
|
60
61
|
updatedAt: Date.now(),
|
package/src/official-list.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
29
|
-
import { homedir
|
|
29
|
+
import { homedir } from 'node:os'
|
|
30
30
|
import { join } from 'node:path'
|
|
31
31
|
|
|
32
32
|
import { entryFromMeta, isAdvertisableId } from './catalog.js'
|
|
@@ -58,10 +58,13 @@ function normalizeItem(item) {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
/** @returns {{ ids: string[], entries: object[], importedAt: number, from: string } | undefined} */
|
|
61
|
-
export function readOfficialList(regionId) {
|
|
61
|
+
export function readOfficialList(regionId, account) {
|
|
62
62
|
try {
|
|
63
63
|
const parsed = JSON.parse(readFileSync(listPath(regionId), 'utf8'))
|
|
64
64
|
if (parsed?.version !== LIST_VERSION || !Array.isArray(parsed.ids)) return undefined
|
|
65
|
+
// An imported list is one account's entitlements; another account must not inherit
|
|
66
|
+
// it, or a freshly logged-in account would show the previous account's models.
|
|
67
|
+
if (account !== undefined && typeof parsed.account === 'string' && parsed.account !== account) return undefined
|
|
65
68
|
const ids = parsed.ids.filter((id) => typeof id === 'string' && id.length > 0)
|
|
66
69
|
if (ids.length === 0) return undefined
|
|
67
70
|
const entries = Array.isArray(parsed.entries) ? parsed.entries : []
|
|
@@ -109,9 +112,11 @@ export function writeOfficialList(regionId, models, options = {}) {
|
|
|
109
112
|
const ids = [...byId.keys()]
|
|
110
113
|
const entries = ids.filter((id) => byId.get(id).rich).map((id) => byId.get(id).entry)
|
|
111
114
|
mkdirSync(listDir(), { recursive: true })
|
|
112
|
-
|
|
115
|
+
// Staged in the target directory so the rename cannot fail with EXDEV.
|
|
116
|
+
const staging = join(listDir(), `.official-${regionId}-${process.pid}.json`)
|
|
113
117
|
writeFileSync(staging, JSON.stringify({
|
|
114
118
|
version: LIST_VERSION,
|
|
119
|
+
...(typeof options.account === 'string' && options.account.length > 0 ? { account: options.account } : {}),
|
|
115
120
|
importedAt: Date.now(),
|
|
116
121
|
from: typeof options.from === 'string' && options.from.length > 0 ? options.from : 'import',
|
|
117
122
|
ids,
|