@kenz1117/dsh-ui-usage-billing 1.7.4 → 1.8.0-alpha.2

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/index.js CHANGED
@@ -1,14 +1,14 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { lstat, mkdir, readFile, rm, stat } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
- import { dirname, join } from "node:path";
4
+ import { dirname, isAbsolute, join } from "node:path";
5
5
  import { defineTool } from "@deepseek-ai/dsh-tools";
6
6
  import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
7
7
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
8
8
  import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
9
9
  import z from "@deepseek-ai/schemastery";
10
10
  import * as sessionTypes from "@deepseek-ai/dsh-session/types";
11
- import { createHash, createHmac } from "node:crypto";
11
+ import { createHash, createHmac, randomUUID } from "node:crypto";
12
12
  //#region lib/types/client/plan-knowledge.js
13
13
  /**
14
14
  * Plan-knowledge reference (adapted from dsh-spend's `knowledge.js`, MIT):
@@ -339,7 +339,7 @@ function isBeijingWeekend(timeMs) {
339
339
  * 编码为空格分隔的 `YYYYMMDD` 列表(按年分组、同年日期连续排列,便于逐年追加):
340
340
  * 每个键长度固定为 8,故 `includes` 不会跨键误匹配;相比「区间 + 运行时展开」
341
341
  * 省掉一段解析代码——client / index 两个 face 都要过本仓自设的体积成本线
342
- * (sync.sh 的 256/320 KiB;DSH Store 对插件 JS bundle 本身无审查上限)。
342
+ * (sync.sh 的 256/500 KiB;DSH Store 对插件 JS bundle 本身无审查上限)。
343
343
  */
344
344
  const CHINA_HOLIDAYS = "20260925 20260926 20260927 20261001 20261002 20261003 20261004 20261005 20261006 20261007";
345
345
  /** 北京日期键 YYYYMMDD(与宿主时区无关)。 */
@@ -357,11 +357,65 @@ const TIER_BOUNDARY_MINUTES = [
357
357
  840,
358
358
  1080
359
359
  ];
360
+ /**
361
+ * 智谱 Coding Plan 的高峰边界(北京时间当日分钟):工作日 14:00–18:00 为高峰,
362
+ * 额度按基础积分全额抵扣;其余时段(含周末全天)按基础积分 5 折抵扣。
363
+ */
364
+ const ZHIPU_CODING_PLAN_BOUNDARY_MINUTES = [840, 1080];
360
365
  /** 北京时间的当日毫秒数(0–86,400,000)。 */
361
366
  function beijingMillisOfDay(timeMs) {
362
367
  return ((timeMs + 288e5) % 864e5 + 864e5) % 864e5;
363
368
  }
364
369
  /**
370
+ * 按给定峰段边界计算当前档位与距下一切换的时长(扫描结构与 tierCountdown 相同)。
371
+ * @param holidays - 是否把法定节假日并入低谷日(见 {@link tierAtWithBounds})。
372
+ */
373
+ function tierCountdownWithBounds(nowMs, bounds, holidays = false) {
374
+ const tier = tierAtWithBounds(nowMs, bounds, holidays);
375
+ const dayMs = beijingMillisOfDay(nowMs);
376
+ for (let dayOffset = 0; dayOffset < 10; dayOffset++) {
377
+ const dayStart = dayOffset * 864e5 - dayMs;
378
+ for (const boundary of bounds) {
379
+ const inMs = dayStart + boundary * 6e4;
380
+ if (inMs <= 0) continue;
381
+ if (tierAtWithBounds(nowMs + inMs, bounds, holidays) !== tier) return {
382
+ tier,
383
+ nextSwitchInMs: inMs
384
+ };
385
+ }
386
+ }
387
+ const firstBoundary = bounds[0] ?? 0;
388
+ return {
389
+ tier,
390
+ nextSwitchInMs: 864e5 - dayMs + firstBoundary * 6e4
391
+ };
392
+ }
393
+ /**
394
+ * 由会话当前模型与订阅状态推断峰谷窗口。DeepSeek 目录模型恒为按量分时;
395
+ * 智谱模型仅在持有 Z.ai Coding Plan(status ok)时适用积分分时——智谱按量价
396
+ * 全天统一,不涉及峰谷。其余模型(含未收录)返回 none:不显示档位、不提醒,
397
+ * 峰谷提示严格跟随当前对话实际使用的模型而非全局规则。
398
+ * @param modelKey - 当前会话最近一轮的计费目录键(归因模型 key);无轮次时 undefined。
399
+ * @param hasZhipuPlan - 是否存在状态正常的 Z.ai Coding Plan 订阅。
400
+ * @returns 命中的峰谷窗口种类。
401
+ */
402
+ function rateChannelOf(modelKey, hasZhipuPlan) {
403
+ if (modelKey === void 0 || modelKey === "") return "none";
404
+ const resolved = resolveCatalogKey(modelKey);
405
+ const provider = modelCatalog().find((entry) => entry.key === resolved)?.provider;
406
+ if (provider === "DeepSeek") return "deepseek-metered";
407
+ if (hasZhipuPlan && provider === "智谱 AI") return "zhipu-coding-plan";
408
+ return "none";
409
+ }
410
+ /** 按计费通道的峰谷倒计时:none 返回 null(调用方据此隐藏档位 UI 与切换预告)。 */
411
+ function channelCountdown(nowMs, channel) {
412
+ switch (channel) {
413
+ case "deepseek-metered": return tierCountdownWithBounds(nowMs, TIER_BOUNDARY_MINUTES, true);
414
+ case "zhipu-coding-plan": return tierCountdownWithBounds(nowMs, ZHIPU_CODING_PLAN_BOUNDARY_MINUTES);
415
+ case "none": return null;
416
+ }
417
+ }
418
+ /**
365
419
  * 内置目录与别名表的运行时注入态:数据本体在 src/builtin-catalog.ts(仅随 node
366
420
  * 半构建发布)。宿主半区在插件 activate 时同步注入;客户端在 /api/billing/pricing
367
421
  * 首次响应时注入。未注入时目录为空——计价消费方走 UNSEEDED_OTHER 兜底(不产生
@@ -9335,6 +9389,551 @@ function registerUsageStatsRoute(ctx, deps) {
9335
9389
  }), "usage-billing: usage-stats route");
9336
9390
  }
9337
9391
  //#endregion
9392
+ //#region lib/types/task-queue.js
9393
+ /**
9394
+ * Durable off-peak FIFO task queue (issue #106, new-host capability preview).
9395
+ *
9396
+ * Safety contract: tasks START only in the DeepSeek off-peak window; an admitted
9397
+ * turn is never cancelled at the next peak. Failed/unknown outcomes never auto-
9398
+ * retry. A file lock + owner lease prevents two DSH instances sharing DSH_HOME
9399
+ * from submitting the same task. Prompts are persisted with mode 0600.
9400
+ */
9401
+ const QUEUE_VERSION = 1;
9402
+ const MAX_TASKS = 100;
9403
+ const MAX_QUEUED = 50;
9404
+ const MAX_PROMPT_LENGTH = 32e3;
9405
+ const LOCK_WAIT_MS = 5e3;
9406
+ const LEASE_MS = 45e3;
9407
+ const HEARTBEAT_MS = 15e3;
9408
+ const OPTIONS_CACHE_MS = 6e4;
9409
+ var TaskQueueError = class extends Error {
9410
+ status;
9411
+ constructor(message, status = 400) {
9412
+ super(message);
9413
+ this.status = status;
9414
+ this.name = "TaskQueueError";
9415
+ }
9416
+ };
9417
+ function messageOf(error) {
9418
+ return (error instanceof Error ? error.message : String(error)).slice(0, 300);
9419
+ }
9420
+ function isTerminal(status) {
9421
+ return status === "completed" || status === "failed";
9422
+ }
9423
+ function validStoredTask(value) {
9424
+ if (value === null || typeof value !== "object") return false;
9425
+ const task = value;
9426
+ return typeof task.id === "string" && task.id !== "" && typeof task.prompt === "string" && task.prompt.trim() !== "" && task.prompt.length <= MAX_PROMPT_LENGTH && typeof task.cwd === "string" && isAbsolute(task.cwd) && typeof task.provider === "string" && task.provider !== "" && typeof task.model === "string" && task.model !== "" && typeof task.createdAt === "number" && Number.isFinite(task.createdAt) && [
9427
+ "queued",
9428
+ "starting",
9429
+ "running",
9430
+ "completed",
9431
+ "failed"
9432
+ ].includes(task.status ?? "") && (task.sessionId === void 0 || typeof task.sessionId === "string") && (task.owner === void 0 || typeof task.owner === "string") && (task.leaseUntil === void 0 || typeof task.leaseUntil === "number");
9433
+ }
9434
+ function publicTask(task) {
9435
+ const { owner: _owner, leaseUntil: _leaseUntil, ...pub } = task;
9436
+ return pub;
9437
+ }
9438
+ /** The host API is an optional capability; absence never blocks plugin startup. */
9439
+ function usableController(value) {
9440
+ if (value === void 0) return void 0;
9441
+ return [
9442
+ "create",
9443
+ "selectModel",
9444
+ "prompt",
9445
+ "follow",
9446
+ "modelCatalog"
9447
+ ].every((key) => typeof value[key] === "function") ? value : void 0;
9448
+ }
9449
+ var TaskQueueEngine = class {
9450
+ options;
9451
+ now;
9452
+ createId;
9453
+ ownerId;
9454
+ pollMs;
9455
+ timer;
9456
+ heartbeat;
9457
+ taskAbort;
9458
+ stopped = true;
9459
+ pumping = false;
9460
+ modelCache;
9461
+ constructor(options) {
9462
+ this.options = options;
9463
+ this.now = options.now ?? Date.now;
9464
+ this.createId = options.createId ?? randomUUID;
9465
+ this.ownerId = options.ownerId ?? randomUUID();
9466
+ this.pollMs = options.pollMs ?? 15e3;
9467
+ }
9468
+ start() {
9469
+ if (!this.stopped) return;
9470
+ this.stopped = false;
9471
+ this.timer = setInterval(() => {
9472
+ this.pump();
9473
+ }, this.pollMs);
9474
+ this.timer.unref();
9475
+ this.pump();
9476
+ }
9477
+ stop() {
9478
+ this.stopped = true;
9479
+ if (this.timer !== void 0) clearInterval(this.timer);
9480
+ if (this.heartbeat !== void 0) clearInterval(this.heartbeat);
9481
+ this.timer = void 0;
9482
+ this.heartbeat = void 0;
9483
+ this.taskAbort?.abort();
9484
+ }
9485
+ async snapshot() {
9486
+ const countdown = channelCountdown(this.now(), "deepseek-metered");
9487
+ const controller = usableController(this.options.getController());
9488
+ const base = {
9489
+ supported: controller !== void 0,
9490
+ tier: tierAt(this.now()),
9491
+ nextSwitchInMs: countdown?.nextSwitchInMs ?? 0,
9492
+ tasks: (await this.readDocument()).tasks.map(publicTask),
9493
+ workspaces: this.options.getWorkspaces()
9494
+ };
9495
+ if (controller === void 0) return {
9496
+ ...base,
9497
+ models: []
9498
+ };
9499
+ try {
9500
+ return {
9501
+ ...base,
9502
+ models: await this.modelOptions(controller)
9503
+ };
9504
+ } catch (error) {
9505
+ return {
9506
+ ...base,
9507
+ models: [],
9508
+ optionsError: messageOf(error)
9509
+ };
9510
+ }
9511
+ }
9512
+ async summary() {
9513
+ const countdown = channelCountdown(this.now(), "deepseek-metered");
9514
+ const document = await this.readDocument();
9515
+ return {
9516
+ supported: usableController(this.options.getController()) !== void 0,
9517
+ tier: tierAt(this.now()),
9518
+ nextSwitchInMs: countdown?.nextSwitchInMs ?? 0,
9519
+ queuedCount: document.tasks.filter((task) => task.status === "queued").length
9520
+ };
9521
+ }
9522
+ async enqueue(input) {
9523
+ await this.enqueueMany([input]);
9524
+ }
9525
+ /** Validate the whole batch before one locked append: no partially admitted import. */
9526
+ async enqueueMany(inputs) {
9527
+ const controller = usableController(this.options.getController());
9528
+ if (controller === void 0) throw new TaskQueueError("this host does not provide Session task control", 409);
9529
+ if (!Array.isArray(inputs) || inputs.length === 0) throw new TaskQueueError("at least one task is required");
9530
+ if (inputs.length > MAX_QUEUED) throw new TaskQueueError("too many active queue items", 409);
9531
+ const workspaces = this.options.getWorkspaces();
9532
+ const models = await this.modelOptions(controller, true);
9533
+ const validated = inputs.map((input, index) => {
9534
+ try {
9535
+ if (input === null || typeof input !== "object" || Array.isArray(input)) throw new TaskQueueError("task must be a JSON object");
9536
+ const { prompt, cwd, provider, model } = input;
9537
+ if (typeof prompt !== "string" || prompt.trim() === "") throw new TaskQueueError("prompt is required");
9538
+ if (prompt.length > MAX_PROMPT_LENGTH) throw new TaskQueueError("prompt is too long");
9539
+ if (typeof cwd !== "string" || !isAbsolute(cwd)) throw new TaskQueueError("a valid workspace is required");
9540
+ if (!workspaces.some((workspace) => workspace.path === cwd)) throw new TaskQueueError("workspace is no longer available", 409);
9541
+ if (typeof provider !== "string" || typeof model !== "string") throw new TaskQueueError("model is required");
9542
+ if (!models.some((candidate) => candidate.provider === provider && candidate.id === model)) throw new TaskQueueError("select an available DeepSeek time-of-day model", 409);
9543
+ return {
9544
+ prompt,
9545
+ cwd,
9546
+ provider,
9547
+ model
9548
+ };
9549
+ } catch (error) {
9550
+ if (inputs.length > 1 && error instanceof TaskQueueError) throw new TaskQueueError(`task ${String(index + 1)}: ${error.message}`, error.status);
9551
+ throw error;
9552
+ }
9553
+ });
9554
+ await this.update((tasks) => {
9555
+ if (tasks.filter((task) => !isTerminal(task.status)).length + validated.length > MAX_QUEUED) throw new TaskQueueError("too many active queue items", 409);
9556
+ const createdAt = this.now();
9557
+ for (const input of validated) tasks.push({
9558
+ id: this.createId(),
9559
+ ...input,
9560
+ status: "queued",
9561
+ createdAt
9562
+ });
9563
+ });
9564
+ this.pump();
9565
+ }
9566
+ async remove(id) {
9567
+ await this.update((tasks) => {
9568
+ const index = tasks.findIndex((task) => task.id === id);
9569
+ if (index < 0) throw new TaskQueueError("queue item not found", 404);
9570
+ const task = tasks[index];
9571
+ if (!isTerminal(task.status) && task.status !== "queued") throw new TaskQueueError("an admitted task cannot be removed; open its Session to review it", 409);
9572
+ tasks.splice(index, 1);
9573
+ });
9574
+ }
9575
+ async clearFinished() {
9576
+ await this.update((tasks) => {
9577
+ for (let i = tasks.length - 1; i >= 0; i -= 1) if (isTerminal(tasks[i].status)) tasks.splice(i, 1);
9578
+ });
9579
+ }
9580
+ async modelOptions(controller, refresh = false) {
9581
+ if (!refresh && this.modelCache !== void 0 && this.now() - this.modelCache.at < OPTIONS_CACHE_MS) return this.modelCache.models;
9582
+ const catalog = await controller.modelCatalog();
9583
+ const models = [];
9584
+ for (const group of catalog.groups) {
9585
+ if (group.id !== "deepseek-official") continue;
9586
+ for (const model of group.models) {
9587
+ if (rateChannelOf(model.id, false) !== "deepseek-metered") continue;
9588
+ models.push({
9589
+ provider: group.id,
9590
+ providerName: group.name,
9591
+ id: model.id,
9592
+ name: model.name
9593
+ });
9594
+ }
9595
+ }
9596
+ this.modelCache = {
9597
+ at: this.now(),
9598
+ models
9599
+ };
9600
+ return models;
9601
+ }
9602
+ async pump() {
9603
+ if (this.stopped || this.pumping) return;
9604
+ this.pumping = true;
9605
+ try {
9606
+ await this.recoverExpired();
9607
+ if (this.stopped || tierAt(this.now()) !== "offPeak") return;
9608
+ if (usableController(this.options.getController()) === void 0) return;
9609
+ const task = await this.claimNext();
9610
+ if (task !== void 0) await this.execute(task);
9611
+ } catch (error) {
9612
+ if (!this.stopped) console.warn("[usage-billing] task queue pump failed:", error);
9613
+ } finally {
9614
+ this.pumping = false;
9615
+ }
9616
+ }
9617
+ async claimNext() {
9618
+ let claimed;
9619
+ await this.update((tasks) => {
9620
+ const task = tasks.find((candidate) => candidate.status === "queued");
9621
+ if (task === void 0) return;
9622
+ const next = {
9623
+ ...task,
9624
+ status: "starting",
9625
+ owner: this.ownerId,
9626
+ leaseUntil: this.now() + LEASE_MS,
9627
+ startedAt: this.now(),
9628
+ error: void 0
9629
+ };
9630
+ claimed = next;
9631
+ tasks[tasks.indexOf(task)] = next;
9632
+ });
9633
+ return claimed;
9634
+ }
9635
+ async recoverExpired() {
9636
+ await this.update((tasks) => {
9637
+ for (let i = 0; i < tasks.length; i += 1) {
9638
+ const task = tasks[i];
9639
+ if (task.status !== "starting" && task.status !== "running" || (task.leaseUntil ?? 0) > this.now()) continue;
9640
+ tasks[i] = {
9641
+ ...task,
9642
+ status: "failed",
9643
+ finishedAt: this.now(),
9644
+ owner: void 0,
9645
+ leaseUntil: void 0,
9646
+ error: "execution result is unconfirmed after the queue owner stopped; review the Session before retrying"
9647
+ };
9648
+ }
9649
+ });
9650
+ }
9651
+ async execute(claimed) {
9652
+ if (this.stopped) {
9653
+ await this.requeue(claimed.id);
9654
+ return;
9655
+ }
9656
+ const controller = usableController(this.options.getController());
9657
+ if (controller === void 0) {
9658
+ await this.finish(claimed.id, "failed", "host Session task control disappeared before start");
9659
+ return;
9660
+ }
9661
+ this.taskAbort = new AbortController();
9662
+ const heartbeat = setInterval(() => {
9663
+ this.renewLease(claimed.id);
9664
+ }, HEARTBEAT_MS);
9665
+ heartbeat.unref();
9666
+ this.heartbeat = heartbeat;
9667
+ try {
9668
+ if (!this.options.getWorkspaces().some((workspace) => workspace.path === claimed.cwd)) throw new TaskQueueError("workspace is no longer available", 409);
9669
+ if (tierAt(this.now()) !== "offPeak") {
9670
+ await this.requeue(claimed.id);
9671
+ return;
9672
+ }
9673
+ if (!(await this.modelOptions(controller, true)).some((model) => model.provider === claimed.provider && model.id === claimed.model)) throw new TaskQueueError("the selected official DeepSeek model is no longer available", 409);
9674
+ const { sessionId } = await controller.create({ cwd: claimed.cwd });
9675
+ if (!await this.patch(claimed.id, { sessionId })) return;
9676
+ await controller.selectModel({
9677
+ sessionId,
9678
+ provider: claimed.provider,
9679
+ model: claimed.model
9680
+ });
9681
+ if (tierAt(this.now()) !== "offPeak") {
9682
+ await this.requeue(claimed.id);
9683
+ return;
9684
+ }
9685
+ if (!await this.patch(claimed.id, { leaseUntil: this.now() + LEASE_MS })) return;
9686
+ const frames = controller.follow({ address: {
9687
+ kind: "session",
9688
+ sessionId
9689
+ } }, this.taskAbort.signal);
9690
+ const requestId = this.createId();
9691
+ await controller.prompt({
9692
+ requestId,
9693
+ sessionId,
9694
+ mode: "queue",
9695
+ content: [{
9696
+ type: "text",
9697
+ text: claimed.prompt
9698
+ }]
9699
+ }, new AbortController().signal);
9700
+ await this.patch(claimed.id, {
9701
+ status: "running",
9702
+ leaseUntil: this.now() + LEASE_MS
9703
+ });
9704
+ const outcome = await this.watch(frames, String(requestId));
9705
+ if (outcome !== void 0) await this.finish(claimed.id, outcome.status, outcome.error);
9706
+ } catch (error) {
9707
+ if (!this.stopped) await this.finish(claimed.id, "failed", messageOf(error));
9708
+ } finally {
9709
+ clearInterval(heartbeat);
9710
+ if (this.heartbeat === heartbeat) this.heartbeat = void 0;
9711
+ this.taskAbort?.abort();
9712
+ this.taskAbort = void 0;
9713
+ }
9714
+ }
9715
+ async watch(frames, requestId) {
9716
+ let admitted = false;
9717
+ for await (const raw of frames) {
9718
+ const frame = raw;
9719
+ if (frame.type !== "event" || frame.event === void 0) continue;
9720
+ if (frame.event.type === "user/message") {
9721
+ const data = frame.event.data;
9722
+ if (data.source?.kind === "user" && data.source.rpcId === requestId) admitted = true;
9723
+ continue;
9724
+ }
9725
+ if (!admitted || frame.event.type !== "turn/end") continue;
9726
+ const data = frame.event.data;
9727
+ const kind = data.reason?.kind;
9728
+ if (kind === "completed") return { status: "completed" };
9729
+ return {
9730
+ status: "failed",
9731
+ error: data.reason?.error?.message ?? `task turn ended: ${kind ?? "unknown"}`
9732
+ };
9733
+ }
9734
+ return this.stopped ? void 0 : {
9735
+ status: "failed",
9736
+ error: "execution result is unconfirmed; review the Session"
9737
+ };
9738
+ }
9739
+ async renewLease(id) {
9740
+ try {
9741
+ await this.patch(id, { leaseUntil: this.now() + LEASE_MS });
9742
+ } catch (error) {
9743
+ console.warn("[usage-billing] task queue lease renewal failed:", error);
9744
+ }
9745
+ }
9746
+ async requeue(id) {
9747
+ await this.patch(id, {
9748
+ status: "queued",
9749
+ owner: void 0,
9750
+ leaseUntil: void 0,
9751
+ startedAt: void 0,
9752
+ sessionId: void 0
9753
+ });
9754
+ }
9755
+ async finish(id, status, error) {
9756
+ await this.patch(id, {
9757
+ status,
9758
+ error,
9759
+ finishedAt: this.now(),
9760
+ owner: void 0,
9761
+ leaseUntil: void 0
9762
+ });
9763
+ }
9764
+ async patch(id, patch) {
9765
+ let applied = false;
9766
+ await this.update((tasks) => {
9767
+ const index = tasks.findIndex((task) => task.id === id);
9768
+ if (index < 0) return;
9769
+ const task = tasks[index];
9770
+ if (task.owner !== this.ownerId) return;
9771
+ tasks[index] = {
9772
+ ...task,
9773
+ ...patch
9774
+ };
9775
+ applied = true;
9776
+ });
9777
+ return applied;
9778
+ }
9779
+ async update(mutate) {
9780
+ await mkdir(dirname(this.options.filePath), {
9781
+ recursive: true,
9782
+ mode: 448
9783
+ });
9784
+ await withFileLock(this.options.filePath, async () => {
9785
+ const { document, rawMain } = await this.readDocumentWithRaw();
9786
+ const tasks = [...document.tasks];
9787
+ mutate(tasks);
9788
+ while (tasks.length > MAX_TASKS) {
9789
+ const index = tasks.findIndex((task) => isTerminal(task.status));
9790
+ if (index < 0) break;
9791
+ tasks.splice(index, 1);
9792
+ }
9793
+ if (JSON.stringify(tasks) === JSON.stringify(document.tasks)) return;
9794
+ if (rawMain !== void 0) await writeFileAtomic(`${this.options.filePath}.bak`, rawMain, {
9795
+ mode: 384,
9796
+ dirMode: 448
9797
+ });
9798
+ const next = {
9799
+ version: QUEUE_VERSION,
9800
+ tasks
9801
+ };
9802
+ await writeFileAtomic(this.options.filePath, JSON.stringify(next), {
9803
+ mode: 384,
9804
+ dirMode: 448
9805
+ });
9806
+ }, { waitMs: LOCK_WAIT_MS });
9807
+ }
9808
+ async readDocument() {
9809
+ return (await this.readDocumentWithRaw()).document;
9810
+ }
9811
+ async readDocumentWithRaw() {
9812
+ let sawFile = false;
9813
+ for (const path of [this.options.filePath, `${this.options.filePath}.bak`]) try {
9814
+ const raw = await readFile(path, "utf8");
9815
+ sawFile = true;
9816
+ const parsed = JSON.parse(raw);
9817
+ if (parsed.version !== QUEUE_VERSION || !Array.isArray(parsed.tasks) || !parsed.tasks.every(validStoredTask)) continue;
9818
+ return {
9819
+ document: {
9820
+ version: QUEUE_VERSION,
9821
+ tasks: parsed.tasks
9822
+ },
9823
+ ...path === this.options.filePath ? { rawMain: raw } : {}
9824
+ };
9825
+ } catch (error) {
9826
+ if (error.code !== "ENOENT") sawFile = true;
9827
+ }
9828
+ if (sawFile) throw new Error("task queue persistence is unreadable");
9829
+ return { document: {
9830
+ version: QUEUE_VERSION,
9831
+ tasks: []
9832
+ } };
9833
+ }
9834
+ };
9835
+ //#endregion
9836
+ //#region lib/types/routes/task-queue.js
9837
+ /** Loopback-only GET/POST /api/billing/task-queue (issue #106). */
9838
+ const BODY_LIMIT = 10485760;
9839
+ const JSON_HEADERS = {
9840
+ "content-type": "application/json; charset=utf-8",
9841
+ "cache-control": "no-store"
9842
+ };
9843
+ function send(res, status, body) {
9844
+ res.writeHead(status, JSON_HEADERS);
9845
+ res.end(JSON.stringify(body));
9846
+ }
9847
+ function registerTaskQueueRoute(ctx, deps) {
9848
+ ctx.effect(() => ctx.webServer.register({
9849
+ kind: "exact",
9850
+ path: "/api/billing/task-queue",
9851
+ handler: async (req, res) => {
9852
+ if (!guardLoopback(req, res, deps.trustedHosts)) return;
9853
+ if (req.method !== "GET" && req.method !== "POST") {
9854
+ send(res, 405, { error: "method not allowed" });
9855
+ return;
9856
+ }
9857
+ if (req.method === "POST") {
9858
+ if (!isLoopbackOrigin(req.headers.origin)) {
9859
+ send(res, 403, { error: "forbidden: loopback only" });
9860
+ return;
9861
+ }
9862
+ if (!(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
9863
+ send(res, 415, { error: "unsupported content-type" });
9864
+ return;
9865
+ }
9866
+ }
9867
+ try {
9868
+ if (req.method === "GET") {
9869
+ send(res, 200, await deps.engine.snapshot());
9870
+ return;
9871
+ }
9872
+ const chunks = [];
9873
+ let bytes = 0;
9874
+ for await (const chunk of req) {
9875
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
9876
+ bytes += buffer.byteLength;
9877
+ if (bytes > BODY_LIMIT) {
9878
+ send(res, 413, { error: "body too large" });
9879
+ return;
9880
+ }
9881
+ chunks.push(buffer);
9882
+ }
9883
+ let input;
9884
+ try {
9885
+ const body = Buffer.concat(chunks).toString("utf8");
9886
+ const parsed = JSON.parse(body === "" ? "{}" : body);
9887
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new TaskQueueError("request body must be a JSON object");
9888
+ input = parsed;
9889
+ } catch (error) {
9890
+ if (error instanceof TaskQueueError) throw error;
9891
+ throw new TaskQueueError("malformed JSON body");
9892
+ }
9893
+ switch (input.action) {
9894
+ case "enqueue":
9895
+ await deps.engine.enqueue(input);
9896
+ break;
9897
+ case "enqueueMany":
9898
+ if (!Array.isArray(input.tasks)) throw new TaskQueueError("tasks must be an array");
9899
+ await deps.engine.enqueueMany(input.tasks);
9900
+ break;
9901
+ case "remove":
9902
+ if (typeof input.id !== "string" || input.id === "") throw new TaskQueueError("queue item id is required");
9903
+ await deps.engine.remove(input.id);
9904
+ break;
9905
+ case "clearFinished":
9906
+ await deps.engine.clearFinished();
9907
+ break;
9908
+ default: throw new TaskQueueError("unknown queue action");
9909
+ }
9910
+ send(res, 200, {
9911
+ ok: true,
9912
+ snapshot: await deps.engine.snapshot()
9913
+ });
9914
+ } catch (error) {
9915
+ send(res, error instanceof TaskQueueError ? error.status : 503, { error: error instanceof Error ? error.message.slice(0, 300) : "queue operation failed" });
9916
+ }
9917
+ }
9918
+ }), "usage-billing: task queue route");
9919
+ ctx.effect(() => ctx.webServer.register({
9920
+ kind: "exact",
9921
+ path: "/api/billing/task-queue-summary",
9922
+ handler: async (req, res) => {
9923
+ if (!guardLoopback(req, res, deps.trustedHosts)) return;
9924
+ if (req.method !== "GET") {
9925
+ send(res, 405, { error: "method not allowed" });
9926
+ return;
9927
+ }
9928
+ try {
9929
+ send(res, 200, await deps.engine.summary());
9930
+ } catch {
9931
+ send(res, 503, { error: "queue summary unavailable" });
9932
+ }
9933
+ }
9934
+ }), "usage-billing: task queue summary route");
9935
+ }
9936
+ //#endregion
9338
9937
  //#region lib/types/client/usage-billing-settings.js
9339
9938
  /** 设置命名空间 id(小写 kebab-case)。 */
9340
9939
  const BILLING_SETTINGS_NAMESPACE = "ui-usage-billing";
@@ -9963,6 +10562,34 @@ function apply(ctx, config = {}) {
9963
10562
  trustedHosts,
9964
10563
  claimsPath: `${snapshotPath}.notify-claims.json`
9965
10564
  });
10565
+ const taskQueue = new TaskQueueEngine({
10566
+ filePath: config.taskQueuePath ?? join(dshHome, ".dsh-usage-task-queue.json"),
10567
+ getController: () => {
10568
+ try {
10569
+ return ctx.get("sessionController");
10570
+ } catch {
10571
+ return;
10572
+ }
10573
+ },
10574
+ getWorkspaces: () => {
10575
+ try {
10576
+ return (ctx.get("workspaceRegistry")?.list?.() ?? []).filter((workspace) => typeof workspace.path === "string" && workspace.path !== "").map((workspace) => ({
10577
+ path: workspace.path,
10578
+ title: workspace.title || workspace.path
10579
+ }));
10580
+ } catch {
10581
+ return [];
10582
+ }
10583
+ }
10584
+ });
10585
+ registerTaskQueueRoute(ctx, {
10586
+ trustedHosts,
10587
+ engine: taskQueue
10588
+ });
10589
+ taskQueue.start();
10590
+ ctx.effect(() => () => {
10591
+ taskQueue.stop();
10592
+ }, "usage-billing: task queue scheduler");
9966
10593
  let usageToolDisposer;
9967
10594
  let usageToolRegister;
9968
10595
  const syncUsageTool = () => {
@@ -10,7 +10,7 @@ import type { UsageBillingKey } from './locales.ts';
10
10
  export interface PeakAlertBannerProps {
11
11
  hit: PeakAlertHit;
12
12
  config: PeakAlertConfig;
13
- t: (key: UsageBillingKey) => string;
13
+ t: (key: UsageBillingKey, params?: Record<string, unknown>) => string;
14
14
  onDismiss: () => void;
15
15
  }
16
16
  /** 渲染一个切档前提醒状态条。 */