tokentracker-cli 1.4.13 → 1.5.0
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 +2 -2
- package/dashboard/dist/assets/{AchievementBadge-6wnHO-Hp.js → AchievementBadge-Dn39pyx9.js} +1 -1
- package/dashboard/dist/assets/{AchievementsPage-BpHA9auG.js → AchievementsPage-9_MxdI7O.js} +2 -2
- package/dashboard/dist/assets/{AchievementsSection-ySXWiLC3.js → AchievementsSection-CKDQGcJh.js} +1 -1
- package/dashboard/dist/assets/{BadgeDetailModal-DuxcntYu.js → BadgeDetailModal-DdrXPVRL.js} +1 -1
- package/dashboard/dist/assets/{BotAnimated-Dg1gSql-.js → BotAnimated-DsJDdDid.js} +1 -1
- package/dashboard/dist/assets/{Card-73qYdlEv.js → Card-Bz-sWY1C.js} +1 -1
- package/dashboard/dist/assets/{CloudCheckoutPage-C7-9p9zk.js → CloudCheckoutPage-DRjZcE0W.js} +1 -1
- package/dashboard/dist/assets/{CloudPage-CKcDcf6O.js → CloudPage-BNDyyX9r.js} +2 -2
- package/dashboard/dist/assets/{CommandPalette-Cq7TSYUV.js → CommandPalette-LbE6Icc0.js} +1 -1
- package/dashboard/dist/assets/{CommunityStatsModal-C0gj8QaO.js → CommunityStatsModal-DuEcAOB9.js} +1 -1
- package/dashboard/dist/assets/{ConfirmModal-Cuf2JDA9.js → ConfirmModal-B5V7ONKv.js} +1 -1
- package/dashboard/dist/assets/{Controls-COaS9RLV.js → Controls-ZfN0QwwD.js} +1 -1
- package/dashboard/dist/assets/{DashboardPage-Beg--N9T.js → DashboardPage-DqZPFmlv.js} +1 -1
- package/dashboard/dist/assets/{DateRangePopover-rNSRXWkr.js → DateRangePopover-CbkBeaEI.js} +1 -1
- package/dashboard/dist/assets/DevicePage-DrHjmpPH.js +1 -0
- package/dashboard/dist/assets/{DialogClose-CEYsCJ6-.js → DialogClose-DHaAwV1u.js} +1 -1
- package/dashboard/dist/assets/DialogDescription-CEY6jbs9.js +1 -0
- package/dashboard/dist/assets/{DialogTitle-CnaGDa7P.js → DialogTitle-CBhd4oLc.js} +1 -1
- package/dashboard/dist/assets/{ElectricLogo-CM0HfX6y.js → ElectricLogo-CSWd3b2H.js} +1 -1
- package/dashboard/dist/assets/{FadeIn-CngXKUqj.js → FadeIn-D5p6nDla.js} +1 -1
- package/dashboard/dist/assets/{HeaderGithubStar-DQn0rAVr.js → HeaderGithubStar-BKVSzY27.js} +1 -1
- package/dashboard/dist/assets/{HoverTooltip-By7PDA5D.js → HoverTooltip-CvkAyCDm.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-BvrRGJNy.js → IpCheckPage-BEMJGKtl.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-C0CnNfi_.js → LandingPage-iRp33enQ.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardPage-iUXnjGy-.js → LeaderboardPage-DVMPrN4z.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardProBadge-CrV2bYaH.js → LeaderboardProBadge-DwnaJoje.js} +1 -1
- package/dashboard/dist/assets/LeaderboardProfileModal-DnzVweLM.js +73 -0
- package/dashboard/dist/assets/{LeaderboardProfilePage-DmiiHi5U.js → LeaderboardProfilePage-BHNuPuW7.js} +1 -1
- package/dashboard/dist/assets/LimitsPage-9LitacBf.js +6 -0
- package/dashboard/dist/assets/{LocalOnlyNotice-0pQm9-gI.js → LocalOnlyNotice-DStlbPET.js} +1 -1
- package/dashboard/dist/assets/{LoginCard-OaDObE8j.js → LoginCard-I47ukXU5.js} +1 -1
- package/dashboard/dist/assets/{LoginModal-C0dRPusa.js → LoginModal-C4iotNjY.js} +1 -1
- package/dashboard/dist/assets/{LoginPage-D6CoX42w.js → LoginPage-46QSEnG0.js} +1 -1
- package/dashboard/dist/assets/{PetPage-Ch5t4JT_.js → PetPage-SvgDUR8o.js} +2 -2
- package/dashboard/dist/assets/{PopoverPopup-UtHXvmNC.js → PopoverPopup-k2ejLK1I.js} +1 -1
- package/dashboard/dist/assets/{ProviderIcon-CRlMy2AY.js → ProviderIcon-Bx1XKzin.js} +1 -1
- package/dashboard/dist/assets/{ResetPasswordPage-DijAhwkr.js → ResetPasswordPage-CcKIDknp.js} +1 -1
- package/dashboard/dist/assets/{Select-D1FvgdDV.js → Select-D0hLQyU8.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-Conq3RQN.js → SelectItemText-DJGBJhDK.js} +1 -1
- package/dashboard/dist/assets/{SelfHostPage-CLUgPEFh.js → SelfHostPage-CeIFGRep.js} +1 -1
- package/dashboard/dist/assets/{ServiceStatusPage-D7KRjgOE.js → ServiceStatusPage-DMtw0YDh.js} +1 -1
- package/dashboard/dist/assets/{SessionsPage-CWNrbLT4.js → SessionsPage-D6h5vSZn.js} +2 -2
- package/dashboard/dist/assets/{SettingsPage-Bh_PuEXU.js → SettingsPage-BZgpB5SB.js} +3 -3
- package/dashboard/dist/assets/{SkillsPage-DxZuztPd.js → SkillsPage-CtFuZ1ob.js} +1 -1
- package/dashboard/dist/assets/{TokenGalaxy-BE-dqlDy.js → TokenGalaxy-DC-no047.js} +1 -1
- package/dashboard/dist/assets/{TooltipPopup-DQrTDsmN.js → TooltipPopup-ZM0Ie2_C.js} +1 -1
- package/dashboard/dist/assets/{TrendMonitor-Cal2kaMh.js → TrendMonitor-fpLaqJ-o.js} +1 -1
- package/dashboard/dist/assets/{WidgetsPage-Cvv2NXRK.js → WidgetsPage-DIoHY6ZJ.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-H99MjZkq.js → WrappedPage-D1-7o8Z1.js} +1 -1
- package/dashboard/dist/assets/{__vite-browser-external-I1de5Swt.js → __vite-browser-external-DZF5L1pt.js} +3 -3
- package/dashboard/dist/assets/{agent-logos-BUBtw9FF.js → agent-logos-YBC1iAbE.js} +1 -1
- package/dashboard/dist/assets/{arrow-left-C99UO2S4.js → arrow-left-BIe3z7iW.js} +1 -1
- package/dashboard/dist/assets/{arrow-right-BMFV-A6L.js → arrow-right-CJmRcvYZ.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-CReo0rju.js → arrow-up-right-IgkBHoCl.js} +1 -1
- package/dashboard/dist/assets/{calendar-days-CiEobm13.js → calendar-days-DTuIv3ts.js} +1 -1
- package/dashboard/dist/assets/{check-BBOwGLur.js → check-DrXhhj46.js} +1 -1
- package/dashboard/dist/assets/{chevron-down-C08QHnpk.js → chevron-down-DHaFKHMK.js} +1 -1
- package/dashboard/dist/assets/{cloud-checkout-NOmtAy01.js → cloud-checkout-BffVMyzI.js} +1 -1
- package/dashboard/dist/assets/{composite-C5GvW04X.js → composite-M_CP_KSx.js} +1 -1
- package/dashboard/dist/assets/{copy-DgY9F9FR.js → copy-D5xoS-Q8.js} +1 -1
- package/dashboard/dist/assets/{download-B7xuZSZ2.js → download-CqomMv4-.js} +1 -1
- package/dashboard/dist/assets/{external-link-1nRJjSL1.js → external-link--q3pCw6b.js} +1 -1
- package/dashboard/dist/assets/{flame-CR1qiptA.js → flame-CUGlDV__.js} +1 -1
- package/dashboard/dist/assets/{format-tokens-DNLvupK2.js → format-tokens-BZ6TX9Km.js} +1 -1
- package/dashboard/dist/assets/{gift-DUUpsPxb.js → gift-BBgOeC20.js} +1 -1
- package/dashboard/dist/assets/{icons-qY8ZAPUu.js → icons-BELeB4ms.js} +1 -1
- package/dashboard/dist/assets/{index-Tl9T3Nm3.js → index-B1B6oAbl.js} +1 -1
- package/dashboard/dist/assets/{index-BvNIpRP0.js → index-CoRqgJ71.js} +1 -1
- package/dashboard/dist/assets/{index-CfdotR_D.js → index-CsdgnKGf.js} +1 -1
- package/dashboard/dist/assets/{index-7VLgsnkO.js → index-DkBvk4Qg.js} +1 -1
- package/dashboard/dist/assets/{info-DnT3wtaz.js → info-BG1vQZ1r.js} +1 -1
- package/dashboard/dist/assets/{link-2-CjxRD2lW.js → link-2-oi5TrXqi.js} +1 -1
- package/dashboard/dist/assets/{loader-circle-BYCsfrhr.js → loader-circle-BQQ_Xbby.js} +1 -1
- package/dashboard/dist/assets/{local-usage-transfer-DBfMvvqx.js → local-usage-transfer-Dwz-clJ-.js} +1 -1
- package/dashboard/dist/assets/{modifiers.esm-DG_gVjqU.js → modifiers.esm-C7a817l7.js} +1 -1
- package/dashboard/dist/assets/{monitor-smartphone-CJUH6nwc.js → monitor-smartphone-Bnbo7uRg.js} +1 -1
- package/dashboard/dist/assets/{play-DY4d-C2J.js → play-C_xI_JM_.js} +1 -1
- package/dashboard/dist/assets/{plus-Dydx6cB7.js → plus-xF6PK5As.js} +1 -1
- package/dashboard/dist/assets/{provider-display-DCtG1A8I.js → provider-display-DxquW3js.js} +1 -1
- package/dashboard/dist/assets/{react-D9IQudPE.js → react-CGK0_x3G.js} +1 -1
- package/dashboard/dist/assets/{safePolygon-CxfZ_vVn.js → safePolygon-CCoDMR4f.js} +1 -1
- package/dashboard/dist/assets/{search-DBso9ppI.js → search-EZAGpmfJ.js} +1 -1
- package/dashboard/dist/assets/{server-Bb_KzpvJ.js → server-2qbrx2G0.js} +1 -1
- package/dashboard/dist/assets/{shield-check-cnqpE0zI.js → shield-check-D8kUejpE.js} +1 -1
- package/dashboard/dist/assets/{skills-api-DMbIT8CJ.js → skills-api-DYPf1Vxh.js} +1 -1
- package/dashboard/dist/assets/{sortable.esm-2Wnda4fq.js → sortable.esm-D9gC08dQ.js} +1 -1
- package/dashboard/dist/assets/{store-CGDaAZE0.js → store-BWUCbK6Y.js} +1 -1
- package/dashboard/dist/assets/{terminal-yZ69kiqV.js → terminal-B16b7r6H.js} +1 -1
- package/dashboard/dist/assets/{trash-DytHiAPM.js → trash-BIEoE13C.js} +1 -1
- package/dashboard/dist/assets/{use-community-stats-DkR1XWs8.js → use-community-stats-DWS7YKbM.js} +1 -1
- package/dashboard/dist/assets/{use-latest-request-guard-D_4XuEoM.js → use-latest-request-guard-CYKu-VOB.js} +1 -1
- package/dashboard/dist/assets/{use-limits-display-prefs-BadNsZG8.js → use-limits-display-prefs-CEAjVxFr.js} +1 -1
- package/dashboard/dist/assets/{use-native-settings-BfxfnQAn.js → use-native-settings-vTg4j5Ln.js} +1 -1
- package/dashboard/dist/assets/{use-ordered-list-BHn-XC6u.js → use-ordered-list-CtXedlqH.js} +1 -1
- package/dashboard/dist/assets/use-reduced-motion-DVLsAhSA.js +1 -0
- package/dashboard/dist/assets/{use-transform-C0qKlh_s.js → use-transform-CdeW5xhz.js} +1 -1
- package/dashboard/dist/assets/{use-usage-limits-CQugaku6.js → use-usage-limits-DmDa5Py8.js} +1 -1
- package/dashboard/dist/assets/{useCurrency-ChAc4UHj.js → useCurrency-bqycM681.js} +1 -1
- package/dashboard/dist/assets/{usePositioner-CGtwrNEt.js → usePositioner-DEXS10fY.js} +1 -1
- package/dashboard/dist/assets/{useTokenFormat-CjvWdxnV.js → useTokenFormat-YF6hY8_m.js} +1 -1
- package/dashboard/dist/assets/{zap-WTlrDMoe.js → zap-Dg-ij-lq.js} +1 -1
- package/dashboard/dist/index.html +1 -1
- package/dashboard/dist/ip-check.html +1 -1
- package/dashboard/dist/leaderboard.html +1 -1
- package/dashboard/dist/share.html +1 -1
- package/package.json +1 -1
- package/src/lib/dsh-session.js +580 -0
- package/src/lib/local-api.js +40 -3
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/session-analytics.js +89 -11
- package/dashboard/dist/assets/DevicePage-jZ9S-taT.js +0 -1
- package/dashboard/dist/assets/DialogDescription-BAaD46Xg.js +0 -1
- package/dashboard/dist/assets/LeaderboardProfileModal-iGgVOvl7.js +0 -73
- package/dashboard/dist/assets/LimitsPage-Bpp0LYGk.js +0 -6
- package/dashboard/dist/assets/use-reduced-motion-BsG6Qxa-.js +0 -1
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Metadata-only DeepSeek Harness (dsh) session reader for the Sessions sidecar.
|
|
4
|
+
// Reuses the same on-disk log format and assistant usage contract as the hourly
|
|
5
|
+
// dsh reader in rollout.js (`~/.dsh/sessions/<project>/<session>/session[.vN].jsonl[.zstd]`).
|
|
6
|
+
// Never persists prompts, assistant bodies, tool arguments, or command output.
|
|
7
|
+
|
|
8
|
+
const fs = require("node:fs");
|
|
9
|
+
const path = require("node:path");
|
|
10
|
+
const crypto = require("node:crypto");
|
|
11
|
+
const {
|
|
12
|
+
isDshSessionLogName,
|
|
13
|
+
parseDshVersion,
|
|
14
|
+
readDshSessionText,
|
|
15
|
+
normalizeDshModelName,
|
|
16
|
+
dshUsageToTotals,
|
|
17
|
+
} = require("./rollout");
|
|
18
|
+
|
|
19
|
+
const TOKEN_FIELDS = [
|
|
20
|
+
"input_tokens",
|
|
21
|
+
"cached_input_tokens",
|
|
22
|
+
"cache_creation_input_tokens",
|
|
23
|
+
"output_tokens",
|
|
24
|
+
"reasoning_output_tokens",
|
|
25
|
+
"total_tokens",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const EDIT_TOOLS = new Set([
|
|
29
|
+
"apply_patch",
|
|
30
|
+
"edit",
|
|
31
|
+
"write",
|
|
32
|
+
"multiedit",
|
|
33
|
+
"multi_edit",
|
|
34
|
+
"notebookedit",
|
|
35
|
+
"ops_edit",
|
|
36
|
+
"ops_replace",
|
|
37
|
+
"ops_write",
|
|
38
|
+
"replace",
|
|
39
|
+
"search_replace",
|
|
40
|
+
"str_replace",
|
|
41
|
+
"str_replace_editor",
|
|
42
|
+
"strreplace",
|
|
43
|
+
"write_file",
|
|
44
|
+
"writefile",
|
|
45
|
+
]);
|
|
46
|
+
const AGENT_TOOLS = new Set([
|
|
47
|
+
"agent",
|
|
48
|
+
"spawn_agent",
|
|
49
|
+
"spawn_subagent",
|
|
50
|
+
"spawn_teammate",
|
|
51
|
+
"subagent",
|
|
52
|
+
"task",
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
// Same ceiling as the hourly dsh reader: a session log is capped when decoded,
|
|
56
|
+
// and chunks/tool blobs are never materialized beyond their raw line.
|
|
57
|
+
const DSH_ACTIVE_GAP_MS = 30 * 60 * 1000;
|
|
58
|
+
// Reject placeholder epoch timestamps so usage cannot land in 1970 and vanish
|
|
59
|
+
// from every date filter (same guard as the other passive readers).
|
|
60
|
+
const MIN_DSH_TIMESTAMP_MS = Date.UTC(2020, 0, 1);
|
|
61
|
+
|
|
62
|
+
function count(value) {
|
|
63
|
+
const number = Number(value);
|
|
64
|
+
return Number.isFinite(number) && number > 0 ? Math.floor(number) : 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function emptyUsage() {
|
|
68
|
+
const usage = {};
|
|
69
|
+
for (const field of TOKEN_FIELDS) usage[field] = 0;
|
|
70
|
+
return usage;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function addUsage(target, delta) {
|
|
74
|
+
for (const field of TOKEN_FIELDS) target[field] = count(target[field]) + count(delta[field]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Harness usage -> our column vocabulary. The hourly reader owns the mapping;
|
|
78
|
+
// drop its queue-only conversation counter so a delta never leaks into a row.
|
|
79
|
+
function dshUsageDelta(usage) {
|
|
80
|
+
const totals = dshUsageToTotals(usage);
|
|
81
|
+
if (!totals) return null;
|
|
82
|
+
const delta = emptyUsage();
|
|
83
|
+
for (const field of TOKEN_FIELDS) delta[field] = count(totals[field]);
|
|
84
|
+
return delta;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function modelName(value) {
|
|
88
|
+
return normalizeDshModelName(value) || null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function sessionIdOf(value) {
|
|
92
|
+
if (typeof value !== "string") return null;
|
|
93
|
+
// Parent sessions persist their id as "session-<uuid>" while subagent
|
|
94
|
+
// headers use the bare uuid; normalize so parent/child links can pair.
|
|
95
|
+
const id = value.trim().replace(/^session-/, "");
|
|
96
|
+
return id || null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function eventTimestampMs(entry) {
|
|
100
|
+
const time = Number(entry?.time);
|
|
101
|
+
return Number.isFinite(time) && time >= MIN_DSH_TIMESTAMP_MS ? time : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function directoryEntryIs(entry, fullPath, kind) {
|
|
105
|
+
const check = kind === "dir" ? entry.isDirectory() : entry.isFile();
|
|
106
|
+
if (check) return true;
|
|
107
|
+
if (entry.isDirectory() || entry.isFile()) return false;
|
|
108
|
+
try {
|
|
109
|
+
const stat = fs.statSync(fullPath);
|
|
110
|
+
return kind === "dir" ? stat.isDirectory() : stat.isFile();
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The harness can leave several artifacts for one session (a plain log plus
|
|
117
|
+
// versioned and backup copies). Mirror the hourly reader's canonical choice:
|
|
118
|
+
// the actively written transcript wins, then the higher format version, then
|
|
119
|
+
// the zstd encoding.
|
|
120
|
+
function selectDshTranscript(sessionDir, artifacts) {
|
|
121
|
+
const candidates = artifacts.filter((name) => isDshSessionLogName(name));
|
|
122
|
+
if (candidates.length === 0) return null;
|
|
123
|
+
if (candidates.length === 1) return path.join(sessionDir, candidates[0]);
|
|
124
|
+
const ranked = candidates.map((name) => {
|
|
125
|
+
const full = path.join(sessionDir, name);
|
|
126
|
+
let mtimeMs = 0;
|
|
127
|
+
try {
|
|
128
|
+
const stat = fs.statSync(full);
|
|
129
|
+
if (stat.isFile()) mtimeMs = Number(stat.mtimeMs) || 0;
|
|
130
|
+
} catch { /* vanished mid-listing: lowest priority */ }
|
|
131
|
+
return { full, name, mtimeMs };
|
|
132
|
+
});
|
|
133
|
+
ranked.sort((left, right) =>
|
|
134
|
+
right.mtimeMs - left.mtimeMs ||
|
|
135
|
+
parseDshVersion(right.name) - parseDshVersion(left.name) ||
|
|
136
|
+
Number(right.name.endsWith(".zstd")) - Number(left.name.endsWith(".zstd")));
|
|
137
|
+
return ranked[0]?.full || null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function discoverDshSessionFiles(dshHomes, incomplete = []) {
|
|
141
|
+
// Walk sessions/ ourselves (instead of trusting a listing helper) so a
|
|
142
|
+
// permission error surfaces as incomplete discovery and the caller keeps the
|
|
143
|
+
// previous complete snapshot rather than caching "no dsh sessions".
|
|
144
|
+
const files = [];
|
|
145
|
+
const seen = new Set();
|
|
146
|
+
for (const dshHome of dshHomes || []) {
|
|
147
|
+
if (!dshHome) continue;
|
|
148
|
+
const sessionsRoot = path.join(dshHome, "sessions");
|
|
149
|
+
let projects;
|
|
150
|
+
try {
|
|
151
|
+
projects = fs.readdirSync(sessionsRoot, { withFileTypes: true });
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error?.code !== "ENOENT") incomplete.push({ path: sessionsRoot, error: error?.code || "unreadable" });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
for (const project of projects) {
|
|
157
|
+
const projectDir = path.join(sessionsRoot, project.name);
|
|
158
|
+
if (!directoryEntryIs(project, projectDir, "dir")) continue;
|
|
159
|
+
let sessions;
|
|
160
|
+
try {
|
|
161
|
+
sessions = fs.readdirSync(projectDir, { withFileTypes: true });
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (error?.code !== "ENOENT") incomplete.push({ path: projectDir, error: error?.code || "unreadable" });
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
for (const session of sessions) {
|
|
167
|
+
const sessionDir = path.join(projectDir, session.name);
|
|
168
|
+
if (!directoryEntryIs(session, sessionDir, "dir")) continue;
|
|
169
|
+
let artifacts;
|
|
170
|
+
try {
|
|
171
|
+
artifacts = fs.readdirSync(sessionDir, { withFileTypes: true });
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (error?.code !== "ENOENT") incomplete.push({ path: sessionDir, error: error?.code || "unreadable" });
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const selected = selectDshTranscript(
|
|
177
|
+
sessionDir,
|
|
178
|
+
artifacts
|
|
179
|
+
.filter((artifact) => directoryEntryIs(artifact, path.join(sessionDir, artifact.name), "file"))
|
|
180
|
+
.map((artifact) => artifact.name),
|
|
181
|
+
);
|
|
182
|
+
if (selected && !seen.has(selected)) {
|
|
183
|
+
seen.add(selected);
|
|
184
|
+
files.push(selected);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
files.sort((a, b) => a.localeCompare(b));
|
|
190
|
+
return files;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function fingerprintPrompt(data) {
|
|
194
|
+
// In-memory only: the fingerprint never reaches the sidecar.
|
|
195
|
+
const content = Array.isArray(data?.content) ? data.content : [];
|
|
196
|
+
const text = content
|
|
197
|
+
.filter((block) => block && typeof block === "object" && block.type === "text")
|
|
198
|
+
.map((block) => (typeof block.text === "string" ? block.text : ""))
|
|
199
|
+
.join("\u0000");
|
|
200
|
+
return crypto.createHash("sha256").update(text).digest("hex");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function eventKey(prefix, id, filePath, seq) {
|
|
204
|
+
if (typeof id === "string" && id.trim()) return `${prefix}:${id.trim()}`;
|
|
205
|
+
// No identity to dedupe on: scope it to the transcript that produced it.
|
|
206
|
+
return `${prefix}:#${filePath}#${seq}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Decoding a log costs far more than reading its metadata, and the sidecar
|
|
210
|
+
// invalidates its whole dsh batch whenever any session changes - so one active
|
|
211
|
+
// conversation would otherwise re-decode every transcript on every poll. Keep
|
|
212
|
+
// the parsed event metadata (never the text) keyed by file signature.
|
|
213
|
+
//
|
|
214
|
+
// The bound is the total number of cached event records, not the number of
|
|
215
|
+
// transcripts: a transcript's memory cost is proportional to its event count,
|
|
216
|
+
// and an entry cap silently stops helping once the inventory is larger than the
|
|
217
|
+
// cap (each pass evicts what the next pass wants). This budget holds a full
|
|
218
|
+
// inventory of a regular history while still capping an extreme one.
|
|
219
|
+
const TRANSCRIPT_CACHE_EVENT_LIMIT = 150000;
|
|
220
|
+
const transcriptCache = new Map();
|
|
221
|
+
let transcriptCacheEvents = 0;
|
|
222
|
+
let transcriptCacheEventLimit = TRANSCRIPT_CACHE_EVENT_LIMIT;
|
|
223
|
+
|
|
224
|
+
function transcriptSignature(filePath) {
|
|
225
|
+
try {
|
|
226
|
+
const stat = fs.statSync(filePath);
|
|
227
|
+
return `${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}`;
|
|
228
|
+
} catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function transcriptEventCost(transcript) {
|
|
234
|
+
return Array.isArray(transcript?.events) ? transcript.events.length : 0;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function forgetTranscript(filePath) {
|
|
238
|
+
const cached = transcriptCache.get(filePath);
|
|
239
|
+
if (!cached) return;
|
|
240
|
+
transcriptCache.delete(filePath);
|
|
241
|
+
transcriptCacheEvents -= cached.events;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function rememberTranscript(filePath, signature, transcript) {
|
|
245
|
+
if (!signature) return;
|
|
246
|
+
forgetTranscript(filePath);
|
|
247
|
+
const events = transcriptEventCost(transcript);
|
|
248
|
+
transcriptCache.set(filePath, { signature, transcript, events });
|
|
249
|
+
transcriptCacheEvents += events;
|
|
250
|
+
// Keep at least one entry so a single oversized transcript still caches.
|
|
251
|
+
while (transcriptCache.size > 1 && transcriptCacheEvents > transcriptCacheEventLimit) {
|
|
252
|
+
forgetTranscript(transcriptCache.keys().next().value);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function parseDshTranscript(filePath) {
|
|
257
|
+
const signature = transcriptSignature(filePath);
|
|
258
|
+
const cached = signature ? transcriptCache.get(filePath) : null;
|
|
259
|
+
if (cached && cached.signature === signature) {
|
|
260
|
+
// Refresh insertion order so the LRU keeps the sessions actually in use.
|
|
261
|
+
transcriptCache.delete(filePath);
|
|
262
|
+
transcriptCache.set(filePath, cached);
|
|
263
|
+
return cached.transcript;
|
|
264
|
+
}
|
|
265
|
+
forgetTranscript(filePath);
|
|
266
|
+
const text = await readDshSessionText(filePath);
|
|
267
|
+
if (text == null) {
|
|
268
|
+
// readDshSessionText answers null for a missing or non-file path (and for
|
|
269
|
+
// any read that fails). Throwing keeps that gap visible: readDshSessions
|
|
270
|
+
// counts it as a skipped transcript so the caller marks the dsh inventory
|
|
271
|
+
// incomplete instead of caching a silently short session list.
|
|
272
|
+
throw new Error("DeepSeek Harness session log is missing or unreadable");
|
|
273
|
+
}
|
|
274
|
+
const transcript = {
|
|
275
|
+
filePath,
|
|
276
|
+
header: null,
|
|
277
|
+
title: null,
|
|
278
|
+
fallbackModel: null,
|
|
279
|
+
events: [],
|
|
280
|
+
};
|
|
281
|
+
const lines = text.split("\n");
|
|
282
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
283
|
+
const line = lines[index];
|
|
284
|
+
if (!line) continue;
|
|
285
|
+
let entry;
|
|
286
|
+
try {
|
|
287
|
+
entry = JSON.parse(line);
|
|
288
|
+
} catch {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (!entry || typeof entry !== "object") continue;
|
|
292
|
+
const data = entry.data && typeof entry.data === "object" ? entry.data : null;
|
|
293
|
+
const seq = Number.isFinite(entry.seq) ? entry.seq : index;
|
|
294
|
+
switch (entry.type) {
|
|
295
|
+
case "session": {
|
|
296
|
+
transcript.header = entry;
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
case "session/title": {
|
|
300
|
+
// Written twice per session (a fallback preview, then the model's
|
|
301
|
+
// title); the last one is the one the harness shows.
|
|
302
|
+
const title = typeof data?.title === "string" ? data.title.trim() : "";
|
|
303
|
+
if (title) transcript.title = title;
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
case "request/header": {
|
|
307
|
+
const model = modelName(data?.header?.config?.model);
|
|
308
|
+
if (model) transcript.fallbackModel = model;
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
case "user/message": {
|
|
312
|
+
if (data?.role && data.role !== "user") break;
|
|
313
|
+
// The harness stores every injected context block (time, memory,
|
|
314
|
+
// skill catalog, plugin guidance, approval prompts) as a user-role
|
|
315
|
+
// message. Only an actual prompt opens a turn; counting the rest
|
|
316
|
+
// inflates turns and drifts edit attribution by ~4x in real logs.
|
|
317
|
+
const kind = typeof data?.source?.kind === "string" ? data.source.kind.trim() : "";
|
|
318
|
+
if (kind && kind !== "user") break;
|
|
319
|
+
transcript.events.push({
|
|
320
|
+
kind: "turn",
|
|
321
|
+
key: eventKey("u", data?.id, filePath, seq),
|
|
322
|
+
ts: eventTimestampMs(entry),
|
|
323
|
+
prompt: fingerprintPrompt(data),
|
|
324
|
+
});
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
case "assistant/message": {
|
|
328
|
+
const message = data?.message && typeof data.message === "object" ? data.message : null;
|
|
329
|
+
const ts = eventTimestampMs(entry);
|
|
330
|
+
const model = modelName(message?.source?.model) || transcript.fallbackModel || "unknown";
|
|
331
|
+
const delta = dshUsageDelta(data?.usage);
|
|
332
|
+
if (delta) {
|
|
333
|
+
transcript.events.push({
|
|
334
|
+
kind: "usage",
|
|
335
|
+
key: eventKey("a", message?.id, filePath, seq),
|
|
336
|
+
ts,
|
|
337
|
+
model,
|
|
338
|
+
delta,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
case "tool/call": {
|
|
344
|
+
const name = typeof data?.name === "string" ? data.name.trim().toLowerCase() : "";
|
|
345
|
+
if (!name) break;
|
|
346
|
+
transcript.events.push({
|
|
347
|
+
kind: "tool",
|
|
348
|
+
key: eventKey("t", data?.callId, filePath, seq),
|
|
349
|
+
ts: eventTimestampMs(entry),
|
|
350
|
+
name,
|
|
351
|
+
});
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
default:
|
|
355
|
+
// Streaming chunks, approvals, prompts-injection and the terminal
|
|
356
|
+
// seed marker carry no accounting of their own. session/end-seed in
|
|
357
|
+
// particular can be stamped weeks after the session ended.
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
rememberTranscript(filePath, signature, transcript);
|
|
362
|
+
return transcript;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function transcriptStartedAt(transcript) {
|
|
366
|
+
const created = Number(transcript?.header?.createdAt);
|
|
367
|
+
if (Number.isFinite(created) && created >= MIN_DSH_TIMESTAMP_MS) return created;
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Harness sessions seeded from a parent log replay the parent's messages
|
|
372
|
+
// verbatim (same message ids, same usage). Attributes every replayed event to
|
|
373
|
+
// the transcript that created it — the earliest one — so a fork or a seeded
|
|
374
|
+
// subagent does not bill the same tokens twice.
|
|
375
|
+
function claimEventsByOrigin(transcripts) {
|
|
376
|
+
const owners = new Map();
|
|
377
|
+
const ordered = [...transcripts].sort((left, right) => {
|
|
378
|
+
const leftAt = transcriptStartedAt(left) ?? Number.MAX_SAFE_INTEGER;
|
|
379
|
+
const rightAt = transcriptStartedAt(right) ?? Number.MAX_SAFE_INTEGER;
|
|
380
|
+
return leftAt - rightAt || left.filePath.localeCompare(right.filePath);
|
|
381
|
+
});
|
|
382
|
+
for (const transcript of ordered) {
|
|
383
|
+
for (const event of transcript.events) {
|
|
384
|
+
if (owners.has(event.key)) continue;
|
|
385
|
+
owners.set(event.key, transcript.filePath);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return owners;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function buildRow(transcript, owners) {
|
|
392
|
+
const header = transcript.header || {};
|
|
393
|
+
const sessionId = sessionIdOf(header.id)
|
|
394
|
+
|| sessionIdOf(path.basename(path.dirname(transcript.filePath)))
|
|
395
|
+
|| null;
|
|
396
|
+
const parentId = sessionIdOf(header.parentSession);
|
|
397
|
+
// A subagent session is a thread of its parent; a seeded root session is a
|
|
398
|
+
// fork (the harness replays the parent log so the branch starts with its
|
|
399
|
+
// context). Only the first one is a parent/child link.
|
|
400
|
+
const originKind = typeof header.origin === "string" ? header.origin.trim().toLowerCase() : "";
|
|
401
|
+
const isSubagent = originKind === "subagent" || count(header.delegationDepth) > 0;
|
|
402
|
+
const tokens = emptyUsage();
|
|
403
|
+
const modelUsage = new Map();
|
|
404
|
+
const buckets = new Map();
|
|
405
|
+
const times = new Set();
|
|
406
|
+
const agentTypes = new Map();
|
|
407
|
+
const modelsForEdits = new Map();
|
|
408
|
+
let turns = 0;
|
|
409
|
+
let edits = 0;
|
|
410
|
+
let retries = 0;
|
|
411
|
+
let agentCalls = 0;
|
|
412
|
+
let lastPrompt = null;
|
|
413
|
+
let turnOpen = false;
|
|
414
|
+
let hadEdit = false;
|
|
415
|
+
let turnModel = transcript.fallbackModel || "unknown";
|
|
416
|
+
|
|
417
|
+
const closeTurn = () => {
|
|
418
|
+
if (hadEdit) {
|
|
419
|
+
edits += 1;
|
|
420
|
+
modelsForEdits.set(turnModel, (modelsForEdits.get(turnModel) || 0) + 1);
|
|
421
|
+
}
|
|
422
|
+
hadEdit = false;
|
|
423
|
+
turnOpen = false;
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const startedAt = transcriptStartedAt(transcript);
|
|
427
|
+
if (startedAt != null) times.add(startedAt);
|
|
428
|
+
|
|
429
|
+
// A message id names one message; a re-emitted line in the same log is a
|
|
430
|
+
// replay of it, not a second charge.
|
|
431
|
+
const seenEvents = new Set();
|
|
432
|
+
for (const event of transcript.events) {
|
|
433
|
+
if (owners.get(event.key) !== transcript.filePath) continue;
|
|
434
|
+
if (seenEvents.has(event.key)) continue;
|
|
435
|
+
seenEvents.add(event.key);
|
|
436
|
+
if (event.ts != null) times.add(event.ts);
|
|
437
|
+
if (event.kind === "turn") {
|
|
438
|
+
closeTurn();
|
|
439
|
+
turnOpen = true;
|
|
440
|
+
turns += 1;
|
|
441
|
+
turnModel = transcript.fallbackModel || "unknown";
|
|
442
|
+
if (lastPrompt && lastPrompt === event.prompt) retries += 1;
|
|
443
|
+
lastPrompt = event.prompt;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (event.kind === "usage") {
|
|
447
|
+
const delta = event.delta;
|
|
448
|
+
const model = event.model || turnModel;
|
|
449
|
+
const modelRow = modelUsage.get(model) || { model, ...emptyUsage(), usage_events: 0 };
|
|
450
|
+
addUsage(tokens, delta);
|
|
451
|
+
addUsage(modelRow, delta);
|
|
452
|
+
modelRow.usage_events += 1;
|
|
453
|
+
modelUsage.set(model, modelRow);
|
|
454
|
+
if (event.ts != null) {
|
|
455
|
+
const bucketTime = new Date(Math.floor(event.ts / 60000) * 60000).toISOString();
|
|
456
|
+
const key = `${model}\u0000${bucketTime}`;
|
|
457
|
+
const bucket = buckets.get(key) || { model, timestamp: bucketTime, ...emptyUsage(), usage_events: 0 };
|
|
458
|
+
addUsage(bucket, delta);
|
|
459
|
+
bucket.usage_events += 1;
|
|
460
|
+
buckets.set(key, bucket);
|
|
461
|
+
}
|
|
462
|
+
turnModel = model;
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
if (event.kind === "tool") {
|
|
466
|
+
if (turnOpen && EDIT_TOOLS.has(event.name)) hadEdit = true;
|
|
467
|
+
if (AGENT_TOOLS.has(event.name)) {
|
|
468
|
+
agentCalls += 1;
|
|
469
|
+
agentTypes.set(event.name, (agentTypes.get(event.name) || 0) + 1);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
closeTurn();
|
|
474
|
+
|
|
475
|
+
const orderedTimes = [...times].sort((a, b) => a - b);
|
|
476
|
+
let activeMs = 0;
|
|
477
|
+
for (let index = 1; index < orderedTimes.length; index += 1) {
|
|
478
|
+
const gap = orderedTimes[index] - orderedTimes[index - 1];
|
|
479
|
+
if (gap > 0 && gap <= DSH_ACTIVE_GAP_MS) activeMs += gap;
|
|
480
|
+
}
|
|
481
|
+
const models = [...modelUsage.values()].map((row) => ({
|
|
482
|
+
...row,
|
|
483
|
+
edit_turns: modelsForEdits.get(row.model) || 0,
|
|
484
|
+
}));
|
|
485
|
+
// A session that only handshook (no user turn, no usage) has nothing to say.
|
|
486
|
+
if (!tokens.total_tokens && !turns) return null;
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
identity: sessionId,
|
|
490
|
+
files: [transcript.filePath],
|
|
491
|
+
session_id: sessionId,
|
|
492
|
+
parent_session_id: isSubagent ? parentId : null,
|
|
493
|
+
forked_from_id: isSubagent ? null : parentId,
|
|
494
|
+
thread_source: originKind || null,
|
|
495
|
+
title: transcript.title,
|
|
496
|
+
project_ref: typeof header.cwd === "string" && header.cwd.trim() ? header.cwd.trim() : null,
|
|
497
|
+
model: models.length === 1 ? models[0].model : "unknown",
|
|
498
|
+
model_usage: models,
|
|
499
|
+
usage_buckets: [...buckets.values()],
|
|
500
|
+
tokens,
|
|
501
|
+
turns,
|
|
502
|
+
edit_turns: edits,
|
|
503
|
+
retry_turns: retries,
|
|
504
|
+
subagent_calls: agentCalls,
|
|
505
|
+
subagent_types: Object.fromEntries([...agentTypes.entries()].sort()),
|
|
506
|
+
usage_events: models.reduce((sum, row) => sum + count(row.usage_events), 0),
|
|
507
|
+
started_at: orderedTimes.length ? new Date(orderedTimes[0]).toISOString() : null,
|
|
508
|
+
ended_at: orderedTimes.length ? new Date(orderedTimes.at(-1)).toISOString() : null,
|
|
509
|
+
active_ms: activeMs,
|
|
510
|
+
provenance: {
|
|
511
|
+
source: "local-session-log",
|
|
512
|
+
confidence: "observed",
|
|
513
|
+
retry_confidence: "inferred",
|
|
514
|
+
content_retained: false,
|
|
515
|
+
usage: "dsh-jsonl-metadata",
|
|
516
|
+
},
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Batch the whole inventory: seeded transcripts repeat their parent's message
|
|
521
|
+
// ids, so the dedupe has to see every file before it emits a row (the same
|
|
522
|
+
// contract as the hourly dsh reader's per-message identity).
|
|
523
|
+
async function readDshSessions(filePaths, { onSkip = null } = {}) {
|
|
524
|
+
const files = [...new Set((filePaths || []).filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
|
525
|
+
const transcripts = [];
|
|
526
|
+
let skippedFiles = 0;
|
|
527
|
+
for (const filePath of files) {
|
|
528
|
+
let transcript = null;
|
|
529
|
+
try {
|
|
530
|
+
transcript = await parseDshTranscript(filePath);
|
|
531
|
+
} catch (error) {
|
|
532
|
+
// One half-written or unreadable transcript must not hide the rest of
|
|
533
|
+
// the inventory; count it so the caller can report the gap.
|
|
534
|
+
skippedFiles += 1;
|
|
535
|
+
if (typeof onSkip === "function") onSkip(filePath, error);
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
if (transcript) transcripts.push(transcript);
|
|
539
|
+
}
|
|
540
|
+
if (transcripts.length === 0) return withSkippedFileCount([], skippedFiles);
|
|
541
|
+
const owners = claimEventsByOrigin(transcripts);
|
|
542
|
+
const rows = [];
|
|
543
|
+
for (const transcript of transcripts) {
|
|
544
|
+
const row = buildRow(transcript, owners);
|
|
545
|
+
if (row) rows.push(row);
|
|
546
|
+
}
|
|
547
|
+
return withSkippedFileCount(rows, skippedFiles);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Non-enumerable so the array still behaves like a plain row list for callers
|
|
551
|
+
// that map/filter/serialize it.
|
|
552
|
+
function withSkippedFileCount(rows, skippedFiles) {
|
|
553
|
+
Object.defineProperty(rows, "skippedFiles", { value: skippedFiles, enumerable: false });
|
|
554
|
+
return rows;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Test hooks: the budget accounting is only observable through the module
|
|
558
|
+
// state, and the dsh inventory is far too large to build in a unit test.
|
|
559
|
+
function resetDshTranscriptCacheForTests() {
|
|
560
|
+
transcriptCache.clear();
|
|
561
|
+
transcriptCacheEvents = 0;
|
|
562
|
+
transcriptCacheEventLimit = TRANSCRIPT_CACHE_EVENT_LIMIT;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function setDshTranscriptCacheEventLimitForTests(limit) {
|
|
566
|
+
transcriptCacheEventLimit = Number.isFinite(limit) && limit > 0 ? limit : TRANSCRIPT_CACHE_EVENT_LIMIT;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function dshTranscriptCacheStatsForTests() {
|
|
570
|
+
return { size: transcriptCache.size, events: transcriptCacheEvents, limit: transcriptCacheEventLimit };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
module.exports = {
|
|
574
|
+
discoverDshSessionFiles,
|
|
575
|
+
readDshSessions,
|
|
576
|
+
selectDshTranscript,
|
|
577
|
+
resetDshTranscriptCacheForTests,
|
|
578
|
+
setDshTranscriptCacheEventLimitForTests,
|
|
579
|
+
dshTranscriptCacheStatsForTests,
|
|
580
|
+
};
|
package/src/lib/local-api.js
CHANGED
|
@@ -27,6 +27,10 @@ const SYNC_TIMEOUT_MS = 120_000;
|
|
|
27
27
|
// request still falls back normally; subsequent fan-out requests reuse that
|
|
28
28
|
// decision for a short cooldown and return local data immediately.
|
|
29
29
|
const ACCOUNT_VIEW_FAILURE_BACKOFF_MS = 2 * 60_000;
|
|
30
|
+
// A caller stops waiting for the cloud after the response timeout, but the
|
|
31
|
+
// read keeps going in the background for this many times as long so a slow
|
|
32
|
+
// edge can still fill the payload cache and end the backoff early.
|
|
33
|
+
const ACCOUNT_VIEW_BACKGROUND_TIMEOUT_FACTOR = 5;
|
|
30
34
|
const TRACKER_BIN = path.resolve(__dirname, "../../bin/tracker.js");
|
|
31
35
|
const MAX_DEVICE_NAME_LENGTH = 128;
|
|
32
36
|
|
|
@@ -1495,6 +1499,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
|
|
|
1495
1499
|
// otherwise a fallback reason (see above) telling the caller — and, through
|
|
1496
1500
|
// the `X-TokenTracker-Account-Fallback` header, the popover — why the local
|
|
1497
1501
|
// single-machine data is being served instead.
|
|
1502
|
+
const ACCOUNT_VIEW_STILL_PENDING = Symbol("account-view-still-pending");
|
|
1498
1503
|
async function tryServeAccountView(usageSlug, url, res) {
|
|
1499
1504
|
if (!getCloudSyncPref()) return ACCOUNT_FALLBACK_CLOUD_SYNC_OFF;
|
|
1500
1505
|
const refreshToken = getRefreshTokenForCloud();
|
|
@@ -1514,7 +1519,15 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
|
|
|
1514
1519
|
const envTimeout = process.env.TOKENTRACKER_HTTP_TIMEOUT_MS
|
|
1515
1520
|
? parseInt(process.env.TOKENTRACKER_HTTP_TIMEOUT_MS, 10)
|
|
1516
1521
|
: 0;
|
|
1517
|
-
|
|
1522
|
+
// `responseTimeoutMs` bounds how long THIS caller waits before it gets the
|
|
1523
|
+
// local fallback. The cloud read itself keeps running in the background
|
|
1524
|
+
// for up to ACCOUNT_VIEW_BACKGROUND_TIMEOUT_FACTOR times that: full-history
|
|
1525
|
+
// aggregates from far regions often take 2-6 s, and the first read after a
|
|
1526
|
+
// backoff window meets an edge that went cold during it. A late success
|
|
1527
|
+
// still fills the payload cache and clears the backoff, so the next poll is
|
|
1528
|
+
// served instead of timing out again.
|
|
1529
|
+
const responseTimeoutMs = envTimeout > 0 ? envTimeout : 4000;
|
|
1530
|
+
const requestTimeoutMs = responseTimeoutMs * ACCOUNT_VIEW_BACKGROUND_TIMEOUT_FACTOR;
|
|
1518
1531
|
try {
|
|
1519
1532
|
// An identical overlapping read shares the entire probe, including its
|
|
1520
1533
|
// token mint. A healthy pending read must not look like a cloud outage
|
|
@@ -1527,7 +1540,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
|
|
|
1527
1540
|
baseUrl: runtime.baseUrl || DEFAULT_BASE_URL,
|
|
1528
1541
|
anonKey: runtime.anonKey,
|
|
1529
1542
|
refreshToken,
|
|
1530
|
-
timeoutMs,
|
|
1543
|
+
timeoutMs: requestTimeoutMs,
|
|
1531
1544
|
onSessionRefreshed: (session) => {
|
|
1532
1545
|
getRuntime();
|
|
1533
1546
|
if (sessionAtStart !== getCloudAccountSessionGeneration()) {
|
|
@@ -1542,8 +1555,32 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
|
|
|
1542
1555
|
if (accountViewInFlight.get(requestKey) === pending) accountViewInFlight.delete(requestKey);
|
|
1543
1556
|
};
|
|
1544
1557
|
pending.then(cleanup, cleanup);
|
|
1558
|
+
// Settle the backoff from the probe itself, so it also counts when
|
|
1559
|
+
// every caller has already stopped waiting.
|
|
1560
|
+
pending.then(
|
|
1561
|
+
(out) => { if (out && out.data != null) accountViewFailures.delete(failureKey); },
|
|
1562
|
+
(e) => { if (e?.code !== "auth_session_changed") rememberAccountViewFailure(failureKey); },
|
|
1563
|
+
);
|
|
1564
|
+
}
|
|
1565
|
+
let responseTimer;
|
|
1566
|
+
const responseDeadline = new Promise((resolve) => {
|
|
1567
|
+
responseTimer = setTimeout(() => resolve(ACCOUNT_VIEW_STILL_PENDING), responseTimeoutMs);
|
|
1568
|
+
if (typeof responseTimer.unref === "function") responseTimer.unref();
|
|
1569
|
+
});
|
|
1570
|
+
let out;
|
|
1571
|
+
try {
|
|
1572
|
+
out = await Promise.race([pending, responseDeadline]);
|
|
1573
|
+
} finally {
|
|
1574
|
+
clearTimeout(responseTimer);
|
|
1575
|
+
}
|
|
1576
|
+
if (out === ACCOUNT_VIEW_STILL_PENDING) {
|
|
1577
|
+
// Back the fan-out off as before; the probe clears this on success.
|
|
1578
|
+
rememberAccountViewFailure(failureKey);
|
|
1579
|
+
if (getRuntime().debug) {
|
|
1580
|
+
console.warn(`[LocalAPI] account view still pending after ${responseTimeoutMs}ms for ${usageSlug}; continuing in background`);
|
|
1581
|
+
}
|
|
1582
|
+
return "transient-timeout";
|
|
1545
1583
|
}
|
|
1546
|
-
const out = await pending;
|
|
1547
1584
|
getRuntime();
|
|
1548
1585
|
// `null` here means the slug has no cloud equivalent, or the session
|
|
1549
1586
|
// vanished mid-request — a routing/session fact, not an outage.
|