dsh-connect-workbuddy 2.3.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +97 -1
- package/README.en.md +62 -1
- package/README.md +63 -1
- package/docs/assets/account-pool-demo.html +986 -0
- package/docs/assets/dsh-connect-workbuddy-account-pool.png +0 -0
- package/lib/bin.js +1 -1
- package/lib/client.js +1453 -54
- package/lib/{host-heartbeat-Uh8JCXqk.js → host-heartbeat-JODdwFRx.js} +29 -1
- package/lib/index.d.ts +402 -44
- package/lib/index.js +1189 -49
- package/package.json +13 -10
- package/screenshots.json +2 -1
package/lib/index.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import { A as WorkBuddyCredentialRejectedError, B as clearAtRestKeyCache, C as hasEncryptedCredentialFields, D as workbuddyAccountId, E as parseWorkBuddyAuth, F as parseReasoning, G as isEncryptedFieldWrapper, H as deriveAtRestKeyId, I as parseUpstreamModel, J as macosNestedAppBundles, K as isWorkbuddyBundle, L as prepareChatBody, M as classifyUpstreamError, N as isCredentialRejectedError, O as workbuddyOwnAuthPath, P as parseCreditMultiplier, R as regionOf, S as defaultDesktopAuthPath, T as legacyWorkbuddyOwnAuthPath, U as fetchAtRestKeyPayload, V as deriveAtRestKey, W as findWorkbuddyAppExecutable, X as readAtRestKey, Y as openEncryptedField, Z as workbuddyAppExecutableCandidates, _ as WorkBuddyCredentialStore, a as readHostHeartbeat, b as defaultDesktopAuthCandidates, c as WORKBUDDY_CONNECT_VERSION, d as WorkBuddyCatalog, f as deriveCatalog, g as WORKBUDDY_AUTH_FILE_ENV, h as WORKBUDDY_AUTH_FILENAME, i as processStartTimeMs, j as WorkBuddyUpstreamClient, k as CREDENTIAL_REJECTED_CODE, l as FALLBACK_WORKBUDDY_MODELS, m as ENCRYPTED_CREDENTIAL_CODE, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as fallbackModelsFor, q as macosBundleExecutable, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, v as WorkBuddyEncryptedCredentialError, w as isEncryptedCredentialError, x as defaultDesktopAuthDirs, y as authFileName, z as WORKBUDDY_APP_EXECUTABLE_ENV } from "./host-heartbeat-
|
|
1
|
+
import { A as WorkBuddyCredentialRejectedError, B as clearAtRestKeyCache, C as hasEncryptedCredentialFields, D as workbuddyAccountId, E as parseWorkBuddyAuth, F as parseReasoning, G as isEncryptedFieldWrapper, H as deriveAtRestKeyId, I as parseUpstreamModel, J as macosNestedAppBundles, K as isWorkbuddyBundle, L as prepareChatBody, M as classifyUpstreamError, N as isCredentialRejectedError, O as workbuddyOwnAuthPath, P as parseCreditMultiplier, R as regionOf, S as defaultDesktopAuthPath, T as legacyWorkbuddyOwnAuthPath, U as fetchAtRestKeyPayload, V as deriveAtRestKey, W as findWorkbuddyAppExecutable, X as readAtRestKey, Y as openEncryptedField, Z as workbuddyAppExecutableCandidates, _ as WorkBuddyCredentialStore, a as readHostHeartbeat, b as defaultDesktopAuthCandidates, c as WORKBUDDY_CONNECT_VERSION, d as WorkBuddyCatalog, f as deriveCatalog, g as WORKBUDDY_AUTH_FILE_ENV, h as WORKBUDDY_AUTH_FILENAME, i as processStartTimeMs, j as WorkBuddyUpstreamClient, k as CREDENTIAL_REJECTED_CODE, l as FALLBACK_WORKBUDDY_MODELS, m as ENCRYPTED_CREDENTIAL_CODE, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as fallbackModelsFor, q as macosBundleExecutable, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, v as WorkBuddyEncryptedCredentialError, w as isEncryptedCredentialError, x as defaultDesktopAuthDirs, y as authFileName, z as WORKBUDDY_APP_EXECUTABLE_ENV } from "./host-heartbeat-JODdwFRx.js";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
3
4
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
|
|
7
|
+
import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
|
|
4
8
|
import { createProvider } from "@earendil-works/pi-ai";
|
|
5
9
|
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
6
10
|
import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
|
|
@@ -501,25 +505,63 @@ function createWorkBuddyShim(options) {
|
|
|
501
505
|
* would report every global model as broken.
|
|
502
506
|
*/
|
|
503
507
|
const PROBE_SYSTEM_PROMPT = "You are a connectivity check. Reply with a single word.";
|
|
504
|
-
/** The one-word user turn; the smallest thing a chat endpoint accepts. */
|
|
505
|
-
const PROBE_USER_PROMPT = "ping";
|
|
506
508
|
/**
|
|
507
|
-
* Output cap for a probe. One token is enough to prove the model answers
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
* while spending as close to nothing as the service allows.
|
|
509
|
+
* Output cap for a probe. One token is enough to prove the model answers.
|
|
510
|
+
*
|
|
511
|
+
* The probe's cost comes from its INPUT, not its output (see
|
|
512
|
+
* {@link PROBE_INPUT_TOKENS}), so paying for a long completion would add money
|
|
513
|
+
* without adding signal.
|
|
513
514
|
*/
|
|
514
515
|
const PROBE_MAX_TOKENS = 1;
|
|
515
516
|
/**
|
|
517
|
+
* How much input a probe sends, in tokens.
|
|
518
|
+
*
|
|
519
|
+
* This is the whole point of the probe, and it is NOT a free choice. The
|
|
520
|
+
* upstream's rate limit (business code 6004) fires on request SIZE, measured on
|
|
521
|
+
* 2026-09-29 by varying only the input against one account and one model:
|
|
522
|
+
*
|
|
523
|
+
* | input | prompt_tokens the upstream counted | result |
|
|
524
|
+
* | --- | --- | --- |
|
|
525
|
+
* | ~10 | 38 | ok |
|
|
526
|
+
* | ~1,000 | 1,138 | ok |
|
|
527
|
+
* | ~10,000 | 11,138 | ok |
|
|
528
|
+
* | ~18,000 | 20,018 | ok |
|
|
529
|
+
* | ~25,000 | 25,023 | ok |
|
|
530
|
+
* | ~30,000 | ~32k | **429 / 6004** |
|
|
531
|
+
*
|
|
532
|
+
* So the threshold sits between 20k and 30k, a SINGLE large request is enough to
|
|
533
|
+
* trip it (it is not a running total), and each account reports its own reset
|
|
534
|
+
* time (two accounts at the same moment: `22:20:33` and `00:32:51`).
|
|
535
|
+
*
|
|
536
|
+
* A probe that sent a handful of tokens answered "usable" while every real
|
|
537
|
+
* request in a long conversation was refused — the probe was asking too small a
|
|
538
|
+
* question. 25k is sized to be a realistic long-conversation payload.
|
|
539
|
+
*/
|
|
540
|
+
const PROBE_INPUT_TOKENS = 25e3;
|
|
541
|
+
/**
|
|
542
|
+
* Characters per token for the filler text, measured.
|
|
543
|
+
*
|
|
544
|
+
* Filling with a fixed English sentence, the upstream counted about 4.5
|
|
545
|
+
* characters per token. An estimate rather than a bundled tokenizer: the body
|
|
546
|
+
* only needs to be the right SIZE CLASS, and the target has ample margin over
|
|
547
|
+
* the measured threshold, so estimate error cannot change the verdict.
|
|
548
|
+
*/
|
|
549
|
+
const CHARS_PER_TOKEN = 4.5;
|
|
550
|
+
/** Neutral filler; carries no user content, only volume. */
|
|
551
|
+
const FILLER_LINE = "The quick brown fox jumps over the lazy dog. ";
|
|
552
|
+
/**
|
|
516
553
|
* Build the request body for one probe.
|
|
517
554
|
*
|
|
518
555
|
* `stream` is forced because the upstream rejects non-streaming chat requests;
|
|
519
556
|
* `prepareChatBody` enforces that too, but sending it explicitly keeps this
|
|
520
557
|
* function's output valid on its own.
|
|
558
|
+
*
|
|
559
|
+
* Measured on the built artifact: 112,713 bytes of body, and the upstream
|
|
560
|
+
* counted `prompt_tokens: 25023` at a cost of `credit: 0.72`.
|
|
521
561
|
*/
|
|
522
562
|
function probeRequestBody(modelId) {
|
|
563
|
+
const targetChars = Math.ceil(PROBE_INPUT_TOKENS * CHARS_PER_TOKEN);
|
|
564
|
+
const filler = FILLER_LINE.repeat(Math.ceil(targetChars / 45));
|
|
523
565
|
return JSON.stringify({
|
|
524
566
|
model: modelId,
|
|
525
567
|
messages: [{
|
|
@@ -527,7 +569,7 @@ function probeRequestBody(modelId) {
|
|
|
527
569
|
content: PROBE_SYSTEM_PROMPT
|
|
528
570
|
}, {
|
|
529
571
|
role: "user",
|
|
530
|
-
content:
|
|
572
|
+
content: `${filler}Reply with a single word.`
|
|
531
573
|
}],
|
|
532
574
|
max_tokens: 1,
|
|
533
575
|
stream: true
|
|
@@ -558,21 +600,71 @@ function parseRetryAfter(value, nowMs) {
|
|
|
558
600
|
return parsed;
|
|
559
601
|
}
|
|
560
602
|
/**
|
|
603
|
+
* The upstream's reset sentence, as its Chinese gateway writes it.
|
|
604
|
+
*
|
|
605
|
+
* Measured on a live 429 (code 6004), verbatim:
|
|
606
|
+
*
|
|
607
|
+
* `您的使用量已超出频率限制,将在 2026-09-30 02:30:30 UTC+8 重置,您也可以切换其他模型继续使用。`
|
|
608
|
+
*
|
|
609
|
+
* The two halves are captured separately because the second one is the whole
|
|
610
|
+
* reason this parser exists: the upstream DOES name a time, and the plugin was
|
|
611
|
+
* showing "the upstream gave no time" while the answer was sitting right there
|
|
612
|
+
* in the body. The earlier note in this module ("the upstream provides no
|
|
613
|
+
* rate-limit metadata") was drawn from the RESPONSE HEADERS alone — true as far
|
|
614
|
+
* as it went, and wrong as a conclusion, because the time is in the body text.
|
|
615
|
+
*
|
|
616
|
+
* Deliberately loose about the surrounding wording (`[\s\S]*?` on both sides)
|
|
617
|
+
* and strict about the parts that must not be guessed: the keyword 重置, the
|
|
618
|
+
* timestamp shape, and an explicit UTC offset. A message that merely mentions
|
|
619
|
+
* 重置 without a parsable offset yields no time, which is the honest answer.
|
|
620
|
+
*/
|
|
621
|
+
const UPSTREAM_RESET_PATTERN = /将在?\s*(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})\s*UTC\s*([+-])(\d{1,2})(?::?(\d{2}))?[\s\S]*?重置/u;
|
|
622
|
+
/**
|
|
623
|
+
* Parse the reset time out of an upstream failure message.
|
|
624
|
+
*
|
|
625
|
+
* Returns epoch ms, or undefined when the message names no parsable time — the
|
|
626
|
+
* caller must then say "the upstream gave no time", never invent one.
|
|
627
|
+
*
|
|
628
|
+
* THE OFFSET IS HONOURED, NOT ASSUMED. `2026-09-30 02:30:30 UTC+8` is 18:30:30
|
|
629
|
+
* UTC the previous day, and `Date.parse` on the bare string would read it as
|
|
630
|
+
* LOCAL time — on a machine set to UTC+8 that happens to be right, and on any
|
|
631
|
+
* other machine it is silently wrong by the offset. So the components are
|
|
632
|
+
* assembled with `Date.UTC` and the stated offset subtracted, which is correct
|
|
633
|
+
* on every host regardless of its own zone.
|
|
634
|
+
*
|
|
635
|
+
* A whole-second resolution is what the upstream prints; sub-second precision
|
|
636
|
+
* would be fiction in the other direction.
|
|
637
|
+
*/
|
|
638
|
+
function parseUpstreamResetAt(message) {
|
|
639
|
+
const match = UPSTREAM_RESET_PATTERN.exec(message);
|
|
640
|
+
if (match === null) return void 0;
|
|
641
|
+
const [, year, month, day, hour, minute, second, sign, offsetHours, offsetMinutes] = match;
|
|
642
|
+
const offsetTotalMinutes = (sign === "-" ? -1 : 1) * (Number(offsetHours) * 60 + Number(offsetMinutes ?? "0"));
|
|
643
|
+
const utcMs = Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second));
|
|
644
|
+
const parsed = new Date(utcMs);
|
|
645
|
+
if (parsed.getUTCFullYear() !== Number(year) || parsed.getUTCMonth() !== Number(month) - 1 || parsed.getUTCDate() !== Number(day) || parsed.getUTCHours() !== Number(hour) || parsed.getUTCMinutes() !== Number(minute) || parsed.getUTCSeconds() !== Number(second)) return;
|
|
646
|
+
if (Math.abs(offsetTotalMinutes) > 840) return void 0;
|
|
647
|
+
return utcMs - offsetTotalMinutes * 6e4;
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
561
650
|
* The cooldown the upstream stated, if it stated one.
|
|
562
651
|
*
|
|
563
|
-
*
|
|
652
|
+
* Three sources, in order of authority:
|
|
564
653
|
*
|
|
565
654
|
* 1. `Retry-After` on the failure response. This is the upstream naming a time
|
|
566
655
|
* for THIS request, so it wins.
|
|
567
|
-
* 2. The
|
|
656
|
+
* 2. The reset time the upstream writes into its own failure body (see
|
|
657
|
+
* {@link parseUpstreamResetAt}). This is the case that actually fires on
|
|
658
|
+
* this service: a 429 from the Chinese gateway carries code 6004 and a
|
|
659
|
+
* `将在 … 重置` sentence, with no `Retry-After` header at all.
|
|
660
|
+
* 3. The region's monthly quota refresh point, but ONLY for an out-of-credit
|
|
568
661
|
* outcome. A quota that resets at a known time is the one case where "when
|
|
569
662
|
* can I use this again" has a real answer even without a header — and it is
|
|
570
663
|
* the answer for the most common real limit on this service.
|
|
571
664
|
*
|
|
572
|
-
* A
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
* a number here would be pure fiction.
|
|
665
|
+
* A limited outcome with none of the three returns `{}`, which the card renders
|
|
666
|
+
* as "the upstream did not say when". That remains the honest answer for a
|
|
667
|
+
* genuinely timeless failure; inventing a number here would be pure fiction.
|
|
576
668
|
*/
|
|
577
669
|
function cooldownOf(input) {
|
|
578
670
|
const fromHeader = parseRetryAfter(input.retryAfter, input.nowMs);
|
|
@@ -580,6 +672,11 @@ function cooldownOf(input) {
|
|
|
580
672
|
retryAtMs: fromHeader,
|
|
581
673
|
retrySource: "retry-after"
|
|
582
674
|
};
|
|
675
|
+
const fromBody = input.body === void 0 ? void 0 : parseUpstreamResetAt(input.body);
|
|
676
|
+
if (fromBody !== void 0) return {
|
|
677
|
+
retryAtMs: fromBody,
|
|
678
|
+
retrySource: "upstream-message"
|
|
679
|
+
};
|
|
583
680
|
if (input.outcome === "out-of-credit" && input.quotaRefreshAtMs !== void 0) return {
|
|
584
681
|
retryAtMs: input.quotaRefreshAtMs,
|
|
585
682
|
retrySource: "quota-refresh"
|
|
@@ -640,9 +737,9 @@ function redact(text) {
|
|
|
640
737
|
* Run one probe for one model and reduce it to the card's row.
|
|
641
738
|
*
|
|
642
739
|
* The successful path drains the SSE stream purely to read the `credit` figure
|
|
643
|
-
* the upstream reports, then discards the rest: the
|
|
644
|
-
*
|
|
645
|
-
* stream worth keeping.
|
|
740
|
+
* the upstream reports, then discards the rest: the question is whether this
|
|
741
|
+
* model answers a real-sized request, and `max_tokens: 1` means there is nothing
|
|
742
|
+
* else in the stream worth keeping.
|
|
646
743
|
*
|
|
647
744
|
* Never throws. A probe is a diagnostic the user asked for, so every failure is
|
|
648
745
|
* a RESULT — one dead model must not abort the batch and hide the other rows.
|
|
@@ -691,10 +788,558 @@ async function probeModel(input) {
|
|
|
691
788
|
outcome,
|
|
692
789
|
retryAfter: answer.retryAfter,
|
|
693
790
|
nowMs: input.nowMs,
|
|
791
|
+
body,
|
|
694
792
|
...input.quotaRefreshAtMs === void 0 ? {} : { quotaRefreshAtMs: input.quotaRefreshAtMs }
|
|
695
793
|
})
|
|
696
794
|
};
|
|
697
795
|
}
|
|
796
|
+
/**
|
|
797
|
+
* Check in every account, one at a time.
|
|
798
|
+
*
|
|
799
|
+
* Idempotent by design (see the module note): an account already checked in
|
|
800
|
+
* today is reported as `already` and NO write is attempted. A failure on one
|
|
801
|
+
* account is recorded on that row and the batch continues.
|
|
802
|
+
*
|
|
803
|
+
* Never throws: every problem becomes a row, because a batch that aborted on
|
|
804
|
+
* its first bad account would hide the state of all the others.
|
|
805
|
+
*/
|
|
806
|
+
async function checkinAllAccounts(targets, deps, nowMs = Date.now) {
|
|
807
|
+
const rows = [];
|
|
808
|
+
for (const [index, target] of targets.entries()) {
|
|
809
|
+
if (index > 0) await (deps.wait ?? defaultWait)(400);
|
|
810
|
+
rows.push(await checkinOne(target, deps));
|
|
811
|
+
}
|
|
812
|
+
return rows;
|
|
813
|
+
}
|
|
814
|
+
/** One account's check-in, reduced to its row. */
|
|
815
|
+
async function checkinOne(target, deps) {
|
|
816
|
+
const base = {
|
|
817
|
+
accountId: target.accountId,
|
|
818
|
+
accountName: target.accountName
|
|
819
|
+
};
|
|
820
|
+
let credential;
|
|
821
|
+
try {
|
|
822
|
+
credential = await deps.credentialFor(target.accountId);
|
|
823
|
+
} catch (error) {
|
|
824
|
+
return {
|
|
825
|
+
...base,
|
|
826
|
+
status: "failed",
|
|
827
|
+
message: messageOf(error)
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
if (credential === void 0) return {
|
|
831
|
+
...base,
|
|
832
|
+
status: "failed",
|
|
833
|
+
message: "no stored credential for this account"
|
|
834
|
+
};
|
|
835
|
+
try {
|
|
836
|
+
const status = await deps.fetchCheckinStatus(credential);
|
|
837
|
+
if (!status.active) return {
|
|
838
|
+
...base,
|
|
839
|
+
status: "failed",
|
|
840
|
+
message: "check-in activity is not active"
|
|
841
|
+
};
|
|
842
|
+
if (status.todayCheckedIn) return {
|
|
843
|
+
...base,
|
|
844
|
+
status: "already"
|
|
845
|
+
};
|
|
846
|
+
const claim = await deps.claimDailyCheckin(credential);
|
|
847
|
+
return {
|
|
848
|
+
...base,
|
|
849
|
+
status: "claimed",
|
|
850
|
+
credit: claim.credit,
|
|
851
|
+
streakDays: claim.streakDays
|
|
852
|
+
};
|
|
853
|
+
} catch (error) {
|
|
854
|
+
return {
|
|
855
|
+
...base,
|
|
856
|
+
status: "failed",
|
|
857
|
+
message: messageOf(error)
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Test every account against one model, one at a time.
|
|
863
|
+
*
|
|
864
|
+
* The model is resolved by the caller (see `resolveTargetModel`) so this
|
|
865
|
+
* function never has to decide "free or not" — an unresolvable target is the
|
|
866
|
+
* caller's problem to report, and testing against a model nobody chose would be
|
|
867
|
+
* exactly the silent spend the plan forbids.
|
|
868
|
+
*
|
|
869
|
+
* Never throws: a failed probe is a ROW, not an aborted batch.
|
|
870
|
+
*/
|
|
871
|
+
async function testAllAccounts(targets, modelId, deps) {
|
|
872
|
+
const rows = [];
|
|
873
|
+
for (const [index, target] of targets.entries()) {
|
|
874
|
+
if (index > 0) await (deps.wait ?? defaultWait)(400);
|
|
875
|
+
rows.push(await testOne(target, modelId, deps));
|
|
876
|
+
}
|
|
877
|
+
return rows;
|
|
878
|
+
}
|
|
879
|
+
/** One account's probe, reduced to its row. */
|
|
880
|
+
async function testOne(target, modelId, deps) {
|
|
881
|
+
const base = {
|
|
882
|
+
accountId: target.accountId,
|
|
883
|
+
accountName: target.accountName
|
|
884
|
+
};
|
|
885
|
+
let credential;
|
|
886
|
+
try {
|
|
887
|
+
credential = await deps.credentialFor(target.accountId);
|
|
888
|
+
} catch (error) {
|
|
889
|
+
return {
|
|
890
|
+
...base,
|
|
891
|
+
result: {
|
|
892
|
+
modelId,
|
|
893
|
+
outcome: "credential-rejected",
|
|
894
|
+
message: messageOf(error)
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
if (credential === void 0) return {
|
|
899
|
+
...base,
|
|
900
|
+
result: {
|
|
901
|
+
modelId,
|
|
902
|
+
outcome: "credential-rejected",
|
|
903
|
+
message: "no stored credential for this account"
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
return {
|
|
907
|
+
...base,
|
|
908
|
+
result: await deps.probe(credential, modelId)
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Turn a test batch's rows into the per-account measurements to remember.
|
|
913
|
+
*
|
|
914
|
+
* A row is stored only when the probe actually measured the MODEL. A row that
|
|
915
|
+
* failed because the credential could not even be read says nothing about the
|
|
916
|
+
* model, and recording it would exclude a possibly-perfect account from the
|
|
917
|
+
* pool — so those accounts keep their previous measurement instead.
|
|
918
|
+
*
|
|
919
|
+
* `atMs` is stamped here, once for the batch, so the card's "tested N minutes
|
|
920
|
+
* ago" reflects when the batch finished rather than drifting per row.
|
|
921
|
+
*/
|
|
922
|
+
function probeUpdatesOf(rows) {
|
|
923
|
+
const atMs = Date.now();
|
|
924
|
+
const updates = {};
|
|
925
|
+
for (const row of rows) {
|
|
926
|
+
if (isTransportFailure(row.result)) continue;
|
|
927
|
+
updates[row.accountId] = {
|
|
928
|
+
outcome: row.result.outcome,
|
|
929
|
+
atMs,
|
|
930
|
+
...row.result.retryAtMs === void 0 ? {} : { retryAtMs: row.result.retryAtMs }
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
return updates;
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Whether a probe result measures the transport rather than the model.
|
|
937
|
+
*
|
|
938
|
+
* `credential-rejected` with the pool's own "no stored credential" message is
|
|
939
|
+
* the one case we can attribute to ourselves rather than the upstream: the
|
|
940
|
+
* request never left. Every other rejection came from the upstream and IS a
|
|
941
|
+
* statement about this account, so it is kept.
|
|
942
|
+
*/
|
|
943
|
+
function isTransportFailure(result) {
|
|
944
|
+
return result.outcome === "credential-rejected" && result.message?.startsWith("no stored credential") === true;
|
|
945
|
+
}
|
|
946
|
+
/** The default inter-account pause. */
|
|
947
|
+
function defaultWait(ms) {
|
|
948
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
949
|
+
}
|
|
950
|
+
/** A safe message from an unknown throwable, bounded in length. */
|
|
951
|
+
function messageOf(error) {
|
|
952
|
+
return (error instanceof Error ? error.message : String(error)).slice(0, 300);
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Whether one account can be billed at `nowMs`.
|
|
956
|
+
*
|
|
957
|
+
* Returns the exclusion reason, or undefined when the account is a candidate.
|
|
958
|
+
* The order matters and is NOT arbitrary:
|
|
959
|
+
*
|
|
960
|
+
* - A `credential-rejected` account is excluded regardless of any cooldown: its
|
|
961
|
+
* token is not accepted, so waiting cannot help and the user must re-auth.
|
|
962
|
+
* - A limited outcome is checked against its stated (or fallback) cooldown, so
|
|
963
|
+
* an account whose limit has already reset is back in the pool without
|
|
964
|
+
* needing another test first.
|
|
965
|
+
* - An `ok` measurement, and no measurement at all, are both candidates. The
|
|
966
|
+
* latter matters: a freshly discovered account has never been tested and must
|
|
967
|
+
* not be invisible.
|
|
968
|
+
*
|
|
969
|
+
* Each unusable outcome keeps its OWN value rather than being folded into
|
|
970
|
+
* `credential-rejected`. The fold made one row state two contradictory things —
|
|
971
|
+
* the name column said "被拒绝" (sign in again) while the probe column, reading
|
|
972
|
+
* the same measurement, said "连不上上游——这是网络问题,不是模型问题" — and it
|
|
973
|
+
* told a user whose upstream had merely blipped that their sign-in was bad.
|
|
974
|
+
* The distinctions are already computed upstream in `outcomeOfFailure`
|
|
975
|
+
* (`src/probe.ts:290-299`); this only stops discarding them.
|
|
976
|
+
*/
|
|
977
|
+
function exclusionOf(probe, nowMs) {
|
|
978
|
+
if (probe === void 0) return void 0;
|
|
979
|
+
switch (probe.outcome) {
|
|
980
|
+
case "ok": return;
|
|
981
|
+
case "credential-rejected": return "credential-rejected";
|
|
982
|
+
case "not-found": return "not-found";
|
|
983
|
+
case "unavailable": return "unavailable";
|
|
984
|
+
case "failed": return "failed";
|
|
985
|
+
case "rate-limited": return retryDue(probe, nowMs) ? void 0 : "rate-limited";
|
|
986
|
+
case "out-of-credit": return retryDue(probe, nowMs) ? void 0 : "out-of-credit";
|
|
987
|
+
default: return "unusable";
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
/** Whether a limited account's cooldown has elapsed (or was never stated). */
|
|
991
|
+
function retryDue(probe, nowMs) {
|
|
992
|
+
return nowMs >= (probe.retryAtMs ?? probe.atMs + 18e5);
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Rank a pool's members best-first.
|
|
996
|
+
*
|
|
997
|
+
* The rule, in priority order — this is the whole feature:
|
|
998
|
+
*
|
|
999
|
+
* 1. **Usability.** Only accounts that can be billed right now are candidates;
|
|
1000
|
+
* they always outrank every excluded one.
|
|
1001
|
+
* 2. **Credits, highest first.** Spend the account that has the most, so an
|
|
1002
|
+
* account running low is conserved rather than drained first.
|
|
1003
|
+
* 3. **Credits expiring soonest first.** Points about to expire are worth
|
|
1004
|
+
* exactly zero after they do, so using them earlier is strictly better than
|
|
1005
|
+
* saving them.
|
|
1006
|
+
* 4. **Freshest credential.** Tie-break so a stale token never wins a draw.
|
|
1007
|
+
* 5. **Account id.** Final tie-break, making the order TOTAL: two accounts
|
|
1008
|
+
* equal on every ranking key would otherwise keep the caller's array order,
|
|
1009
|
+
* so the same pool could rotate differently between two runs.
|
|
1010
|
+
*
|
|
1011
|
+
* Excluded accounts are still RETURNED (sorted after the candidates) so the
|
|
1012
|
+
* card can explain why each is out; they differ from candidates by
|
|
1013
|
+
* `excludedBy` being set on them, never by being silently dropped.
|
|
1014
|
+
*
|
|
1015
|
+
* Pure and total: the same input always yields the same order, and it never
|
|
1016
|
+
* throws on missing data (an account with no credits is ranked as 0, not
|
|
1017
|
+
* crashed on).
|
|
1018
|
+
*/
|
|
1019
|
+
function rankPool(members, nowMs) {
|
|
1020
|
+
const rows = members.map((member) => {
|
|
1021
|
+
const excludedBy = exclusionOf(member.probe, nowMs);
|
|
1022
|
+
return {
|
|
1023
|
+
account: member.account,
|
|
1024
|
+
score: scoreOf(member),
|
|
1025
|
+
...excludedBy === void 0 ? {} : { excludedBy },
|
|
1026
|
+
_usable: excludedBy === void 0,
|
|
1027
|
+
_expiring: member.credits?.nearestExpiryMs ?? Number.POSITIVE_INFINITY,
|
|
1028
|
+
_tokenExpiresAtMs: member.tokenExpiresAtMs ?? 0
|
|
1029
|
+
};
|
|
1030
|
+
});
|
|
1031
|
+
rows.sort((left, right) => {
|
|
1032
|
+
if (left._usable !== right._usable) return left._usable ? -1 : 1;
|
|
1033
|
+
if (left.score !== right.score) return right.score - left.score;
|
|
1034
|
+
if (left._expiring !== right._expiring) return left._expiring - right._expiring;
|
|
1035
|
+
if (left._tokenExpiresAtMs !== right._tokenExpiresAtMs) return right._tokenExpiresAtMs - left._tokenExpiresAtMs;
|
|
1036
|
+
return left.account.id < right.account.id ? -1 : left.account.id > right.account.id ? 1 : 0;
|
|
1037
|
+
});
|
|
1038
|
+
return rows.map(({ account, score, excludedBy }) => ({
|
|
1039
|
+
account,
|
|
1040
|
+
score,
|
|
1041
|
+
...excludedBy === void 0 ? {} : { excludedBy }
|
|
1042
|
+
}));
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* The checked ids that still resolve to a local sign-in, in the caller's order.
|
|
1046
|
+
*
|
|
1047
|
+
* This is THE definition of "effective members", and it exists because the same
|
|
1048
|
+
* predicate was written five separate times: the status document
|
|
1049
|
+
* (`web-status.ts`), the batch route's empty-pool guard, the Host dependency the
|
|
1050
|
+
* card reads, `poolMemberAccounts` and the browser helper. Five copies of one
|
|
1051
|
+
* rule is this project's single most productive bug shape ("fixed one end,
|
|
1052
|
+
* missed the other"), so the rule now has one home and every site delegates to
|
|
1053
|
+
* it.
|
|
1054
|
+
*
|
|
1055
|
+
* ORDER IS THE CALLER'S, and comes from `orderedIds`: the answer preserves the
|
|
1056
|
+
* order of THAT argument. Callers pass whichever list's order they want to show
|
|
1057
|
+
* — the saved list for the user's own order, the store's roster for the account
|
|
1058
|
+
* table's. Neither is silently reshuffled by the filter. (The first parameter was
|
|
1059
|
+
* once named `saved`, which read as "this must be the saved list" even though
|
|
1060
|
+
* `web-status.ts` legitimately passes the roster for store order; the name was
|
|
1061
|
+
* the trap, so it now describes the ROLE instead of the source.)
|
|
1062
|
+
*
|
|
1063
|
+
* Pure, so it is unit-testable without a store, and it never mutates its input.
|
|
1064
|
+
*/
|
|
1065
|
+
function effectiveMembersOf(orderedIds, listedIds) {
|
|
1066
|
+
return orderedIds.filter((id) => listedIds.has(id));
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* The account to bill, or undefined when the pool has no usable member.
|
|
1070
|
+
*
|
|
1071
|
+
* A thin selector over {@link rankPool} so the winner is decided in exactly one
|
|
1072
|
+
* place: every caller that needs "who serves now" gets the same answer.
|
|
1073
|
+
*/
|
|
1074
|
+
function pickAccount(members, nowMs) {
|
|
1075
|
+
return rankPool(members, nowMs).find((row) => row.excludedBy === void 0)?.account;
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* The credit figure used for ordering.
|
|
1079
|
+
*
|
|
1080
|
+
* Absent credits rank as zero. Deliberately NOT "unknown ranks first": an
|
|
1081
|
+
* account whose balance could not be read must never outrank one we positively
|
|
1082
|
+
* know is full, or a failing credits route would capture all the traffic.
|
|
1083
|
+
*/
|
|
1084
|
+
function scoreOf(member) {
|
|
1085
|
+
return member.credits?.total ?? 0;
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* Pick the free model to test against, from one region's catalog.
|
|
1089
|
+
*
|
|
1090
|
+
* "Free" means `creditMultiplier === 0` EXACTLY. An absent multiplier is NOT
|
|
1091
|
+
* free — see the module note: the CN static fallback carries no multiplier at
|
|
1092
|
+
* all, so treating absence as zero would silently bill real credits.
|
|
1093
|
+
*
|
|
1094
|
+
* Among the free candidates the LARGEST context window wins, because the probe
|
|
1095
|
+
* sends a real-sized payload (25k input) and a long-conversation probe is the
|
|
1096
|
+
* point: a small-window model would either reject it or answer a question the
|
|
1097
|
+
* user never asked.
|
|
1098
|
+
*
|
|
1099
|
+
* Returns undefined when the region has no free model, which the caller must
|
|
1100
|
+
* render as "none available" — never as a fallback to a paid model.
|
|
1101
|
+
*/
|
|
1102
|
+
function pickFreeModel(catalog) {
|
|
1103
|
+
let best;
|
|
1104
|
+
for (const model of catalog) {
|
|
1105
|
+
if (model.creditMultiplier !== 0) continue;
|
|
1106
|
+
if (best === void 0 || model.contextWindow > best.contextWindow) best = model;
|
|
1107
|
+
}
|
|
1108
|
+
return best;
|
|
1109
|
+
}
|
|
1110
|
+
/**
|
|
1111
|
+
* A latest-wins guard for one region's asynchronous work.
|
|
1112
|
+
*
|
|
1113
|
+
* Exists because `applyRotation` awaits (it fetches credits per member), so two
|
|
1114
|
+
* overlapping calls can interleave: a slow call that read "rotation is ON" can
|
|
1115
|
+
* resume AFTER a newer call cleared the override for "rotation is OFF" and write
|
|
1116
|
+
* the stale account back — leaving the card showing the pool switched off while
|
|
1117
|
+
* a rotated account is still billed.
|
|
1118
|
+
*
|
|
1119
|
+
* `begin()` claims a new generation and returns a probe; the caller must check
|
|
1120
|
+
* it after EVERY await and bail when it reports stale. Extracted as a pure
|
|
1121
|
+
* factory rather than inlined so the rule is unit-testable: an inlined token is
|
|
1122
|
+
* only observable by reproducing a real interleaving, which is slow and easy to
|
|
1123
|
+
* get subtly wrong (a harness that never actually overlaps passes either way).
|
|
1124
|
+
*/
|
|
1125
|
+
function createLatestWins() {
|
|
1126
|
+
const generations = {};
|
|
1127
|
+
return { begin(key) {
|
|
1128
|
+
const generation = (generations[key] ?? 0) + 1;
|
|
1129
|
+
generations[key] = generation;
|
|
1130
|
+
return () => generations[key] !== generation;
|
|
1131
|
+
} };
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* How often the pool scheduler wakes to check whether a region is due.
|
|
1135
|
+
*
|
|
1136
|
+
* A fixed, coarse heartbeat rather than one timer per region: the heartbeat
|
|
1137
|
+
* itself does no network work (it only reads config and compares timestamps),
|
|
1138
|
+
* so it is free, and it makes re-arming on a config change unnecessary — the
|
|
1139
|
+
* next tick simply reads the new interval.
|
|
1140
|
+
*/
|
|
1141
|
+
const POOL_TICK_MS = 6e4;
|
|
1142
|
+
/**
|
|
1143
|
+
* Whether an automatic test is due for one region.
|
|
1144
|
+
*
|
|
1145
|
+
* `lastRunMs === undefined` means "never armed", and returns FALSE: the clock
|
|
1146
|
+
* starts when the scheduler first sees the region, so enabling the pool does
|
|
1147
|
+
* not immediately spend credits at startup or after a restart. Waiting one
|
|
1148
|
+
* interval is the least surprising reading of "test every N minutes" — the
|
|
1149
|
+
* alternative (fire immediately) would bill the user on every launch.
|
|
1150
|
+
*
|
|
1151
|
+
* Pure and total so the scheduling rule can be pinned without real timers.
|
|
1152
|
+
*/
|
|
1153
|
+
function poolDueAt(input) {
|
|
1154
|
+
if (input.lastRunMs === void 0) return false;
|
|
1155
|
+
const minutes = Math.max(5, Math.round(input.intervalMinutes));
|
|
1156
|
+
return input.nowMs - input.lastRunMs >= minutes * 6e4;
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Decide which regions a scheduler tick should run.
|
|
1160
|
+
*
|
|
1161
|
+
* The FIRST sighting of an enabled region ARMS its clock instead of running it.
|
|
1162
|
+
* That split is the whole reason this is a function rather than an `if`:
|
|
1163
|
+
* {@link poolDueAt} answers "has enough time passed", which is FALSE for a
|
|
1164
|
+
* region that has never run — so a scheduler that only armed the clock when it
|
|
1165
|
+
* decided to run would never arm it at all, and the scheduled test would
|
|
1166
|
+
* silently never fire. Returning the clock state explicitly makes that
|
|
1167
|
+
* impossible to get wrong, and testable without real timers.
|
|
1168
|
+
*
|
|
1169
|
+
* A region that is switched off keeps NO clock: enabling it later must wait a
|
|
1170
|
+
* full interval rather than inheriting a stale timestamp and firing at once.
|
|
1171
|
+
*/
|
|
1172
|
+
function duePoolRegions(input) {
|
|
1173
|
+
const due = [];
|
|
1174
|
+
const next = {};
|
|
1175
|
+
for (const region of input.regions) {
|
|
1176
|
+
if (!input.enabledOf(region)) continue;
|
|
1177
|
+
const last = input.state[region];
|
|
1178
|
+
if (last === void 0) {
|
|
1179
|
+
next[region] = input.nowMs;
|
|
1180
|
+
continue;
|
|
1181
|
+
}
|
|
1182
|
+
if (!poolDueAt({
|
|
1183
|
+
lastRunMs: last,
|
|
1184
|
+
intervalMinutes: input.intervalMinutesOf(region),
|
|
1185
|
+
nowMs: input.nowMs
|
|
1186
|
+
})) {
|
|
1187
|
+
next[region] = last;
|
|
1188
|
+
continue;
|
|
1189
|
+
}
|
|
1190
|
+
next[region] = input.nowMs;
|
|
1191
|
+
due.push(region);
|
|
1192
|
+
}
|
|
1193
|
+
return {
|
|
1194
|
+
due,
|
|
1195
|
+
next
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* Resolve the model the pool should test against.
|
|
1200
|
+
*
|
|
1201
|
+
* A user-specified id always wins (it is a preference, not a computed default),
|
|
1202
|
+
* and is returned even when it is a PAID model: the card says so explicitly
|
|
1203
|
+
* rather than silently overriding the user's choice.
|
|
1204
|
+
*
|
|
1205
|
+
* But the choice is now VALIDATED against the region's catalog, because the
|
|
1206
|
+
* catalog changes: the CN roster is empty until its first refresh, and models
|
|
1207
|
+
* come and go. A saved id that is no longer offered used to be returned as
|
|
1208
|
+
* `preferred`, which let the card enable its test button and the Host run a
|
|
1209
|
+
* batch against a model nobody offers. It is reported as `stale` instead —
|
|
1210
|
+
* never silently replaced by the free model (that would test something the
|
|
1211
|
+
* user did not choose) and never run.
|
|
1212
|
+
*
|
|
1213
|
+
* A catalog that has not loaded yet therefore reads as `stale` for a saved id.
|
|
1214
|
+
* That is the safe direction: no credits are spent while the roster is unknown,
|
|
1215
|
+
* and the card offers to switch back to automatic.
|
|
1216
|
+
*/
|
|
1217
|
+
function resolveTargetModel(catalog, preferredId) {
|
|
1218
|
+
if (preferredId !== void 0 && preferredId !== "") return catalog.some((model) => model.id === preferredId) ? {
|
|
1219
|
+
modelId: preferredId,
|
|
1220
|
+
source: "preferred"
|
|
1221
|
+
} : {
|
|
1222
|
+
staleModelId: preferredId,
|
|
1223
|
+
source: "stale"
|
|
1224
|
+
};
|
|
1225
|
+
const free = pickFreeModel(catalog);
|
|
1226
|
+
return free === void 0 ? { source: "none" } : {
|
|
1227
|
+
modelId: free.id,
|
|
1228
|
+
source: "free"
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
//#endregion
|
|
1232
|
+
//#region src/account-pool-store.ts
|
|
1233
|
+
/**
|
|
1234
|
+
* The account pool's RUNTIME store: measured facts, on disk, outside settings.
|
|
1235
|
+
*
|
|
1236
|
+
* 参考:本仓库 `src/auth.ts` 的插件自有文件约定(`$DSH_HOME` 下的
|
|
1237
|
+
* `.workbuddy-auth.<region>.json`,`withFileLock` + `writeFileAtomic`,
|
|
1238
|
+
* `mode: 0o600`)。本模块沿用同一套写入方式与同一份隐私标准。
|
|
1239
|
+
* 改动:存的是**观测结果**而非凭据 —— 因此文件里不含任何 token,
|
|
1240
|
+
* 并且它与用户设置**分开落盘**,原因见下。
|
|
1241
|
+
*
|
|
1242
|
+
* 为什么这些事实不放 settings(`regions[region].pool`):
|
|
1243
|
+
*
|
|
1244
|
+
* 卡片对用户偏好用的是「草稿 → 保存 → 丢弃草稿」模式:保存成功会丢弃草稿。
|
|
1245
|
+
* 如果探测结果也住在那个槽里,用户手工点一次「保存」就会用**几分钟前的草稿**
|
|
1246
|
+
* 覆盖掉定时器刚写进去的结果 —— 那等于把测量结果回退,测试白做。
|
|
1247
|
+
* 观测数据由插件高频写入、且不是用户偏好,所以它必须落在自己的文件里。
|
|
1248
|
+
*
|
|
1249
|
+
* 本模块的读写**从不抛异常给调用方**:池是增强功能,一个损坏的观测文件绝不能让
|
|
1250
|
+
* 卡片或 host 启动失败 —— 最坏情况是「回到未测试」,而不是整个插件不可用。
|
|
1251
|
+
*
|
|
1252
|
+
* @module dsh-connect-workbuddy/account-pool-store
|
|
1253
|
+
*/
|
|
1254
|
+
/** On-disk format version; readers reject anything else. */
|
|
1255
|
+
const STORE_FORMAT_VERSION = 1;
|
|
1256
|
+
/** Filename prefix for one region's measured pool facts. */
|
|
1257
|
+
const POOL_STORE_PREFIX = ".workbuddy-pool";
|
|
1258
|
+
/** Whether a parsed value looks like a stored probe. */
|
|
1259
|
+
function isProbe(value) {
|
|
1260
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1261
|
+
const candidate = value;
|
|
1262
|
+
return typeof candidate["outcome"] === "string" && typeof candidate["atMs"] === "number" && (candidate["retryAtMs"] === void 0 || typeof candidate["retryAtMs"] === "number");
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* One region's path for measured pool facts.
|
|
1266
|
+
*
|
|
1267
|
+
* Region-keyed for the same reason the credential copies are: the two regions
|
|
1268
|
+
* are parallel stacks and must never overwrite each other's measurements.
|
|
1269
|
+
*/
|
|
1270
|
+
function workbuddyPoolStorePath(region) {
|
|
1271
|
+
return join(resolveDshHome(), `${POOL_STORE_PREFIX}.${region}.json`);
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* Read one region's measured facts.
|
|
1275
|
+
*
|
|
1276
|
+
* Contains NO credentials — only outcome words and timestamps — so it is not
|
|
1277
|
+
* written with the credential store's stricter secrecy requirements, though it
|
|
1278
|
+
* still uses the same atomic-write helpers.
|
|
1279
|
+
*
|
|
1280
|
+
* Returns an empty map for a missing, unreadable, malformed, or
|
|
1281
|
+
* version-mismatched file: all four mean "nothing measured yet", which is a
|
|
1282
|
+
* valid state the pool renders as "untested".
|
|
1283
|
+
*/
|
|
1284
|
+
async function readPoolProbes(region) {
|
|
1285
|
+
let raw;
|
|
1286
|
+
try {
|
|
1287
|
+
raw = await readFile(workbuddyPoolStorePath(region), "utf8");
|
|
1288
|
+
} catch {
|
|
1289
|
+
return {};
|
|
1290
|
+
}
|
|
1291
|
+
let parsed;
|
|
1292
|
+
try {
|
|
1293
|
+
parsed = JSON.parse(raw);
|
|
1294
|
+
} catch {
|
|
1295
|
+
return {};
|
|
1296
|
+
}
|
|
1297
|
+
if (typeof parsed !== "object" || parsed === null) return {};
|
|
1298
|
+
const document = parsed;
|
|
1299
|
+
if (document.version !== STORE_FORMAT_VERSION) return {};
|
|
1300
|
+
if (typeof document.probes !== "object" || document.probes === null) return {};
|
|
1301
|
+
const probes = {};
|
|
1302
|
+
for (const [accountId, probe] of Object.entries(document.probes)) {
|
|
1303
|
+
if (accountId === "" || !isProbe(probe)) continue;
|
|
1304
|
+
probes[accountId] = {
|
|
1305
|
+
outcome: probe.outcome,
|
|
1306
|
+
atMs: probe.atMs,
|
|
1307
|
+
...probe.retryAtMs === void 0 ? {} : { retryAtMs: probe.retryAtMs }
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
return probes;
|
|
1311
|
+
}
|
|
1312
|
+
/**
|
|
1313
|
+
* Merge measurements into one region's store and write it atomically.
|
|
1314
|
+
*
|
|
1315
|
+
* A MERGE rather than a replace, because batches measure one account at a time
|
|
1316
|
+
* and a partial failure must not erase what earlier runs learned. An account
|
|
1317
|
+
* absent from `updates` keeps its previous measurement.
|
|
1318
|
+
*
|
|
1319
|
+
* Never throws: a failed write loses only the newest measurements, and the
|
|
1320
|
+
* caller's results are still returned to the user. Surfacing a disk error here
|
|
1321
|
+
* would fail an operation that actually succeeded.
|
|
1322
|
+
*/
|
|
1323
|
+
async function writePoolProbes(region, updates) {
|
|
1324
|
+
if (Object.keys(updates).length === 0) return;
|
|
1325
|
+
const path = workbuddyPoolStorePath(region);
|
|
1326
|
+
try {
|
|
1327
|
+
await withFileLock(path, async () => {
|
|
1328
|
+
const existing = await readPoolProbes(region);
|
|
1329
|
+
const document = {
|
|
1330
|
+
version: STORE_FORMAT_VERSION,
|
|
1331
|
+
probes: {
|
|
1332
|
+
...existing,
|
|
1333
|
+
...updates
|
|
1334
|
+
}
|
|
1335
|
+
};
|
|
1336
|
+
await writeFileAtomic(path, `${JSON.stringify(document, null, 2)}\n`, {
|
|
1337
|
+
mode: 384,
|
|
1338
|
+
dirMode: 448
|
|
1339
|
+
});
|
|
1340
|
+
});
|
|
1341
|
+
} catch {}
|
|
1342
|
+
}
|
|
698
1343
|
//#endregion
|
|
699
1344
|
//#region src/status-paths.ts
|
|
700
1345
|
/**
|
|
@@ -727,6 +1372,28 @@ const WORKBUDDY_CHECKIN_PATH = "/plugins/dsh-connect-workbuddy/checkin";
|
|
|
727
1372
|
* browser half.
|
|
728
1373
|
*/
|
|
729
1374
|
const WORKBUDDY_PROBE_PATH = "/plugins/dsh-connect-workbuddy/probe";
|
|
1375
|
+
/**
|
|
1376
|
+
* Plugin-owned account-pool endpoint: one batch action per call.
|
|
1377
|
+
*
|
|
1378
|
+
* `?action=checkin` claims the daily reward for every account of a region;
|
|
1379
|
+
* `?action=test` probes every account against the region's target model. Both
|
|
1380
|
+
* are POST and loopback-only because both cost something real — check-in writes
|
|
1381
|
+
* to the user's account, a probe spends credits.
|
|
1382
|
+
*
|
|
1383
|
+
* Deliberately ONE endpoint rather than two: the two actions share every
|
|
1384
|
+
* guard (method, origin, region, pool-enabled) and differ only in the work, so
|
|
1385
|
+
* splitting them would duplicate the guards and invite them to drift apart.
|
|
1386
|
+
*/
|
|
1387
|
+
const WORKBUDDY_POOL_PATH = "/plugins/dsh-connect-workbuddy/pool";
|
|
1388
|
+
/** Query parameter selecting which pool batch action a request means. */
|
|
1389
|
+
const WORKBUDDY_POOL_ACTION_PARAM = "action";
|
|
1390
|
+
/** Read the pool action off a request URL; unknown/absent means undefined. */
|
|
1391
|
+
function poolActionOf(url) {
|
|
1392
|
+
const at = url.indexOf("?");
|
|
1393
|
+
if (at === -1) return void 0;
|
|
1394
|
+
const value = new URLSearchParams(url.slice(at + 1)).get(WORKBUDDY_POOL_ACTION_PARAM);
|
|
1395
|
+
return value === "checkin" || value === "test" ? value : void 0;
|
|
1396
|
+
}
|
|
730
1397
|
/** Query parameter naming the region a card request addresses. */
|
|
731
1398
|
const WORKBUDDY_REGION_PARAM = "region";
|
|
732
1399
|
/** Every region, in card tab order. */
|
|
@@ -798,22 +1465,6 @@ function nextRegionSlots(regions, region, slot) {
|
|
|
798
1465
|
[region]: slot
|
|
799
1466
|
};
|
|
800
1467
|
}
|
|
801
|
-
/**
|
|
802
|
-
* Build the next `regions` settings value for a provider on/off toggle. ONLY
|
|
803
|
-
* the target region's `enabled` flag changes: every other field of that slot
|
|
804
|
-
* (its directory, selection, image opt-ins, context budgets) and every other
|
|
805
|
-
* region's slot are carried over verbatim, so switching a provider off never
|
|
806
|
-
* discards the user's model picks and switching it back on restores them.
|
|
807
|
-
*
|
|
808
|
-
* This is deliberately separate from {@link nextRegionSlots}: that helper
|
|
809
|
-
* writes a whole slot from a signed-in tab's draft, while this one must work
|
|
810
|
-
* for a region that is signed OUT — which is precisely the region a user wants
|
|
811
|
-
* to switch off (no international install, no international account).
|
|
812
|
-
*
|
|
813
|
-
* `value` may be the whole settings section or the `regions` map; the RETURN
|
|
814
|
-
* value is always the `regions` map, i.e. exactly what `settingsScope.set(
|
|
815
|
-
* 'regions', ...)` needs.
|
|
816
|
-
*/
|
|
817
1468
|
function nextRegionEnabled(value, region, enabled) {
|
|
818
1469
|
return nextRegionSlots(regionsMapOf(value), region, {
|
|
819
1470
|
...regionSlotOf(value, region),
|
|
@@ -897,14 +1548,6 @@ function json(res, status, body) {
|
|
|
897
1548
|
});
|
|
898
1549
|
res.end(payload);
|
|
899
1550
|
}
|
|
900
|
-
/**
|
|
901
|
-
* Cap on how many models one probe request may name.
|
|
902
|
-
*
|
|
903
|
-
* A probe is a real (if tiny) request against a metered service, and the batch
|
|
904
|
-
* runs sequentially, so an unbounded list would let one click hold the route for
|
|
905
|
-
* minutes. The card never sends more than the models on its own screen.
|
|
906
|
-
*/
|
|
907
|
-
const PROBE_BATCH_LIMIT = 64;
|
|
908
1551
|
/** Request-body cap for the probe route: model ids only, never content. */
|
|
909
1552
|
const PROBE_BODY_LIMIT = 65536;
|
|
910
1553
|
/**
|
|
@@ -1103,7 +1746,90 @@ async function workBuddyWebStatus(deps, region) {
|
|
|
1103
1746
|
...creditsResult.status === "fulfilled" ? { credits: toCredits(creditsResult.value) } : { creditsError: safeMessage(creditsResult.reason) },
|
|
1104
1747
|
...checkinResult.status === "fulfilled" ? { checkin: checkinResult.value } : { checkinError: safeMessage(checkinResult.reason) },
|
|
1105
1748
|
...!rejected ? {} : { credentialRejected: true },
|
|
1106
|
-
...recovery === void 0 ? {} : { recovery }
|
|
1749
|
+
...recovery === void 0 ? {} : { recovery },
|
|
1750
|
+
...await poolSectionOf(deps, region)
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
/** The pool block for a status document, or nothing when it cannot be built. */
|
|
1754
|
+
async function poolSectionOf(deps, region) {
|
|
1755
|
+
if (deps.pool === void 0) return {};
|
|
1756
|
+
try {
|
|
1757
|
+
const pool = await workBuddyWebPool(deps, region);
|
|
1758
|
+
return pool === void 0 ? {} : { pool };
|
|
1759
|
+
} catch {
|
|
1760
|
+
return {};
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
/**
|
|
1764
|
+
* Assemble one region's pool state for the card.
|
|
1765
|
+
*
|
|
1766
|
+
* The ranking is computed HERE, on the Host, and shipped as an ordered,
|
|
1767
|
+
* annotated list. The card therefore never re-derives "who is eligible" — it
|
|
1768
|
+
* renders the Host's answer, so the account the user sees marked as current is
|
|
1769
|
+
* decided by the same rule that would actually bill.
|
|
1770
|
+
*
|
|
1771
|
+
* `current` is answered by the Host too (`currentAccountId`), not inferred from
|
|
1772
|
+
* the ranking, because those are different questions: the ranking says who
|
|
1773
|
+
* SHOULD serve, `current` says who IS serving. Marking the winner as current
|
|
1774
|
+
* would make the card claim a switch that may not have been applied yet.
|
|
1775
|
+
*/
|
|
1776
|
+
async function workBuddyWebPool(deps, region) {
|
|
1777
|
+
const pool = deps.pool;
|
|
1778
|
+
if (pool === void 0) return void 0;
|
|
1779
|
+
const preferences = pool.preferences(region);
|
|
1780
|
+
const members = await pool.members(region);
|
|
1781
|
+
const ranked = rankPool(members, Date.now());
|
|
1782
|
+
const target = resolveTargetModel(pool.catalog?.(region) ?? [], preferences.targetModelId);
|
|
1783
|
+
const currentAccountId = await pool.currentAccountId?.(region);
|
|
1784
|
+
const byId = new Map(members.map((member) => [member.account.id, member]));
|
|
1785
|
+
const rankedIds = new Set(ranked.map((row) => row.account.id));
|
|
1786
|
+
const others = await pool.otherAccounts?.(region) ?? [];
|
|
1787
|
+
const rotatedTo = await pool.rotatedToAccountId?.(region).catch(() => void 0);
|
|
1788
|
+
const checkedIn = await pool.checkedInToday?.(region).catch(() => ({})) ?? {};
|
|
1789
|
+
const accounts = [...ranked.map((row) => ({
|
|
1790
|
+
account: row.account,
|
|
1791
|
+
member: true,
|
|
1792
|
+
ranked: row
|
|
1793
|
+
})), ...others.filter((account) => !rankedIds.has(account.id)).map((account) => ({
|
|
1794
|
+
account,
|
|
1795
|
+
member: false
|
|
1796
|
+
}))].map(({ account, member, ranked: row }) => {
|
|
1797
|
+
const measured = byId.get(account.id);
|
|
1798
|
+
return {
|
|
1799
|
+
accountId: account.id,
|
|
1800
|
+
accountName: account.accountName,
|
|
1801
|
+
...measured?.credits === void 0 ? {} : {
|
|
1802
|
+
credits: measured.credits.total,
|
|
1803
|
+
expiringSoon: measured.credits.expiringSoon,
|
|
1804
|
+
...measured.credits.nearestExpiryMs === void 0 ? {} : { nearestExpiryMs: measured.credits.nearestExpiryMs }
|
|
1805
|
+
},
|
|
1806
|
+
...measured?.probe === void 0 ? {} : { probe: {
|
|
1807
|
+
outcome: measured.probe.outcome,
|
|
1808
|
+
atMs: measured.probe.atMs,
|
|
1809
|
+
...measured.probe.retryAtMs === void 0 ? {} : { retryAtMs: measured.probe.retryAtMs }
|
|
1810
|
+
} },
|
|
1811
|
+
...row?.excludedBy === void 0 ? {} : { excludedBy: row.excludedBy },
|
|
1812
|
+
current: account.id === currentAccountId,
|
|
1813
|
+
member,
|
|
1814
|
+
...checkedIn[account.id] === void 0 ? {} : { checkedInToday: checkedIn[account.id] }
|
|
1815
|
+
};
|
|
1816
|
+
});
|
|
1817
|
+
return {
|
|
1818
|
+
enabled: preferences.enabled,
|
|
1819
|
+
rotateByCredits: preferences.rotateByCredits,
|
|
1820
|
+
autoTestIntervalMinutes: preferences.autoTestIntervalMinutes,
|
|
1821
|
+
...target.modelId === void 0 ? {} : { targetModelId: target.modelId },
|
|
1822
|
+
...target.staleModelId === void 0 ? {} : { staleTargetModelId: target.staleModelId },
|
|
1823
|
+
targetModelSource: target.source,
|
|
1824
|
+
memberAccountIds: preferences.memberAccountIds,
|
|
1825
|
+
effectiveMemberAccountIds: effectiveMembersOf(accounts.map((account) => account.accountId), new Set(preferences.memberAccountIds)),
|
|
1826
|
+
...rotatedTo === void 0 ? {} : { rotatedToAccountId: rotatedTo },
|
|
1827
|
+
accounts,
|
|
1828
|
+
catalog: deps.displayModels(region).map((model) => ({
|
|
1829
|
+
id: model.id,
|
|
1830
|
+
name: model.name,
|
|
1831
|
+
...model.creditMultiplier === void 0 ? {} : { creditMultiplier: model.creditMultiplier }
|
|
1832
|
+
}))
|
|
1107
1833
|
};
|
|
1108
1834
|
}
|
|
1109
1835
|
/**
|
|
@@ -1218,7 +1944,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
1218
1944
|
const body = await readJsonBody(req);
|
|
1219
1945
|
const modelIds = Array.isArray(body.modelIds) ? body.modelIds.filter((id) => typeof id === "string" && id !== "") : [];
|
|
1220
1946
|
if (modelIds.length === 0) return json(res, 400, { error: "modelIds must be a non-empty array of strings" });
|
|
1221
|
-
if (modelIds.length >
|
|
1947
|
+
if (modelIds.length > 1) return json(res, 400, { error: "a probe accepts exactly one model" });
|
|
1222
1948
|
const results = [];
|
|
1223
1949
|
for (const modelId of modelIds) results.push(...await deps.probeModels(region, [modelId]));
|
|
1224
1950
|
json(res, 200, { results });
|
|
@@ -1227,6 +1953,70 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
1227
1953
|
}
|
|
1228
1954
|
}
|
|
1229
1955
|
});
|
|
1956
|
+
const disposePool = ctx.webServer.register({
|
|
1957
|
+
kind: "exact",
|
|
1958
|
+
path: WORKBUDDY_POOL_PATH,
|
|
1959
|
+
handler: async (req, res) => {
|
|
1960
|
+
if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
|
|
1961
|
+
if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
|
|
1962
|
+
const region = requestRegion(req, res);
|
|
1963
|
+
if (region === void 0) return;
|
|
1964
|
+
const action = poolActionOf(req.url ?? "/");
|
|
1965
|
+
if (action === void 0) return json(res, 400, { error: "action must be checkin or test" });
|
|
1966
|
+
const pool = deps.pool;
|
|
1967
|
+
if (pool === void 0) return json(res, 503, {
|
|
1968
|
+
reason: "pool-unavailable",
|
|
1969
|
+
error: "account pool unavailable"
|
|
1970
|
+
});
|
|
1971
|
+
try {
|
|
1972
|
+
if (!pool.preferences(region).enabled) return json(res, 409, {
|
|
1973
|
+
reason: "pool-disabled",
|
|
1974
|
+
error: "account pool is disabled for this region"
|
|
1975
|
+
});
|
|
1976
|
+
const savedMembers = pool.preferences(region).memberAccountIds;
|
|
1977
|
+
if ((await pool.effectiveMemberAccountIds?.(region).catch(() => void 0) ?? savedMembers).length === 0) {
|
|
1978
|
+
const noneChecked = savedMembers.length === 0;
|
|
1979
|
+
return json(res, 409, {
|
|
1980
|
+
reason: noneChecked ? "no-members" : "no-live-members",
|
|
1981
|
+
error: noneChecked ? "no accounts are checked into this region's pool" : "no checked account still has a local sign-in"
|
|
1982
|
+
});
|
|
1983
|
+
}
|
|
1984
|
+
if (action === "checkin") {
|
|
1985
|
+
if (pool.checkin === void 0) return json(res, 503, {
|
|
1986
|
+
reason: "pool-unavailable",
|
|
1987
|
+
error: "pool check-in unavailable"
|
|
1988
|
+
});
|
|
1989
|
+
return json(res, 200, {
|
|
1990
|
+
action: "checkin",
|
|
1991
|
+
rows: await pool.checkin(region)
|
|
1992
|
+
});
|
|
1993
|
+
}
|
|
1994
|
+
if (pool.test === void 0) return json(res, 503, {
|
|
1995
|
+
reason: "pool-unavailable",
|
|
1996
|
+
error: "pool test unavailable"
|
|
1997
|
+
});
|
|
1998
|
+
const target = resolveTargetModel(pool.catalog?.(region) ?? [], pool.preferences(region).targetModelId);
|
|
1999
|
+
if (target.modelId === void 0) return json(res, 409, {
|
|
2000
|
+
action: "test",
|
|
2001
|
+
modelId: void 0,
|
|
2002
|
+
rows: [],
|
|
2003
|
+
reason: target.source === "stale" ? "target-model-stale" : "no-free-model",
|
|
2004
|
+
error: target.source === "stale" ? "the saved target model is no longer offered by this region; pick another or switch back to automatic" : "no zero-multiplier model in this region; refresh the catalog or set a target model"
|
|
2005
|
+
});
|
|
2006
|
+
const rows = await pool.test(region, target.modelId);
|
|
2007
|
+
return json(res, 200, {
|
|
2008
|
+
action: "test",
|
|
2009
|
+
modelId: target.modelId,
|
|
2010
|
+
rows
|
|
2011
|
+
});
|
|
2012
|
+
} catch (error) {
|
|
2013
|
+
json(res, 500, {
|
|
2014
|
+
reason: "pool-failed",
|
|
2015
|
+
error: safeMessage(error)
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
});
|
|
1230
2020
|
const disposeDiagWrite = ctx.webServer.register({
|
|
1231
2021
|
kind: "exact",
|
|
1232
2022
|
path: "/plugins/dsh-connect-workbuddy/__save",
|
|
@@ -1283,6 +2073,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
1283
2073
|
disposeAccounts();
|
|
1284
2074
|
disposeUsage();
|
|
1285
2075
|
disposeProbe();
|
|
2076
|
+
disposePool();
|
|
1286
2077
|
disposeDiagWrite();
|
|
1287
2078
|
};
|
|
1288
2079
|
}, "dsh-connect-workbuddy: Web status route");
|
|
@@ -1375,12 +2166,29 @@ const modelConfig = z.object({
|
|
|
1375
2166
|
contextWindow: z.number().step(1).min(1),
|
|
1376
2167
|
maxTokens: z.number().step(1).min(1)
|
|
1377
2168
|
});
|
|
2169
|
+
/**
|
|
2170
|
+
* One region's account-pool PREFERENCES.
|
|
2171
|
+
*
|
|
2172
|
+
* Only user choices live here. The pool's MEASURED facts (probe outcomes,
|
|
2173
|
+
* cooldowns, the account currently billed) are deliberately NOT part of this
|
|
2174
|
+
* schema — see `src/account-pool-store.ts`. The card saves preferences with a
|
|
2175
|
+
* draft-and-discard write, so storing measurements in the same slot would let
|
|
2176
|
+
* one "Save" press overwrite results the timer had just written.
|
|
2177
|
+
*/
|
|
2178
|
+
const poolConfig = z.object({
|
|
2179
|
+
enabled: z.boolean().default(false).description("Whether the account pool is active for this region (opt-in)"),
|
|
2180
|
+
rotateByCredits: z.boolean().default(false).description("Whether the plugin may switch the billed account by credits (opt-in; disables manual selection)"),
|
|
2181
|
+
autoTestIntervalMinutes: z.number().step(1).min(5).max(1440).default(30).description("Minutes between automatic pool tests (5..1440)"),
|
|
2182
|
+
targetModelId: z.string().default("").description("Model id to test; empty means pick a zero-multiplier model from this region's catalog"),
|
|
2183
|
+
memberAccountIds: z.array(z.string()).default([]).description("Account ids checked into this pool (opt-in; empty means the pool covers nothing)")
|
|
2184
|
+
});
|
|
1378
2185
|
const regionStateConfig = z.object({
|
|
1379
2186
|
enabled: z.boolean().default(true).description("Whether this region's provider is offered to DSH (opt-out; false withdraws it entirely)"),
|
|
1380
2187
|
lastCatalog: z.array(modelConfig).default([]),
|
|
1381
2188
|
enabledModelIds: z.array(z.string()).default([]),
|
|
1382
2189
|
imageModelIds: z.array(z.string()).default([]),
|
|
1383
|
-
contextBudgets: z.dict(z.number().step(1).min(1)).default({})
|
|
2190
|
+
contextBudgets: z.dict(z.number().step(1).min(1)).default({}),
|
|
2191
|
+
pool: poolConfig.default({})
|
|
1384
2192
|
});
|
|
1385
2193
|
const accountSelectionConfig = z.object({
|
|
1386
2194
|
cn: z.string().description("Selected domestic (CN) account id (never a token)"),
|
|
@@ -1541,6 +2349,31 @@ async function legacyAttributionRegion(value, accountsFor) {
|
|
|
1541
2349
|
*/
|
|
1542
2350
|
function apply(ctx, config) {
|
|
1543
2351
|
const client = new WorkBuddyUpstreamClient();
|
|
2352
|
+
/**
|
|
2353
|
+
* How long a check-in reading is reused before asking the upstream again.
|
|
2354
|
+
*
|
|
2355
|
+
* The card polls the usage route every 60 seconds, but "did this account
|
|
2356
|
+
* check in today" changes only when a check-in runs or the day rolls over.
|
|
2357
|
+
* Without a cache, a 10-account pool cost ~14,400 upstream requests a day for
|
|
2358
|
+
* an answer that was almost always identical.
|
|
2359
|
+
*/
|
|
2360
|
+
const CHECKIN_CACHE_MS = 6e5;
|
|
2361
|
+
/**
|
|
2362
|
+
* Per-region, per-account check-in readings.
|
|
2363
|
+
*
|
|
2364
|
+
* Invalidated by this plugin's own check-in batch (the only writer that can
|
|
2365
|
+
* change the answer during a session), so the user sees their result
|
|
2366
|
+
* immediately instead of waiting out the TTL.
|
|
2367
|
+
*/
|
|
2368
|
+
const checkinStateCache = {};
|
|
2369
|
+
/**
|
|
2370
|
+
* Latest-wins guard for `applyRotation`, keyed by region.
|
|
2371
|
+
*
|
|
2372
|
+
* Declared here (near the top of `apply`) rather than beside `applyRotation`
|
|
2373
|
+
* itself: that function is CALLED from the startup path above its definition,
|
|
2374
|
+
* so a `const` declared down there would be in its temporal dead zone.
|
|
2375
|
+
*/
|
|
2376
|
+
const rotationGuard = createLatestWins();
|
|
1544
2377
|
const settingsNs = settingsNamespaceOf(ctx);
|
|
1545
2378
|
const stacks = {};
|
|
1546
2379
|
for (const region of REGION_KEYS) {
|
|
@@ -1687,7 +2520,7 @@ function apply(ctx, config) {
|
|
|
1687
2520
|
* out-of-credit result. Every other limited outcome has no time anywhere, and
|
|
1688
2521
|
* the probe reports exactly that instead of inventing one.
|
|
1689
2522
|
*/
|
|
1690
|
-
async probeModels(region, modelIds,
|
|
2523
|
+
async probeModels(region, modelIds, options) {
|
|
1691
2524
|
let credential;
|
|
1692
2525
|
try {
|
|
1693
2526
|
credential = await stacks[region].store.resolve();
|
|
@@ -1706,12 +2539,112 @@ function apply(ctx, config) {
|
|
|
1706
2539
|
modelId,
|
|
1707
2540
|
nowMs: Date.now(),
|
|
1708
2541
|
...quotaRefreshAtMs === void 0 ? {} : { quotaRefreshAtMs },
|
|
1709
|
-
...signal === void 0 ? {} : { signal }
|
|
2542
|
+
...options?.signal === void 0 ? {} : { signal: options.signal }
|
|
1710
2543
|
}));
|
|
1711
2544
|
return results;
|
|
1712
2545
|
},
|
|
1713
2546
|
regionUsable(region, usable) {
|
|
1714
2547
|
if (stacks[region].catalog.setRegionUsable(usable)) invalidateCatalog();
|
|
2548
|
+
},
|
|
2549
|
+
/**
|
|
2550
|
+
* The account pool, resolved per region.
|
|
2551
|
+
*
|
|
2552
|
+
* Region-scoped throughout: the CN and international pools are separate
|
|
2553
|
+
* stacks of accounts, so one region's pool can never rank or bill the
|
|
2554
|
+
* other's accounts.
|
|
2555
|
+
*
|
|
2556
|
+
* `rotateByCredits` is consumed HERE, by the credential store's selection
|
|
2557
|
+
* hook, rather than by writing `config.accounts[region]`. That separation
|
|
2558
|
+
* is the whole reason "turn rotation off" cleanly restores the user's own
|
|
2559
|
+
* choice: rotation decides who serves at runtime and never overwrites the
|
|
2560
|
+
* record of what the user picked.
|
|
2561
|
+
*/
|
|
2562
|
+
pool: {
|
|
2563
|
+
preferences: (region) => poolPreferencesOf(current(), region),
|
|
2564
|
+
members: (region) => poolMembersOf(region),
|
|
2565
|
+
/**
|
|
2566
|
+
* The checked accounts that resolve to a local sign-in.
|
|
2567
|
+
*
|
|
2568
|
+
* A local scan only — no upstream calls — so the route's empty-pool guard
|
|
2569
|
+
* can use it on every batch. {@link poolMembersOf} is the expensive one
|
|
2570
|
+
* (it fetches credits per account) and is not suitable there.
|
|
2571
|
+
*/
|
|
2572
|
+
effectiveMemberAccountIds: async (region) => {
|
|
2573
|
+
const saved = poolPreferencesOf(current(), region).memberAccountIds;
|
|
2574
|
+
if (saved.length === 0) return [];
|
|
2575
|
+
const accounts = await stacks[region].store.accounts();
|
|
2576
|
+
return effectiveMembersOf(saved, new Set(accounts.map((account) => account.id)));
|
|
2577
|
+
},
|
|
2578
|
+
/**
|
|
2579
|
+
* Local sign-ins the pool does NOT cover.
|
|
2580
|
+
*
|
|
2581
|
+
* Sent so the card can offer them to check in. Membership is an explicit
|
|
2582
|
+
* opt-in, and a user cannot opt in to an account the card never shows.
|
|
2583
|
+
*/
|
|
2584
|
+
async otherAccounts(region) {
|
|
2585
|
+
const wanted = new Set(poolPreferencesOf(current(), region).memberAccountIds);
|
|
2586
|
+
return (await stacks[region].store.accounts()).filter((account) => !wanted.has(account.id)).map((account) => ({
|
|
2587
|
+
id: account.id,
|
|
2588
|
+
accountName: account.accountName
|
|
2589
|
+
}));
|
|
2590
|
+
},
|
|
2591
|
+
/**
|
|
2592
|
+
* Today's check-in state per account, for the pool table.
|
|
2593
|
+
*
|
|
2594
|
+
* Read per account through `credentialFor` (never `resolve`), so asking
|
|
2595
|
+
* about an account cannot change which one is billing. A per-account
|
|
2596
|
+
* failure is simply omitted, which the card renders as unknown.
|
|
2597
|
+
*/
|
|
2598
|
+
async checkedInToday(region) {
|
|
2599
|
+
if (!poolPreferencesOf(current(), region).enabled) return {};
|
|
2600
|
+
const store = stacks[region].store;
|
|
2601
|
+
const accounts = await store.accounts();
|
|
2602
|
+
const now = Date.now();
|
|
2603
|
+
const entries = await Promise.all(accounts.map(async (account) => {
|
|
2604
|
+
const cached = checkinStateCache[region]?.[account.id];
|
|
2605
|
+
if (cached !== void 0 && now - cached.atMs < CHECKIN_CACHE_MS) return [account.id, cached.checkedIn];
|
|
2606
|
+
const credential = await store.credentialFor(account.id).catch(() => void 0);
|
|
2607
|
+
if (credential === void 0) return void 0;
|
|
2608
|
+
const status = await client.fetchCheckinStatus(credential).catch(() => void 0);
|
|
2609
|
+
if (status === void 0) return void 0;
|
|
2610
|
+
checkinStateCache[region] = {
|
|
2611
|
+
...checkinStateCache[region],
|
|
2612
|
+
[account.id]: {
|
|
2613
|
+
checkedIn: status.todayCheckedIn,
|
|
2614
|
+
atMs: now
|
|
2615
|
+
}
|
|
2616
|
+
};
|
|
2617
|
+
return [account.id, status.todayCheckedIn];
|
|
2618
|
+
}));
|
|
2619
|
+
return Object.fromEntries(entries.filter((entry) => entry !== void 0));
|
|
2620
|
+
},
|
|
2621
|
+
/**
|
|
2622
|
+
* The account rotation last moved to, read from the store's runtime
|
|
2623
|
+
* override. Absent means rotation is not currently redirecting billing.
|
|
2624
|
+
*/
|
|
2625
|
+
async rotatedToAccountId(region) {
|
|
2626
|
+
return stacks[region].store.rotatedAccount();
|
|
2627
|
+
},
|
|
2628
|
+
async currentAccountId(region) {
|
|
2629
|
+
const credential = await stacks[region].store.current().catch(() => void 0);
|
|
2630
|
+
return credential === void 0 ? void 0 : workbuddyAccountId(credential);
|
|
2631
|
+
},
|
|
2632
|
+
checkin: async (region) => {
|
|
2633
|
+
const rows = await checkinAllAccounts(await poolTargets(region), poolRunnerDeps(region));
|
|
2634
|
+
for (const row of rows) {
|
|
2635
|
+
if (row.status === "failed") continue;
|
|
2636
|
+
const cached = checkinStateCache[region];
|
|
2637
|
+
if (cached !== void 0) delete cached[row.accountId];
|
|
2638
|
+
}
|
|
2639
|
+
return rows;
|
|
2640
|
+
},
|
|
2641
|
+
test: async (region, modelId) => {
|
|
2642
|
+
const rows = await testAllAccounts(await poolTargets(region), modelId, poolRunnerDeps(region));
|
|
2643
|
+
await writePoolProbes(region, probeUpdatesOf(rows));
|
|
2644
|
+
await applyRotation(region);
|
|
2645
|
+
return rows;
|
|
2646
|
+
},
|
|
2647
|
+
catalog: (region) => displayModels(current(), region)
|
|
1715
2648
|
}
|
|
1716
2649
|
}));
|
|
1717
2650
|
/**
|
|
@@ -1731,14 +2664,142 @@ function apply(ctx, config) {
|
|
|
1731
2664
|
return;
|
|
1732
2665
|
}
|
|
1733
2666
|
};
|
|
2667
|
+
/**
|
|
2668
|
+
* Apply the pool's rotation to one region's store.
|
|
2669
|
+
*
|
|
2670
|
+
* The store's runtime override is set only when rotation is switched ON and a
|
|
2671
|
+
* usable account exists; otherwise it is CLEARED, which restores the user's
|
|
2672
|
+
* own selection immediately. Clearing on every non-rotating path is what
|
|
2673
|
+
* makes "turn rotation off" complete rather than leaving a stale override
|
|
2674
|
+
* billing under a switch that reads as off.
|
|
2675
|
+
*
|
|
2676
|
+
* Never writes settings: the user's saved choice is untouched throughout.
|
|
2677
|
+
*/
|
|
2678
|
+
const applyRotation = async (region) => {
|
|
2679
|
+
const store = stacks[region].store;
|
|
2680
|
+
const stale = rotationGuard.begin(region);
|
|
2681
|
+
const preferences = poolPreferencesOf(current(), region);
|
|
2682
|
+
if (!preferences.enabled || !preferences.rotateByCredits) {
|
|
2683
|
+
store.setRotatedAccount(void 0);
|
|
2684
|
+
return;
|
|
2685
|
+
}
|
|
2686
|
+
const members = await poolMembersOf(region);
|
|
2687
|
+
if (stale()) return;
|
|
2688
|
+
const picked = pickAccount(members, Date.now());
|
|
2689
|
+
const latest = poolPreferencesOf(current(), region);
|
|
2690
|
+
if (!latest.enabled || !latest.rotateByCredits) {
|
|
2691
|
+
store.setRotatedAccount(void 0);
|
|
2692
|
+
return;
|
|
2693
|
+
}
|
|
2694
|
+
store.setRotatedAccount(picked?.id);
|
|
2695
|
+
};
|
|
2696
|
+
/**
|
|
2697
|
+
* One region's POOL members, each with its credits and last measurement.
|
|
2698
|
+
*
|
|
2699
|
+
* Only CHECKED accounts: an unchecked account is not a candidate to bill, so
|
|
2700
|
+
* it must not reach the ranking that decides who serves.
|
|
2701
|
+
*
|
|
2702
|
+
* Read through `credentialFor()` — never `resolve()` — so no account's
|
|
2703
|
+
* selection state is touched: a batch must measure every member without
|
|
2704
|
+
* changing which one is billing. Credits are fetched per account and a
|
|
2705
|
+
* failure degrades to "unknown" for that row only — one unreadable balance
|
|
2706
|
+
* must not blank the whole pool.
|
|
2707
|
+
*/
|
|
2708
|
+
async function poolMembersOf(region) {
|
|
2709
|
+
const store = stacks[region].store;
|
|
2710
|
+
const accounts = await poolMemberAccounts(region);
|
|
2711
|
+
const probes = await readPoolProbes(region);
|
|
2712
|
+
return await Promise.all(accounts.map(async (account) => {
|
|
2713
|
+
const credential = await store.credentialFor(account.id).catch(() => void 0);
|
|
2714
|
+
const credits = credential === void 0 ? void 0 : await client.fetchCredits(credential).catch(() => void 0);
|
|
2715
|
+
return {
|
|
2716
|
+
account: {
|
|
2717
|
+
id: account.id,
|
|
2718
|
+
accountName: account.accountName
|
|
2719
|
+
},
|
|
2720
|
+
...credits === void 0 ? {} : { credits: {
|
|
2721
|
+
total: credits.total,
|
|
2722
|
+
expiringSoon: credits.expiringSoon,
|
|
2723
|
+
...credits.nearestExpiryMs === void 0 ? {} : { nearestExpiryMs: credits.nearestExpiryMs }
|
|
2724
|
+
} },
|
|
2725
|
+
...probes[account.id] === void 0 ? {} : { probe: probes[account.id] },
|
|
2726
|
+
tokenExpiresAtMs: account.tokenExpiresAtMs
|
|
2727
|
+
};
|
|
2728
|
+
}));
|
|
2729
|
+
}
|
|
2730
|
+
/** One region's pool preferences, with the schema's defaults applied. */
|
|
2731
|
+
const poolPreferencesOf = (config, region) => {
|
|
2732
|
+
const pool = regionStateOf(config, region).pool;
|
|
2733
|
+
return {
|
|
2734
|
+
enabled: pool?.enabled === true,
|
|
2735
|
+
rotateByCredits: pool?.rotateByCredits === true,
|
|
2736
|
+
autoTestIntervalMinutes: pool?.autoTestIntervalMinutes ?? 30,
|
|
2737
|
+
targetModelId: pool?.targetModelId ?? "",
|
|
2738
|
+
memberAccountIds: pool?.memberAccountIds ?? []
|
|
2739
|
+
};
|
|
2740
|
+
};
|
|
2741
|
+
/**
|
|
2742
|
+
* The accounts the user has checked into this region's pool.
|
|
2743
|
+
*
|
|
2744
|
+
* Membership is an EXPLICIT opt-in list, not "every local sign-in": the two
|
|
2745
|
+
* batch actions spend real credits and claim real rewards, so which accounts
|
|
2746
|
+
* they touch has to be the user's decision rather than a side effect of
|
|
2747
|
+
* having signed in on this machine. An account that is not checked is
|
|
2748
|
+
* untouched by check-in, testing, and rotation alike.
|
|
2749
|
+
*/
|
|
2750
|
+
async function poolMemberAccounts(region) {
|
|
2751
|
+
const saved = poolPreferencesOf(current(), region).memberAccountIds;
|
|
2752
|
+
if (saved.length === 0) return [];
|
|
2753
|
+
const accounts = await stacks[region].store.accounts();
|
|
2754
|
+
const byId = new Map(accounts.map((account) => [account.id, account]));
|
|
2755
|
+
return effectiveMembersOf(accounts.map((account) => account.id), new Set(saved)).flatMap((id) => {
|
|
2756
|
+
const account = byId.get(id);
|
|
2757
|
+
return account === void 0 ? [] : [account];
|
|
2758
|
+
});
|
|
2759
|
+
}
|
|
2760
|
+
/** The batch runner's view of one region's pool members. */
|
|
2761
|
+
async function poolTargets(region) {
|
|
2762
|
+
return (await poolMemberAccounts(region)).map((account) => ({
|
|
2763
|
+
accountId: account.id,
|
|
2764
|
+
accountName: account.accountName
|
|
2765
|
+
}));
|
|
2766
|
+
}
|
|
2767
|
+
/**
|
|
2768
|
+
* The two actions' shared dependency set.
|
|
2769
|
+
*
|
|
2770
|
+
* `credentialFor` — never `resolve()` — is what makes a batch possible at
|
|
2771
|
+
* all: it fetches any account's credential WITHOUT changing the region's
|
|
2772
|
+
* selection, so testing every account cannot switch the account that bills
|
|
2773
|
+
* the user's live traffic.
|
|
2774
|
+
*/
|
|
2775
|
+
function poolRunnerDeps(region) {
|
|
2776
|
+
const store = stacks[region].store;
|
|
2777
|
+
return {
|
|
2778
|
+
credentialFor: (accountId) => store.credentialFor(accountId),
|
|
2779
|
+
fetchCheckinStatus: (credential) => client.fetchCheckinStatus(credential),
|
|
2780
|
+
claimDailyCheckin: (credential) => client.claimDailyCheckin(credential),
|
|
2781
|
+
probe: async (credential, modelId) => {
|
|
2782
|
+
const quotaRefreshAtMs = await quotaRefreshOf(region, credential);
|
|
2783
|
+
return probeModel({
|
|
2784
|
+
client,
|
|
2785
|
+
credential,
|
|
2786
|
+
modelId,
|
|
2787
|
+
nowMs: Date.now(),
|
|
2788
|
+
...quotaRefreshAtMs === void 0 ? {} : { quotaRefreshAtMs }
|
|
2789
|
+
});
|
|
2790
|
+
}
|
|
2791
|
+
};
|
|
2792
|
+
}
|
|
1734
2793
|
ctx.inject(["settings"], (sctx) => {
|
|
1735
2794
|
const settings = sctx.settings;
|
|
1736
2795
|
ctx.effect(() => settings.configure({ auto: true }, ctx.fiber));
|
|
1737
2796
|
});
|
|
1738
2797
|
ctx.on("loader/volatile-update", () => {
|
|
1739
2798
|
applySelection(current());
|
|
2799
|
+
for (const region of REGION_KEYS) applyRotation(region).catch(() => {});
|
|
1740
2800
|
});
|
|
1741
2801
|
applySelection(config);
|
|
2802
|
+
for (const region of REGION_KEYS) applyRotation(region).catch(() => {});
|
|
1742
2803
|
(async () => {
|
|
1743
2804
|
try {
|
|
1744
2805
|
const region = await legacyAttributionRegion(current(), async (candidate) => stacks[candidate].store.accounts());
|
|
@@ -1748,6 +2809,85 @@ function apply(ctx, config) {
|
|
|
1748
2809
|
} catch {}
|
|
1749
2810
|
})();
|
|
1750
2811
|
let stopped = false;
|
|
2812
|
+
/**
|
|
2813
|
+
* When each region's automatic test last ran, in epoch ms.
|
|
2814
|
+
*
|
|
2815
|
+
* In-memory only, and deliberately NOT persisted: it exists to space out
|
|
2816
|
+
* runs within one session. Persisting it would make a restart skip a test the
|
|
2817
|
+
* user was owed, and losing it only costs one interval.
|
|
2818
|
+
*/
|
|
2819
|
+
const poolLastRunMs = {};
|
|
2820
|
+
/**
|
|
2821
|
+
* The pool scheduler: ONE heartbeat for both regions.
|
|
2822
|
+
*
|
|
2823
|
+
* A single timer rather than one per region, because the tick does no network
|
|
2824
|
+
* work of its own — it reads the config, compares timestamps, and only then
|
|
2825
|
+
* runs a batch. That also means a settings change needs no re-arming: the next
|
|
2826
|
+
* tick reads whatever interval is now saved.
|
|
2827
|
+
*
|
|
2828
|
+
* This is what makes the "自动测试间隔" setting real. Before it existed the
|
|
2829
|
+
* interval was stored and never consumed, so the card promised a behaviour the
|
|
2830
|
+
* plugin did not have.
|
|
2831
|
+
*/
|
|
2832
|
+
ctx.effect(() => {
|
|
2833
|
+
const timer = setInterval(() => {
|
|
2834
|
+
(async () => {
|
|
2835
|
+
if (stopped) return;
|
|
2836
|
+
const schedule = duePoolRegions({
|
|
2837
|
+
state: poolLastRunMs,
|
|
2838
|
+
regions: REGION_KEYS,
|
|
2839
|
+
enabledOf: (region) => poolPreferencesOf(current(), region).enabled && regionEnabled(current(), region),
|
|
2840
|
+
intervalMinutesOf: (region) => poolPreferencesOf(current(), region).autoTestIntervalMinutes,
|
|
2841
|
+
nowMs: Date.now()
|
|
2842
|
+
});
|
|
2843
|
+
for (const region of REGION_KEYS) {
|
|
2844
|
+
const next = schedule.next[region];
|
|
2845
|
+
if (next === void 0) delete poolLastRunMs[region];
|
|
2846
|
+
else poolLastRunMs[region] = next;
|
|
2847
|
+
}
|
|
2848
|
+
for (const region of schedule.due) await runScheduledPoolTest(region).catch(() => {});
|
|
2849
|
+
})();
|
|
2850
|
+
}, POOL_TICK_MS);
|
|
2851
|
+
return () => {
|
|
2852
|
+
clearInterval(timer);
|
|
2853
|
+
};
|
|
2854
|
+
}, "dsh-connect-workbuddy: account pool scheduler");
|
|
2855
|
+
/**
|
|
2856
|
+
* One scheduled pass for a region: test the pool's members, then re-rank.
|
|
2857
|
+
*
|
|
2858
|
+
* Never bills a paid model on a timer: the target is the user's saved model
|
|
2859
|
+
* when it resolves, and the region's FREE model when the saved one has left
|
|
2860
|
+
* the catalog. An unresolvable target with no free model available stops the
|
|
2861
|
+
* pass — the free-target rule is what stops a timer from spending credits.
|
|
2862
|
+
*
|
|
2863
|
+
* The free FALLBACK for a stale saved model is D3's fix, not a convenience.
|
|
2864
|
+
* The scheduler's job is to keep MEASUREMENTS fresh, and a measurement is
|
|
2865
|
+
* what keeps rotation off a dead account. It used to return early whenever
|
|
2866
|
+
* `resolveTargetModel` produced no id — which is exactly what a saved model
|
|
2867
|
+
* that left the catalog produces — so a stale *display preference* froze the
|
|
2868
|
+
* measurement loop for the whole region. The two exclusions then behaved
|
|
2869
|
+
* asymmetrically: a `rate-limited` member clears by the clock alone
|
|
2870
|
+
* (`exclusionOf` consults `retryAtMs`), while an `unavailable` member can
|
|
2871
|
+
* only be cleared by a NEW probe — and no probe was ever written. The member
|
|
2872
|
+
* hung in the pool's "excluded" state indefinitely, until the user noticed
|
|
2873
|
+
* the stale model and fixed it by hand.
|
|
2874
|
+
*
|
|
2875
|
+
* The manual batch route keeps refusing a stale target (`reason:
|
|
2876
|
+
* 'target-model-stale'`): there the user asked to test THAT model, so
|
|
2877
|
+
* substituting another one would answer a question they did not ask. Here
|
|
2878
|
+
* nobody asked for anything; the pass exists to refresh the pool's own
|
|
2879
|
+
* picture, so it must not be starved by a configuration error it can work
|
|
2880
|
+
* around without spending anything.
|
|
2881
|
+
*/
|
|
2882
|
+
async function runScheduledPoolTest(region) {
|
|
2883
|
+
const targets = await poolTargets(region);
|
|
2884
|
+
if (targets.length === 0) return;
|
|
2885
|
+
const catalog = displayModels(current(), region);
|
|
2886
|
+
const modelId = resolveTargetModel(catalog, poolPreferencesOf(current(), region).targetModelId).modelId ?? pickFreeModel(catalog)?.id;
|
|
2887
|
+
if (modelId === void 0) return;
|
|
2888
|
+
await writePoolProbes(region, probeUpdatesOf(await testAllAccounts(targets, modelId, poolRunnerDeps(region))));
|
|
2889
|
+
await applyRotation(region);
|
|
2890
|
+
}
|
|
1751
2891
|
ctx.effect(() => () => {
|
|
1752
2892
|
stopped = true;
|
|
1753
2893
|
for (const region of REGION_KEYS) stacks[region].shim.close();
|
|
@@ -1847,4 +2987,4 @@ function apply(ctx, config) {
|
|
|
1847
2987
|
});
|
|
1848
2988
|
}
|
|
1849
2989
|
//#endregion
|
|
1850
|
-
export { CREDENTIAL_REJECTED_CODE, Config, ENCRYPTED_CREDENTIAL_CODE, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, NATIVE_MODALITY_BY_MODEL_ID, PROBE_MAX_TOKENS, PROBE_SYSTEM_PROMPT,
|
|
2990
|
+
export { CREDENTIAL_REJECTED_CODE, Config, ENCRYPTED_CREDENTIAL_CODE, FALLBACK_WORKBUDDY_MODELS, FALLBACK_WORKBUDDY_MODELS_GLOBAL, NATIVE_MODALITY_BY_MODEL_ID, PROBE_INPUT_TOKENS, PROBE_MAX_TOKENS, PROBE_SYSTEM_PROMPT, WORKBUDDY_ACCOUNTS_REFRESH_PATH, WORKBUDDY_APP_EXECUTABLE_ENV, WORKBUDDY_AUTH_FILENAME, WORKBUDDY_AUTH_FILE_ENV, WORKBUDDY_CHECKIN_PATH, WORKBUDDY_CONNECT_VERSION, WORKBUDDY_GLOBAL_PROVIDER, WORKBUDDY_HOST_HEARTBEAT_FILENAME, WORKBUDDY_MODELS_REFRESH_PATH, WORKBUDDY_PROBE_PATH, WORKBUDDY_PROVIDER, WORKBUDDY_PROVIDERS, WORKBUDDY_PROVIDER_DISPLAY_NAMES, WORKBUDDY_REGIONS, WORKBUDDY_REGION_PARAM, WORKBUDDY_SETTINGS_NS, WORKBUDDY_STREAM_IDLE_TIMEOUT_MS, WORKBUDDY_USAGE_PATH, WorkBuddyCatalog, WorkBuddyCredentialRejectedError, WorkBuddyCredentialStore, WorkBuddyEncryptedCredentialError, WorkBuddyUpstreamClient, apply, asVolatile, authFileName, classifyUpstreamError, clearAtRestKeyCache, clearHostHeartbeat, cooldownOf, createAccountUsabilityProbe, createWorkBuddyAdapter, createWorkBuddyShim, creditOfStream, defaultDesktopAuthCandidates, defaultDesktopAuthDirs, defaultDesktopAuthPath, deriveAtRestKey, deriveAtRestKeyId, deriveCatalog, fallbackModelsFor, fetchAtRestKeyPayload, findWorkbuddyAppExecutable, hasEncryptedCredentialFields, imageDefaultFor, inject, isCredentialRejectedError, isEncryptedCredentialError, isEncryptedFieldWrapper, isHeartbeatProcessAlive, isWorkbuddyBundle, legacyAttributionRegion, legacyWorkbuddyOwnAuthPath, macosBundleExecutable, macosNestedAppBundles, name, nativeModalityOf, nextRegionEnabled, nextRegionSlots, openEncryptedField, outcomeOfFailure, parseCreditMultiplier, parseReasoning, parseRetryAfter, parseUpstreamModel, parseWorkBuddyAuth, prepareChatBody, probeModel, probeRequestBody, probeSucceeded, processStartTimeMs, readAtRestKey, readHostHeartbeat, regionCleared, regionEnabled, regionEnabledOf, regionOf, regionOfProvider, regionOfStatusUrl, regionStateOf, registerWorkBuddyStatusRoute, resolveCredentialRecovery, selectAccountFor, settingsNamespaceOf, toPersistedWorkBuddyModel, unwrapVolatileDeep, withWorkBuddyRegion, workBuddyDisplayName, workBuddyModelInput, workBuddyThinkingLevelMap, workBuddyWebStatus, workbuddyAccountId, workbuddyAppExecutableCandidates, workbuddyHostHeartbeatPath, workbuddyOwnAuthPath, writeHostHeartbeat };
|