dsh-workbuddy-xdpool 1.7.9 → 1.8.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/CHANGELOG.md +72 -0
- package/lib/bin.js +130 -36
- package/lib/client.js +108 -26
- package/lib/index.d.ts +63 -2
- package/lib/index.js +146 -39
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,78 @@
|
|
|
4
4
|
|
|
5
5
|
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
6
6
|
|
|
7
|
+
## 1.8.1 — 一个过期的登录状态,不再拖着整个池子陪跑
|
|
8
|
+
|
|
9
|
+
> 有人的卡片上,好几个账号下面顶着一大段红色英文,大意是「上游不认这个登录了,去桌面端重新登录」。话说得没错,可问题是:它只说了结论,没解决过程——那几个账号明明已经废了,每次请求还是要挨个撞一遍,撞得头破血流,旁边几个好端端的账号一次都轮不上。
|
|
10
|
+
|
|
11
|
+
### 事情是怎么变成这样的
|
|
12
|
+
|
|
13
|
+
上游吊销一个登录状态时,**不会顺手把凭据文件里的过期时间改掉**。于是本机留着一份「看起来还有效」的凭据:过期时间写着 11 月 4 日,今天才 10 月,一切正常。
|
|
14
|
+
|
|
15
|
+
插件就信了。它把这三个账号当成健康账号,按顺序一个一个试:
|
|
16
|
+
|
|
17
|
+
1. 拿账号 A 去请求,上游回 401;
|
|
18
|
+
2. 插件想「可能是 token 过期了」,于是刷新 token;
|
|
19
|
+
3. 上游回答「这个 refresh token 早就没了」(`invalid_grant: Offline user session not found`);
|
|
20
|
+
4. 插件刷新失败,但**没有记仇**,转头又拿 A 去试;
|
|
21
|
+
5. 八次重试名额(`maxAttempts`)就这么全喂给了同一个死账号,健康账号一次都没被碰过。
|
|
22
|
+
|
|
23
|
+
所以现象是「明明还有能用的账号,请求却一直失败」,外加卡片上一片红。
|
|
24
|
+
|
|
25
|
+
### 现在怎么处理
|
|
26
|
+
|
|
27
|
+
给账号加了第三种状态:**判死**(前两种是「正常」和「限流冷却」)。
|
|
28
|
+
|
|
29
|
+
- **只有上游亲口说死才算死。** 判据是 `invalid_grant` / `Offline user session not found` 这类明确答复。网络超时、DNS 抽风、5xx 一律不算——否则一次网络抖动就会把好账号关进小黑屋半小时,那比原来的 bug 还难查。
|
|
30
|
+
- **判死的账号立刻退出轮换**,不再参与 `acquire` 的选择,请求当场换健康账号继续,不用等到八次重试耗尽。
|
|
31
|
+
- **想复活有两条路**:在 WorkBuddy 桌面端重新登录该账号(插件发现 refresh token 变了,自动放行),或者点卡片上的「清除所有冷却」手动再给它一次机会。
|
|
32
|
+
- **全池都死了的时候**,返回的不再是含含糊糊的「你还没登录」,而是 401 `session_dead`:`N/M WorkBuddy account(s) for this gateway were rejected by the upstream (their sign-in was revoked); sign in again in the WorkBuddy desktop app to restore them`。「等待」和「重新登录」是两种完全不同的处置方式,不能混着报。
|
|
33
|
+
|
|
34
|
+
### 卡片上的红字也收拾了一下
|
|
35
|
+
|
|
36
|
+
- 原来那段整段英文报错**直接铺在「积分包」面板里**,把面板撑得老长,右边的「合计」都被挤出屏幕。现在折叠成一个红色小标「**登录已被拒绝**」,鼠标悬停才显示原文。
|
|
37
|
+
- 账号行上加了红色的「登录已被拒绝」标签,下面补一句「请在 WorkBuddy 桌面端重新登录该账号以恢复」——红色是故意的,用琥珀色会让人以为「等一会儿就好」,而这个状态等多久都不会好。
|
|
38
|
+
- 判死的账号**不再参与卡片刷新时的积分 / 签到探测**。以前每刷新一次卡片,就对每个死账号白打两个必定 401 的请求,刷新越勤越热闹。
|
|
39
|
+
|
|
40
|
+
### 已知边界
|
|
41
|
+
|
|
42
|
+
- 判死状态记在内存里,重启后重新判定。也就是说重启后的第一次请求还会再试一次那个账号,然后才把它请出去——这一次的代价是必要的,否则「重启一下就好了」的临时故障会被误判成永久失效。
|
|
43
|
+
- 国际版每日签到活动(`checkin-activity-status`)实测当前是 `active: false`,所以「多账号自动领 30 积分」目前没有可领的东西;国内版的活动是正常的,逐账号领取的链路本来就有。
|
|
44
|
+
|
|
45
|
+
## 1.8.0 — 四种模式,终于排得像个样子了
|
|
46
|
+
|
|
47
|
+
> 上一版加了第四种模式「每对话固定」,功能是好的,排版是坏的。坏得很实在:格子还是按三个排的,第四个只好委屈地掉到下一行,占着半个身位,像个插班生。
|
|
48
|
+
|
|
49
|
+
### 问题出在哪
|
|
50
|
+
|
|
51
|
+
「账号使用方式」这一块本来写着 `grid-template-columns: repeat(3, ...)`——三列。模式从三个变成四个之后,没人去改这个数字。
|
|
52
|
+
|
|
53
|
+
于是:
|
|
54
|
+
|
|
55
|
+
- 第四张卡被挤到第二行,左边空出一大块,右边孤零零一张;
|
|
56
|
+
- 每张卡只剩大约 190 像素宽,中文说明被折成两三行,长短参差不齐,眼睛没法竖着扫;
|
|
57
|
+
- 更尴尬的是,**测试全绿、构建全过**——因为没有任何一个测试会去数 CSS 里的网格列数。这种错只能靠眼睛发现。
|
|
58
|
+
|
|
59
|
+
### 现在长什么样
|
|
60
|
+
|
|
61
|
+
- **两列排布**,四张卡正好 2×2,方方正正;窄窗口下自动变成一列,每张卡独占一行,说明文字不再折行。
|
|
62
|
+
- **顶部多了一个「当前:每对话固定」的小徽章**,不用在四张卡里自己找哪张亮着。
|
|
63
|
+
- **选中的那张卡明显抬高**:底色提亮一层,左侧加一条竖色条,一眼就能认出来。
|
|
64
|
+
- 「推荐」不再塞进标题文字里(`每对话固定(推荐)`这种写法会让标题长度不齐),改成一个**独立的小绿标**挂在卡片右上角。
|
|
65
|
+
|
|
66
|
+
### 配色上的一点坚持
|
|
67
|
+
|
|
68
|
+
这次参考了 `dingminhua/dsh-connect-workbuddy` 的界面,它的思路很清楚:给「当前项」一条竖色条 + 抬高的底色。这套手法我们借来了。
|
|
69
|
+
|
|
70
|
+
但**颜色没借**。它用的是宿主品牌蓝 `#5686fe`——那是 DSH 自己的颜色,不是这张卡片的。我们的卡片有自己的一套:底色用和上面区域切换按钮选中时同一个「抬升表面」色,竖色条用中性色,和宿主保持一致但不冒充宿主。
|
|
71
|
+
|
|
72
|
+
绿色只留给「推荐」徽章,因为卡片下方那个绿点已经在表示「账号健康」了,别让两种绿打架。
|
|
73
|
+
|
|
74
|
+
### 已知边界
|
|
75
|
+
|
|
76
|
+
- 这次只动了样式和排版,**分发逻辑一行没改**——四种模式的行为和 1.7.9 完全一样。
|
|
77
|
+
- 布局测试是**读源码文本**断言的(比如「网格必须是两列」「选中态不许出现 brand-primary」),能防住改错,但防不住视觉上「两列也不好看」。真正的判断还得靠眼睛。
|
|
78
|
+
|
|
7
79
|
## 1.7.9 — 一个对话只用一个账号,新对话才换人
|
|
8
80
|
|
|
9
81
|
> 有人反馈:除了「优先模式」,换别的模式都会掉命中率、掉速度。这个观察是对的,而且原因不在你的网络——在账号切换本身。所以这次加了一种新模式,专门解决这件事。
|
package/lib/bin.js
CHANGED
|
@@ -2644,6 +2644,28 @@ function idleWeight(lastUsedAt, now) {
|
|
|
2644
2644
|
}
|
|
2645
2645
|
/** Default rest for an account whose credits ran out (packs reset on their own schedule). */
|
|
2646
2646
|
const EXHAUST_COOLDOWN_MS = 18e5;
|
|
2647
|
+
/**
|
|
2648
|
+
* Default rest for an account the upstream rejected the sign-in of (401/403).
|
|
2649
|
+
*
|
|
2650
|
+
* Unlike a rate limit there is no reset time to read: only signing in again in
|
|
2651
|
+
* the desktop app revives the account. The rest is therefore long enough that a
|
|
2652
|
+
* single request never burns its whole retry budget on the same dead credential,
|
|
2653
|
+
* but short enough that a user who just signed in is not locked out of their own
|
|
2654
|
+
* account for long — and a newer credential file clears the mark outright.
|
|
2655
|
+
*/
|
|
2656
|
+
const CREDENTIAL_DEAD_COOLDOWN_MS = 18e5;
|
|
2657
|
+
/**
|
|
2658
|
+
* Does this refresh failure prove the credential itself is dead?
|
|
2659
|
+
*
|
|
2660
|
+
* The upstream answers a revoked refresh token with `invalid_grant: Offline user
|
|
2661
|
+
* session not found` (HTTP 400/401). Network failures, DNS errors and 5xx do NOT
|
|
2662
|
+
* prove anything about the credential and must not mark it dead — that would
|
|
2663
|
+
* take a working account out of rotation for half an hour over a hiccup.
|
|
2664
|
+
*/
|
|
2665
|
+
function isCredentialDeadError(error) {
|
|
2666
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2667
|
+
return message.includes("invalid_grant") || message.includes("Offline user session not found") || message.includes("session_dead");
|
|
2668
|
+
}
|
|
2647
2669
|
var WorkBuddyAccountPool = class {
|
|
2648
2670
|
logger;
|
|
2649
2671
|
authDirs;
|
|
@@ -2654,6 +2676,11 @@ var WorkBuddyAccountPool = class {
|
|
|
2654
2676
|
* rate-limit window, so this is much longer than `cooldownMs`.
|
|
2655
2677
|
*/
|
|
2656
2678
|
exhaustCooldownMs;
|
|
2679
|
+
/**
|
|
2680
|
+
* How long an account stays out of rotation after the upstream rejected its
|
|
2681
|
+
* sign-in. Cleared by a newer credential file or an explicit sign-in.
|
|
2682
|
+
*/
|
|
2683
|
+
credentialDeadCooldownMs;
|
|
2657
2684
|
client;
|
|
2658
2685
|
refreshMarginMs;
|
|
2659
2686
|
accounts = [];
|
|
@@ -2736,6 +2763,7 @@ var WorkBuddyAccountPool = class {
|
|
|
2736
2763
|
this.authDirs = options.authDirs ?? candidateAuthDirs();
|
|
2737
2764
|
this.cooldownMs = options.cooldownMs ?? 6e4;
|
|
2738
2765
|
this.exhaustCooldownMs = options.exhaustCooldownMs ?? EXHAUST_COOLDOWN_MS;
|
|
2766
|
+
this.credentialDeadCooldownMs = options.credentialDeadCooldownMs ?? CREDENTIAL_DEAD_COOLDOWN_MS;
|
|
2739
2767
|
this.client = options.client;
|
|
2740
2768
|
this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
|
|
2741
2769
|
this.distribution = options.distribution ?? "priority";
|
|
@@ -2822,11 +2850,15 @@ var WorkBuddyAccountPool = class {
|
|
|
2822
2850
|
});
|
|
2823
2851
|
continue;
|
|
2824
2852
|
}
|
|
2825
|
-
if (isFresher(credential, existing.credential))
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2853
|
+
if (isFresher(credential, existing.credential)) {
|
|
2854
|
+
const signedInAgain = credential.refreshToken !== existing.credential.refreshToken || credential.accessToken !== existing.credential.accessToken;
|
|
2855
|
+
byId.set(id, {
|
|
2856
|
+
...existing,
|
|
2857
|
+
credential,
|
|
2858
|
+
label: accountLabel(credential),
|
|
2859
|
+
...signedInAgain ? { credentialDeadUntilMs: 0 } : {}
|
|
2860
|
+
});
|
|
2861
|
+
}
|
|
2830
2862
|
}
|
|
2831
2863
|
const ordered = [...byId.values()];
|
|
2832
2864
|
ordered.sort((a, b) => compareFreshness(a.credential, b.credential));
|
|
@@ -2857,6 +2889,7 @@ var WorkBuddyAccountPool = class {
|
|
|
2857
2889
|
if (balance !== void 0 && balance <= reserve) return false;
|
|
2858
2890
|
}
|
|
2859
2891
|
if (account.cooldownUntilMs > now) return false;
|
|
2892
|
+
if ((account.credentialDeadUntilMs ?? 0) > now) return false;
|
|
2860
2893
|
if (modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now) return false;
|
|
2861
2894
|
if (region !== void 0 && regionOf(account.credential.domain) !== region) return false;
|
|
2862
2895
|
return true;
|
|
@@ -2882,15 +2915,21 @@ var WorkBuddyAccountPool = class {
|
|
|
2882
2915
|
total: 0,
|
|
2883
2916
|
cooling: 0,
|
|
2884
2917
|
disabled: 0,
|
|
2918
|
+
dead: 0,
|
|
2885
2919
|
reason: "empty"
|
|
2886
2920
|
};
|
|
2887
2921
|
let cooling = 0;
|
|
2888
2922
|
let disabled = 0;
|
|
2923
|
+
let dead = 0;
|
|
2889
2924
|
for (const account of inRegion) {
|
|
2890
2925
|
if (this.disabledIds.has(account.id)) {
|
|
2891
2926
|
disabled += 1;
|
|
2892
2927
|
continue;
|
|
2893
2928
|
}
|
|
2929
|
+
if ((account.credentialDeadUntilMs ?? 0) > now) {
|
|
2930
|
+
dead += 1;
|
|
2931
|
+
continue;
|
|
2932
|
+
}
|
|
2894
2933
|
const modelCooling = modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now;
|
|
2895
2934
|
if (account.cooldownUntilMs > now || modelCooling) {
|
|
2896
2935
|
cooling += 1;
|
|
@@ -2903,26 +2942,37 @@ var WorkBuddyAccountPool = class {
|
|
|
2903
2942
|
total: inRegion.length,
|
|
2904
2943
|
cooling,
|
|
2905
2944
|
disabled,
|
|
2945
|
+
dead,
|
|
2906
2946
|
reason: "reserve"
|
|
2907
2947
|
};
|
|
2908
2948
|
}
|
|
2909
2949
|
}
|
|
2950
|
+
if (dead > 0) return {
|
|
2951
|
+
total: inRegion.length,
|
|
2952
|
+
cooling,
|
|
2953
|
+
disabled,
|
|
2954
|
+
dead,
|
|
2955
|
+
reason: "session_dead"
|
|
2956
|
+
};
|
|
2910
2957
|
if (cooling > 0) return {
|
|
2911
2958
|
total: inRegion.length,
|
|
2912
2959
|
cooling,
|
|
2913
2960
|
disabled,
|
|
2961
|
+
dead,
|
|
2914
2962
|
reason: "cooling"
|
|
2915
2963
|
};
|
|
2916
2964
|
if (disabled > 0) return {
|
|
2917
2965
|
total: inRegion.length,
|
|
2918
2966
|
cooling,
|
|
2919
2967
|
disabled,
|
|
2968
|
+
dead,
|
|
2920
2969
|
reason: "disabled"
|
|
2921
2970
|
};
|
|
2922
2971
|
return {
|
|
2923
2972
|
total: inRegion.length,
|
|
2924
2973
|
cooling,
|
|
2925
2974
|
disabled,
|
|
2975
|
+
dead,
|
|
2926
2976
|
reason: "none"
|
|
2927
2977
|
};
|
|
2928
2978
|
}
|
|
@@ -3026,31 +3076,28 @@ var WorkBuddyAccountPool = class {
|
|
|
3026
3076
|
*/
|
|
3027
3077
|
async acquire(modelId, region, conversationKey) {
|
|
3028
3078
|
if (this.accounts.length === 0) await this.scan();
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
if (this.preferredId !== void 0) {
|
|
3036
|
-
const preferred = pool.find((account) => account.id === this.preferredId);
|
|
3037
|
-
if (preferred !== void 0) {
|
|
3038
|
-
await this.ensureFresh(preferred);
|
|
3039
|
-
return preferred;
|
|
3079
|
+
const maxTries = this.accounts.length + 1;
|
|
3080
|
+
for (let attempt = 0; attempt < maxTries; attempt += 1) {
|
|
3081
|
+
let pool = this.available(Date.now(), modelId, region);
|
|
3082
|
+
if (pool.length === 0 && attempt === 0) {
|
|
3083
|
+
await this.scan();
|
|
3084
|
+
pool = this.available(Date.now(), modelId, region);
|
|
3040
3085
|
}
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3086
|
+
if (pool.length === 0) return void 0;
|
|
3087
|
+
if (this.preferredId !== void 0) {
|
|
3088
|
+
const preferred = pool.find((account) => account.id === this.preferredId);
|
|
3089
|
+
if (preferred !== void 0 && await this.ensureFresh(preferred)) return preferred;
|
|
3090
|
+
}
|
|
3091
|
+
if (this.distribution === "sticky") {
|
|
3092
|
+
const bound = this.affinityAccount(pool, conversationKey);
|
|
3093
|
+
if (bound !== void 0 && await this.ensureFresh(bound)) return bound;
|
|
3047
3094
|
}
|
|
3095
|
+
const account = this.distribution === "round-robin" || this.distribution === "sticky" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
|
|
3096
|
+
if (account === void 0) return void 0;
|
|
3097
|
+
if (!await this.ensureFresh(account)) continue;
|
|
3098
|
+
if (this.distribution === "sticky" && conversationKey !== void 0 && conversationKey !== "") this.bindAffinity(conversationKey, account.id);
|
|
3099
|
+
return account;
|
|
3048
3100
|
}
|
|
3049
|
-
const account = this.distribution === "round-robin" || this.distribution === "sticky" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
|
|
3050
|
-
if (account === void 0) return void 0;
|
|
3051
|
-
if (this.distribution === "sticky" && conversationKey !== void 0 && conversationKey !== "") this.bindAffinity(conversationKey, account.id);
|
|
3052
|
-
await this.ensureFresh(account);
|
|
3053
|
-
return account;
|
|
3054
3101
|
}
|
|
3055
3102
|
/** Pin the account the plugin card should prefer; tokens stay out of settings. */
|
|
3056
3103
|
/** How the pool currently spreads requests. Shown on the card. */
|
|
@@ -3154,31 +3201,68 @@ var WorkBuddyAccountPool = class {
|
|
|
3154
3201
|
return newest?.id;
|
|
3155
3202
|
}
|
|
3156
3203
|
/** Best-effort refresh of one account after a session-dead upstream answer. */
|
|
3157
|
-
async refreshAccount(accountId) {
|
|
3204
|
+
async refreshAccount(accountId, options) {
|
|
3205
|
+
const account = this.accounts.find((item) => item.id === accountId);
|
|
3206
|
+
if (account === void 0) return false;
|
|
3207
|
+
return await this.ensureFresh(account, options?.force === true);
|
|
3208
|
+
}
|
|
3209
|
+
/**
|
|
3210
|
+
* Cool a whole account because the upstream REJECTED its sign-in (401/403).
|
|
3211
|
+
*
|
|
3212
|
+
* Called with direct evidence (the request just came back `session_dead`), so
|
|
3213
|
+
* it does not need to guess: without this, the next retry of the SAME request
|
|
3214
|
+
* picks the same account again — its credential file still claims to be valid
|
|
3215
|
+
* — and a request with 8 attempts spends all 8 on one dead account while
|
|
3216
|
+
* healthy accounts are never tried.
|
|
3217
|
+
*/
|
|
3218
|
+
penalizeCredentialDead(accountId) {
|
|
3158
3219
|
const account = this.accounts.find((item) => item.id === accountId);
|
|
3159
3220
|
if (account === void 0) return;
|
|
3160
|
-
|
|
3221
|
+
const until = Date.now() + this.credentialDeadCooldownMs;
|
|
3222
|
+
account.credentialDeadUntilMs = Math.max(account.credentialDeadUntilMs ?? 0, until);
|
|
3223
|
+
this.logger?.warn(`dsh-workbuddy-xdpool: ${account.label} sign-in was rejected by the upstream; keeping it out of rotation until ${new Date(until).toISOString()} (sign in again to restore it)`);
|
|
3224
|
+
}
|
|
3225
|
+
/** Put an account back in rotation after its sign-in was proven good again. */
|
|
3226
|
+
clearCredentialDead(accountId) {
|
|
3227
|
+
const account = this.accounts.find((item) => item.id === accountId);
|
|
3228
|
+
if (account === void 0) return;
|
|
3229
|
+
account.credentialDeadUntilMs = 0;
|
|
3230
|
+
}
|
|
3231
|
+
/** Accounts currently kept out of rotation because their sign-in was rejected. */
|
|
3232
|
+
deadCredentials() {
|
|
3233
|
+
const now = Date.now();
|
|
3234
|
+
return this.accounts.filter((account) => (account.credentialDeadUntilMs ?? 0) > now);
|
|
3161
3235
|
}
|
|
3162
3236
|
/**
|
|
3163
3237
|
* Refresh the account's access token when it is within the margin (or already
|
|
3164
3238
|
* expired), in-flight de-duped per account. A failed refresh keeps the
|
|
3165
3239
|
* existing token when it has not yet expired, so an unreachable refresh
|
|
3166
3240
|
* endpoint never takes down a working session.
|
|
3241
|
+
*
|
|
3242
|
+
* `force` skips the "is it expiring?" gate. That gate is a cost optimisation,
|
|
3243
|
+
* not a correctness rule: it exists so a healthy token is not re-fetched on
|
|
3244
|
+
* every acquire. After the upstream has ALREADY rejected the token, the gate
|
|
3245
|
+
* is actively wrong — a revoked token keeps its future `expiresAtMs` — and the
|
|
3246
|
+
* caller needs the refresh attempted so the credential can be proven dead.
|
|
3247
|
+
*
|
|
3248
|
+
* Returns true when the token is known good afterwards (refreshed, or still
|
|
3249
|
+
* valid), false when the credential was proven dead.
|
|
3167
3250
|
*/
|
|
3168
|
-
async ensureFresh(account) {
|
|
3169
|
-
if (this.client === void 0) return;
|
|
3251
|
+
async ensureFresh(account, force = false) {
|
|
3252
|
+
if (this.client === void 0) return true;
|
|
3170
3253
|
const credential = account.credential;
|
|
3171
|
-
if (!(credential.expiresAtMs <= 0 || credential.expiresAtMs <= Date.now() + this.refreshMarginMs)) return;
|
|
3254
|
+
if (!(credential.expiresAtMs <= 0 || credential.expiresAtMs <= Date.now() + this.refreshMarginMs) && !force) return true;
|
|
3172
3255
|
const existing = this.refreshInflight.get(account.id);
|
|
3173
3256
|
if (existing !== void 0) {
|
|
3174
3257
|
await existing;
|
|
3175
|
-
return;
|
|
3258
|
+
return (account.credentialDeadUntilMs ?? 0) <= Date.now();
|
|
3176
3259
|
}
|
|
3177
3260
|
const run = (async () => {
|
|
3178
3261
|
if (credential.refreshToken === "") {
|
|
3179
|
-
if (credential.expiresAtMs > Date.now() + 3e4) return;
|
|
3262
|
+
if (credential.expiresAtMs > Date.now() + 3e4) return true;
|
|
3180
3263
|
this.logger?.warn(`dsh-workbuddy-xdpool: ${account.label} token expired with no refresh token; sign in again`);
|
|
3181
|
-
|
|
3264
|
+
this.penalizeCredentialDead(account.id);
|
|
3265
|
+
return false;
|
|
3182
3266
|
}
|
|
3183
3267
|
try {
|
|
3184
3268
|
const outcome = await this.client.refreshToken(credential);
|
|
@@ -3189,15 +3273,23 @@ var WorkBuddyAccountPool = class {
|
|
|
3189
3273
|
expiresAtMs: outcome.expiresInSec !== void 0 ? Date.now() + outcome.expiresInSec * 1e3 : credential.expiresAtMs,
|
|
3190
3274
|
...outcome.domain === void 0 || outcome.domain === "" ? {} : { domain: outcome.domain }
|
|
3191
3275
|
};
|
|
3276
|
+
this.clearCredentialDead(account.id);
|
|
3192
3277
|
this.logger?.info?.(`dsh-workbuddy-xdpool: refreshed token for ${account.label}`);
|
|
3278
|
+
return true;
|
|
3193
3279
|
} catch (error) {
|
|
3280
|
+
if (isCredentialDeadError(error)) {
|
|
3281
|
+
this.penalizeCredentialDead(account.id);
|
|
3282
|
+
this.logger?.warn?.(`dsh-workbuddy-xdpool: ${account.label} refresh token was revoked upstream; sign in again to restore it`, error);
|
|
3283
|
+
return false;
|
|
3284
|
+
}
|
|
3194
3285
|
if (credential.expiresAtMs > Date.now() + 3e4) this.logger?.warn?.(`dsh-workbuddy-xdpool: token refresh failed but token still valid for ${account.label}`, error);
|
|
3195
3286
|
else this.logger?.error?.(`dsh-workbuddy-xdpool: token refresh failed and token expired for ${account.label}`, error);
|
|
3287
|
+
return true;
|
|
3196
3288
|
}
|
|
3197
3289
|
})();
|
|
3198
3290
|
this.refreshInflight.set(account.id, run);
|
|
3199
3291
|
try {
|
|
3200
|
-
await run;
|
|
3292
|
+
return await run;
|
|
3201
3293
|
} finally {
|
|
3202
3294
|
this.refreshInflight.delete(account.id);
|
|
3203
3295
|
}
|
|
@@ -3246,6 +3338,7 @@ ${new Date(until).toISOString()}
|
|
|
3246
3338
|
account.cooldownUntilMs = 0;
|
|
3247
3339
|
account.modelCooldowns = {};
|
|
3248
3340
|
account.rateLimitHits = 0;
|
|
3341
|
+
account.credentialDeadUntilMs = 0;
|
|
3249
3342
|
}
|
|
3250
3343
|
}
|
|
3251
3344
|
/** Diagnostics snapshot. Account-wide cooling count (per-model cooling excluded:
|
|
@@ -3255,6 +3348,7 @@ ${new Date(until).toISOString()}
|
|
|
3255
3348
|
return {
|
|
3256
3349
|
count: this.accounts.length,
|
|
3257
3350
|
cooling: this.accounts.filter((account) => account.cooldownUntilMs > now).length,
|
|
3351
|
+
dead: this.accounts.filter((account) => (account.credentialDeadUntilMs ?? 0) > now).length,
|
|
3258
3352
|
lastScanAtMs: this.lastScanAtMs
|
|
3259
3353
|
};
|
|
3260
3354
|
}
|
package/lib/client.js
CHANGED
|
@@ -165,17 +165,38 @@ window.__ModuleLoader__.load({
|
|
|
165
165
|
.dsm-workbuddy-xdpool-usage-status{display:flex;align-items:center;gap:10px;font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary,#e6e6e6)}
|
|
166
166
|
.dsm-workbuddy-xdpool-usage-dot{width:9px;height:9px;border-radius:50%;flex:0 0 auto}
|
|
167
167
|
.dsm-workbuddy-xdpool-usage-hint{margin:0;color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:12px;line-height:18px}
|
|
168
|
-
/*
|
|
169
|
-
|
|
168
|
+
/*
|
|
169
|
+
* Distribution picker: four modes, so the old three-column grid left the
|
|
170
|
+
* fourth card wrapped onto a line of its own. Two columns give every card room
|
|
171
|
+
* for a full hint sentence and keep the reading order 2x2.
|
|
172
|
+
*
|
|
173
|
+
* The selection cue is this card's own: an inset accent bar plus a solid
|
|
174
|
+
* surface, the same "current one is a raised chip" language the account list
|
|
175
|
+
* uses for the serving account. It is deliberately NOT the brand blue (that is
|
|
176
|
+
* the host's, not ours) and not the health green (that already means "this
|
|
177
|
+
* account works" two cards down).
|
|
178
|
+
*/
|
|
179
|
+
.dsm-workbuddy-xdpool-dist{display:flex;flex-direction:column;gap:9px}
|
|
180
|
+
.dsm-workbuddy-xdpool-dist-head{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}
|
|
170
181
|
.dsm-workbuddy-xdpool-dist-title{color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:11px;line-height:16px;letter-spacing:.03em;text-transform:uppercase;font-weight:600}
|
|
171
|
-
|
|
172
|
-
.
|
|
182
|
+
/* "now: X" — repeats the answer in the header so the user does not have to
|
|
183
|
+
scan four cards for the raised one. */
|
|
184
|
+
.dsm-workbuddy-xdpool-dist-now{flex:none;padding:2px 10px;border-radius:999px;border:1px solid color-mix(in oklab, var(--dsw-alias-label-primary,#e6e6e6) 26%, transparent);background:color-mix(in oklab, var(--dsw-alias-label-primary,#e6e6e6) 10%, transparent);color:var(--dsw-alias-label-primary,#e6e6e6);font-size:12px;font-weight:600;line-height:18px;white-space:nowrap}
|
|
185
|
+
.dsm-workbuddy-xdpool-dist-options{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
|
|
186
|
+
.dsm-workbuddy-xdpool-dist-option{appearance:none;font:inherit;cursor:pointer;text-align:left;display:flex;flex-direction:column;justify-content:center;gap:4px;min-height:62px;border:1px solid color-mix(in oklab, var(--dsw-alias-border-l2,#3a3d45) 80%, transparent);border-radius:12px;padding:9px 13px;background:transparent;color:var(--dsw-alias-label-tertiary,#9aa0a8);transition:color .16s,border-color .16s,background .16s,box-shadow .16s}
|
|
187
|
+
.dsm-workbuddy-xdpool-dist-option:hover:not(:disabled):not(.dsm-workbuddy-xdpool-dist-option-active){color:var(--dsw-alias-label-secondary,#c6c9d0);border-color:var(--dsw-alias-label-dimmed,#777);background:rgba(255,255,255,.03)}
|
|
173
188
|
.dsm-workbuddy-xdpool-dist-option:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
|
|
174
|
-
|
|
189
|
+
/* Selected: raised onto layer-3 (the same surface the region tabs use when
|
|
190
|
+
active) with an inset accent bar. The bar is what survives a glance — a
|
|
191
|
+
tinted wash alone is indistinguishable from hover. */
|
|
192
|
+
.dsm-workbuddy-xdpool-dist-option-active{background:var(--dsw-alias-bg-layer-3,#2a2c33);border-color:color-mix(in oklab, var(--dsw-alias-label-primary,#e6e6e6) 34%, transparent);box-shadow:inset 3px 0 0 var(--dsw-alias-label-primary,#e6e6e6);color:var(--dsw-alias-label-primary,#e6e6e6)}
|
|
193
|
+
.dsm-workbuddy-xdpool-dist-option-active .dsm-workbuddy-xdpool-dist-option-name{color:var(--dsw-alias-label-primary,#e6e6e6)}
|
|
175
194
|
.dsm-workbuddy-xdpool-dist-option:disabled{cursor:default;opacity:.6}
|
|
176
|
-
.dsm-workbuddy-xdpool-dist-option-
|
|
177
|
-
.dsm-workbuddy-xdpool-dist-option-
|
|
178
|
-
|
|
195
|
+
.dsm-workbuddy-xdpool-dist-option-top{display:flex;align-items:center;gap:7px;min-width:0}
|
|
196
|
+
.dsm-workbuddy-xdpool-dist-option-name{font-size:12.5px;line-height:18px;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
197
|
+
/* "recommended" chip: quiet, and only on the mode this release argues for. */
|
|
198
|
+
.dsm-workbuddy-xdpool-dist-option-badge{flex:none;margin-left:auto;padding:1px 7px;border-radius:999px;background:var(--dsw-alias-state-success-subtle,rgba(34,160,107,.14));color:var(--dsw-alias-state-success-primary,#22a06b);font-size:10px;font-weight:600;line-height:15px;white-space:nowrap}
|
|
199
|
+
.dsm-workbuddy-xdpool-dist-option-hint{font-size:11px;line-height:16px;opacity:.85}
|
|
179
200
|
.dsm-workbuddy-xdpool-usage-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
180
201
|
|
|
181
202
|
/* Account list (each account = a labeled subpanel, same as dingminhua). */
|
|
@@ -228,7 +249,11 @@ window.__ModuleLoader__.load({
|
|
|
228
249
|
.dsm-workbuddy-xdpool-account-tag{padding:1px 8px;border-radius:999px;font-size:11px;line-height:18px;background:var(--dsw-alias-state-success-subtle,rgba(34,160,107,.12));color:var(--dsw-alias-state-success-primary,#22a06b)}
|
|
229
250
|
.dsm-workbuddy-xdpool-account-tag-cooling{background:var(--dsw-alias-state-warning-subtle,rgba(217,119,6,.15));color:var(--dsw-alias-state-warning-primary,#d97706)}
|
|
230
251
|
.dsm-workbuddy-xdpool-account-tag-error{background:var(--dsw-alias-state-error-subtle,rgba(239,68,68,.12));color:var(--dsw-alias-state-error-primary,#ef4444)}
|
|
252
|
+
/* A rejected sign-in is red, not amber: unlike a cooldown it will not clear by
|
|
253
|
+
waiting, and it is the one state where the user has to go do something. */
|
|
254
|
+
.dsm-workbuddy-xdpool-account-tag-dead{background:var(--dsw-alias-state-error-subtle,rgba(239,68,68,.12));color:var(--dsw-alias-state-error-primary,#ef4444)}
|
|
231
255
|
.dsm-workbuddy-xdpool-account-meta{color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:12px;line-height:18px;display:flex;flex-wrap:wrap;gap:10px}
|
|
256
|
+
.dsm-workbuddy-xdpool-account-meta-dead{color:var(--dsw-alias-state-error-primary,#ef4444)}
|
|
232
257
|
.dsm-workbuddy-xdpool-account-modelcool{display:flex;flex-wrap:wrap;gap:6px;margin-top:2px}
|
|
233
258
|
.dsm-workbuddy-xdpool-account-modelcool-chip{display:inline-flex;align-items:center;gap:4px;padding:1px 8px;border-radius:999px;font-size:11px;line-height:18px;background:var(--dsw-alias-state-warning-subtle,rgba(217,119,6,.12));color:var(--dsw-alias-state-warning-primary,#d97706)}
|
|
234
259
|
.dsm-workbuddy-xdpool-account-error{margin:0;color:var(--dsw-alias-state-error-primary,#ef4444);font-size:13px;line-height:20px}
|
|
@@ -239,6 +264,9 @@ window.__ModuleLoader__.load({
|
|
|
239
264
|
.dsm-workbuddy-xdpool-panel-title{color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px;letter-spacing:.03em;text-transform:uppercase;font-weight:600}
|
|
240
265
|
.dsm-workbuddy-xdpool-panel-empty{color:var(--dsw-alias-label-tertiary,#999);font-size:14px;line-height:20px}
|
|
241
266
|
.dsm-workbuddy-xdpool-panel-error{color:var(--dsw-alias-state-error-primary,#ef4444);font-size:12px;line-height:18px;word-break:break-word}
|
|
267
|
+
/* A rejected sign-in gets a compact chip: the upstream paragraph is long, and
|
|
268
|
+
printed inline it pushed the neighbouring "Total" panel off screen. */
|
|
269
|
+
.dsm-workbuddy-xdpool-panel-error-dead{display:inline-flex;align-items:center;align-self:flex-start;padding:1px 8px;border-radius:999px;background:var(--dsw-alias-state-error-subtle,rgba(239,68,68,.12));font-size:11px;line-height:18px;white-space:nowrap;cursor:help}
|
|
242
270
|
.dsm-workbuddy-xdpool-panel-foot{display:flex;align-items:baseline;justify-content:space-between;gap:10px;margin-top:9px;padding-top:9px;border-top:1px solid var(--dsw-alias-border-l2,#36373b);color:var(--dsw-alias-label-secondary,#c6c9d0);font-size:12px;line-height:18px}
|
|
243
271
|
.dsm-workbuddy-xdpool-panel-foot strong{color:var(--dsw-alias-label-primary,#e6e6e6);font-size:15px;font-variant-numeric:tabular-nums}
|
|
244
272
|
.dsm-workbuddy-xdpool-packages{display:flex;flex-direction:column;gap:5px;margin:0;padding:0;list-style:none}
|
|
@@ -334,6 +362,10 @@ window.__ModuleLoader__.load({
|
|
|
334
362
|
.dsm-workbuddy-xdpool-checkin{align-items:stretch}
|
|
335
363
|
.dsm-workbuddy-xdpool-checkin-meta{align-items:flex-start}
|
|
336
364
|
.dsm-workbuddy-xdpool-checkin-bonus{text-align:left}
|
|
365
|
+
/* One mode per row: at this width a two-up grid leaves ~150px per card, and
|
|
366
|
+
the hint sentence wraps to four lines. */
|
|
367
|
+
.dsm-workbuddy-xdpool-dist-options{grid-template-columns:minmax(0,1fr)}
|
|
368
|
+
.dsm-workbuddy-xdpool-dist-option{min-height:0}
|
|
337
369
|
}
|
|
338
370
|
|
|
339
371
|
/* Automation panel: schedule and last-run summary, collapsed behind a switch. */
|
|
@@ -597,6 +629,32 @@ window.__ModuleLoader__.load({
|
|
|
597
629
|
if (value >= 1e3 && value % 1e3 === 0) return `${value / 1e3}K`;
|
|
598
630
|
return String(value);
|
|
599
631
|
}
|
|
632
|
+
/**
|
|
633
|
+
* The four ways the pool can hand out accounts.
|
|
634
|
+
*
|
|
635
|
+
* Order is the reading order of the picker: the recommended mode first, then
|
|
636
|
+
* the two cache-friendly extremes, then the two spreading modes.
|
|
637
|
+
*/
|
|
638
|
+
const DIST_OPTIONS = [
|
|
639
|
+
"sticky",
|
|
640
|
+
"priority",
|
|
641
|
+
"balanced",
|
|
642
|
+
"round-robin"
|
|
643
|
+
];
|
|
644
|
+
/** Localized name of one distribution mode. */
|
|
645
|
+
function distLabel(option, t) {
|
|
646
|
+
if (option === "sticky") return t?.("row.distSticky") ?? "Per conversation";
|
|
647
|
+
if (option === "priority") return t?.("row.distPriority") ?? "Priority";
|
|
648
|
+
if (option === "balanced") return t?.("row.distBalanced") ?? "Balanced";
|
|
649
|
+
return t?.("row.distRoundRobin") ?? "Round-robin";
|
|
650
|
+
}
|
|
651
|
+
/** One-line explanation of what a mode does to caching and spend. */
|
|
652
|
+
function distHint(option, t) {
|
|
653
|
+
if (option === "sticky") return t?.("row.distStickyHint") ?? "";
|
|
654
|
+
if (option === "priority") return t?.("row.distPriorityHint") ?? "";
|
|
655
|
+
if (option === "balanced") return t?.("row.distBalancedHint") ?? "";
|
|
656
|
+
return t?.("row.distRoundRobinHint") ?? "";
|
|
657
|
+
}
|
|
600
658
|
/** Build the draft from the server's selection + catalog flags. */
|
|
601
659
|
function draftFromStatus(status) {
|
|
602
660
|
const selection = status.selection;
|
|
@@ -1298,20 +1356,21 @@ window.__ModuleLoader__.load({
|
|
|
1298
1356
|
className: "dsm-workbuddy-xdpool-dist",
|
|
1299
1357
|
role: "radiogroup",
|
|
1300
1358
|
"aria-label": t?.("row.distTitle") ?? "Account usage",
|
|
1301
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.
|
|
1302
|
-
className: "dsm-workbuddy-xdpool-dist-
|
|
1303
|
-
children:
|
|
1359
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1360
|
+
className: "dsm-workbuddy-xdpool-dist-head",
|
|
1361
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1362
|
+
className: "dsm-workbuddy-xdpool-dist-title",
|
|
1363
|
+
children: t?.("row.distTitle") ?? "Account usage"
|
|
1364
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1365
|
+
className: "dsm-workbuddy-xdpool-dist-now",
|
|
1366
|
+
children: distLabel(status.distribution ?? "priority", t)
|
|
1367
|
+
})]
|
|
1304
1368
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1305
1369
|
className: "dsm-workbuddy-xdpool-dist-options",
|
|
1306
|
-
children:
|
|
1307
|
-
"sticky",
|
|
1308
|
-
"priority",
|
|
1309
|
-
"balanced",
|
|
1310
|
-
"round-robin"
|
|
1311
|
-
].map((option) => {
|
|
1370
|
+
children: DIST_OPTIONS.map((option) => {
|
|
1312
1371
|
const active = (status.distribution ?? "priority") === option;
|
|
1313
|
-
const label =
|
|
1314
|
-
const hint =
|
|
1372
|
+
const label = distLabel(option, t);
|
|
1373
|
+
const hint = distHint(option, t);
|
|
1315
1374
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
1316
1375
|
type: "button",
|
|
1317
1376
|
role: "radio",
|
|
@@ -1322,9 +1381,15 @@ window.__ModuleLoader__.load({
|
|
|
1322
1381
|
onClick: () => {
|
|
1323
1382
|
setDistribution(option);
|
|
1324
1383
|
},
|
|
1325
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.
|
|
1326
|
-
className: "dsm-workbuddy-xdpool-dist-option-
|
|
1327
|
-
children:
|
|
1384
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1385
|
+
className: "dsm-workbuddy-xdpool-dist-option-top",
|
|
1386
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1387
|
+
className: "dsm-workbuddy-xdpool-dist-option-name",
|
|
1388
|
+
children: label
|
|
1389
|
+
}), option === "sticky" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1390
|
+
className: "dsm-workbuddy-xdpool-dist-option-badge",
|
|
1391
|
+
children: t?.("row.distRecommended") ?? "Recommended"
|
|
1392
|
+
}) : null]
|
|
1328
1393
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1329
1394
|
className: "dsm-workbuddy-xdpool-dist-option-hint",
|
|
1330
1395
|
children: hint
|
|
@@ -1677,9 +1742,13 @@ window.__ModuleLoader__.load({
|
|
|
1677
1742
|
function AccountBlock({ account, t, checkinBusyId, onClaimCheckin, accountBusyId, onToggleDisabled, onIgnoreAccount, onSaveCreditReserve, reserveBusyId }) {
|
|
1678
1743
|
const isDisabled = account.disabled === true;
|
|
1679
1744
|
const isCooling = account.cooling === true;
|
|
1745
|
+
const isDead = account.credentialDead === true;
|
|
1680
1746
|
const cooldownUntil = account.cooldownUntil !== void 0 ? Date.parse(account.cooldownUntil) : void 0;
|
|
1681
1747
|
const modelCooldowns = account.modelCooldowns ?? [];
|
|
1682
|
-
const tag =
|
|
1748
|
+
const tag = isDead ? {
|
|
1749
|
+
text: t?.("row.credentialDeadTag") ?? "Sign-in rejected",
|
|
1750
|
+
cls: "dsm-workbuddy-xdpool-account-tag dsm-workbuddy-xdpool-account-tag-dead"
|
|
1751
|
+
} : isCooling ? {
|
|
1683
1752
|
text: t?.("row.cooling") ?? "Cooling",
|
|
1684
1753
|
cls: "dsm-workbuddy-xdpool-account-tag dsm-workbuddy-xdpool-account-tag-cooling"
|
|
1685
1754
|
} : null;
|
|
@@ -1737,6 +1806,10 @@ window.__ModuleLoader__.load({
|
|
|
1737
1806
|
t?.("row.cooldownHits", { hits: account.rateLimitHits ?? 0 }) ?? `${account.rateLimitHits ?? 0} hit(s)`
|
|
1738
1807
|
]
|
|
1739
1808
|
}) : null,
|
|
1809
|
+
isDead ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1810
|
+
className: "dsm-workbuddy-xdpool-account-meta dsm-workbuddy-xdpool-account-meta-dead",
|
|
1811
|
+
children: t?.("row.credentialDeadHint") ?? "Sign in again in the WorkBuddy desktop app to restore it"
|
|
1812
|
+
}) : null,
|
|
1740
1813
|
modelCooldowns.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1741
1814
|
className: "dsm-workbuddy-xdpool-account-modelcool",
|
|
1742
1815
|
children: modelCooldowns.map((mc) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
@@ -1918,8 +1991,9 @@ window.__ModuleLoader__.load({
|
|
|
1918
1991
|
children: t?.("row.creditsPackages") ?? "Credit packages"
|
|
1919
1992
|
}),
|
|
1920
1993
|
account.creditsError !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1921
|
-
className: "dsm-workbuddy-xdpool-panel-error"
|
|
1922
|
-
|
|
1994
|
+
className: `dsm-workbuddy-xdpool-panel-error${account.credentialDead === true ? " dsm-workbuddy-xdpool-panel-error-dead" : ""}`,
|
|
1995
|
+
title: account.creditsError,
|
|
1996
|
+
children: account.credentialDead === true ? t?.("row.credentialDead") ?? "Sign-in rejected" : t?.("row.creditsError") ?? "credits unavailable"
|
|
1923
1997
|
}) : packages.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1924
1998
|
className: "dsm-workbuddy-xdpool-panel-empty",
|
|
1925
1999
|
children: "–"
|
|
@@ -2224,6 +2298,9 @@ window.__ModuleLoader__.load({
|
|
|
2224
2298
|
"row.creditsPackages": "Credit packages",
|
|
2225
2299
|
"row.creditsPackage": "{remain} / {size}",
|
|
2226
2300
|
"row.creditsError": "credits unavailable",
|
|
2301
|
+
"row.credentialDead": "Sign-in rejected",
|
|
2302
|
+
"row.credentialDeadTag": "Sign-in rejected",
|
|
2303
|
+
"row.credentialDeadHint": "The upstream no longer accepts this sign-in. Sign in again in the WorkBuddy desktop app to restore it.",
|
|
2227
2304
|
"row.creditsSoon": "Expiring in 3 days",
|
|
2228
2305
|
"row.creditsExpiresAt": "Expires {time}",
|
|
2229
2306
|
"row.creditsExpiresSoonTitle": "Expiring within 3 days",
|
|
@@ -2285,6 +2362,7 @@ window.__ModuleLoader__.load({
|
|
|
2285
2362
|
"row.distBalancedHint": "Draw at random, favouring the account idle longest",
|
|
2286
2363
|
"row.distSticky": "Per conversation",
|
|
2287
2364
|
"row.distStickyHint": "One account per chat, new chats take the next account — keeps the prompt cache warm",
|
|
2365
|
+
"row.distRecommended": "Recommended",
|
|
2288
2366
|
"row.resetCooldowns": "Clear all cooldowns",
|
|
2289
2367
|
"row.resetCooldownsBusy": "Clearing…",
|
|
2290
2368
|
"row.resetCooldownsDone": "Cooldowns cleared",
|
|
@@ -2378,6 +2456,9 @@ window.__ModuleLoader__.load({
|
|
|
2378
2456
|
"row.creditsPackages": "积分包",
|
|
2379
2457
|
"row.creditsPackage": "{remain} / {size}",
|
|
2380
2458
|
"row.creditsError": "积分不可用",
|
|
2459
|
+
"row.credentialDead": "登录已被拒绝",
|
|
2460
|
+
"row.credentialDeadTag": "登录已被拒绝",
|
|
2461
|
+
"row.credentialDeadHint": "上游已不再接受这个登录状态。请在 WorkBuddy 桌面端重新登录该账号以恢复。",
|
|
2381
2462
|
"row.creditsSoon": "3 天内到期",
|
|
2382
2463
|
"row.creditsExpiresAt": "到期 {time}",
|
|
2383
2464
|
"row.creditsExpiresSoonTitle": "3 天内到期",
|
|
@@ -2437,8 +2518,9 @@ window.__ModuleLoader__.load({
|
|
|
2437
2518
|
"row.distRoundRobinHint": "按顺序轮流使用,积分均匀分摊",
|
|
2438
2519
|
"row.distBalanced": "均衡模式",
|
|
2439
2520
|
"row.distBalancedHint": "随机抽取,闲置越久的账号被选中概率越高",
|
|
2440
|
-
"row.distSticky": "
|
|
2521
|
+
"row.distSticky": "每对话固定",
|
|
2441
2522
|
"row.distStickyHint": "一个对话只用一个账号,新对话自动换下一个账号——保住上游缓存,命中率和速度都更好",
|
|
2523
|
+
"row.distRecommended": "推荐",
|
|
2442
2524
|
"row.resetCooldowns": "清除所有冷却",
|
|
2443
2525
|
"row.resetCooldownsBusy": "正在清除…",
|
|
2444
2526
|
"row.resetCooldownsDone": "冷却已清除",
|
package/lib/index.d.ts
CHANGED
|
@@ -810,6 +810,20 @@ interface WorkBuddyAccount {
|
|
|
810
810
|
modelCooldowns: Record<string, number>;
|
|
811
811
|
/** Consecutive rate-limit hits, for diagnostics. */
|
|
812
812
|
rateLimitHits: number;
|
|
813
|
+
/**
|
|
814
|
+
* Epoch ms until which this account is skipped because the upstream REJECTED
|
|
815
|
+
* its sign-in (401/403), not because of a rate limit.
|
|
816
|
+
*
|
|
817
|
+
* A revoked session is invisible from the credential file: the upstream does
|
|
818
|
+
* not rewrite `expiresAtMs` when it kills a refresh token, so a dead account
|
|
819
|
+
* looks perfectly fresh and, without this field, gets picked again on the very
|
|
820
|
+
* next attempt. With `maxAttempts` retries that meant one dead account could
|
|
821
|
+
* absorb an entire request while healthy accounts were never tried.
|
|
822
|
+
*
|
|
823
|
+
* Only a fresh sign-in in the desktop app clears it, so the cooldown is long;
|
|
824
|
+
* `0`/undefined means "not known to be dead".
|
|
825
|
+
*/
|
|
826
|
+
credentialDeadUntilMs?: number;
|
|
813
827
|
}
|
|
814
828
|
/**
|
|
815
829
|
* Platform-default directories holding the desktop app's auth files.
|
|
@@ -845,6 +859,11 @@ interface AccountPoolOptions {
|
|
|
845
859
|
cooldownMs?: number;
|
|
846
860
|
/** How long an account rests after its credits run out (default 30 minutes). */
|
|
847
861
|
exhaustCooldownMs?: number;
|
|
862
|
+
/**
|
|
863
|
+
* How long an account rests after the upstream rejects its sign-in
|
|
864
|
+
* (default 30 minutes). Only a fresh sign-in in the desktop app clears it.
|
|
865
|
+
*/
|
|
866
|
+
credentialDeadCooldownMs?: number;
|
|
848
867
|
/** Upstream client used to refresh near-expiry tokens. */
|
|
849
868
|
client?: TokenRefresher;
|
|
850
869
|
/** Refresh this long before actual expiry; default five minutes. */
|
|
@@ -874,6 +893,11 @@ export declare class WorkBuddyAccountPool {
|
|
|
874
893
|
* rate-limit window, so this is much longer than `cooldownMs`.
|
|
875
894
|
*/
|
|
876
895
|
private exhaustCooldownMs;
|
|
896
|
+
/**
|
|
897
|
+
* How long an account stays out of rotation after the upstream rejected its
|
|
898
|
+
* sign-in. Cleared by a newer credential file or an explicit sign-in.
|
|
899
|
+
*/
|
|
900
|
+
private credentialDeadCooldownMs;
|
|
877
901
|
private readonly client;
|
|
878
902
|
private readonly refreshMarginMs;
|
|
879
903
|
private accounts;
|
|
@@ -1018,7 +1042,9 @@ export declare class WorkBuddyAccountPool {
|
|
|
1018
1042
|
total: number;
|
|
1019
1043
|
cooling: number;
|
|
1020
1044
|
disabled: number;
|
|
1021
|
-
|
|
1045
|
+
/** Accounts skipped because the upstream rejected their sign-in. */
|
|
1046
|
+
dead: number;
|
|
1047
|
+
reason: 'empty' | 'cooling' | 'disabled' | 'reserve' | 'session_dead' | 'none';
|
|
1022
1048
|
};
|
|
1023
1049
|
/** Round-robin: the legacy cursor walk, kept for the distribution that asks for it. */
|
|
1024
1050
|
private pickRoundRobin;
|
|
@@ -1135,12 +1161,37 @@ export declare class WorkBuddyAccountPool {
|
|
|
1135
1161
|
*/
|
|
1136
1162
|
lastServedId(): string | undefined;
|
|
1137
1163
|
/** Best-effort refresh of one account after a session-dead upstream answer. */
|
|
1138
|
-
refreshAccount(accountId: string
|
|
1164
|
+
refreshAccount(accountId: string, options?: {
|
|
1165
|
+
force?: boolean;
|
|
1166
|
+
}): Promise<boolean>;
|
|
1167
|
+
/**
|
|
1168
|
+
* Cool a whole account because the upstream REJECTED its sign-in (401/403).
|
|
1169
|
+
*
|
|
1170
|
+
* Called with direct evidence (the request just came back `session_dead`), so
|
|
1171
|
+
* it does not need to guess: without this, the next retry of the SAME request
|
|
1172
|
+
* picks the same account again — its credential file still claims to be valid
|
|
1173
|
+
* — and a request with 8 attempts spends all 8 on one dead account while
|
|
1174
|
+
* healthy accounts are never tried.
|
|
1175
|
+
*/
|
|
1176
|
+
penalizeCredentialDead(accountId: string): void;
|
|
1177
|
+
/** Put an account back in rotation after its sign-in was proven good again. */
|
|
1178
|
+
clearCredentialDead(accountId: string): void;
|
|
1179
|
+
/** Accounts currently kept out of rotation because their sign-in was rejected. */
|
|
1180
|
+
deadCredentials(): readonly WorkBuddyAccount[];
|
|
1139
1181
|
/**
|
|
1140
1182
|
* Refresh the account's access token when it is within the margin (or already
|
|
1141
1183
|
* expired), in-flight de-duped per account. A failed refresh keeps the
|
|
1142
1184
|
* existing token when it has not yet expired, so an unreachable refresh
|
|
1143
1185
|
* endpoint never takes down a working session.
|
|
1186
|
+
*
|
|
1187
|
+
* `force` skips the "is it expiring?" gate. That gate is a cost optimisation,
|
|
1188
|
+
* not a correctness rule: it exists so a healthy token is not re-fetched on
|
|
1189
|
+
* every acquire. After the upstream has ALREADY rejected the token, the gate
|
|
1190
|
+
* is actively wrong — a revoked token keeps its future `expiresAtMs` — and the
|
|
1191
|
+
* caller needs the refresh attempted so the credential can be proven dead.
|
|
1192
|
+
*
|
|
1193
|
+
* Returns true when the token is known good afterwards (refreshed, or still
|
|
1194
|
+
* valid), false when the credential was proven dead.
|
|
1144
1195
|
*/
|
|
1145
1196
|
private ensureFresh;
|
|
1146
1197
|
/**
|
|
@@ -1168,6 +1219,7 @@ export declare class WorkBuddyAccountPool {
|
|
|
1168
1219
|
status(): {
|
|
1169
1220
|
count: number;
|
|
1170
1221
|
cooling: number;
|
|
1222
|
+
dead: number;
|
|
1171
1223
|
lastScanAtMs: number;
|
|
1172
1224
|
};
|
|
1173
1225
|
}
|
|
@@ -1218,6 +1270,15 @@ interface PoolWebAccount {
|
|
|
1218
1270
|
cooling: boolean;
|
|
1219
1271
|
/** ISO timestamp when the account-wide 429 cooldown lifts; only while cooling. */
|
|
1220
1272
|
cooldownUntil?: string;
|
|
1273
|
+
/**
|
|
1274
|
+
* The upstream REJECTED this account's sign-in (401/403) — the credential file
|
|
1275
|
+
* still looks valid because the upstream never rewrites its expiry when it
|
|
1276
|
+
* revokes a token. Distinct from `cooling`: waiting does not fix it, only
|
|
1277
|
+
* signing in again in the desktop app does.
|
|
1278
|
+
*/
|
|
1279
|
+
credentialDead?: boolean;
|
|
1280
|
+
/** ISO timestamp when the dead mark expires and the account is retried; only while `credentialDead`. */
|
|
1281
|
+
credentialDeadUntil?: string;
|
|
1221
1282
|
/**
|
|
1222
1283
|
* Per-model cooldowns currently active. The account is NOT `cooling` while a
|
|
1223
1284
|
* model is limited — its other models still serve — but each entry tells the
|
package/lib/index.js
CHANGED
|
@@ -2752,6 +2752,28 @@ function idleWeight(lastUsedAt, now) {
|
|
|
2752
2752
|
}
|
|
2753
2753
|
/** Default rest for an account whose credits ran out (packs reset on their own schedule). */
|
|
2754
2754
|
const EXHAUST_COOLDOWN_MS = 18e5;
|
|
2755
|
+
/**
|
|
2756
|
+
* Default rest for an account the upstream rejected the sign-in of (401/403).
|
|
2757
|
+
*
|
|
2758
|
+
* Unlike a rate limit there is no reset time to read: only signing in again in
|
|
2759
|
+
* the desktop app revives the account. The rest is therefore long enough that a
|
|
2760
|
+
* single request never burns its whole retry budget on the same dead credential,
|
|
2761
|
+
* but short enough that a user who just signed in is not locked out of their own
|
|
2762
|
+
* account for long — and a newer credential file clears the mark outright.
|
|
2763
|
+
*/
|
|
2764
|
+
const CREDENTIAL_DEAD_COOLDOWN_MS = 18e5;
|
|
2765
|
+
/**
|
|
2766
|
+
* Does this refresh failure prove the credential itself is dead?
|
|
2767
|
+
*
|
|
2768
|
+
* The upstream answers a revoked refresh token with `invalid_grant: Offline user
|
|
2769
|
+
* session not found` (HTTP 400/401). Network failures, DNS errors and 5xx do NOT
|
|
2770
|
+
* prove anything about the credential and must not mark it dead — that would
|
|
2771
|
+
* take a working account out of rotation for half an hour over a hiccup.
|
|
2772
|
+
*/
|
|
2773
|
+
function isCredentialDeadError(error) {
|
|
2774
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2775
|
+
return message.includes("invalid_grant") || message.includes("Offline user session not found") || message.includes("session_dead");
|
|
2776
|
+
}
|
|
2755
2777
|
var WorkBuddyAccountPool = class {
|
|
2756
2778
|
logger;
|
|
2757
2779
|
authDirs;
|
|
@@ -2762,6 +2784,11 @@ var WorkBuddyAccountPool = class {
|
|
|
2762
2784
|
* rate-limit window, so this is much longer than `cooldownMs`.
|
|
2763
2785
|
*/
|
|
2764
2786
|
exhaustCooldownMs;
|
|
2787
|
+
/**
|
|
2788
|
+
* How long an account stays out of rotation after the upstream rejected its
|
|
2789
|
+
* sign-in. Cleared by a newer credential file or an explicit sign-in.
|
|
2790
|
+
*/
|
|
2791
|
+
credentialDeadCooldownMs;
|
|
2765
2792
|
client;
|
|
2766
2793
|
refreshMarginMs;
|
|
2767
2794
|
accounts = [];
|
|
@@ -2844,6 +2871,7 @@ var WorkBuddyAccountPool = class {
|
|
|
2844
2871
|
this.authDirs = options.authDirs ?? candidateAuthDirs();
|
|
2845
2872
|
this.cooldownMs = options.cooldownMs ?? 6e4;
|
|
2846
2873
|
this.exhaustCooldownMs = options.exhaustCooldownMs ?? EXHAUST_COOLDOWN_MS;
|
|
2874
|
+
this.credentialDeadCooldownMs = options.credentialDeadCooldownMs ?? CREDENTIAL_DEAD_COOLDOWN_MS;
|
|
2847
2875
|
this.client = options.client;
|
|
2848
2876
|
this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
|
|
2849
2877
|
this.distribution = options.distribution ?? "priority";
|
|
@@ -2930,11 +2958,15 @@ var WorkBuddyAccountPool = class {
|
|
|
2930
2958
|
});
|
|
2931
2959
|
continue;
|
|
2932
2960
|
}
|
|
2933
|
-
if (isFresher(credential, existing.credential))
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2961
|
+
if (isFresher(credential, existing.credential)) {
|
|
2962
|
+
const signedInAgain = credential.refreshToken !== existing.credential.refreshToken || credential.accessToken !== existing.credential.accessToken;
|
|
2963
|
+
byId.set(id, {
|
|
2964
|
+
...existing,
|
|
2965
|
+
credential,
|
|
2966
|
+
label: accountLabel(credential),
|
|
2967
|
+
...signedInAgain ? { credentialDeadUntilMs: 0 } : {}
|
|
2968
|
+
});
|
|
2969
|
+
}
|
|
2938
2970
|
}
|
|
2939
2971
|
const ordered = [...byId.values()];
|
|
2940
2972
|
ordered.sort((a, b) => compareFreshness(a.credential, b.credential));
|
|
@@ -2965,6 +2997,7 @@ var WorkBuddyAccountPool = class {
|
|
|
2965
2997
|
if (balance !== void 0 && balance <= reserve) return false;
|
|
2966
2998
|
}
|
|
2967
2999
|
if (account.cooldownUntilMs > now) return false;
|
|
3000
|
+
if ((account.credentialDeadUntilMs ?? 0) > now) return false;
|
|
2968
3001
|
if (modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now) return false;
|
|
2969
3002
|
if (region !== void 0 && regionOf(account.credential.domain) !== region) return false;
|
|
2970
3003
|
return true;
|
|
@@ -2990,15 +3023,21 @@ var WorkBuddyAccountPool = class {
|
|
|
2990
3023
|
total: 0,
|
|
2991
3024
|
cooling: 0,
|
|
2992
3025
|
disabled: 0,
|
|
3026
|
+
dead: 0,
|
|
2993
3027
|
reason: "empty"
|
|
2994
3028
|
};
|
|
2995
3029
|
let cooling = 0;
|
|
2996
3030
|
let disabled = 0;
|
|
3031
|
+
let dead = 0;
|
|
2997
3032
|
for (const account of inRegion) {
|
|
2998
3033
|
if (this.disabledIds.has(account.id)) {
|
|
2999
3034
|
disabled += 1;
|
|
3000
3035
|
continue;
|
|
3001
3036
|
}
|
|
3037
|
+
if ((account.credentialDeadUntilMs ?? 0) > now) {
|
|
3038
|
+
dead += 1;
|
|
3039
|
+
continue;
|
|
3040
|
+
}
|
|
3002
3041
|
const modelCooling = modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now;
|
|
3003
3042
|
if (account.cooldownUntilMs > now || modelCooling) {
|
|
3004
3043
|
cooling += 1;
|
|
@@ -3011,26 +3050,37 @@ var WorkBuddyAccountPool = class {
|
|
|
3011
3050
|
total: inRegion.length,
|
|
3012
3051
|
cooling,
|
|
3013
3052
|
disabled,
|
|
3053
|
+
dead,
|
|
3014
3054
|
reason: "reserve"
|
|
3015
3055
|
};
|
|
3016
3056
|
}
|
|
3017
3057
|
}
|
|
3058
|
+
if (dead > 0) return {
|
|
3059
|
+
total: inRegion.length,
|
|
3060
|
+
cooling,
|
|
3061
|
+
disabled,
|
|
3062
|
+
dead,
|
|
3063
|
+
reason: "session_dead"
|
|
3064
|
+
};
|
|
3018
3065
|
if (cooling > 0) return {
|
|
3019
3066
|
total: inRegion.length,
|
|
3020
3067
|
cooling,
|
|
3021
3068
|
disabled,
|
|
3069
|
+
dead,
|
|
3022
3070
|
reason: "cooling"
|
|
3023
3071
|
};
|
|
3024
3072
|
if (disabled > 0) return {
|
|
3025
3073
|
total: inRegion.length,
|
|
3026
3074
|
cooling,
|
|
3027
3075
|
disabled,
|
|
3076
|
+
dead,
|
|
3028
3077
|
reason: "disabled"
|
|
3029
3078
|
};
|
|
3030
3079
|
return {
|
|
3031
3080
|
total: inRegion.length,
|
|
3032
3081
|
cooling,
|
|
3033
3082
|
disabled,
|
|
3083
|
+
dead,
|
|
3034
3084
|
reason: "none"
|
|
3035
3085
|
};
|
|
3036
3086
|
}
|
|
@@ -3134,31 +3184,28 @@ var WorkBuddyAccountPool = class {
|
|
|
3134
3184
|
*/
|
|
3135
3185
|
async acquire(modelId, region, conversationKey) {
|
|
3136
3186
|
if (this.accounts.length === 0) await this.scan();
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
if (this.preferredId !== void 0) {
|
|
3144
|
-
const preferred = pool.find((account) => account.id === this.preferredId);
|
|
3145
|
-
if (preferred !== void 0) {
|
|
3146
|
-
await this.ensureFresh(preferred);
|
|
3147
|
-
return preferred;
|
|
3187
|
+
const maxTries = this.accounts.length + 1;
|
|
3188
|
+
for (let attempt = 0; attempt < maxTries; attempt += 1) {
|
|
3189
|
+
let pool = this.available(Date.now(), modelId, region);
|
|
3190
|
+
if (pool.length === 0 && attempt === 0) {
|
|
3191
|
+
await this.scan();
|
|
3192
|
+
pool = this.available(Date.now(), modelId, region);
|
|
3148
3193
|
}
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3194
|
+
if (pool.length === 0) return void 0;
|
|
3195
|
+
if (this.preferredId !== void 0) {
|
|
3196
|
+
const preferred = pool.find((account) => account.id === this.preferredId);
|
|
3197
|
+
if (preferred !== void 0 && await this.ensureFresh(preferred)) return preferred;
|
|
3198
|
+
}
|
|
3199
|
+
if (this.distribution === "sticky") {
|
|
3200
|
+
const bound = this.affinityAccount(pool, conversationKey);
|
|
3201
|
+
if (bound !== void 0 && await this.ensureFresh(bound)) return bound;
|
|
3155
3202
|
}
|
|
3203
|
+
const account = this.distribution === "round-robin" || this.distribution === "sticky" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
|
|
3204
|
+
if (account === void 0) return void 0;
|
|
3205
|
+
if (!await this.ensureFresh(account)) continue;
|
|
3206
|
+
if (this.distribution === "sticky" && conversationKey !== void 0 && conversationKey !== "") this.bindAffinity(conversationKey, account.id);
|
|
3207
|
+
return account;
|
|
3156
3208
|
}
|
|
3157
|
-
const account = this.distribution === "round-robin" || this.distribution === "sticky" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
|
|
3158
|
-
if (account === void 0) return void 0;
|
|
3159
|
-
if (this.distribution === "sticky" && conversationKey !== void 0 && conversationKey !== "") this.bindAffinity(conversationKey, account.id);
|
|
3160
|
-
await this.ensureFresh(account);
|
|
3161
|
-
return account;
|
|
3162
3209
|
}
|
|
3163
3210
|
/** Pin the account the plugin card should prefer; tokens stay out of settings. */
|
|
3164
3211
|
/** How the pool currently spreads requests. Shown on the card. */
|
|
@@ -3262,31 +3309,68 @@ var WorkBuddyAccountPool = class {
|
|
|
3262
3309
|
return newest?.id;
|
|
3263
3310
|
}
|
|
3264
3311
|
/** Best-effort refresh of one account after a session-dead upstream answer. */
|
|
3265
|
-
async refreshAccount(accountId) {
|
|
3312
|
+
async refreshAccount(accountId, options) {
|
|
3313
|
+
const account = this.accounts.find((item) => item.id === accountId);
|
|
3314
|
+
if (account === void 0) return false;
|
|
3315
|
+
return await this.ensureFresh(account, options?.force === true);
|
|
3316
|
+
}
|
|
3317
|
+
/**
|
|
3318
|
+
* Cool a whole account because the upstream REJECTED its sign-in (401/403).
|
|
3319
|
+
*
|
|
3320
|
+
* Called with direct evidence (the request just came back `session_dead`), so
|
|
3321
|
+
* it does not need to guess: without this, the next retry of the SAME request
|
|
3322
|
+
* picks the same account again — its credential file still claims to be valid
|
|
3323
|
+
* — and a request with 8 attempts spends all 8 on one dead account while
|
|
3324
|
+
* healthy accounts are never tried.
|
|
3325
|
+
*/
|
|
3326
|
+
penalizeCredentialDead(accountId) {
|
|
3327
|
+
const account = this.accounts.find((item) => item.id === accountId);
|
|
3328
|
+
if (account === void 0) return;
|
|
3329
|
+
const until = Date.now() + this.credentialDeadCooldownMs;
|
|
3330
|
+
account.credentialDeadUntilMs = Math.max(account.credentialDeadUntilMs ?? 0, until);
|
|
3331
|
+
this.logger?.warn(`dsh-workbuddy-xdpool: ${account.label} sign-in was rejected by the upstream; keeping it out of rotation until ${new Date(until).toISOString()} (sign in again to restore it)`);
|
|
3332
|
+
}
|
|
3333
|
+
/** Put an account back in rotation after its sign-in was proven good again. */
|
|
3334
|
+
clearCredentialDead(accountId) {
|
|
3266
3335
|
const account = this.accounts.find((item) => item.id === accountId);
|
|
3267
3336
|
if (account === void 0) return;
|
|
3268
|
-
|
|
3337
|
+
account.credentialDeadUntilMs = 0;
|
|
3338
|
+
}
|
|
3339
|
+
/** Accounts currently kept out of rotation because their sign-in was rejected. */
|
|
3340
|
+
deadCredentials() {
|
|
3341
|
+
const now = Date.now();
|
|
3342
|
+
return this.accounts.filter((account) => (account.credentialDeadUntilMs ?? 0) > now);
|
|
3269
3343
|
}
|
|
3270
3344
|
/**
|
|
3271
3345
|
* Refresh the account's access token when it is within the margin (or already
|
|
3272
3346
|
* expired), in-flight de-duped per account. A failed refresh keeps the
|
|
3273
3347
|
* existing token when it has not yet expired, so an unreachable refresh
|
|
3274
3348
|
* endpoint never takes down a working session.
|
|
3349
|
+
*
|
|
3350
|
+
* `force` skips the "is it expiring?" gate. That gate is a cost optimisation,
|
|
3351
|
+
* not a correctness rule: it exists so a healthy token is not re-fetched on
|
|
3352
|
+
* every acquire. After the upstream has ALREADY rejected the token, the gate
|
|
3353
|
+
* is actively wrong — a revoked token keeps its future `expiresAtMs` — and the
|
|
3354
|
+
* caller needs the refresh attempted so the credential can be proven dead.
|
|
3355
|
+
*
|
|
3356
|
+
* Returns true when the token is known good afterwards (refreshed, or still
|
|
3357
|
+
* valid), false when the credential was proven dead.
|
|
3275
3358
|
*/
|
|
3276
|
-
async ensureFresh(account) {
|
|
3277
|
-
if (this.client === void 0) return;
|
|
3359
|
+
async ensureFresh(account, force = false) {
|
|
3360
|
+
if (this.client === void 0) return true;
|
|
3278
3361
|
const credential = account.credential;
|
|
3279
|
-
if (!(credential.expiresAtMs <= 0 || credential.expiresAtMs <= Date.now() + this.refreshMarginMs)) return;
|
|
3362
|
+
if (!(credential.expiresAtMs <= 0 || credential.expiresAtMs <= Date.now() + this.refreshMarginMs) && !force) return true;
|
|
3280
3363
|
const existing = this.refreshInflight.get(account.id);
|
|
3281
3364
|
if (existing !== void 0) {
|
|
3282
3365
|
await existing;
|
|
3283
|
-
return;
|
|
3366
|
+
return (account.credentialDeadUntilMs ?? 0) <= Date.now();
|
|
3284
3367
|
}
|
|
3285
3368
|
const run = (async () => {
|
|
3286
3369
|
if (credential.refreshToken === "") {
|
|
3287
|
-
if (credential.expiresAtMs > Date.now() + 3e4) return;
|
|
3370
|
+
if (credential.expiresAtMs > Date.now() + 3e4) return true;
|
|
3288
3371
|
this.logger?.warn(`dsh-workbuddy-xdpool: ${account.label} token expired with no refresh token; sign in again`);
|
|
3289
|
-
|
|
3372
|
+
this.penalizeCredentialDead(account.id);
|
|
3373
|
+
return false;
|
|
3290
3374
|
}
|
|
3291
3375
|
try {
|
|
3292
3376
|
const outcome = await this.client.refreshToken(credential);
|
|
@@ -3297,15 +3381,23 @@ var WorkBuddyAccountPool = class {
|
|
|
3297
3381
|
expiresAtMs: outcome.expiresInSec !== void 0 ? Date.now() + outcome.expiresInSec * 1e3 : credential.expiresAtMs,
|
|
3298
3382
|
...outcome.domain === void 0 || outcome.domain === "" ? {} : { domain: outcome.domain }
|
|
3299
3383
|
};
|
|
3384
|
+
this.clearCredentialDead(account.id);
|
|
3300
3385
|
this.logger?.info?.(`dsh-workbuddy-xdpool: refreshed token for ${account.label}`);
|
|
3386
|
+
return true;
|
|
3301
3387
|
} catch (error) {
|
|
3388
|
+
if (isCredentialDeadError(error)) {
|
|
3389
|
+
this.penalizeCredentialDead(account.id);
|
|
3390
|
+
this.logger?.warn?.(`dsh-workbuddy-xdpool: ${account.label} refresh token was revoked upstream; sign in again to restore it`, error);
|
|
3391
|
+
return false;
|
|
3392
|
+
}
|
|
3302
3393
|
if (credential.expiresAtMs > Date.now() + 3e4) this.logger?.warn?.(`dsh-workbuddy-xdpool: token refresh failed but token still valid for ${account.label}`, error);
|
|
3303
3394
|
else this.logger?.error?.(`dsh-workbuddy-xdpool: token refresh failed and token expired for ${account.label}`, error);
|
|
3395
|
+
return true;
|
|
3304
3396
|
}
|
|
3305
3397
|
})();
|
|
3306
3398
|
this.refreshInflight.set(account.id, run);
|
|
3307
3399
|
try {
|
|
3308
|
-
await run;
|
|
3400
|
+
return await run;
|
|
3309
3401
|
} finally {
|
|
3310
3402
|
this.refreshInflight.delete(account.id);
|
|
3311
3403
|
}
|
|
@@ -3354,6 +3446,7 @@ ${new Date(until).toISOString()}
|
|
|
3354
3446
|
account.cooldownUntilMs = 0;
|
|
3355
3447
|
account.modelCooldowns = {};
|
|
3356
3448
|
account.rateLimitHits = 0;
|
|
3449
|
+
account.credentialDeadUntilMs = 0;
|
|
3357
3450
|
}
|
|
3358
3451
|
}
|
|
3359
3452
|
/** Diagnostics snapshot. Account-wide cooling count (per-model cooling excluded:
|
|
@@ -3363,6 +3456,7 @@ ${new Date(until).toISOString()}
|
|
|
3363
3456
|
return {
|
|
3364
3457
|
count: this.accounts.length,
|
|
3365
3458
|
cooling: this.accounts.filter((account) => account.cooldownUntilMs > now).length,
|
|
3459
|
+
dead: this.accounts.filter((account) => (account.credentialDeadUntilMs ?? 0) > now).length,
|
|
3366
3460
|
lastScanAtMs: this.lastScanAtMs
|
|
3367
3461
|
};
|
|
3368
3462
|
}
|
|
@@ -5900,6 +5994,10 @@ function createWorkBuddyShim(options) {
|
|
|
5900
5994
|
writeOpenAIError(res, 402, "reserve_floor", `every WorkBuddy account is at or below its credit reserve (${why.total} account(s)); lower the reserve on the pool card or top up credits`);
|
|
5901
5995
|
return;
|
|
5902
5996
|
}
|
|
5997
|
+
if (why.reason === "session_dead") {
|
|
5998
|
+
writeOpenAIError(res, 401, "session_dead", `${why.dead}/${why.total} WorkBuddy account(s) for this gateway were rejected by the upstream (their sign-in was revoked); sign in again in the WorkBuddy desktop app to restore them`);
|
|
5999
|
+
return;
|
|
6000
|
+
}
|
|
5903
6001
|
writeOpenAIError(res, 401, "not_signed_in", why.total === 0 ? "no WorkBuddy credential found for this gateway; sign in on the desktop app (or set WORKBUDDY_AUTH_FILE)" : `no usable WorkBuddy credential among ${why.total} account(s) for this gateway; sign in again on the desktop app`);
|
|
5904
6002
|
return;
|
|
5905
6003
|
}
|
|
@@ -5916,7 +6014,11 @@ function createWorkBuddyShim(options) {
|
|
|
5916
6014
|
};
|
|
5917
6015
|
if (result.kind === "session_dead") {
|
|
5918
6016
|
logger?.warn(`dsh-workbuddy-xdpool: ${account.label} session dead; refreshing token and retrying`);
|
|
5919
|
-
|
|
6017
|
+
const before = account.credential.accessToken;
|
|
6018
|
+
if (!await pool.refreshAccount(account.id, { force: true }) || account.credential.accessToken === before) {
|
|
6019
|
+
pool.penalizeCredentialDead(account.id);
|
|
6020
|
+
logger?.warn(`dsh-workbuddy-xdpool: ${account.label} sign-in is no longer valid (attempt ${attempt + 1}/${maxAttempts}); rotating`);
|
|
6021
|
+
}
|
|
5920
6022
|
continue;
|
|
5921
6023
|
}
|
|
5922
6024
|
if (result.kind === "hard_credit") {
|
|
@@ -6109,7 +6211,8 @@ async function recoverFromContextOverrun(options) {
|
|
|
6109
6211
|
detail: "prompt still exceeded the window after compaction"
|
|
6110
6212
|
};
|
|
6111
6213
|
if (result.kind === "session_dead") {
|
|
6112
|
-
|
|
6214
|
+
const before = account.credential.accessToken;
|
|
6215
|
+
if (!await pool.refreshAccount(account.id, { force: true }) || account.credential.accessToken === before) pool.penalizeCredentialDead(account.id);
|
|
6113
6216
|
continue;
|
|
6114
6217
|
}
|
|
6115
6218
|
if (result.kind === "hard_credit") {
|
|
@@ -6401,6 +6504,8 @@ function parseAutomationRun(body) {
|
|
|
6401
6504
|
function toWebAccount(account, disabled, reserve, reserved) {
|
|
6402
6505
|
const now = Date.now();
|
|
6403
6506
|
const cooling = account.cooldownUntilMs > now;
|
|
6507
|
+
const deadUntil = account.credentialDeadUntilMs ?? 0;
|
|
6508
|
+
const credentialDead = deadUntil > now;
|
|
6404
6509
|
const modelCooldowns = Object.entries(account.modelCooldowns).filter(([, until]) => until > now).sort((a, b) => a[1] - b[1]).map(([modelId, until]) => ({
|
|
6405
6510
|
modelId,
|
|
6406
6511
|
until: new Date(until).toISOString()
|
|
@@ -6413,6 +6518,8 @@ function toWebAccount(account, disabled, reserve, reserved) {
|
|
|
6413
6518
|
...account.credential.expiresAtMs === 0 ? {} : { expiresAt: new Date(account.credential.expiresAtMs).toISOString() },
|
|
6414
6519
|
cooling,
|
|
6415
6520
|
...cooling ? { cooldownUntil: new Date(account.cooldownUntilMs).toISOString() } : {},
|
|
6521
|
+
credentialDead,
|
|
6522
|
+
...credentialDead ? { credentialDeadUntil: new Date(deadUntil).toISOString() } : {},
|
|
6416
6523
|
...modelCooldowns.length === 0 ? {} : { modelCooldowns },
|
|
6417
6524
|
disabled,
|
|
6418
6525
|
creditReserve: reserve,
|
|
@@ -6452,7 +6559,7 @@ async function poolWebStatus(deps, region = "cn") {
|
|
|
6452
6559
|
const row = toWebAccount(account, deps.pool.isDisabled(account.id), deps.pool.creditReserveOf(account.id), deps.pool.isReserved(account.id));
|
|
6453
6560
|
const earned = deps.scheduler?.().earningsToday[account.id];
|
|
6454
6561
|
if (earned !== void 0) Object.assign(row, { automationToday: earned });
|
|
6455
|
-
if (!row.cooling) {
|
|
6562
|
+
if (!row.cooling && row.credentialDead !== true) {
|
|
6456
6563
|
try {
|
|
6457
6564
|
const credits = await deps.client.fetchCredits(account.credential);
|
|
6458
6565
|
deps.pool.noteCredits(account.id, credits.total);
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "dsh-workbuddy-xdpool",
|
|
3
3
|
"displayName": "DSH WorkBuddy XD Pool",
|
|
4
4
|
"description": "Merge every locally signed-in WorkBuddy account into DeepSeek Harness as one auto-failing-over model pool (multi-account rotation, live credits, daily check-in and model catalog).",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.8.1",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "XDTrees",
|
|
8
8
|
"homepage": "https://github.com/XDTrees/dsh-workbuddy-xdpool#readme",
|