@kenz1117/dsh-ui-usage-billing 1.7.3 → 1.8.0-alpha.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/README.en.md +13 -9
- package/README.md +13 -9
- package/lib/client.js +5 -5
- package/lib/index.js +592 -3
- package/lib/types/client/TaskQueuePanel.d.ts +11 -0
- package/lib/types/client/api/task-queue.d.ts +17 -0
- package/lib/types/client/domain/types.d.ts +4 -4
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/config.d.ts +3 -0
- package/lib/types/routes/task-queue.d.ts +9 -0
- package/lib/types/task-queue-shared.d.ts +40 -0
- package/lib/types/task-queue.d.ts +65 -0
- package/package.json +35 -34
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/
|
|
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 兜底(不产生
|
|
@@ -1694,6 +1748,18 @@ const BUILTIN_MODEL_CATALOG = [
|
|
|
1694
1748
|
output: 10
|
|
1695
1749
|
}
|
|
1696
1750
|
},
|
|
1751
|
+
{
|
|
1752
|
+
key: "gpt-6.1-terra",
|
|
1753
|
+
name: "GPT-6.1 Terra",
|
|
1754
|
+
provider: "OpenAI",
|
|
1755
|
+
colorVar: "ds-green",
|
|
1756
|
+
price: {
|
|
1757
|
+
currency: "USD",
|
|
1758
|
+
input: 1,
|
|
1759
|
+
cacheHit: .05,
|
|
1760
|
+
output: 5
|
|
1761
|
+
}
|
|
1762
|
+
},
|
|
1697
1763
|
{
|
|
1698
1764
|
key: "gpt-5",
|
|
1699
1765
|
name: "GPT-5",
|
|
@@ -9323,6 +9389,501 @@ function registerUsageStatsRoute(ctx, deps) {
|
|
|
9323
9389
|
}), "usage-billing: usage-stats route");
|
|
9324
9390
|
}
|
|
9325
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 enqueue(input) {
|
|
9513
|
+
const controller = usableController(this.options.getController());
|
|
9514
|
+
if (controller === void 0) throw new TaskQueueError("this host does not provide Session task control", 409);
|
|
9515
|
+
const { prompt, cwd, provider, model } = input;
|
|
9516
|
+
if (typeof prompt !== "string" || prompt.trim() === "") throw new TaskQueueError("prompt is required");
|
|
9517
|
+
if (prompt.length > MAX_PROMPT_LENGTH) throw new TaskQueueError("prompt is too long");
|
|
9518
|
+
if (typeof cwd !== "string" || !isAbsolute(cwd)) throw new TaskQueueError("a valid workspace is required");
|
|
9519
|
+
if (!this.options.getWorkspaces().some((workspace) => workspace.path === cwd)) throw new TaskQueueError("workspace is no longer available", 409);
|
|
9520
|
+
if (typeof provider !== "string" || typeof model !== "string") throw new TaskQueueError("model is required");
|
|
9521
|
+
if (!(await this.modelOptions(controller, true)).some((candidate) => candidate.provider === provider && candidate.id === model)) throw new TaskQueueError("select an available DeepSeek time-of-day model", 409);
|
|
9522
|
+
await this.update((tasks) => {
|
|
9523
|
+
if (tasks.filter((task) => !isTerminal(task.status)).length >= MAX_QUEUED) throw new TaskQueueError("too many active queue items", 409);
|
|
9524
|
+
tasks.push({
|
|
9525
|
+
id: this.createId(),
|
|
9526
|
+
prompt,
|
|
9527
|
+
cwd,
|
|
9528
|
+
provider,
|
|
9529
|
+
model,
|
|
9530
|
+
status: "queued",
|
|
9531
|
+
createdAt: this.now()
|
|
9532
|
+
});
|
|
9533
|
+
});
|
|
9534
|
+
this.pump();
|
|
9535
|
+
}
|
|
9536
|
+
async remove(id) {
|
|
9537
|
+
await this.update((tasks) => {
|
|
9538
|
+
const index = tasks.findIndex((task) => task.id === id);
|
|
9539
|
+
if (index < 0) throw new TaskQueueError("queue item not found", 404);
|
|
9540
|
+
const task = tasks[index];
|
|
9541
|
+
if (!isTerminal(task.status) && task.status !== "queued") throw new TaskQueueError("an admitted task cannot be removed; open its Session to review it", 409);
|
|
9542
|
+
tasks.splice(index, 1);
|
|
9543
|
+
});
|
|
9544
|
+
}
|
|
9545
|
+
async clearFinished() {
|
|
9546
|
+
await this.update((tasks) => {
|
|
9547
|
+
for (let i = tasks.length - 1; i >= 0; i -= 1) if (isTerminal(tasks[i].status)) tasks.splice(i, 1);
|
|
9548
|
+
});
|
|
9549
|
+
}
|
|
9550
|
+
async modelOptions(controller, refresh = false) {
|
|
9551
|
+
if (!refresh && this.modelCache !== void 0 && this.now() - this.modelCache.at < OPTIONS_CACHE_MS) return this.modelCache.models;
|
|
9552
|
+
const catalog = await controller.modelCatalog();
|
|
9553
|
+
const models = [];
|
|
9554
|
+
for (const group of catalog.groups) {
|
|
9555
|
+
if (group.id !== "deepseek-official") continue;
|
|
9556
|
+
for (const model of group.models) {
|
|
9557
|
+
if (rateChannelOf(model.id, false) !== "deepseek-metered") continue;
|
|
9558
|
+
models.push({
|
|
9559
|
+
provider: group.id,
|
|
9560
|
+
providerName: group.name,
|
|
9561
|
+
id: model.id,
|
|
9562
|
+
name: model.name
|
|
9563
|
+
});
|
|
9564
|
+
}
|
|
9565
|
+
}
|
|
9566
|
+
this.modelCache = {
|
|
9567
|
+
at: this.now(),
|
|
9568
|
+
models
|
|
9569
|
+
};
|
|
9570
|
+
return models;
|
|
9571
|
+
}
|
|
9572
|
+
async pump() {
|
|
9573
|
+
if (this.stopped || this.pumping) return;
|
|
9574
|
+
this.pumping = true;
|
|
9575
|
+
try {
|
|
9576
|
+
await this.recoverExpired();
|
|
9577
|
+
if (this.stopped || tierAt(this.now()) !== "offPeak") return;
|
|
9578
|
+
if (usableController(this.options.getController()) === void 0) return;
|
|
9579
|
+
const task = await this.claimNext();
|
|
9580
|
+
if (task !== void 0) await this.execute(task);
|
|
9581
|
+
} catch (error) {
|
|
9582
|
+
if (!this.stopped) console.warn("[usage-billing] task queue pump failed:", error);
|
|
9583
|
+
} finally {
|
|
9584
|
+
this.pumping = false;
|
|
9585
|
+
}
|
|
9586
|
+
}
|
|
9587
|
+
async claimNext() {
|
|
9588
|
+
let claimed;
|
|
9589
|
+
await this.update((tasks) => {
|
|
9590
|
+
const task = tasks.find((candidate) => candidate.status === "queued");
|
|
9591
|
+
if (task === void 0) return;
|
|
9592
|
+
const next = {
|
|
9593
|
+
...task,
|
|
9594
|
+
status: "starting",
|
|
9595
|
+
owner: this.ownerId,
|
|
9596
|
+
leaseUntil: this.now() + LEASE_MS,
|
|
9597
|
+
startedAt: this.now(),
|
|
9598
|
+
error: void 0
|
|
9599
|
+
};
|
|
9600
|
+
claimed = next;
|
|
9601
|
+
tasks[tasks.indexOf(task)] = next;
|
|
9602
|
+
});
|
|
9603
|
+
return claimed;
|
|
9604
|
+
}
|
|
9605
|
+
async recoverExpired() {
|
|
9606
|
+
await this.update((tasks) => {
|
|
9607
|
+
for (let i = 0; i < tasks.length; i += 1) {
|
|
9608
|
+
const task = tasks[i];
|
|
9609
|
+
if (task.status !== "starting" && task.status !== "running" || (task.leaseUntil ?? 0) > this.now()) continue;
|
|
9610
|
+
tasks[i] = {
|
|
9611
|
+
...task,
|
|
9612
|
+
status: "failed",
|
|
9613
|
+
finishedAt: this.now(),
|
|
9614
|
+
owner: void 0,
|
|
9615
|
+
leaseUntil: void 0,
|
|
9616
|
+
error: "execution result is unconfirmed after the queue owner stopped; review the Session before retrying"
|
|
9617
|
+
};
|
|
9618
|
+
}
|
|
9619
|
+
});
|
|
9620
|
+
}
|
|
9621
|
+
async execute(claimed) {
|
|
9622
|
+
if (this.stopped) {
|
|
9623
|
+
await this.requeue(claimed.id);
|
|
9624
|
+
return;
|
|
9625
|
+
}
|
|
9626
|
+
const controller = usableController(this.options.getController());
|
|
9627
|
+
if (controller === void 0) {
|
|
9628
|
+
await this.finish(claimed.id, "failed", "host Session task control disappeared before start");
|
|
9629
|
+
return;
|
|
9630
|
+
}
|
|
9631
|
+
this.taskAbort = new AbortController();
|
|
9632
|
+
const heartbeat = setInterval(() => {
|
|
9633
|
+
this.renewLease(claimed.id);
|
|
9634
|
+
}, HEARTBEAT_MS);
|
|
9635
|
+
heartbeat.unref();
|
|
9636
|
+
this.heartbeat = heartbeat;
|
|
9637
|
+
try {
|
|
9638
|
+
if (!this.options.getWorkspaces().some((workspace) => workspace.path === claimed.cwd)) throw new TaskQueueError("workspace is no longer available", 409);
|
|
9639
|
+
if (tierAt(this.now()) !== "offPeak") {
|
|
9640
|
+
await this.requeue(claimed.id);
|
|
9641
|
+
return;
|
|
9642
|
+
}
|
|
9643
|
+
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);
|
|
9644
|
+
const { sessionId } = await controller.create({ cwd: claimed.cwd });
|
|
9645
|
+
if (!await this.patch(claimed.id, { sessionId })) return;
|
|
9646
|
+
await controller.selectModel({
|
|
9647
|
+
sessionId,
|
|
9648
|
+
provider: claimed.provider,
|
|
9649
|
+
model: claimed.model
|
|
9650
|
+
});
|
|
9651
|
+
if (tierAt(this.now()) !== "offPeak") {
|
|
9652
|
+
await this.requeue(claimed.id);
|
|
9653
|
+
return;
|
|
9654
|
+
}
|
|
9655
|
+
if (!await this.patch(claimed.id, { leaseUntil: this.now() + LEASE_MS })) return;
|
|
9656
|
+
const frames = controller.follow({ address: {
|
|
9657
|
+
kind: "session",
|
|
9658
|
+
sessionId
|
|
9659
|
+
} }, this.taskAbort.signal);
|
|
9660
|
+
const requestId = this.createId();
|
|
9661
|
+
await controller.prompt({
|
|
9662
|
+
requestId,
|
|
9663
|
+
sessionId,
|
|
9664
|
+
mode: "queue",
|
|
9665
|
+
content: [{
|
|
9666
|
+
type: "text",
|
|
9667
|
+
text: claimed.prompt
|
|
9668
|
+
}]
|
|
9669
|
+
}, new AbortController().signal);
|
|
9670
|
+
await this.patch(claimed.id, {
|
|
9671
|
+
status: "running",
|
|
9672
|
+
leaseUntil: this.now() + LEASE_MS
|
|
9673
|
+
});
|
|
9674
|
+
const outcome = await this.watch(frames, String(requestId));
|
|
9675
|
+
if (outcome !== void 0) await this.finish(claimed.id, outcome.status, outcome.error);
|
|
9676
|
+
} catch (error) {
|
|
9677
|
+
if (!this.stopped) await this.finish(claimed.id, "failed", messageOf(error));
|
|
9678
|
+
} finally {
|
|
9679
|
+
clearInterval(heartbeat);
|
|
9680
|
+
if (this.heartbeat === heartbeat) this.heartbeat = void 0;
|
|
9681
|
+
this.taskAbort?.abort();
|
|
9682
|
+
this.taskAbort = void 0;
|
|
9683
|
+
}
|
|
9684
|
+
}
|
|
9685
|
+
async watch(frames, requestId) {
|
|
9686
|
+
let admitted = false;
|
|
9687
|
+
for await (const raw of frames) {
|
|
9688
|
+
const frame = raw;
|
|
9689
|
+
if (frame.type !== "event" || frame.event === void 0) continue;
|
|
9690
|
+
if (frame.event.type === "user/message") {
|
|
9691
|
+
const data = frame.event.data;
|
|
9692
|
+
if (data.source?.kind === "user" && data.source.rpcId === requestId) admitted = true;
|
|
9693
|
+
continue;
|
|
9694
|
+
}
|
|
9695
|
+
if (!admitted || frame.event.type !== "turn/end") continue;
|
|
9696
|
+
const data = frame.event.data;
|
|
9697
|
+
const kind = data.reason?.kind;
|
|
9698
|
+
if (kind === "completed") return { status: "completed" };
|
|
9699
|
+
return {
|
|
9700
|
+
status: "failed",
|
|
9701
|
+
error: data.reason?.error?.message ?? `task turn ended: ${kind ?? "unknown"}`
|
|
9702
|
+
};
|
|
9703
|
+
}
|
|
9704
|
+
return this.stopped ? void 0 : {
|
|
9705
|
+
status: "failed",
|
|
9706
|
+
error: "execution result is unconfirmed; review the Session"
|
|
9707
|
+
};
|
|
9708
|
+
}
|
|
9709
|
+
async renewLease(id) {
|
|
9710
|
+
try {
|
|
9711
|
+
await this.patch(id, { leaseUntil: this.now() + LEASE_MS });
|
|
9712
|
+
} catch (error) {
|
|
9713
|
+
console.warn("[usage-billing] task queue lease renewal failed:", error);
|
|
9714
|
+
}
|
|
9715
|
+
}
|
|
9716
|
+
async requeue(id) {
|
|
9717
|
+
await this.patch(id, {
|
|
9718
|
+
status: "queued",
|
|
9719
|
+
owner: void 0,
|
|
9720
|
+
leaseUntil: void 0,
|
|
9721
|
+
startedAt: void 0,
|
|
9722
|
+
sessionId: void 0
|
|
9723
|
+
});
|
|
9724
|
+
}
|
|
9725
|
+
async finish(id, status, error) {
|
|
9726
|
+
await this.patch(id, {
|
|
9727
|
+
status,
|
|
9728
|
+
error,
|
|
9729
|
+
finishedAt: this.now(),
|
|
9730
|
+
owner: void 0,
|
|
9731
|
+
leaseUntil: void 0
|
|
9732
|
+
});
|
|
9733
|
+
}
|
|
9734
|
+
async patch(id, patch) {
|
|
9735
|
+
let applied = false;
|
|
9736
|
+
await this.update((tasks) => {
|
|
9737
|
+
const index = tasks.findIndex((task) => task.id === id);
|
|
9738
|
+
if (index < 0) return;
|
|
9739
|
+
const task = tasks[index];
|
|
9740
|
+
if (task.owner !== this.ownerId) return;
|
|
9741
|
+
tasks[index] = {
|
|
9742
|
+
...task,
|
|
9743
|
+
...patch
|
|
9744
|
+
};
|
|
9745
|
+
applied = true;
|
|
9746
|
+
});
|
|
9747
|
+
return applied;
|
|
9748
|
+
}
|
|
9749
|
+
async update(mutate) {
|
|
9750
|
+
await mkdir(dirname(this.options.filePath), {
|
|
9751
|
+
recursive: true,
|
|
9752
|
+
mode: 448
|
|
9753
|
+
});
|
|
9754
|
+
await withFileLock(this.options.filePath, async () => {
|
|
9755
|
+
const { document, rawMain } = await this.readDocumentWithRaw();
|
|
9756
|
+
const tasks = [...document.tasks];
|
|
9757
|
+
mutate(tasks);
|
|
9758
|
+
while (tasks.length > MAX_TASKS) {
|
|
9759
|
+
const index = tasks.findIndex((task) => isTerminal(task.status));
|
|
9760
|
+
if (index < 0) break;
|
|
9761
|
+
tasks.splice(index, 1);
|
|
9762
|
+
}
|
|
9763
|
+
if (JSON.stringify(tasks) === JSON.stringify(document.tasks)) return;
|
|
9764
|
+
if (rawMain !== void 0) await writeFileAtomic(`${this.options.filePath}.bak`, rawMain, {
|
|
9765
|
+
mode: 384,
|
|
9766
|
+
dirMode: 448
|
|
9767
|
+
});
|
|
9768
|
+
const next = {
|
|
9769
|
+
version: QUEUE_VERSION,
|
|
9770
|
+
tasks
|
|
9771
|
+
};
|
|
9772
|
+
await writeFileAtomic(this.options.filePath, JSON.stringify(next), {
|
|
9773
|
+
mode: 384,
|
|
9774
|
+
dirMode: 448
|
|
9775
|
+
});
|
|
9776
|
+
}, { waitMs: LOCK_WAIT_MS });
|
|
9777
|
+
}
|
|
9778
|
+
async readDocument() {
|
|
9779
|
+
return (await this.readDocumentWithRaw()).document;
|
|
9780
|
+
}
|
|
9781
|
+
async readDocumentWithRaw() {
|
|
9782
|
+
let sawFile = false;
|
|
9783
|
+
for (const path of [this.options.filePath, `${this.options.filePath}.bak`]) try {
|
|
9784
|
+
const raw = await readFile(path, "utf8");
|
|
9785
|
+
sawFile = true;
|
|
9786
|
+
const parsed = JSON.parse(raw);
|
|
9787
|
+
if (parsed.version !== QUEUE_VERSION || !Array.isArray(parsed.tasks) || !parsed.tasks.every(validStoredTask)) continue;
|
|
9788
|
+
return {
|
|
9789
|
+
document: {
|
|
9790
|
+
version: QUEUE_VERSION,
|
|
9791
|
+
tasks: parsed.tasks
|
|
9792
|
+
},
|
|
9793
|
+
...path === this.options.filePath ? { rawMain: raw } : {}
|
|
9794
|
+
};
|
|
9795
|
+
} catch (error) {
|
|
9796
|
+
if (error.code !== "ENOENT") sawFile = true;
|
|
9797
|
+
}
|
|
9798
|
+
if (sawFile) throw new Error("task queue persistence is unreadable");
|
|
9799
|
+
return { document: {
|
|
9800
|
+
version: QUEUE_VERSION,
|
|
9801
|
+
tasks: []
|
|
9802
|
+
} };
|
|
9803
|
+
}
|
|
9804
|
+
};
|
|
9805
|
+
//#endregion
|
|
9806
|
+
//#region lib/types/routes/task-queue.js
|
|
9807
|
+
/** Loopback-only GET/POST /api/billing/task-queue (issue #106). */
|
|
9808
|
+
const BODY_LIMIT = 262144;
|
|
9809
|
+
const JSON_HEADERS = {
|
|
9810
|
+
"content-type": "application/json; charset=utf-8",
|
|
9811
|
+
"cache-control": "no-store"
|
|
9812
|
+
};
|
|
9813
|
+
function send(res, status, body) {
|
|
9814
|
+
res.writeHead(status, JSON_HEADERS);
|
|
9815
|
+
res.end(JSON.stringify(body));
|
|
9816
|
+
}
|
|
9817
|
+
function registerTaskQueueRoute(ctx, deps) {
|
|
9818
|
+
ctx.effect(() => ctx.webServer.register({
|
|
9819
|
+
kind: "exact",
|
|
9820
|
+
path: "/api/billing/task-queue",
|
|
9821
|
+
handler: async (req, res) => {
|
|
9822
|
+
if (!guardLoopback(req, res, deps.trustedHosts)) return;
|
|
9823
|
+
if (req.method !== "GET" && req.method !== "POST") {
|
|
9824
|
+
send(res, 405, { error: "method not allowed" });
|
|
9825
|
+
return;
|
|
9826
|
+
}
|
|
9827
|
+
if (req.method === "POST") {
|
|
9828
|
+
if (!isLoopbackOrigin(req.headers.origin)) {
|
|
9829
|
+
send(res, 403, { error: "forbidden: loopback only" });
|
|
9830
|
+
return;
|
|
9831
|
+
}
|
|
9832
|
+
if (!(req.headers["content-type"] ?? "").toLowerCase().includes("application/json")) {
|
|
9833
|
+
send(res, 415, { error: "unsupported content-type" });
|
|
9834
|
+
return;
|
|
9835
|
+
}
|
|
9836
|
+
}
|
|
9837
|
+
try {
|
|
9838
|
+
if (req.method === "GET") {
|
|
9839
|
+
send(res, 200, await deps.engine.snapshot());
|
|
9840
|
+
return;
|
|
9841
|
+
}
|
|
9842
|
+
const chunks = [];
|
|
9843
|
+
let bytes = 0;
|
|
9844
|
+
for await (const chunk of req) {
|
|
9845
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
9846
|
+
bytes += buffer.byteLength;
|
|
9847
|
+
if (bytes > BODY_LIMIT) {
|
|
9848
|
+
send(res, 413, { error: "body too large" });
|
|
9849
|
+
return;
|
|
9850
|
+
}
|
|
9851
|
+
chunks.push(buffer);
|
|
9852
|
+
}
|
|
9853
|
+
let input;
|
|
9854
|
+
try {
|
|
9855
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
9856
|
+
const parsed = JSON.parse(body === "" ? "{}" : body);
|
|
9857
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new TaskQueueError("request body must be a JSON object");
|
|
9858
|
+
input = parsed;
|
|
9859
|
+
} catch (error) {
|
|
9860
|
+
if (error instanceof TaskQueueError) throw error;
|
|
9861
|
+
throw new TaskQueueError("malformed JSON body");
|
|
9862
|
+
}
|
|
9863
|
+
switch (input.action) {
|
|
9864
|
+
case "enqueue":
|
|
9865
|
+
await deps.engine.enqueue(input);
|
|
9866
|
+
break;
|
|
9867
|
+
case "remove":
|
|
9868
|
+
if (typeof input.id !== "string" || input.id === "") throw new TaskQueueError("queue item id is required");
|
|
9869
|
+
await deps.engine.remove(input.id);
|
|
9870
|
+
break;
|
|
9871
|
+
case "clearFinished":
|
|
9872
|
+
await deps.engine.clearFinished();
|
|
9873
|
+
break;
|
|
9874
|
+
default: throw new TaskQueueError("unknown queue action");
|
|
9875
|
+
}
|
|
9876
|
+
send(res, 200, {
|
|
9877
|
+
ok: true,
|
|
9878
|
+
snapshot: await deps.engine.snapshot()
|
|
9879
|
+
});
|
|
9880
|
+
} catch (error) {
|
|
9881
|
+
send(res, error instanceof TaskQueueError ? error.status : 503, { error: error instanceof Error ? error.message.slice(0, 300) : "queue operation failed" });
|
|
9882
|
+
}
|
|
9883
|
+
}
|
|
9884
|
+
}), "usage-billing: task queue route");
|
|
9885
|
+
}
|
|
9886
|
+
//#endregion
|
|
9326
9887
|
//#region lib/types/client/usage-billing-settings.js
|
|
9327
9888
|
/** 设置命名空间 id(小写 kebab-case)。 */
|
|
9328
9889
|
const BILLING_SETTINGS_NAMESPACE = "ui-usage-billing";
|
|
@@ -9951,6 +10512,34 @@ function apply(ctx, config = {}) {
|
|
|
9951
10512
|
trustedHosts,
|
|
9952
10513
|
claimsPath: `${snapshotPath}.notify-claims.json`
|
|
9953
10514
|
});
|
|
10515
|
+
const taskQueue = new TaskQueueEngine({
|
|
10516
|
+
filePath: config.taskQueuePath ?? join(dshHome, ".dsh-usage-task-queue.json"),
|
|
10517
|
+
getController: () => {
|
|
10518
|
+
try {
|
|
10519
|
+
return ctx.get("sessionController");
|
|
10520
|
+
} catch {
|
|
10521
|
+
return;
|
|
10522
|
+
}
|
|
10523
|
+
},
|
|
10524
|
+
getWorkspaces: () => {
|
|
10525
|
+
try {
|
|
10526
|
+
return (ctx.get("workspaceRegistry")?.list?.() ?? []).filter((workspace) => typeof workspace.path === "string" && workspace.path !== "").map((workspace) => ({
|
|
10527
|
+
path: workspace.path,
|
|
10528
|
+
title: workspace.title || workspace.path
|
|
10529
|
+
}));
|
|
10530
|
+
} catch {
|
|
10531
|
+
return [];
|
|
10532
|
+
}
|
|
10533
|
+
}
|
|
10534
|
+
});
|
|
10535
|
+
registerTaskQueueRoute(ctx, {
|
|
10536
|
+
trustedHosts,
|
|
10537
|
+
engine: taskQueue
|
|
10538
|
+
});
|
|
10539
|
+
taskQueue.start();
|
|
10540
|
+
ctx.effect(() => () => {
|
|
10541
|
+
taskQueue.stop();
|
|
10542
|
+
}, "usage-billing: task queue scheduler");
|
|
9954
10543
|
let usageToolDisposer;
|
|
9955
10544
|
let usageToolRegister;
|
|
9956
10545
|
const syncUsageTool = () => {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Off-peak task queue panel: explicit enqueue, visible outcomes, safe old-host fallback. */
|
|
2
|
+
import type { BillingLanguage } from './usage-billing-settings.ts';
|
|
3
|
+
import type { UsageBillingKey } from './locales.ts';
|
|
4
|
+
interface TaskQueuePanelProps {
|
|
5
|
+
readonly t: (key: UsageBillingKey, params?: Record<string, unknown>) => string;
|
|
6
|
+
readonly language: BillingLanguage;
|
|
7
|
+
readonly navigateToSession: (sessionId: string) => 'opened' | 'unavailable' | 'failed';
|
|
8
|
+
}
|
|
9
|
+
export declare function TaskQueuePanel({ t, language, navigateToSession }: TaskQueuePanelProps): React.ReactNode;
|
|
10
|
+
export {};
|
|
11
|
+
//# sourceMappingURL=TaskQueuePanel.d.ts.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** API client for the new-host off-peak task queue preview (issue #106). */
|
|
2
|
+
import type { TaskQueueSnapshot } from '../../task-queue-shared.ts';
|
|
3
|
+
export declare function loadTaskQueue(): Promise<TaskQueueSnapshot>;
|
|
4
|
+
export type TaskQueueAction = {
|
|
5
|
+
action: 'enqueue';
|
|
6
|
+
prompt: string;
|
|
7
|
+
cwd: string;
|
|
8
|
+
provider: string;
|
|
9
|
+
model: string;
|
|
10
|
+
} | {
|
|
11
|
+
action: 'remove';
|
|
12
|
+
id: string;
|
|
13
|
+
} | {
|
|
14
|
+
action: 'clearFinished';
|
|
15
|
+
};
|
|
16
|
+
export declare function mutateTaskQueue(action: TaskQueueAction): Promise<TaskQueueSnapshot>;
|
|
17
|
+
//# sourceMappingURL=task-queue.d.ts.map
|
|
@@ -27,12 +27,12 @@ export interface ModelHealth {
|
|
|
27
27
|
/** Idle health state before the probe settles. */
|
|
28
28
|
export declare const IDLE_HEALTH: ModelHealth;
|
|
29
29
|
/** 仪表盘分区 Tab id。 */
|
|
30
|
-
export type DashboardTab = 'overview' | 'context' | 'agents' | 'token' | 'trends' | 'providers' | 'pricing' | 'settings';
|
|
30
|
+
export type DashboardTab = 'overview' | 'queue' | 'context' | 'agents' | 'token' | 'trends' | 'providers' | 'pricing' | 'settings';
|
|
31
31
|
import type { UsageBillingKey } from '../locales.ts';
|
|
32
32
|
/**
|
|
33
|
-
* Tab 定义(顺序即渲染顺序):概览=主数字/KPI
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* Tab 定义(顺序即渲染顺序):概览=主数字/KPI/热力图,队列=低谷任务(新宿主预览),
|
|
34
|
+
* 账单=厂商计费与订阅,用量=Token 用量,趋势=趋势图/每轮费用,费率=模型单价表,
|
|
35
|
+
* 设置=预算与峰谷提醒。导出供测试断言 tab 与文案 key 对齐。
|
|
36
36
|
*/
|
|
37
37
|
export declare const DASHBOARD_TABS: readonly {
|
|
38
38
|
id: DashboardTab;
|