dsh-token-total 1.1.5 → 1.1.6
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.md +3 -1
- package/lib/client.js +46 -18
- package/lib/index.js +114 -21
- package/lib/types/client/api.d.ts +1 -1
- package/lib/types/session-tree.d.ts +6 -1
- package/lib/types/session-usage.d.ts +20 -6
- package/package.json +1 -1
- package/src/client/api.ts +2 -2
- package/src/client/use-session-usage.ts +44 -12
- package/src/index.ts +15 -12
- package/src/session-tree.ts +13 -0
- package/src/session-usage.ts +124 -16
package/README.md
CHANGED
|
@@ -88,7 +88,9 @@ dsh plugin --profile <profile> add "file:D:\work\dsh-token-total"
|
|
|
88
88
|
- **事件来源**:`sessionPersistence` 的会话日志。兼容 `assistant/message` 的顶层 `usage`,也读取消息及失败 `assistant/attempt` 的原始流式用量;同一次请求以最后一份用量为准,修正值按差额更新。插件订阅 `session/event` 实时增量累加,同时一次性回扫已有历史会话;`llm/retry-started` 会清空当前 `(turn, step)` 槽位,因此失败尝试和后续重试分别计入,不重复累加同一次请求的多份报告。
|
|
89
89
|
- **本地持久化**:聚合结果写入 storage-domain 的 `token_total_history` 域(`$DSH_HOME/storages/` 下),纯本地、无外部依赖。回扫游标按会话记录已消费的事件序号,重启后从断点继续。
|
|
90
90
|
- **上下文与花费估算**:两张卡都从该会话自身的日志重放「下一次请求会带上什么」——被 `compaction/prune` / `compaction/summary` 遮蔽的事件剔除、压缩摘要按注入记忆回放、工具目录取最近一次 `request/header`——再按字符类估算 token(ASCII 约 4 字符 / token,CJK 约 1 token / 字符),所以分桶上的数字都是近似值。左卡的真实占用取最近一次回答上报的 `totalTokens − outputTokens`;仅当两者相差超过 15%(说明上游有压缩或裁剪,日志里的历史比模型实际收到的多)时,才多出一行说明,并把「已用上下文」改用供应商实测值。右卡的分桶是「每次请求重放内容之和」,圆环中心的总量与缓存命中率直接取自 `usage` 记录,保持精确。
|
|
91
|
-
-
|
|
91
|
+
- **单会话统计**:不走聚合存储(那里没有会话维度),而是按需重放该会话及全部子孙会话的自有日志;已加载会话优先使用实时事件快照,避免持久化缓冲造成漏算。日志结果返回后以主会话与全部子树的自有用量为准,压缩调用已包含其中,胶囊与弹窗共用同一口径;尚未获得日志结果时回退到原生主会话投影。
|
|
92
|
+
- **会话统计缓存**:完整结果使用最多 64 项的 LRU 缓存,60 秒到期且命中不续期。新事件只失效发生变化的会话及相关祖先,支持尚未出现在持久化目录中的子/孙会话;父链信息不足时保守失效,避免漏算。没有事件订阅接口的旧宿主仍每 4 秒重新校验。同一会话树、同一数据源的普通并发请求共用一次计算;显式取消信号保持独立。失败、取消或不完整的计算均不缓存,后续请求可以重试缺失的日志。
|
|
93
|
+
- **会话统计刷新**:前台首次读取,并在每次请求完成后等待 4 秒再轮询;会话签名变化时即时刷新,慢请求期间的变化合并为一次追刷。页面隐藏时暂停后续轮询,恢复可见时立即刷新;切换或卸载会取消浏览器请求,并丢弃迟到的旧响应。相同结果不重复写入 React 状态。根日志或目录读取失败保留上次读数;部分子树不可读时显示可读用量与不完整标记,不把漏读当作完整统计。浏览器取消不等于取消宿主端已经开始的统计计算。
|
|
92
94
|
- **缓存命中率** = 缓存命中 token ÷ 输入 token 总量(命中 + 未命中)。
|
|
93
95
|
- **失败数量**:以宿主记录的 `assistant/attempt` 事件为准。宿主只有在流以错误结束、或调用在提交任何内容之前被取消时才写 `assistant/attempt`,其余调用一律写 `assistant/message`,两者恰好划分全部调用,所以该事件就是「一次失败的 provider 调用」的精确边界(`interrupted: true` 但已有内容的 `assistant/message` 不计为失败)。失败数是请求数的子集,不额外计入请求;失败调用若在流中断前已上报用量,token 仍按用量计入、不会丢失。该计数晚于日 / 小时两份游标引入,因此老版本升级后会额外做一次只写失败数的增量回扫(`failuresReady` 标记完成,不重复累加 token)。
|
|
94
96
|
|
package/lib/client.js
CHANGED
|
@@ -29423,8 +29423,8 @@ window.__ModuleLoader__.load({
|
|
|
29423
29423
|
* every subagent session delegated from it. The native `tokenUsage` projection
|
|
29424
29424
|
* covers only the main agent, so this is what makes a delegating session's
|
|
29425
29425
|
* cost visible. */
|
|
29426
|
-
async function fetchSessionUsage(sessionId) {
|
|
29427
|
-
return post("session", { sessionId });
|
|
29426
|
+
async function fetchSessionUsage(sessionId, signal) {
|
|
29427
|
+
return post("session", { sessionId }, signal);
|
|
29428
29428
|
}
|
|
29429
29429
|
/** Log-owned requests and live route states for the per-session Usage view. */
|
|
29430
29430
|
async function fetchSessionDetail(sessionId, signal) {
|
|
@@ -31550,8 +31550,9 @@ window.__ModuleLoader__.load({
|
|
|
31550
31550
|
//#region src/client/use-session-usage.ts
|
|
31551
31551
|
/** The composer row's window onto the whole session tree. The native usage
|
|
31552
31552
|
* projection only moves for main-agent calls, so poll the displayed session
|
|
31553
|
-
* even before its first child appears.
|
|
31554
|
-
*
|
|
31553
|
+
* even before its first child appears. Hidden pages pause; slow responses do
|
|
31554
|
+
* not queue timer retries. Disposed requests are aborted and late answers are
|
|
31555
|
+
* discarded. Failures keep the last good reading. */
|
|
31555
31556
|
const SESSION_POLL_MS = 4e3;
|
|
31556
31557
|
function useSessionUsage(sessionId, signature) {
|
|
31557
31558
|
const [reading, setReading] = (0, react.useState)(null);
|
|
@@ -31559,34 +31560,61 @@ window.__ModuleLoader__.load({
|
|
|
31559
31560
|
(0, react.useEffect)(() => {
|
|
31560
31561
|
if (typeof sessionId !== "string" || sessionId === "") return;
|
|
31561
31562
|
let active = true;
|
|
31562
|
-
let pending = false;
|
|
31563
31563
|
let again = false;
|
|
31564
|
+
let lastResult;
|
|
31565
|
+
let timer;
|
|
31566
|
+
let controller;
|
|
31567
|
+
const visible = () => typeof document === "undefined" || document.visibilityState !== "hidden";
|
|
31568
|
+
const clearTimer = () => {
|
|
31569
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
31570
|
+
timer = void 0;
|
|
31571
|
+
};
|
|
31564
31572
|
const request = () => {
|
|
31565
|
-
if (!active) return;
|
|
31566
|
-
if (
|
|
31573
|
+
if (!active || !visible()) return;
|
|
31574
|
+
if (controller !== void 0) {
|
|
31567
31575
|
again = true;
|
|
31568
31576
|
return;
|
|
31569
31577
|
}
|
|
31570
|
-
|
|
31571
|
-
|
|
31572
|
-
|
|
31573
|
-
|
|
31574
|
-
|
|
31575
|
-
|
|
31578
|
+
clearTimer();
|
|
31579
|
+
const current = new AbortController();
|
|
31580
|
+
controller = current;
|
|
31581
|
+
fetchSessionUsage(sessionId, current.signal).then((tree) => {
|
|
31582
|
+
if (!active || controller !== current || current.signal.aborted) return;
|
|
31583
|
+
const result = JSON.stringify(tree);
|
|
31584
|
+
if (result !== lastResult) {
|
|
31585
|
+
lastResult = result;
|
|
31586
|
+
setReading({
|
|
31587
|
+
sessionId,
|
|
31588
|
+
tree
|
|
31589
|
+
});
|
|
31590
|
+
}
|
|
31576
31591
|
}).catch(() => {}).finally(() => {
|
|
31577
|
-
|
|
31578
|
-
|
|
31592
|
+
if (controller !== current) return;
|
|
31593
|
+
controller = void 0;
|
|
31594
|
+
if (!active || !visible()) return;
|
|
31595
|
+
if (again) {
|
|
31579
31596
|
again = false;
|
|
31580
31597
|
request();
|
|
31581
|
-
}
|
|
31598
|
+
} else timer = setTimeout(request, SESSION_POLL_MS);
|
|
31582
31599
|
});
|
|
31583
31600
|
};
|
|
31601
|
+
const onVisibility = () => {
|
|
31602
|
+
clearTimer();
|
|
31603
|
+
if (!visible()) {
|
|
31604
|
+
again = false;
|
|
31605
|
+
controller?.abort();
|
|
31606
|
+
controller = void 0;
|
|
31607
|
+
} else request();
|
|
31608
|
+
};
|
|
31584
31609
|
refresh.current = request;
|
|
31585
|
-
|
|
31610
|
+
if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisibility);
|
|
31586
31611
|
return () => {
|
|
31587
31612
|
active = false;
|
|
31588
31613
|
refresh.current = null;
|
|
31589
|
-
|
|
31614
|
+
clearTimer();
|
|
31615
|
+
controller?.abort();
|
|
31616
|
+
controller = void 0;
|
|
31617
|
+
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisibility);
|
|
31590
31618
|
};
|
|
31591
31619
|
}, [sessionId]);
|
|
31592
31620
|
(0, react.useEffect)(() => {
|
package/lib/index.js
CHANGED
|
@@ -1593,7 +1593,7 @@ async function readSession(persistence, id, header, live, signal) {
|
|
|
1593
1593
|
}
|
|
1594
1594
|
throw new Error("no readable persistence seam (need open or inspect)");
|
|
1595
1595
|
}
|
|
1596
|
-
async function readUsageTree(persistence, sessions, rootId, signal) {
|
|
1596
|
+
async function readUsageTree(persistence, sessions, rootId, signal, onCatalog) {
|
|
1597
1597
|
signal?.throwIfAborted();
|
|
1598
1598
|
const rows = await persistence.list(typeof persistence.open === "function" ? signal === void 0 ? void 0 : { signal } : signal);
|
|
1599
1599
|
const headers = /* @__PURE__ */ new Map();
|
|
@@ -1603,11 +1603,14 @@ async function readUsageTree(persistence, sessions, rootId, signal) {
|
|
|
1603
1603
|
}
|
|
1604
1604
|
const live = new Map((sessions?.list() ?? []).map((handle) => [handle.id, handle]));
|
|
1605
1605
|
const children = /* @__PURE__ */ new Map();
|
|
1606
|
+
const parents = /* @__PURE__ */ new Map();
|
|
1607
|
+
for (const id of [...headers.keys(), ...live.keys()]) parents.set(id, /* @__PURE__ */ new Set());
|
|
1606
1608
|
const link = (id, parent) => {
|
|
1607
1609
|
if (typeof parent !== "string" || parent === "" || parent === id) return;
|
|
1608
1610
|
const bucket = children.get(parent) ?? /* @__PURE__ */ new Set();
|
|
1609
1611
|
bucket.add(id);
|
|
1610
1612
|
children.set(parent, bucket);
|
|
1613
|
+
parents.get(id)?.add(parent);
|
|
1611
1614
|
};
|
|
1612
1615
|
for (const header of headers.values()) link(header.id, header.parentSession);
|
|
1613
1616
|
for (const handle of live.values()) link(handle.id, handle.header?.parentSession ?? handle.parentSession);
|
|
@@ -1620,6 +1623,10 @@ async function readUsageTree(persistence, sessions, rootId, signal) {
|
|
|
1620
1623
|
ids.push(id);
|
|
1621
1624
|
queue.push(id);
|
|
1622
1625
|
}
|
|
1626
|
+
onCatalog?.({
|
|
1627
|
+
parents,
|
|
1628
|
+
members: seen
|
|
1629
|
+
});
|
|
1623
1630
|
const root = await readSession(persistence, rootId, headers.get(rootId), live.get(rootId), signal);
|
|
1624
1631
|
const logs = new Array(ids.length);
|
|
1625
1632
|
const issues = new Array(ids.length);
|
|
@@ -2937,25 +2944,99 @@ function sumOneSession(log) {
|
|
|
2937
2944
|
compaction
|
|
2938
2945
|
};
|
|
2939
2946
|
}
|
|
2940
|
-
const CACHE_TTL_MS =
|
|
2947
|
+
const CACHE_TTL_MS = 6e4;
|
|
2941
2948
|
const CACHE_MAX = 64;
|
|
2942
2949
|
var SessionUsageService = class {
|
|
2950
|
+
cacheTtlMs;
|
|
2943
2951
|
cache = /* @__PURE__ */ new Map();
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
/**
|
|
2951
|
-
*
|
|
2952
|
-
|
|
2952
|
+
parents = /* @__PURE__ */ new Map();
|
|
2953
|
+
catalogVersion = 0;
|
|
2954
|
+
catalogOrder = 0;
|
|
2955
|
+
appliedCatalogOrder = 0;
|
|
2956
|
+
reads = /* @__PURE__ */ new Set();
|
|
2957
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
2958
|
+
/** Events keep live trees fresh; the TTL bounds out-of-band catalog changes.
|
|
2959
|
+
* Hosts without a live event hook can select the old four-second fallback. */
|
|
2960
|
+
constructor(cacheTtlMs = CACHE_TTL_MS) {
|
|
2961
|
+
this.cacheTtlMs = cacheTtlMs;
|
|
2962
|
+
}
|
|
2963
|
+
/** Invalidate the changed session and every old/new ancestor, not other trees.
|
|
2964
|
+
* null is a known root; undefined means parent metadata was not supplied.
|
|
2965
|
+
* An unknown parent chain conservatively falls back instead of missing a new
|
|
2966
|
+
* child that appeared before the persistence catalog caught up. */
|
|
2967
|
+
invalidate(sessionId, parentSession) {
|
|
2968
|
+
this.catalogVersion++;
|
|
2969
|
+
const previous = this.parents.get(sessionId);
|
|
2970
|
+
const queue = [...previous ?? []];
|
|
2971
|
+
let uncertain = previous === void 0 && parentSession === void 0;
|
|
2972
|
+
if (parentSession !== void 0) {
|
|
2973
|
+
const current = /* @__PURE__ */ new Set();
|
|
2974
|
+
if (parentSession !== null && parentSession !== "" && parentSession !== sessionId) current.add(parentSession);
|
|
2975
|
+
if (previous !== void 0) this.parents.set(sessionId, current);
|
|
2976
|
+
queue.push(...current);
|
|
2977
|
+
}
|
|
2978
|
+
const affected = /* @__PURE__ */ new Set([sessionId]);
|
|
2979
|
+
for (let index = 0; index < queue.length; index++) {
|
|
2980
|
+
const id = queue[index];
|
|
2981
|
+
if (affected.has(id)) continue;
|
|
2982
|
+
affected.add(id);
|
|
2983
|
+
const ancestors = this.parents.get(id);
|
|
2984
|
+
if (ancestors === void 0) uncertain = true;
|
|
2985
|
+
else queue.push(...ancestors);
|
|
2986
|
+
}
|
|
2987
|
+
const touches = (read) => {
|
|
2988
|
+
if (affected.has(read.rootId)) return true;
|
|
2989
|
+
for (const id of affected) if (read.members.has(id)) return true;
|
|
2990
|
+
return false;
|
|
2991
|
+
};
|
|
2992
|
+
for (const [rootId, entry] of this.cache) if (uncertain || touches(entry.read)) this.cache.delete(rootId);
|
|
2993
|
+
for (const read of this.reads) if (!read.catalogReady || uncertain || touches(read)) read.invalidated = true;
|
|
2994
|
+
}
|
|
2995
|
+
/** A bad descendant leaves readable totals available, but is never cached.
|
|
2996
|
+
* Signal-less browser requests share one cold read per root. Explicit signals
|
|
2997
|
+
* retain independent ownership and are passed through unchanged: aborting one
|
|
2998
|
+
* caller cannot cancel another caller's readers or publish its partial totals. */
|
|
2953
2999
|
async tree(persistence, sessions, rootId, signal) {
|
|
2954
3000
|
signal?.throwIfAborted();
|
|
2955
3001
|
const hit = this.cache.get(rootId);
|
|
2956
|
-
if (hit !== void 0 && Date.now() - hit.at <
|
|
2957
|
-
|
|
2958
|
-
|
|
3002
|
+
if (hit !== void 0 && hit.read.persistence === persistence && hit.read.sessions === sessions && Date.now() - hit.at < this.cacheTtlMs) {
|
|
3003
|
+
this.cache.delete(rootId);
|
|
3004
|
+
this.cache.set(rootId, hit);
|
|
3005
|
+
return hit.value;
|
|
3006
|
+
}
|
|
3007
|
+
this.cache.delete(rootId);
|
|
3008
|
+
const pending = this.inFlight.get(rootId);
|
|
3009
|
+
if (signal === void 0 && pending !== void 0 && !pending.read.invalidated && pending.read.persistence === persistence && pending.read.sessions === sessions) return pending.promise;
|
|
3010
|
+
const read = {
|
|
3011
|
+
rootId,
|
|
3012
|
+
persistence,
|
|
3013
|
+
sessions,
|
|
3014
|
+
members: /* @__PURE__ */ new Set([rootId]),
|
|
3015
|
+
invalidated: false,
|
|
3016
|
+
catalogReady: false,
|
|
3017
|
+
catalogVersion: this.catalogVersion,
|
|
3018
|
+
catalogOrder: ++this.catalogOrder
|
|
3019
|
+
};
|
|
3020
|
+
this.reads.add(read);
|
|
3021
|
+
const promise = this.compute(read, signal).finally(() => {
|
|
3022
|
+
this.reads.delete(read);
|
|
3023
|
+
if (this.inFlight.get(rootId)?.read === read) this.inFlight.delete(rootId);
|
|
3024
|
+
});
|
|
3025
|
+
if (signal === void 0) this.inFlight.set(rootId, {
|
|
3026
|
+
read,
|
|
3027
|
+
promise
|
|
3028
|
+
});
|
|
3029
|
+
return promise;
|
|
3030
|
+
}
|
|
3031
|
+
async compute(read, signal) {
|
|
3032
|
+
const { root, descendants, coverage } = await readUsageTree(read.persistence, read.sessions, read.rootId, signal, (catalog) => {
|
|
3033
|
+
read.members = catalog.members;
|
|
3034
|
+
read.catalogReady = true;
|
|
3035
|
+
if (read.catalogVersion === this.catalogVersion && read.catalogOrder > this.appliedCatalogOrder) {
|
|
3036
|
+
this.parents = new Map(catalog.parents);
|
|
3037
|
+
this.appliedCatalogOrder = read.catalogOrder;
|
|
3038
|
+
}
|
|
3039
|
+
});
|
|
2959
3040
|
const own = sumOneSession(root);
|
|
2960
3041
|
const subagents = zero();
|
|
2961
3042
|
for (const log of descendants) add(subagents, sumOneSession(log).totals);
|
|
@@ -2970,11 +3051,18 @@ var SessionUsageService = class {
|
|
|
2970
3051
|
coverage
|
|
2971
3052
|
};
|
|
2972
3053
|
signal?.throwIfAborted();
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
this.
|
|
3054
|
+
const newer = this.cache.get(read.rootId);
|
|
3055
|
+
if (coverage.complete && !read.invalidated && (newer === void 0 || newer.read.catalogOrder < read.catalogOrder)) {
|
|
3056
|
+
for (const older of this.reads) if (older.rootId === read.rootId && older.persistence === read.persistence && older.sessions === read.sessions && older.catalogOrder < read.catalogOrder) older.invalidated = true;
|
|
3057
|
+
this.cache.delete(read.rootId);
|
|
3058
|
+
if (this.cache.size >= CACHE_MAX) {
|
|
3059
|
+
const oldest = this.cache.keys().next().value;
|
|
3060
|
+
if (oldest !== void 0) this.cache.delete(oldest);
|
|
3061
|
+
}
|
|
3062
|
+
this.cache.set(read.rootId, {
|
|
2976
3063
|
at: Date.now(),
|
|
2977
|
-
value
|
|
3064
|
+
value,
|
|
3065
|
+
read
|
|
2978
3066
|
});
|
|
2979
3067
|
}
|
|
2980
3068
|
return value;
|
|
@@ -3026,7 +3114,8 @@ function apply(ctx, config = {}) {
|
|
|
3026
3114
|
source: config.source,
|
|
3027
3115
|
backfillConcurrency: config.backfillConcurrency
|
|
3028
3116
|
});
|
|
3029
|
-
const
|
|
3117
|
+
const sessionEvents = ctx;
|
|
3118
|
+
const sessionUsage = new SessionUsageService(typeof sessionEvents.on === "function" ? void 0 : 4e3);
|
|
3030
3119
|
const trust = () => {
|
|
3031
3120
|
const runtime = ctx.get("webRuntime");
|
|
3032
3121
|
const startup = ctx.get("webStartup");
|
|
@@ -3128,9 +3217,13 @@ function apply(ctx, config = {}) {
|
|
|
3128
3217
|
collector.start();
|
|
3129
3218
|
const onEvent = (session) => {
|
|
3130
3219
|
const id = typeof session?.id === "string" ? session.id : "";
|
|
3131
|
-
if (id
|
|
3220
|
+
if (id === "") return;
|
|
3221
|
+
const owner = ctx.sessions.get?.(id) ?? session;
|
|
3222
|
+
const parent = owner.header?.parentSession ?? owner.parentSession;
|
|
3223
|
+
const knownParent = owner.header !== void 0 || "parentSession" in owner;
|
|
3224
|
+
sessionUsage.invalidate(id, knownParent ? parent ?? null : void 0);
|
|
3132
3225
|
};
|
|
3133
|
-
|
|
3226
|
+
sessionEvents.on?.("session/event", onEvent);
|
|
3134
3227
|
(async () => {
|
|
3135
3228
|
await rescan();
|
|
3136
3229
|
await ensureHourHistory();
|
|
@@ -28,6 +28,6 @@ export declare function rebuildStats(): Promise<BackfillStatus>;
|
|
|
28
28
|
* every subagent session delegated from it. The native `tokenUsage` projection
|
|
29
29
|
* covers only the main agent, so this is what makes a delegating session's
|
|
30
30
|
* cost visible. */
|
|
31
|
-
export declare function fetchSessionUsage(sessionId: string): Promise<SessionUsageTree>;
|
|
31
|
+
export declare function fetchSessionUsage(sessionId: string, signal?: AbortSignal): Promise<SessionUsageTree>;
|
|
32
32
|
/** Log-owned requests and live route states for the per-session Usage view. */
|
|
33
33
|
export declare function fetchSessionDetail(sessionId: string, signal?: AbortSignal): Promise<SessionUsageDetail>;
|
|
@@ -9,7 +9,12 @@ export interface UsageSessionLog {
|
|
|
9
9
|
events: readonly UsageSessionEvent[];
|
|
10
10
|
inheritedCut: number;
|
|
11
11
|
}
|
|
12
|
-
|
|
12
|
+
/** Metadata only: includes unreadable descendants and both persisted/live links. */
|
|
13
|
+
export interface UsageTreeCatalog {
|
|
14
|
+
parents: ReadonlyMap<string, ReadonlySet<string>>;
|
|
15
|
+
members: ReadonlySet<string>;
|
|
16
|
+
}
|
|
17
|
+
export declare function readUsageTree(persistence: UsageSessionPersistence, sessions: UsageSessionStore | undefined, rootId: string, signal?: AbortSignal, onCatalog?: (catalog: UsageTreeCatalog) => void): Promise<{
|
|
13
18
|
root: UsageSessionLog;
|
|
14
19
|
descendants: UsageSessionLog[];
|
|
15
20
|
coverage: SessionUsageCoverage;
|
|
@@ -3,12 +3,26 @@ import type { UsageSessionPersistence, UsageSessionStore } from './context-types
|
|
|
3
3
|
import type { SessionUsageTree } from './wire.ts';
|
|
4
4
|
export type { SessionUsageTotals, SessionUsageTree } from './wire.ts';
|
|
5
5
|
export declare class SessionUsageService {
|
|
6
|
+
private readonly cacheTtlMs;
|
|
6
7
|
private cache;
|
|
7
|
-
private
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
private parents;
|
|
9
|
+
private catalogVersion;
|
|
10
|
+
private catalogOrder;
|
|
11
|
+
private appliedCatalogOrder;
|
|
12
|
+
private reads;
|
|
13
|
+
private inFlight;
|
|
14
|
+
/** Events keep live trees fresh; the TTL bounds out-of-band catalog changes.
|
|
15
|
+
* Hosts without a live event hook can select the old four-second fallback. */
|
|
16
|
+
constructor(cacheTtlMs?: number);
|
|
17
|
+
/** Invalidate the changed session and every old/new ancestor, not other trees.
|
|
18
|
+
* null is a known root; undefined means parent metadata was not supplied.
|
|
19
|
+
* An unknown parent chain conservatively falls back instead of missing a new
|
|
20
|
+
* child that appeared before the persistence catalog caught up. */
|
|
21
|
+
invalidate(sessionId: string, parentSession?: string | null): void;
|
|
22
|
+
/** A bad descendant leaves readable totals available, but is never cached.
|
|
23
|
+
* Signal-less browser requests share one cold read per root. Explicit signals
|
|
24
|
+
* retain independent ownership and are passed through unchanged: aborting one
|
|
25
|
+
* caller cannot cancel another caller's readers or publish its partial totals. */
|
|
13
26
|
tree(persistence: UsageSessionPersistence, sessions: UsageSessionStore | undefined, rootId: string, signal?: AbortSignal): Promise<SessionUsageTree>;
|
|
27
|
+
private compute;
|
|
14
28
|
}
|
package/package.json
CHANGED
package/src/client/api.ts
CHANGED
|
@@ -71,8 +71,8 @@ export async function rebuildStats(): Promise<BackfillStatus> {
|
|
|
71
71
|
* every subagent session delegated from it. The native `tokenUsage` projection
|
|
72
72
|
* covers only the main agent, so this is what makes a delegating session's
|
|
73
73
|
* cost visible. */
|
|
74
|
-
export async function fetchSessionUsage(sessionId: string): Promise<SessionUsageTree> {
|
|
75
|
-
return post<SessionUsageTree>('session', { sessionId })
|
|
74
|
+
export async function fetchSessionUsage(sessionId: string, signal?: AbortSignal): Promise<SessionUsageTree> {
|
|
75
|
+
return post<SessionUsageTree>('session', { sessionId }, signal)
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
/** Log-owned requests and live route states for the per-session Usage view. */
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/** The composer row's window onto the whole session tree. The native usage
|
|
2
2
|
* projection only moves for main-agent calls, so poll the displayed session
|
|
3
|
-
* even before its first child appears.
|
|
4
|
-
*
|
|
3
|
+
* even before its first child appears. Hidden pages pause; slow responses do
|
|
4
|
+
* not queue timer retries. Disposed requests are aborted and late answers are
|
|
5
|
+
* discarded. Failures keep the last good reading. */
|
|
5
6
|
import { useEffect, useRef, useState } from 'react'
|
|
6
7
|
import { fetchSessionUsage } from './api.ts'
|
|
7
8
|
import { combinedTotals } from './session-usage-core.ts'
|
|
@@ -23,33 +24,64 @@ export function useSessionUsage(sessionId: string | undefined, signature: string
|
|
|
23
24
|
useEffect(() => {
|
|
24
25
|
if (typeof sessionId !== 'string' || sessionId === '') return
|
|
25
26
|
let active = true
|
|
26
|
-
let pending = false
|
|
27
27
|
let again = false
|
|
28
|
+
let lastResult: string | undefined
|
|
29
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
30
|
+
let controller: AbortController | undefined
|
|
31
|
+
const visible = (): boolean => typeof document === 'undefined' || document.visibilityState !== 'hidden'
|
|
32
|
+
const clearTimer = (): void => {
|
|
33
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
34
|
+
timer = undefined
|
|
35
|
+
}
|
|
28
36
|
const request = (): void => {
|
|
29
|
-
if (!active) return
|
|
30
|
-
if (
|
|
37
|
+
if (!active || !visible()) return
|
|
38
|
+
if (controller !== undefined) {
|
|
31
39
|
again = true
|
|
32
40
|
return
|
|
33
41
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
42
|
+
clearTimer()
|
|
43
|
+
const current = new AbortController()
|
|
44
|
+
controller = current
|
|
45
|
+
void fetchSessionUsage(sessionId, current.signal).then((tree) => {
|
|
46
|
+
if (!active || controller !== current || current.signal.aborted) return
|
|
47
|
+
const result = JSON.stringify(tree)
|
|
48
|
+
if (result !== lastResult) {
|
|
49
|
+
lastResult = result
|
|
50
|
+
setReading({ sessionId, tree })
|
|
51
|
+
}
|
|
37
52
|
}).catch(() => {
|
|
38
53
|
// Retry on the next beat/signature; do not replace good data with zero.
|
|
39
54
|
}).finally(() => {
|
|
40
|
-
|
|
41
|
-
|
|
55
|
+
if (controller !== current) return
|
|
56
|
+
controller = undefined
|
|
57
|
+
if (!active || !visible()) return
|
|
58
|
+
if (again) {
|
|
42
59
|
again = false
|
|
43
60
|
request()
|
|
61
|
+
} else {
|
|
62
|
+
timer = setTimeout(request, SESSION_POLL_MS)
|
|
44
63
|
}
|
|
45
64
|
})
|
|
46
65
|
}
|
|
66
|
+
const onVisibility = (): void => {
|
|
67
|
+
clearTimer()
|
|
68
|
+
if (!visible()) {
|
|
69
|
+
again = false
|
|
70
|
+
controller?.abort()
|
|
71
|
+
controller = undefined
|
|
72
|
+
} else {
|
|
73
|
+
request()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
47
76
|
refresh.current = request
|
|
48
|
-
|
|
77
|
+
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility)
|
|
49
78
|
return () => {
|
|
50
79
|
active = false
|
|
51
80
|
refresh.current = null
|
|
52
|
-
|
|
81
|
+
clearTimer()
|
|
82
|
+
controller?.abort()
|
|
83
|
+
controller = undefined
|
|
84
|
+
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibility)
|
|
53
85
|
}
|
|
54
86
|
}, [sessionId])
|
|
55
87
|
useEffect(() => {
|
package/src/index.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* the browser half over the fenced route.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import type { Context, UsageWebRuntime, UsageWebStartup } from './context-types.ts'
|
|
15
|
+
import type { Context, UsageSessionHandle, UsageWebRuntime, UsageWebStartup } from './context-types.ts'
|
|
16
16
|
import type { TrustInputs } from './trust-fence.ts'
|
|
17
17
|
import { UsageStore } from './store.ts'
|
|
18
18
|
import { UsageCollector } from './collector.ts'
|
|
@@ -72,10 +72,11 @@ export function apply(ctx: Context, config: TokenTotalConfig = {}): void {
|
|
|
72
72
|
source: config.source,
|
|
73
73
|
backfillConcurrency: config.backfillConcurrency,
|
|
74
74
|
})
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
const
|
|
75
|
+
// Committed events invalidate only the touched tree and its ancestors. With
|
|
76
|
+
// that hook, unchanged histories can stay cached longer; older hosts without
|
|
77
|
+
// it retain four-second catalog revalidation rather than stale live totals.
|
|
78
|
+
const sessionEvents = ctx as unknown as { on?: (name: string, fn: (s: Partial<UsageSessionHandle>) => void) => void }
|
|
79
|
+
const sessionUsage = new SessionUsageService(typeof sessionEvents.on === 'function' ? undefined : 4_000)
|
|
79
80
|
|
|
80
81
|
// The /token-total/api trust inputs, resolved live so the fence reads the same sources
|
|
81
82
|
// the /api gateway derives them from. DSH 0.2.1-alpha.2 moved the explicit
|
|
@@ -242,15 +243,17 @@ export function apply(ctx: Context, config: TokenTotalConfig = {}): void {
|
|
|
242
243
|
|
|
243
244
|
ctx.effect(() => {
|
|
244
245
|
collector.start()
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
const onEvent = (session: { id?: unknown }): void => {
|
|
246
|
+
// Include live ancestry: a new child may not be in the persisted catalog
|
|
247
|
+
// yet. Missing metadata takes the service's conservative fallback path.
|
|
248
|
+
const onEvent = (session: Partial<UsageSessionHandle>): void => {
|
|
249
249
|
const id = typeof session?.id === 'string' ? session.id : ''
|
|
250
|
-
if (id
|
|
250
|
+
if (id === '') return
|
|
251
|
+
const owner = ctx.sessions.get?.(id) ?? session
|
|
252
|
+
const parent = owner.header?.parentSession ?? owner.parentSession
|
|
253
|
+
const knownParent = owner.header !== undefined || 'parentSession' in owner
|
|
254
|
+
sessionUsage.invalidate(id, knownParent ? parent ?? null : undefined)
|
|
251
255
|
}
|
|
252
|
-
|
|
253
|
-
.on?.('session/event', onEvent)
|
|
256
|
+
sessionEvents.on?.('session/event', onEvent)
|
|
254
257
|
void (async () => {
|
|
255
258
|
await rescan()
|
|
256
259
|
await ensureHourHistory()
|
package/src/session-tree.ts
CHANGED
|
@@ -18,6 +18,12 @@ export interface UsageSessionLog {
|
|
|
18
18
|
inheritedCut: number
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** Metadata only: includes unreadable descendants and both persisted/live links. */
|
|
22
|
+
export interface UsageTreeCatalog {
|
|
23
|
+
parents: ReadonlyMap<string, ReadonlySet<string>>
|
|
24
|
+
members: ReadonlySet<string>
|
|
25
|
+
}
|
|
26
|
+
|
|
21
27
|
function headerOf(item: UsageSessionHeader | UsageSessionSnapshot): UsageSessionHeader | null {
|
|
22
28
|
if (!item || typeof item !== 'object') return null
|
|
23
29
|
const header = 'header' in item ? item.header : item
|
|
@@ -84,6 +90,7 @@ export async function readUsageTree(
|
|
|
84
90
|
sessions: UsageSessionStore | undefined,
|
|
85
91
|
rootId: string,
|
|
86
92
|
signal?: AbortSignal,
|
|
93
|
+
onCatalog?: (catalog: UsageTreeCatalog) => void,
|
|
87
94
|
): Promise<{ root: UsageSessionLog; descendants: UsageSessionLog[]; coverage: SessionUsageCoverage }> {
|
|
88
95
|
signal?.throwIfAborted()
|
|
89
96
|
const rows = await persistence.list(typeof persistence.open === 'function'
|
|
@@ -96,11 +103,14 @@ export async function readUsageTree(
|
|
|
96
103
|
}
|
|
97
104
|
const live = new Map((sessions?.list() ?? []).map(handle => [handle.id, handle]))
|
|
98
105
|
const children = new Map<string, Set<string>>()
|
|
106
|
+
const parents = new Map<string, Set<string>>()
|
|
107
|
+
for (const id of [...headers.keys(), ...live.keys()]) parents.set(id, new Set())
|
|
99
108
|
const link = (id: string, parent: unknown): void => {
|
|
100
109
|
if (typeof parent !== 'string' || parent === '' || parent === id) return
|
|
101
110
|
const bucket = children.get(parent) ?? new Set<string>()
|
|
102
111
|
bucket.add(id)
|
|
103
112
|
children.set(parent, bucket)
|
|
113
|
+
parents.get(id)?.add(parent)
|
|
104
114
|
}
|
|
105
115
|
for (const header of headers.values()) link(header.id, header.parentSession)
|
|
106
116
|
for (const handle of live.values()) link(handle.id, handle.header?.parentSession ?? handle.parentSession)
|
|
@@ -115,6 +125,9 @@ export async function readUsageTree(
|
|
|
115
125
|
queue.push(id)
|
|
116
126
|
}
|
|
117
127
|
}
|
|
128
|
+
// Publish membership before any asynchronous log read, so events arriving
|
|
129
|
+
// during that read can invalidate the right root, including bad descendants.
|
|
130
|
+
onCatalog?.({ parents, members: seen })
|
|
118
131
|
const root = await readSession(persistence, rootId, headers.get(rootId), live.get(rootId), signal)
|
|
119
132
|
const logs: Array<UsageSessionLog | undefined> = new Array(ids.length)
|
|
120
133
|
const issues: Array<SessionUsageReadIssue | undefined> = new Array(ids.length)
|
package/src/session-usage.ts
CHANGED
|
@@ -34,22 +34,85 @@ function sumOneSession(log: UsageSessionLog): { totals: SessionUsageTotals; comp
|
|
|
34
34
|
return { totals, compaction }
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
const CACHE_TTL_MS =
|
|
37
|
+
const CACHE_TTL_MS = 60_000
|
|
38
38
|
const CACHE_MAX = 64
|
|
39
39
|
|
|
40
|
+
interface TreeRead {
|
|
41
|
+
rootId: string
|
|
42
|
+
persistence: UsageSessionPersistence
|
|
43
|
+
sessions: UsageSessionStore | undefined
|
|
44
|
+
members: ReadonlySet<string>
|
|
45
|
+
invalidated: boolean
|
|
46
|
+
catalogReady: boolean
|
|
47
|
+
catalogVersion: number
|
|
48
|
+
catalogOrder: number
|
|
49
|
+
}
|
|
50
|
+
interface CachedTree {
|
|
51
|
+
at: number
|
|
52
|
+
value: SessionUsageTree
|
|
53
|
+
read: TreeRead
|
|
54
|
+
}
|
|
55
|
+
|
|
40
56
|
export class SessionUsageService {
|
|
41
|
-
private cache = new Map<string,
|
|
42
|
-
private
|
|
57
|
+
private cache = new Map<string, CachedTree>()
|
|
58
|
+
private parents = new Map<string, ReadonlySet<string>>()
|
|
59
|
+
private catalogVersion = 0
|
|
60
|
+
private catalogOrder = 0
|
|
61
|
+
private appliedCatalogOrder = 0
|
|
62
|
+
private reads = new Set<TreeRead>()
|
|
63
|
+
private inFlight = new Map<string, { read: TreeRead; promise: Promise<SessionUsageTree> }>()
|
|
64
|
+
|
|
65
|
+
/** Events keep live trees fresh; the TTL bounds out-of-band catalog changes.
|
|
66
|
+
* Hosts without a live event hook can select the old four-second fallback. */
|
|
67
|
+
constructor(private readonly cacheTtlMs = CACHE_TTL_MS) {}
|
|
43
68
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
69
|
+
/** Invalidate the changed session and every old/new ancestor, not other trees.
|
|
70
|
+
* null is a known root; undefined means parent metadata was not supplied.
|
|
71
|
+
* An unknown parent chain conservatively falls back instead of missing a new
|
|
72
|
+
* child that appeared before the persistence catalog caught up. */
|
|
73
|
+
invalidate(sessionId: string, parentSession?: string | null): void {
|
|
74
|
+
this.catalogVersion++
|
|
75
|
+
const previous = this.parents.get(sessionId)
|
|
76
|
+
const queue = [...(previous ?? [])]
|
|
77
|
+
let uncertain = previous === undefined && parentSession === undefined
|
|
78
|
+
if (parentSession !== undefined) {
|
|
79
|
+
const current = new Set<string>()
|
|
80
|
+
if (parentSession !== null && parentSession !== '' && parentSession !== sessionId) current.add(parentSession)
|
|
81
|
+
// Do not accumulate unbounded event-only ids between catalog reads.
|
|
82
|
+
// Unknown hints still invalidate their ancestors immediately.
|
|
83
|
+
if (previous !== undefined) this.parents.set(sessionId, current)
|
|
84
|
+
queue.push(...current)
|
|
85
|
+
}
|
|
86
|
+
const affected = new Set([sessionId])
|
|
87
|
+
for (let index = 0; index < queue.length; index++) {
|
|
88
|
+
const id = queue[index]!
|
|
89
|
+
if (affected.has(id)) continue
|
|
90
|
+
affected.add(id)
|
|
91
|
+
const ancestors = this.parents.get(id)
|
|
92
|
+
if (ancestors === undefined) uncertain = true
|
|
93
|
+
else queue.push(...ancestors)
|
|
94
|
+
}
|
|
95
|
+
const touches = (read: TreeRead): boolean => {
|
|
96
|
+
if (affected.has(read.rootId)) return true
|
|
97
|
+
// A newer catalog may have moved an ancestor away from this older tree.
|
|
98
|
+
// Changes below that ancestor must still invalidate its old membership.
|
|
99
|
+
for (const id of affected) if (read.members.has(id)) return true
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
for (const [rootId, entry] of this.cache) {
|
|
103
|
+
if (uncertain || touches(entry.read)) this.cache.delete(rootId)
|
|
104
|
+
}
|
|
105
|
+
// Detached older reads remain tracked until they settle. A new request must
|
|
106
|
+
// not join an invalidated read, and that read must never repopulate the cache.
|
|
107
|
+
for (const read of this.reads) {
|
|
108
|
+
if (!read.catalogReady || uncertain || touches(read)) read.invalidated = true
|
|
109
|
+
}
|
|
48
110
|
}
|
|
49
111
|
|
|
50
|
-
/** A bad descendant leaves
|
|
51
|
-
*
|
|
52
|
-
*
|
|
112
|
+
/** A bad descendant leaves readable totals available, but is never cached.
|
|
113
|
+
* Signal-less browser requests share one cold read per root. Explicit signals
|
|
114
|
+
* retain independent ownership and are passed through unchanged: aborting one
|
|
115
|
+
* caller cannot cancel another caller's readers or publish its partial totals. */
|
|
53
116
|
async tree(
|
|
54
117
|
persistence: UsageSessionPersistence,
|
|
55
118
|
sessions: UsageSessionStore | undefined,
|
|
@@ -58,9 +121,43 @@ export class SessionUsageService {
|
|
|
58
121
|
): Promise<SessionUsageTree> {
|
|
59
122
|
signal?.throwIfAborted()
|
|
60
123
|
const hit = this.cache.get(rootId)
|
|
61
|
-
if (hit !== undefined &&
|
|
62
|
-
|
|
63
|
-
|
|
124
|
+
if (hit !== undefined && hit.read.persistence === persistence && hit.read.sessions === sessions
|
|
125
|
+
&& Date.now() - hit.at < this.cacheTtlMs) {
|
|
126
|
+
// Map insertion order is the LRU; a hit must protect a popular history.
|
|
127
|
+
this.cache.delete(rootId)
|
|
128
|
+
this.cache.set(rootId, hit)
|
|
129
|
+
return hit.value
|
|
130
|
+
}
|
|
131
|
+
this.cache.delete(rootId)
|
|
132
|
+
const pending = this.inFlight.get(rootId)
|
|
133
|
+
if (signal === undefined && pending !== undefined && !pending.read.invalidated
|
|
134
|
+
&& pending.read.persistence === persistence && pending.read.sessions === sessions) return pending.promise
|
|
135
|
+
|
|
136
|
+
const read: TreeRead = {
|
|
137
|
+
rootId, persistence, sessions, members: new Set([rootId]), invalidated: false,
|
|
138
|
+
catalogReady: false, catalogVersion: this.catalogVersion, catalogOrder: ++this.catalogOrder,
|
|
139
|
+
}
|
|
140
|
+
this.reads.add(read)
|
|
141
|
+
const promise = this.compute(read, signal).finally(() => {
|
|
142
|
+
this.reads.delete(read)
|
|
143
|
+
if (this.inFlight.get(rootId)?.read === read) this.inFlight.delete(rootId)
|
|
144
|
+
})
|
|
145
|
+
if (signal === undefined) this.inFlight.set(rootId, { read, promise })
|
|
146
|
+
return promise
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private async compute(read: TreeRead, signal?: AbortSignal): Promise<SessionUsageTree> {
|
|
150
|
+
const { root, descendants, coverage } = await readUsageTree(
|
|
151
|
+
read.persistence, read.sessions, read.rootId, signal, (catalog) => {
|
|
152
|
+
read.members = catalog.members
|
|
153
|
+
read.catalogReady = true
|
|
154
|
+
// A slow older listing must not undo newer catalogs or event hints.
|
|
155
|
+
if (read.catalogVersion === this.catalogVersion && read.catalogOrder > this.appliedCatalogOrder) {
|
|
156
|
+
this.parents = new Map(catalog.parents)
|
|
157
|
+
this.appliedCatalogOrder = read.catalogOrder
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
)
|
|
64
161
|
const own = sumOneSession(root)
|
|
65
162
|
const subagents = zero()
|
|
66
163
|
for (const log of descendants) add(subagents, sumOneSession(log).totals)
|
|
@@ -75,9 +172,20 @@ export class SessionUsageService {
|
|
|
75
172
|
coverage,
|
|
76
173
|
}
|
|
77
174
|
signal?.throwIfAborted()
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
175
|
+
const newer = this.cache.get(read.rootId)
|
|
176
|
+
if (coverage.complete && !read.invalidated && (newer === undefined || newer.read.catalogOrder < read.catalogOrder)) {
|
|
177
|
+
// Retire older same-source snapshots before publishing. LRU eviction of
|
|
178
|
+
// this result must not let an older detached read be joined or cached.
|
|
179
|
+
for (const older of this.reads) {
|
|
180
|
+
if (older.rootId === read.rootId && older.persistence === read.persistence
|
|
181
|
+
&& older.sessions === read.sessions && older.catalogOrder < read.catalogOrder) older.invalidated = true
|
|
182
|
+
}
|
|
183
|
+
this.cache.delete(read.rootId)
|
|
184
|
+
if (this.cache.size >= CACHE_MAX) {
|
|
185
|
+
const oldest = this.cache.keys().next().value
|
|
186
|
+
if (oldest !== undefined) this.cache.delete(oldest)
|
|
187
|
+
}
|
|
188
|
+
this.cache.set(read.rootId, { at: Date.now(), value, read })
|
|
81
189
|
}
|
|
82
190
|
return value
|
|
83
191
|
}
|