dsh-token-total 1.1.4 → 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 +4 -2
- package/lib/client.js +46 -18
- package/lib/index.js +137 -28
- 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-detail.ts +28 -10
- package/src/session-tree.ts +13 -0
- package/src/session-usage.ts +124 -16
package/README.md
CHANGED
|
@@ -60,7 +60,7 @@ DSH(DeepSeek Harness)Web 插件:**Token 用量统计看板** + **单会话
|
|
|
60
60
|
- 两张卡都由会话明细轮询驱动(每 1.5 秒一次),不额外起计时器;旧宿主只提供其中一份数据时,另一张卡显示空态而非整组消失,两份都缺时整组不渲染。窄视口下自动上下堆叠。
|
|
61
61
|
|
|
62
62
|
- 浅色页面两行统计卡:第一行请求数量(附失败数)、总 tokens(附精确值)、缓存命中率(附命中 token)、token 速度(附平均首 token);第二行输入、输出、缓存读取、缓存写入四个 token 桶。不显示费用。
|
|
63
|
-
-
|
|
63
|
+
- 子代理明细与请求记录使用每个子代理的**真实名称**:优先子代理自身标签,再取最新非空会话标题;旧 CLI 记录缺少标题时,读取本次会话树中实际父会话的 `subagent/catalog` 任务标签,最后才回退到日志头名称、工作目录或会话 id。只使用各日志的自有事件,不把继承的父标题/目录当作自身名称,不改写历史日志,也不改变 Token 统计。若直接打开旧子会话、实际父会话不在本次读取树中,则保留原有回退名称。
|
|
64
64
|
- 中部为等宽、等高的 **子代理明细** 与 **按模型明细**;窄视口自动堆叠。使用固定列布局而非横向滚动,长名称省略并可悬浮查看全名,token 数同样可悬浮查看精确值。按模型明细的名称使用模型选择器的**显示名称**(路由 id 保留在悬浮提示中,无法解析时回退为 id);当多行显示名称相同(忽略大小写与分隔符)时,在名称下方用灰色小字标明各自供应商以便区分。
|
|
65
65
|
- 请求记录按**轮**折叠(压缩调用归入独立分组),**默认全部折叠**;展开某一轮可查看该轮每一次请求的时间、模型、tokens、命中率、状态与入 / 出 / 读 / 写明细,支持单组展开和全部展开 / 收起。失败尝试、实际重试、压缩调用纳入统计;仅继承的父会话历史不重复计算。
|
|
66
66
|
- 页面每 1.5 秒串行读取当前会话明细,切换或离开 tab 会取消请求和计时器。统计含本会话及所有后代的自有历史日志,缺失的首 token / 速度显示 `—`,读取错误保留上次成功结果。
|
|
@@ -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);
|
|
@@ -2217,8 +2224,11 @@ function nameOf(h, id) {
|
|
|
2217
2224
|
if (typeof x?.cwd === "string" && x.cwd) return x.cwd.split(/[\\/]/).filter(Boolean).pop() || id;
|
|
2218
2225
|
return id;
|
|
2219
2226
|
}
|
|
2220
|
-
function
|
|
2221
|
-
|
|
2227
|
+
function ownEventsOf(log) {
|
|
2228
|
+
return log.events.filter((e) => !(log.inheritedCut > 0 && typeof e.seq === "number" && e.seq < log.inheritedCut));
|
|
2229
|
+
}
|
|
2230
|
+
function one(log, parentCatalogLabel) {
|
|
2231
|
+
const { id, header, live } = log;
|
|
2222
2232
|
const fold = new UsageFold();
|
|
2223
2233
|
const totals = zero$1();
|
|
2224
2234
|
const records = [];
|
|
@@ -2229,15 +2239,14 @@ function one(log) {
|
|
|
2229
2239
|
let effort = "";
|
|
2230
2240
|
let pendingTopic = "";
|
|
2231
2241
|
const turnTopics = /* @__PURE__ */ new Map();
|
|
2232
|
-
const own =
|
|
2242
|
+
const own = ownEventsOf(log);
|
|
2233
2243
|
let label = "", title = "";
|
|
2234
2244
|
for (const ev of own) {
|
|
2235
2245
|
const d = ev.data ?? {};
|
|
2236
2246
|
if (!label && ev.type === "subagent/descriptor" && typeof d.label === "string" && d.label.trim()) label = d.label.trim();
|
|
2237
|
-
if (
|
|
2238
|
-
if (label && title) break;
|
|
2247
|
+
if (ev.type === "session/title" && typeof d.title === "string" && d.title.trim()) title = d.title.trim();
|
|
2239
2248
|
}
|
|
2240
|
-
const name = label || title || nameOf(header, id);
|
|
2249
|
+
const name = label || title || parentCatalogLabel || nameOf(header, id);
|
|
2241
2250
|
const stepTokens = /* @__PURE__ */ new Map();
|
|
2242
2251
|
const closedSteps = /* @__PURE__ */ new Set();
|
|
2243
2252
|
for (const ev of own) {
|
|
@@ -2418,7 +2427,21 @@ function one(log) {
|
|
|
2418
2427
|
}
|
|
2419
2428
|
async function buildSessionUsageDetail(persistence, sessions, rootId, ctx) {
|
|
2420
2429
|
const { root, descendants, coverage } = await readUsageTree(persistence, sessions, rootId);
|
|
2421
|
-
const
|
|
2430
|
+
const logs = [root, ...descendants];
|
|
2431
|
+
const catalogLabels = /* @__PURE__ */ new Map();
|
|
2432
|
+
for (const log of logs) {
|
|
2433
|
+
const labels = /* @__PURE__ */ new Map();
|
|
2434
|
+
for (const event of ownEventsOf(log)) {
|
|
2435
|
+
if (event.type !== "subagent/catalog") continue;
|
|
2436
|
+
const data = event.data;
|
|
2437
|
+
if (typeof data?.childId === "string" && data.childId && typeof data.label === "string" && data.label.trim()) labels.set(data.childId, data.label.trim());
|
|
2438
|
+
}
|
|
2439
|
+
catalogLabels.set(log.id, labels);
|
|
2440
|
+
}
|
|
2441
|
+
const parts = logs.map((log) => {
|
|
2442
|
+
const parentId = log.header?.parentSession ?? log.live?.header?.parentSession ?? log.live?.parentSession;
|
|
2443
|
+
return one(log, parentId === void 0 ? void 0 : catalogLabels.get(parentId)?.get(log.id));
|
|
2444
|
+
});
|
|
2422
2445
|
const totals = zero$1();
|
|
2423
2446
|
const models = /* @__PURE__ */ new Map();
|
|
2424
2447
|
const records = parts.flatMap((x) => x.records);
|
|
@@ -2921,25 +2944,99 @@ function sumOneSession(log) {
|
|
|
2921
2944
|
compaction
|
|
2922
2945
|
};
|
|
2923
2946
|
}
|
|
2924
|
-
const CACHE_TTL_MS =
|
|
2947
|
+
const CACHE_TTL_MS = 6e4;
|
|
2925
2948
|
const CACHE_MAX = 64;
|
|
2926
2949
|
var SessionUsageService = class {
|
|
2950
|
+
cacheTtlMs;
|
|
2927
2951
|
cache = /* @__PURE__ */ new Map();
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
/**
|
|
2935
|
-
*
|
|
2936
|
-
|
|
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. */
|
|
2937
2999
|
async tree(persistence, sessions, rootId, signal) {
|
|
2938
3000
|
signal?.throwIfAborted();
|
|
2939
3001
|
const hit = this.cache.get(rootId);
|
|
2940
|
-
if (hit !== void 0 && Date.now() - hit.at <
|
|
2941
|
-
|
|
2942
|
-
|
|
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
|
+
});
|
|
2943
3040
|
const own = sumOneSession(root);
|
|
2944
3041
|
const subagents = zero();
|
|
2945
3042
|
for (const log of descendants) add(subagents, sumOneSession(log).totals);
|
|
@@ -2954,11 +3051,18 @@ var SessionUsageService = class {
|
|
|
2954
3051
|
coverage
|
|
2955
3052
|
};
|
|
2956
3053
|
signal?.throwIfAborted();
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
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, {
|
|
2960
3063
|
at: Date.now(),
|
|
2961
|
-
value
|
|
3064
|
+
value,
|
|
3065
|
+
read
|
|
2962
3066
|
});
|
|
2963
3067
|
}
|
|
2964
3068
|
return value;
|
|
@@ -3010,7 +3114,8 @@ function apply(ctx, config = {}) {
|
|
|
3010
3114
|
source: config.source,
|
|
3011
3115
|
backfillConcurrency: config.backfillConcurrency
|
|
3012
3116
|
});
|
|
3013
|
-
const
|
|
3117
|
+
const sessionEvents = ctx;
|
|
3118
|
+
const sessionUsage = new SessionUsageService(typeof sessionEvents.on === "function" ? void 0 : 4e3);
|
|
3014
3119
|
const trust = () => {
|
|
3015
3120
|
const runtime = ctx.get("webRuntime");
|
|
3016
3121
|
const startup = ctx.get("webStartup");
|
|
@@ -3112,9 +3217,13 @@ function apply(ctx, config = {}) {
|
|
|
3112
3217
|
collector.start();
|
|
3113
3218
|
const onEvent = (session) => {
|
|
3114
3219
|
const id = typeof session?.id === "string" ? session.id : "";
|
|
3115
|
-
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);
|
|
3116
3225
|
};
|
|
3117
|
-
|
|
3226
|
+
sessionEvents.on?.("session/event", onEvent);
|
|
3118
3227
|
(async () => {
|
|
3119
3228
|
await rescan();
|
|
3120
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-detail.ts
CHANGED
|
@@ -86,8 +86,12 @@ function nameOf(h: UsageSessionHeader | undefined, id: string): string {
|
|
|
86
86
|
return id
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
function
|
|
90
|
-
|
|
89
|
+
function ownEventsOf(log: UsageSessionLog) {
|
|
90
|
+
return log.events.filter(e => !(log.inheritedCut > 0 && typeof e.seq === 'number' && e.seq < log.inheritedCut))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function one(log: UsageSessionLog, parentCatalogLabel?: string) {
|
|
94
|
+
const { id, header, live } = log
|
|
91
95
|
const fold = new UsageFold(); const totals = zero(); const records: MutableRecord[] = []
|
|
92
96
|
const byKey = new Map<string, MutableRecord>(); let route = live?.requestContext?.() ?? {}
|
|
93
97
|
let ordinal = 0
|
|
@@ -96,18 +100,16 @@ function one(log: UsageSessionLog) {
|
|
|
96
100
|
// the prompt usually lands before `turn/start`, so buffer it until the turn opens.
|
|
97
101
|
let currentTurn = 0; let effort = ''; let pendingTopic = ''
|
|
98
102
|
const turnTopics = new Map<number, string>()
|
|
99
|
-
const own =
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
// generated `session/title`; only fall back to the header/cwd/id guesses.
|
|
103
|
+
const own = ownEventsOf(log)
|
|
104
|
+
// Prefer the child's own delegation label or latest title. Older CLI records
|
|
105
|
+
// carry their task name only in the actual parent's subagent/catalog.
|
|
103
106
|
let label = '', title = ''
|
|
104
107
|
for (const ev of own) {
|
|
105
108
|
const d: any = ev.data ?? {}
|
|
106
109
|
if (!label && ev.type === 'subagent/descriptor' && typeof d.label === 'string' && d.label.trim()) label = d.label.trim()
|
|
107
|
-
if (
|
|
108
|
-
if (label && title) break
|
|
110
|
+
if (ev.type === 'session/title' && typeof d.title === 'string' && d.title.trim()) title = d.title.trim()
|
|
109
111
|
}
|
|
110
|
-
const name = label || title || nameOf(header, id)
|
|
112
|
+
const name = label || title || parentCatalogLabel || nameOf(header, id)
|
|
111
113
|
// Decode accounting mirrors the official `sessionStats` projection: a step
|
|
112
114
|
// records its first token once (the earliest attempt wins, surviving an
|
|
113
115
|
// in-step retry), and only the assembled `assistant/message` closes the
|
|
@@ -192,7 +194,23 @@ function one(log: UsageSessionLog) {
|
|
|
192
194
|
|
|
193
195
|
export async function buildSessionUsageDetail(persistence: UsageSessionPersistence, sessions: UsageSessionStore | undefined, rootId: string, ctx?: Context): Promise<SessionUsageDetail> {
|
|
194
196
|
const { root, descendants, coverage } = await readUsageTree(persistence, sessions, rootId)
|
|
195
|
-
const
|
|
197
|
+
const logs = [root, ...descendants]
|
|
198
|
+
const catalogLabels = new Map<string, Map<string, string>>()
|
|
199
|
+
for (const log of logs) {
|
|
200
|
+
const labels = new Map<string, string>()
|
|
201
|
+
for (const event of ownEventsOf(log)) {
|
|
202
|
+
if (event.type !== 'subagent/catalog') continue
|
|
203
|
+
const data = event.data as { childId?: unknown; label?: unknown } | null
|
|
204
|
+
if (typeof data?.childId === 'string' && data.childId && typeof data.label === 'string' && data.label.trim()) {
|
|
205
|
+
labels.set(data.childId, data.label.trim())
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catalogLabels.set(log.id, labels)
|
|
209
|
+
}
|
|
210
|
+
const parts = logs.map(log => {
|
|
211
|
+
const parentId = log.header?.parentSession ?? log.live?.header?.parentSession ?? log.live?.parentSession
|
|
212
|
+
return one(log, parentId === undefined ? undefined : catalogLabels.get(parentId)?.get(log.id))
|
|
213
|
+
})
|
|
196
214
|
const totals = zero(); const models = new Map<string, { totals: SessionUsageTotals; requests: number; providers: Set<string> }>(); const records = parts.flatMap(x => x.records); for (const p of parts) add(totals, p.totals)
|
|
197
215
|
for (const r of records) { const key = r.model || '(unknown)'; const m = models.get(key) ?? { totals: zero(), requests: 0, providers: new Set<string>() }; add(m.totals, r.totals); m.requests++; if (r.provider) m.providers.add(r.provider); models.set(key, m) }
|
|
198
216
|
const subagents = parts.slice(1).map(p => ({ sessionId: p.id, name: p.name, totals: p.totals, requests: p.requests, averageFirstTokenMs: p.averageFirstTokenMs, tokensPerSecond: p.tokensPerSecond }))
|
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
|
}
|