dsh-xdpool 2.5.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/bin.js CHANGED
@@ -993,7 +993,8 @@ function parseTask(value) {
993
993
  status: typeof raw["status"] === "string" ? raw["status"] : "",
994
994
  claimable: !claimed && target > 0 && current >= target,
995
995
  claimed,
996
- locked: raw["locked"] === true
996
+ locked: raw["locked"] === true,
997
+ jumpUrl: typeof raw["jump_url"] === "string" ? raw["jump_url"] : ""
997
998
  };
998
999
  }
999
1000
  var WorkBuddyUpstreamClient = class {
@@ -1354,17 +1355,14 @@ var WorkBuddyUpstreamClient = class {
1354
1355
  if (typeof raw !== "object" || raw === null) continue;
1355
1356
  const account = raw;
1356
1357
  const num = (key) => typeof account[key] === "number" ? account[key] : 0;
1357
- const capacityType = num("CapacityType");
1358
- const monthly = capacityType === 4;
1359
- const periodic = monthly || capacityType === 2;
1360
- const size = periodic ? num("CycleCapacitySize") : num("CapacitySize");
1361
- const remain = periodic ? num("CycleCapacityRemain") : num("CapacityRemain");
1358
+ const monthly = num("CapacityType") === 4;
1359
+ const size = monthly ? num("CycleCapacitySize") : num("CapacitySize");
1360
+ const remain = monthly ? num("CycleCapacityRemain") : num("CapacityRemain");
1362
1361
  const capped = remain < 0 ? 0 : remain;
1363
1362
  const cycleEndMs = parseDate(account["CycleEndTime"]);
1364
- const expiresAtMs = monthly ? void 0 : capacityType === 2 ? parseDate(account["DeductionEndTime"]) : parseDate(account["ExpiredTime"]) ?? cycleEndMs;
1363
+ const expiresAtMs = monthly ? void 0 : parseDate(account["ExpiredTime"]) ?? cycleEndMs;
1365
1364
  const refreshAtMs = monthly ? cycleEndMs === void 0 ? void 0 : cycleEndMs + 1e3 : void 0;
1366
- if (expiresAtMs !== void 0 && expiresAtMs <= Date.now()) continue;
1367
- if (!periodic && capped <= 0) continue;
1365
+ if (!monthly && (capped <= 0 || expiresAtMs !== void 0 && expiresAtMs <= Date.now())) continue;
1368
1366
  total += capped;
1369
1367
  if (expiresAtMs !== void 0) {
1370
1368
  if (nearestExpiryMs === void 0 || expiresAtMs < nearestExpiryMs) nearestExpiryMs = expiresAtMs;
@@ -3696,11 +3694,11 @@ var WorkBuddyAccountPool = class {
3696
3694
  * model id the legacy account-wide check applies (callers that cannot name a
3697
3695
  * model, e.g. CLI diagnostics).
3698
3696
  */
3699
- available(now, modelId, region) {
3697
+ available(now, modelId, region, reserveExempt) {
3700
3698
  return this.accounts.filter((account) => {
3701
3699
  if (this.disabledIds.has(account.id)) return false;
3702
3700
  const reserve = this.creditReserves.get(account.id);
3703
- if (reserve !== void 0 && reserve > 0) {
3701
+ if (!reserveExempt?.has(account.id) && reserve !== void 0 && reserve > 0) {
3704
3702
  const balance = this.creditBalances.get(account.id);
3705
3703
  if (balance !== void 0 && balance <= reserve) return false;
3706
3704
  }
@@ -3724,7 +3722,7 @@ var WorkBuddyAccountPool = class {
3724
3722
  * Counts are over the region's accounts, since a provider only ever sees its
3725
3723
  * own gateway.
3726
3724
  */
3727
- unavailableReason(modelId, region) {
3725
+ unavailableReason(modelId, region, reserveExempt) {
3728
3726
  const now = Date.now();
3729
3727
  const inRegion = this.accounts.filter((account) => region === void 0 || regionOf(account.credential.domain) === region);
3730
3728
  if (inRegion.length === 0) return {
@@ -3737,6 +3735,29 @@ var WorkBuddyAccountPool = class {
3737
3735
  let cooling = 0;
3738
3736
  let disabled = 0;
3739
3737
  let dead = 0;
3738
+ /**
3739
+ * Accounts that survived every OTHER filter and are held back only by their
3740
+ * credit floor.
3741
+ *
3742
+ * Counted rather than returned on the first hit. The old `return` fired the
3743
+ * moment it met one reserved account, so a single account parked under its
3744
+ * floor reported `reserve` for the whole gateway — even while four other
3745
+ * accounts were healthy and had just been tried. The caller turns
3746
+ * `reason === 'reserve'` into a 402 telling the user to lower the reserve,
3747
+ * which pointed at a setting that was not the problem (#98: a five-account
3748
+ * pool reported `reserve_floor` because of one account that had sat below
3749
+ * its floor for weeks, while the actual state was "the rest are cooling").
3750
+ */
3751
+ let reserved = 0;
3752
+ /**
3753
+ * Accounts that reached the end of the walk unblocked.
3754
+ *
3755
+ * Non-zero means the pool still has somewhere to send a request, so a
3756
+ * reserve is NOT what this answer is about: editing a number cannot fix a
3757
+ * request that some other account is already able to serve, and saying
3758
+ * otherwise sends the reader to the wrong setting.
3759
+ */
3760
+ let usable = 0;
3740
3761
  for (const account of inRegion) {
3741
3762
  if (this.disabledIds.has(account.id)) {
3742
3763
  disabled += 1;
@@ -3752,16 +3773,14 @@ var WorkBuddyAccountPool = class {
3752
3773
  continue;
3753
3774
  }
3754
3775
  const reserve = this.creditReserves.get(account.id);
3755
- if (reserve !== void 0 && reserve > 0) {
3776
+ if (!reserveExempt?.has(account.id) && reserve !== void 0 && reserve > 0) {
3756
3777
  const balance = this.creditBalances.get(account.id);
3757
- if (balance !== void 0 && balance <= reserve) return {
3758
- total: inRegion.length,
3759
- cooling,
3760
- disabled,
3761
- dead,
3762
- reason: "reserve"
3763
- };
3778
+ if (balance !== void 0 && balance <= reserve) {
3779
+ reserved += 1;
3780
+ continue;
3781
+ }
3764
3782
  }
3783
+ usable += 1;
3765
3784
  }
3766
3785
  if (dead > 0) return {
3767
3786
  total: inRegion.length,
@@ -3784,6 +3803,13 @@ var WorkBuddyAccountPool = class {
3784
3803
  dead,
3785
3804
  reason: "disabled"
3786
3805
  };
3806
+ if (usable === 0 && reserved > 0) return {
3807
+ total: inRegion.length,
3808
+ cooling,
3809
+ disabled,
3810
+ dead,
3811
+ reason: "reserve"
3812
+ };
3787
3813
  return {
3788
3814
  total: inRegion.length,
3789
3815
  cooling,
@@ -3960,16 +3986,18 @@ var WorkBuddyAccountPool = class {
3960
3986
  * Scans on first use, and rescans when every known account is cooling down: a
3961
3987
  * fresh desktop login is the usual way out of an exhausted pool.
3962
3988
  *
3989
+ * `reserveExempt` is host-only: account ids whose own credentials freshly
3990
+ * confirmed a zero-credit model. It never bypasses disabled, dead credentials, cooldowns or region.
3963
3991
  * `conversationKey` is only consulted under `sticky`; other modes ignore it.
3964
3992
  */
3965
- async acquire(modelId, region, conversationKey) {
3993
+ async acquire(modelId, region, conversationKey, reserveExempt) {
3966
3994
  if (this.accounts.length === 0) await this.scan();
3967
3995
  const maxTries = this.accounts.length + 1;
3968
3996
  for (let attempt = 0; attempt < maxTries; attempt += 1) {
3969
- let pool = this.available(Date.now(), modelId, region);
3997
+ let pool = this.available(Date.now(), modelId, region, reserveExempt);
3970
3998
  if (pool.length === 0 && attempt === 0) {
3971
3999
  await this.scan();
3972
- pool = this.available(Date.now(), modelId, region);
4000
+ pool = this.available(Date.now(), modelId, region, reserveExempt);
3973
4001
  }
3974
4002
  if (pool.length === 0) return void 0;
3975
4003
  if (this.preferredAccountId !== void 0) {
@@ -9967,6 +9995,71 @@ function appearanceChain(themeKey = APPEARANCE_THEME_KEY) {
9967
9995
  };
9968
9996
  }
9969
9997
  /**
9998
+ * Pull the `expertId` out of a task's `jump_url`.
9999
+ *
10000
+ * The "体验XX专家" tasks (#98) name an expert that is NOT in the marketplace
10001
+ * listing — the reporter翻遍了 marketExpertList in both `agent` and `team`
10002
+ * types and got zero hits — so the only place the id appears is the deeplink
10003
+ * embedded in the task itself:
10004
+ *
10005
+ * https://www.workbuddy.cn/work/launch/?deeplink=workbuddy%3A%2F%2Fexperts%3FexpertId%3Dex_GDl31WahyIH0&buddy_type=workbuddy
10006
+ *
10007
+ * The deeplink is percent-encoded, and it has to be: the `?` and `&` inside it
10008
+ * would otherwise end the outer query. So the parse is two steps — decode the
10009
+ * `deeplink` parameter, then read `expertId` off the scheme it decodes to — and
10010
+ * NOT a regex over the whole URL, which would also match the outer `expertId`
10011
+ * spelling in a future URL shape and silently pick the wrong one.
10012
+ *
10013
+ * Returns undefined for the tasks whose deeplink carries no id at all
10014
+ * (`WorkBuddy_Valorant` points at `workbuddy://expert` with no query), which is
10015
+ * the honest answer: those need a different source and are not guessed here.
10016
+ */
10017
+ function expertIdFromJumpUrl(jumpUrl) {
10018
+ if (jumpUrl === "") return void 0;
10019
+ /** Read `expertId` off any URL-ish string, decoded or not. */
10020
+ const readId = (value) => {
10021
+ let query = value;
10022
+ try {
10023
+ const parsed = new URL(value);
10024
+ query = parsed.search === "" ? value : parsed.search;
10025
+ } catch {
10026
+ query = value;
10027
+ }
10028
+ const marker = query.indexOf("expertId=");
10029
+ if (marker === -1) return void 0;
10030
+ const rest = query.slice(marker + 9);
10031
+ const end = rest.search(/[&#]/u);
10032
+ const id = (end === -1 ? rest : rest.slice(0, end)).trim();
10033
+ return id === "" ? void 0 : id;
10034
+ };
10035
+ let source = jumpUrl;
10036
+ try {
10037
+ const raw = new URL(jumpUrl).searchParams.get("deeplink");
10038
+ if (raw !== null && raw !== "") source = raw;
10039
+ } catch {
10040
+ source = jumpUrl;
10041
+ }
10042
+ return readId(source);
10043
+ }
10044
+ /**
10045
+ * Build a summon record for an expert the marketplace does not list.
10046
+ *
10047
+ * Everything the chains need beyond the id is cosmetic — the scorer keys on
10048
+ * `expertId` — so the title from the task is reused as the display name rather
10049
+ * than inventing one. `version` is left empty, which every consumer already
10050
+ * defaults to `1.0.0`.
10051
+ */
10052
+ function expertFromTask(expertId, title) {
10053
+ return {
10054
+ expertId,
10055
+ expertType: "agent",
10056
+ displayName: title,
10057
+ profession: title,
10058
+ version: "",
10059
+ categories: []
10060
+ };
10061
+ }
10062
+ /**
9970
10063
  * The three "summon an expert" events (`expert_summon_click` and friends).
9971
10064
  *
9972
10065
  * Paid before the conversation, in the order the app emits them.
@@ -10080,7 +10173,12 @@ const EVENT_CHAIN_BUILDERS = {
10080
10173
  skill_1: true,
10081
10174
  expert_5: true,
10082
10175
  Expert_team_use_3: true,
10083
- Expert_lighthouse: true
10176
+ Expert_lighthouse: true,
10177
+ expert_use_maidehao: true,
10178
+ expert_use_douyinyin: true,
10179
+ expert_use_shangjia_xuanchuan: true,
10180
+ expert_use_bowangwang: true,
10181
+ WorkBuddy_Valorant: true
10084
10182
  };
10085
10183
  const AUTOMATION_TICK_MS = 6e4;
10086
10184
  const JOB_KINDS = [
@@ -10269,6 +10367,16 @@ var WorkBuddyScheduler = class {
10269
10367
  /** Host hooks that persist the ledger across restarts. */
10270
10368
  loadEarnings;
10271
10369
  saveEarningsFn;
10370
+ /**
10371
+ * The most recent ledger write that may still be in flight.
10372
+ *
10373
+ * `persistEarnings` is synchronous from its callers' point of view, but the
10374
+ * host's settings write is not: it is an async document update. Keeping the
10375
+ * promise here is what lets {@link flushEarnings} wait for the write that
10376
+ * matters — the last one before a restart — without making every counter
10377
+ * update await disk.
10378
+ */
10379
+ pendingEarningsWrite = Promise.resolve();
10272
10380
  constructor(pool, client, options = {}) {
10273
10381
  this.pool = pool;
10274
10382
  this.client = client;
@@ -10305,6 +10413,18 @@ var WorkBuddyScheduler = class {
10305
10413
  this.saveEarningsFn = save;
10306
10414
  }
10307
10415
  /**
10416
+ * Wait for the ledger write that is (or was last) in flight.
10417
+ *
10418
+ * Called at the END of a task pass, right after the last claim, so the rewards
10419
+ * that were just collected are on disk before a restart can lose them. It is
10420
+ * deliberately NOT awaited on every counter bump: the settings document write
10421
+ * is the slow part, and paying it per account would slow the pass for no
10422
+ * benefit while the process is still alive.
10423
+ */
10424
+ async flushEarnings() {
10425
+ await this.pendingEarningsWrite;
10426
+ }
10427
+ /**
10308
10428
  * Fold a previously persisted ledger back in, when it belongs to today.
10309
10429
  *
10310
10430
  * Used after the settings document becomes readable, which happens after
@@ -10574,7 +10694,7 @@ var WorkBuddyScheduler = class {
10574
10694
  compensationCredit: (existing?.compensationCredit ?? 0) + compensationCredit,
10575
10695
  date: today
10576
10696
  });
10577
- this.persistEarnings();
10697
+ this.pendingEarningsWrite = this.persistEarnings();
10578
10698
  }
10579
10699
  /** Clear the per-account counters when the local day changes. */
10580
10700
  rollEarnings(today) {
@@ -10590,9 +10710,18 @@ var WorkBuddyScheduler = class {
10590
10710
  * Best effort on purpose: a failed save must never abort a run that has
10591
10711
  * already collected rewards, and the in-memory ledger keeps serving the card
10592
10712
  * for the rest of the session either way.
10713
+ *
10714
+ * The returned promise settles when the write has landed (or failed). Callers
10715
+ * that are about to END a run await it: reported in #98, a task pass that
10716
+ * claimed 21 tasks had its process restarted before the volatile settings
10717
+ * write reached disk, so `automationEarnings` still read `credit: 0` while
10718
+ * the upstream had really paid out. Fire-and-forget is fine for a counter the
10719
+ * card will re-read in this process; it is not fine for the last write before
10720
+ * exit.
10593
10721
  */
10594
10722
  persistEarnings() {
10595
- if (this.saveEarningsFn === void 0) return;
10723
+ const save = this.saveEarningsFn;
10724
+ if (save === void 0) return Promise.resolve();
10596
10725
  const accounts = {};
10597
10726
  for (const [id, entry] of this.earnings) accounts[id] = entry;
10598
10727
  const ledger = {
@@ -10600,12 +10729,14 @@ var WorkBuddyScheduler = class {
10600
10729
  accounts
10601
10730
  };
10602
10731
  try {
10603
- const saved = this.saveEarningsFn(ledger);
10604
- if (saved !== void 0 && typeof saved.then === "function") saved.then(void 0, (error) => {
10732
+ const saved = save(ledger);
10733
+ if (saved !== void 0 && typeof saved.then === "function") return Promise.resolve(saved).then(void 0, (error) => {
10605
10734
  this.logger.warn?.("dsh-xdpool: could not persist automation earnings:", error);
10606
10735
  });
10736
+ return Promise.resolve();
10607
10737
  } catch (error) {
10608
10738
  this.logger.warn?.("dsh-xdpool: could not persist automation earnings:", error);
10739
+ return Promise.resolve();
10609
10740
  }
10610
10741
  }
10611
10742
  /**
@@ -10714,6 +10845,7 @@ var WorkBuddyScheduler = class {
10714
10845
  state.detail = detail;
10715
10846
  state.progress = progressNote;
10716
10847
  this.logger.info?.(`automation ${accountWord}: ${state.message}`);
10848
+ if (kind === "tasks" || kind === "checkin" || kind === "streak" || kind === "travel" || kind === "compensation") await this.flushEarnings();
10717
10849
  }
10718
10850
  /** Compose the one-line summary shown on the card. */
10719
10851
  summarise(kind, ok, failed, claimed, credit, energy) {
@@ -10836,10 +10968,43 @@ var WorkBuddyScheduler = class {
10836
10968
  case "expert_5": return this.expertChains(credential, "agent", task);
10837
10969
  case "Expert_team_use_3": return this.expertChains(credential, "team", task);
10838
10970
  case "Expert_lighthouse": return this.lighthouseChains(credential);
10971
+ case "expert_use_maidehao":
10972
+ case "expert_use_douyinyin":
10973
+ case "expert_use_shangjia_xuanchuan":
10974
+ case "expert_use_bowangwang":
10975
+ case "WorkBuddy_Valorant": return this.taskExpertChains(credential, task);
10839
10976
  default: return [];
10840
10977
  }
10841
10978
  }
10842
10979
  /**
10980
+ * Summon-and-use one expert NAMED BY THE TASK.
10981
+ *
10982
+ * Structurally `expertChains` minus the marketplace: the expert id is read
10983
+ * from the task's deeplink instead of by browsing `marketExpertList`, because
10984
+ * this family's experts are not listed there at all (#98 measured 500 entries
10985
+ * across both `agent` and `team`, zero hits).
10986
+ *
10987
+ * Returns `[]` — not a throw — when the task carries no usable id: the caller
10988
+ * logs "could not be built" only for exceptions, and a task shape this code
10989
+ * does not understand is information, not a fault.
10990
+ */
10991
+ async taskExpertChains(credential, task) {
10992
+ const expertId = expertIdFromJumpUrl(task.jumpUrl);
10993
+ if (expertId === void 0) {
10994
+ this.logger.warn?.(`automation events ${task.taskCode}: no expertId in jump_url, skipping`);
10995
+ return [];
10996
+ }
10997
+ const expert = expertFromTask(expertId, task.title);
10998
+ await this.client.reportDesktopEvents(credential, expertSummonEvents(expert));
10999
+ if (this.delayMs > 0 && !this.stopped) await sleep(this.delayMs);
11000
+ const chat = await this.client.openConversation(credential, expert.expertId);
11001
+ if (chat === void 0) throw new Error(`${task.taskCode}: no server conversation`);
11002
+ return [{
11003
+ transport: "desktop",
11004
+ events: [...expertChatEvents(expert, chat.conversationId, chat.requestId), expertActualUseEvent(expert, chat.conversationId, chat.requestId)]
11005
+ }];
11006
+ }
11007
+ /**
10843
11008
  * The summon-and-use chains for the expert tasks.
10844
11009
  *
10845
11010
  * Two steps per expert, and both are load-bearing: the summon events alone are
@@ -11306,8 +11471,7 @@ z.object({
11306
11471
  modelSelectionCn: asVolatile(modelSelectionSchema.description("Model selection for the domestic gateway")),
11307
11472
  modelSelectionGlobal: asVolatile(modelSelectionSchema.description("Model selection for the international gateway")),
11308
11473
  automation: asVolatile(automationSchema.description("Daily points automation (activity report, task claiming, check-in)")),
11309
- automationEarnings: asVolatile(z.any().description("Automation earnings ledger (written by the scheduler)")),
11310
- sidebarRow: asVolatile(z.boolean().default(true).description("Show the XD Pool entry in the left sidebar (the Settings page keeps working either way)"))
11474
+ automationEarnings: asVolatile(z.any().description("Automation earnings ledger (written by the scheduler)"))
11311
11475
  });
11312
11476
  /** Assemble the runtime objects without registering anything. */
11313
11477
  /**