dsh-connect-workbuddy 2.3.1 → 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 +42 -0
- package/README.en.md +59 -0
- package/README.md +60 -0
- 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 +1428 -17
- package/lib/{host-heartbeat-CDZRWbFv.js → host-heartbeat-JODdwFRx.js} +29 -1
- package/lib/index.d.ts +340 -17
- package/lib/index.js +1073 -19
- 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";
|
|
@@ -789,6 +793,553 @@ async function probeModel(input) {
|
|
|
789
793
|
})
|
|
790
794
|
};
|
|
791
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
|
+
}
|
|
792
1343
|
//#endregion
|
|
793
1344
|
//#region src/status-paths.ts
|
|
794
1345
|
/**
|
|
@@ -821,6 +1372,28 @@ const WORKBUDDY_CHECKIN_PATH = "/plugins/dsh-connect-workbuddy/checkin";
|
|
|
821
1372
|
* browser half.
|
|
822
1373
|
*/
|
|
823
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
|
+
}
|
|
824
1397
|
/** Query parameter naming the region a card request addresses. */
|
|
825
1398
|
const WORKBUDDY_REGION_PARAM = "region";
|
|
826
1399
|
/** Every region, in card tab order. */
|
|
@@ -892,22 +1465,6 @@ function nextRegionSlots(regions, region, slot) {
|
|
|
892
1465
|
[region]: slot
|
|
893
1466
|
};
|
|
894
1467
|
}
|
|
895
|
-
/**
|
|
896
|
-
* Build the next `regions` settings value for a provider on/off toggle. ONLY
|
|
897
|
-
* the target region's `enabled` flag changes: every other field of that slot
|
|
898
|
-
* (its directory, selection, image opt-ins, context budgets) and every other
|
|
899
|
-
* region's slot are carried over verbatim, so switching a provider off never
|
|
900
|
-
* discards the user's model picks and switching it back on restores them.
|
|
901
|
-
*
|
|
902
|
-
* This is deliberately separate from {@link nextRegionSlots}: that helper
|
|
903
|
-
* writes a whole slot from a signed-in tab's draft, while this one must work
|
|
904
|
-
* for a region that is signed OUT — which is precisely the region a user wants
|
|
905
|
-
* to switch off (no international install, no international account).
|
|
906
|
-
*
|
|
907
|
-
* `value` may be the whole settings section or the `regions` map; the RETURN
|
|
908
|
-
* value is always the `regions` map, i.e. exactly what `settingsScope.set(
|
|
909
|
-
* 'regions', ...)` needs.
|
|
910
|
-
*/
|
|
911
1468
|
function nextRegionEnabled(value, region, enabled) {
|
|
912
1469
|
return nextRegionSlots(regionsMapOf(value), region, {
|
|
913
1470
|
...regionSlotOf(value, region),
|
|
@@ -1189,7 +1746,90 @@ async function workBuddyWebStatus(deps, region) {
|
|
|
1189
1746
|
...creditsResult.status === "fulfilled" ? { credits: toCredits(creditsResult.value) } : { creditsError: safeMessage(creditsResult.reason) },
|
|
1190
1747
|
...checkinResult.status === "fulfilled" ? { checkin: checkinResult.value } : { checkinError: safeMessage(checkinResult.reason) },
|
|
1191
1748
|
...!rejected ? {} : { credentialRejected: true },
|
|
1192
|
-
...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
|
+
}))
|
|
1193
1833
|
};
|
|
1194
1834
|
}
|
|
1195
1835
|
/**
|
|
@@ -1313,6 +1953,70 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
1313
1953
|
}
|
|
1314
1954
|
}
|
|
1315
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
|
+
});
|
|
1316
2020
|
const disposeDiagWrite = ctx.webServer.register({
|
|
1317
2021
|
kind: "exact",
|
|
1318
2022
|
path: "/plugins/dsh-connect-workbuddy/__save",
|
|
@@ -1369,6 +2073,7 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
1369
2073
|
disposeAccounts();
|
|
1370
2074
|
disposeUsage();
|
|
1371
2075
|
disposeProbe();
|
|
2076
|
+
disposePool();
|
|
1372
2077
|
disposeDiagWrite();
|
|
1373
2078
|
};
|
|
1374
2079
|
}, "dsh-connect-workbuddy: Web status route");
|
|
@@ -1461,12 +2166,29 @@ const modelConfig = z.object({
|
|
|
1461
2166
|
contextWindow: z.number().step(1).min(1),
|
|
1462
2167
|
maxTokens: z.number().step(1).min(1)
|
|
1463
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
|
+
});
|
|
1464
2185
|
const regionStateConfig = z.object({
|
|
1465
2186
|
enabled: z.boolean().default(true).description("Whether this region's provider is offered to DSH (opt-out; false withdraws it entirely)"),
|
|
1466
2187
|
lastCatalog: z.array(modelConfig).default([]),
|
|
1467
2188
|
enabledModelIds: z.array(z.string()).default([]),
|
|
1468
2189
|
imageModelIds: z.array(z.string()).default([]),
|
|
1469
|
-
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({})
|
|
1470
2192
|
});
|
|
1471
2193
|
const accountSelectionConfig = z.object({
|
|
1472
2194
|
cn: z.string().description("Selected domestic (CN) account id (never a token)"),
|
|
@@ -1627,6 +2349,31 @@ async function legacyAttributionRegion(value, accountsFor) {
|
|
|
1627
2349
|
*/
|
|
1628
2350
|
function apply(ctx, config) {
|
|
1629
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();
|
|
1630
2377
|
const settingsNs = settingsNamespaceOf(ctx);
|
|
1631
2378
|
const stacks = {};
|
|
1632
2379
|
for (const region of REGION_KEYS) {
|
|
@@ -1798,6 +2545,106 @@ function apply(ctx, config) {
|
|
|
1798
2545
|
},
|
|
1799
2546
|
regionUsable(region, usable) {
|
|
1800
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)
|
|
1801
2648
|
}
|
|
1802
2649
|
}));
|
|
1803
2650
|
/**
|
|
@@ -1817,14 +2664,142 @@ function apply(ctx, config) {
|
|
|
1817
2664
|
return;
|
|
1818
2665
|
}
|
|
1819
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
|
+
}
|
|
1820
2793
|
ctx.inject(["settings"], (sctx) => {
|
|
1821
2794
|
const settings = sctx.settings;
|
|
1822
2795
|
ctx.effect(() => settings.configure({ auto: true }, ctx.fiber));
|
|
1823
2796
|
});
|
|
1824
2797
|
ctx.on("loader/volatile-update", () => {
|
|
1825
2798
|
applySelection(current());
|
|
2799
|
+
for (const region of REGION_KEYS) applyRotation(region).catch(() => {});
|
|
1826
2800
|
});
|
|
1827
2801
|
applySelection(config);
|
|
2802
|
+
for (const region of REGION_KEYS) applyRotation(region).catch(() => {});
|
|
1828
2803
|
(async () => {
|
|
1829
2804
|
try {
|
|
1830
2805
|
const region = await legacyAttributionRegion(current(), async (candidate) => stacks[candidate].store.accounts());
|
|
@@ -1834,6 +2809,85 @@ function apply(ctx, config) {
|
|
|
1834
2809
|
} catch {}
|
|
1835
2810
|
})();
|
|
1836
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
|
+
}
|
|
1837
2891
|
ctx.effect(() => () => {
|
|
1838
2892
|
stopped = true;
|
|
1839
2893
|
for (const region of REGION_KEYS) stacks[region].shim.close();
|