tokentracker-cli 0.81.2 → 0.81.3
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/dashboard/dist/assets/AchievementBadge-DiSoAalB.js +1 -0
- package/dashboard/dist/assets/AchievementsPage-DI_cvzqy.js +2 -0
- package/dashboard/dist/assets/AchievementsSection-ClWZZNvc.js +1 -0
- package/dashboard/dist/assets/BadgeDetailModal-DpQeG_6H.js +1 -0
- package/dashboard/dist/assets/Card-B0hyofoy.js +1 -0
- package/dashboard/dist/assets/ClawdAnimated-DZJGQ3-9.js +1 -0
- package/dashboard/dist/assets/CommunityStatsModal-Dcikt0kT.js +1 -0
- package/dashboard/dist/assets/ConfirmModal-DCfRP4oI.js +1 -0
- package/dashboard/dist/assets/DashboardPage-DBajsWEp.js +1 -0
- package/dashboard/dist/assets/DevicePage-De6KuFaJ.js +1 -0
- package/dashboard/dist/assets/DialogDescription-uQ4gVIv9.js +1 -0
- package/dashboard/dist/assets/DialogTitle-BvElfi0r.js +1 -0
- package/dashboard/dist/assets/FadeIn-DZoN7vbQ.js +1 -0
- package/dashboard/dist/assets/HeaderGithubStar-C9HQnEsI.js +1 -0
- package/dashboard/dist/assets/{IpCheckPage-CMf9CHFr.js → IpCheckPage-82zniXwO.js} +3 -3
- package/dashboard/dist/assets/{LandingPage-BT61_TqT.js → LandingPage-Df9fdR0n.js} +168 -168
- package/dashboard/dist/assets/LeaderboardAvatar-CjfEgoR8.js +1 -0
- package/dashboard/dist/assets/LeaderboardPage-BdbE6Nhj.js +2 -0
- package/dashboard/dist/assets/{LeaderboardProfileModal-C_kxqOMn.js → LeaderboardProfileModal-CDTK21dp.js} +4 -4
- package/dashboard/dist/assets/{LeaderboardProfilePage-zgNsE0EQ.js → LeaderboardProfilePage-B6QWnwB1.js} +3 -3
- package/dashboard/dist/assets/{LimitsPage-BiQpqxj8.js → LimitsPage-f0L0d15j.js} +4 -4
- package/dashboard/dist/assets/LocalOnlyNotice-DOcSDrOy.js +1 -0
- package/dashboard/dist/assets/LoginPage-WY_sRA7I.js +1 -0
- package/dashboard/dist/assets/PetPage-CNbgNE6f.js +1 -0
- package/dashboard/dist/assets/PopoverPopup-Cv525-dF.js +1 -0
- package/dashboard/dist/assets/ResetPasswordPage-vrcj6bBP.js +1 -0
- package/dashboard/dist/assets/Select-3HkgkO8y.js +1 -0
- package/dashboard/dist/assets/SelectItemText-Bz8FAKEv.js +1 -0
- package/dashboard/dist/assets/SettingsPage-CVldAt69.js +1 -0
- package/dashboard/dist/assets/SkillsPage-Dnz3eGcE.js +1 -0
- package/dashboard/dist/assets/TrendMonitor-OopQ06B8.js +19 -0
- package/dashboard/dist/assets/WidgetsPage-4dWjZkc9.js +1 -0
- package/dashboard/dist/assets/WrappedPage-Dbd-8EKD.js +1 -0
- package/dashboard/dist/assets/__vite-browser-external-CQxW1DuO.css +1 -0
- package/dashboard/dist/assets/__vite-browser-external-CxN2E6q5.js +1312 -0
- package/dashboard/dist/assets/agent-logos-CT9fumzx.js +1 -0
- package/dashboard/dist/assets/arrow-up-right-CGnVz9Km.js +1 -0
- package/dashboard/dist/assets/check-CkI3WNti.js +1 -0
- package/dashboard/dist/assets/copy-Bh3g2_O-.js +1 -0
- package/dashboard/dist/assets/download-BITw7nKX.js +1 -0
- package/dashboard/dist/assets/flame-ByAsnNar.js +1 -0
- package/dashboard/dist/assets/format-tokens-Cu8JtuN4.js +1 -0
- package/dashboard/dist/assets/icons-D9OB0N43.js +1 -0
- package/dashboard/dist/assets/index-lKnLA6Pd.js +1 -0
- package/dashboard/dist/assets/info-Mu2CLf-A.js +1 -0
- package/dashboard/dist/assets/link-2-CSlVaUw9.js +1 -0
- package/dashboard/dist/assets/loader-circle-CYck0YaS.js +1 -0
- package/dashboard/dist/assets/provider-display-BB925tPa.js +42 -0
- package/dashboard/dist/assets/terminal-4YysOnTe.js +1 -0
- package/dashboard/dist/assets/trash-2-C8-ZfrZG.js +1 -0
- package/dashboard/dist/assets/use-community-stats-Cxt-otko.js +1 -0
- package/dashboard/dist/assets/use-limits-display-prefs-BUFZh6fe.js +1 -0
- package/dashboard/dist/assets/use-native-settings-DbdPXxGf.js +1 -0
- package/dashboard/dist/assets/{use-ordered-list-DPbiKnPc.js → use-ordered-list-BZMM-iIj.js} +2 -2
- package/dashboard/dist/assets/use-quality-per-dollar-pref-DXoW5A0_.js +1 -0
- package/dashboard/dist/assets/use-transform-CuAoUb8H.js +1 -0
- package/dashboard/dist/assets/use-usage-limits-BEc3QMzR.js +1 -0
- package/dashboard/dist/assets/useCurrency-CpDnFC0N.js +1 -0
- package/dashboard/dist/assets/useScrollLock-Vyaif-7C.js +1 -0
- package/dashboard/dist/assets/useTokenFormat-B8FrUarf.js +1 -0
- package/dashboard/dist/assets/zap-CdeKJ6HL.js +1 -0
- package/dashboard/dist/index.html +2 -2
- package/dashboard/dist/share.html +2 -2
- package/package.json +1 -1
- package/src/commands/serve.js +8 -6
- package/src/commands/status.js +2 -1
- package/src/commands/sync.js +164 -36
- package/src/lib/codex-context-breakdown.js +330 -13
- package/src/lib/codex-rollout-parser.js +323 -42
- package/src/lib/cursor-store.js +1289 -0
- package/src/lib/diagnostics.js +8 -5
- package/src/lib/fs.js +254 -16
- package/src/lib/local-api.js +106 -17
- package/src/lib/pricing/index.js +8 -0
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/rollout.js +101 -9
- package/dashboard/dist/assets/AchievementBadge-DXWHZrAL.js +0 -1
- package/dashboard/dist/assets/AchievementsPage-BhrQXniL.js +0 -2
- package/dashboard/dist/assets/AchievementsSection-gzP3m1Ml.js +0 -1
- package/dashboard/dist/assets/BadgeDetailModal-DGKPuvDU.js +0 -1
- package/dashboard/dist/assets/Card-BRtYQ-Ot.js +0 -1
- package/dashboard/dist/assets/ClawdAnimated-UsNG-qDv.js +0 -1
- package/dashboard/dist/assets/CommunityStatsModal-CJQw-P4t.js +0 -1
- package/dashboard/dist/assets/ConfirmModal-ByY4GDsy.js +0 -1
- package/dashboard/dist/assets/DashboardPage-CuLVnW98.js +0 -19
- package/dashboard/dist/assets/DevicePage-D67CxpmR.js +0 -1
- package/dashboard/dist/assets/DialogClose-Ci8hkTw5.js +0 -1
- package/dashboard/dist/assets/DialogDescription-C9vXUZU0.js +0 -1
- package/dashboard/dist/assets/DialogTitle-sLJxKWjv.js +0 -1
- package/dashboard/dist/assets/FadeIn-xVds37w6.js +0 -1
- package/dashboard/dist/assets/HeaderGithubStar-kK3omgmB.js +0 -1
- package/dashboard/dist/assets/LeaderboardAvatar-DyKz6NdT.js +0 -1
- package/dashboard/dist/assets/LeaderboardPage-DJ4zihJJ.js +0 -2
- package/dashboard/dist/assets/LocalOnlyNotice-DTrrUVbZ.js +0 -1
- package/dashboard/dist/assets/LoginPage-BOM9Q2sI.js +0 -1
- package/dashboard/dist/assets/PetPage-BURk7RG-.js +0 -1
- package/dashboard/dist/assets/PopoverPopup-BV_aoNBd.js +0 -1
- package/dashboard/dist/assets/ResetPasswordPage-SBL2LvXC.js +0 -1
- package/dashboard/dist/assets/Select-BsRdHde3.js +0 -1
- package/dashboard/dist/assets/SelectItemText-DgkrZxIq.js +0 -1
- package/dashboard/dist/assets/SettingsPage-DqxVsfnB.js +0 -1
- package/dashboard/dist/assets/SkillsPage-B0VfWkOh.js +0 -1
- package/dashboard/dist/assets/WidgetsPage-4QQ8wI_g.js +0 -1
- package/dashboard/dist/assets/WrappedPage-BK53khVY.js +0 -1
- package/dashboard/dist/assets/agent-logos-DXpW_Wmz.js +0 -1
- package/dashboard/dist/assets/arrow-up-right-DCc--V5G.js +0 -1
- package/dashboard/dist/assets/check-DqxuosV2.js +0 -1
- package/dashboard/dist/assets/copy-qChAT7MY.js +0 -1
- package/dashboard/dist/assets/download-W9R3sBph.js +0 -1
- package/dashboard/dist/assets/flame-0eyhMnfU.js +0 -1
- package/dashboard/dist/assets/format-tokens-CkfBUcoa.js +0 -1
- package/dashboard/dist/assets/info-DY1O85-U.js +0 -1
- package/dashboard/dist/assets/link-2-B_Z66hFT.js +0 -1
- package/dashboard/dist/assets/loader-circle-xH40lon2.js +0 -1
- package/dashboard/dist/assets/main-CeaB5N36.js +0 -1259
- package/dashboard/dist/assets/main-DAc77vwF.css +0 -1
- package/dashboard/dist/assets/provider-display-C_XKZQcF.js +0 -42
- package/dashboard/dist/assets/trash-2-DVhNJ74r.js +0 -1
- package/dashboard/dist/assets/use-community-stats-CO-nr2Vf.js +0 -1
- package/dashboard/dist/assets/use-limits-display-prefs-RRqmyoxt.js +0 -1
- package/dashboard/dist/assets/use-native-settings-79piPeET.js +0 -1
- package/dashboard/dist/assets/use-quality-per-dollar-pref-DyF49QRF.js +0 -1
- package/dashboard/dist/assets/use-transform-BvXyGAtb.js +0 -1
- package/dashboard/dist/assets/use-usage-limits-BiZ_I-KE.js +0 -1
- package/dashboard/dist/assets/useCurrency-D-C1e7eE.js +0 -1
- package/dashboard/dist/assets/useScrollLock-BfU2EMvG.js +0 -1
- package/dashboard/dist/assets/useTokenFormat-Cm4EvrQp.js +0 -1
- package/dashboard/dist/assets/zap-DlAOTwFs.js +0 -1
package/src/lib/diagnostics.js
CHANGED
|
@@ -3,6 +3,7 @@ const path = require("node:path");
|
|
|
3
3
|
const fs = require("node:fs/promises");
|
|
4
4
|
|
|
5
5
|
const { readJson } = require("./fs");
|
|
6
|
+
const { readCursorStateSummary } = require("./cursor-store");
|
|
6
7
|
const { readCodexNotify, readEveryCodeNotify } = require("./codex-config");
|
|
7
8
|
const { areClaudeUsageHooksConfigured, buildClaudeHookCommand } = require("./claude-config");
|
|
8
9
|
const {
|
|
@@ -59,7 +60,8 @@ async function collectTrackerDiagnostics({
|
|
|
59
60
|
path.join(home, ".grok");
|
|
60
61
|
|
|
61
62
|
const config = await readJson(configPath);
|
|
62
|
-
const
|
|
63
|
+
const cursorSummary = await readCursorStateSummary({ trackerDir, cursorsPath });
|
|
64
|
+
const cursors = cursorSummary.cursors;
|
|
63
65
|
const queueState = (await readJson(queueStatePath)) || { offset: 0 };
|
|
64
66
|
const uploadThrottle = normalizeUploadState(await readJson(uploadThrottlePath));
|
|
65
67
|
const autoRetry = await readJson(autoRetryPath);
|
|
@@ -165,10 +167,11 @@ async function collectTrackerDiagnostics({
|
|
|
165
167
|
},
|
|
166
168
|
parse: {
|
|
167
169
|
updated_at: typeof cursors?.updatedAt === "string" ? cursors.updatedAt : null,
|
|
168
|
-
file_count:
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
170
|
+
file_count: cursorSummary.fileCount,
|
|
171
|
+
cursor_store: cursorSummary.mode,
|
|
172
|
+
cursor_store_legacy_drift: cursorSummary.legacyDrift === true,
|
|
173
|
+
codex_file_count: cursorSummary.codexFileCount,
|
|
174
|
+
codex_event_count: cursorSummary.codexEventCount,
|
|
172
175
|
},
|
|
173
176
|
queue: {
|
|
174
177
|
size_bytes: queueSize,
|
package/src/lib/fs.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const fs = require("node:fs/promises");
|
|
2
|
+
const crypto = require("node:crypto");
|
|
2
3
|
const path = require("node:path");
|
|
3
4
|
|
|
4
5
|
async function ensureDir(p) {
|
|
@@ -48,28 +49,265 @@ async function chmod600IfPossible(filePath) {
|
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
const LOCK_STALE_MS = 5 * 60 * 1000; // 5 minutes
|
|
52
|
+
const LOCK_HEARTBEAT_MS = 30 * 1000;
|
|
53
|
+
const MAX_RECLAIM_DEPTH = 4;
|
|
54
|
+
const TRANSITION_GUARD_RETRY_MS = 5;
|
|
55
|
+
const TRANSITION_GUARD_ATTEMPTS = 1000;
|
|
51
56
|
|
|
52
|
-
|
|
57
|
+
function parseLockOwner(raw) {
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(raw);
|
|
60
|
+
const pid = Number(parsed?.pid);
|
|
61
|
+
const token = typeof parsed?.token === "string" ? parsed.token : null;
|
|
62
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || !token) return null;
|
|
63
|
+
return { pid, token };
|
|
64
|
+
} catch (_e) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function heartbeatPathFor(lockPath, token) {
|
|
70
|
+
const tokenDigest = crypto.createHash("sha256").update(token, "utf8").digest("hex");
|
|
71
|
+
return `${lockPath}.heartbeat.${tokenDigest}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isProcessAlive(pid) {
|
|
75
|
+
try {
|
|
76
|
+
process.kill(pid, 0);
|
|
77
|
+
return true;
|
|
78
|
+
} catch (e) {
|
|
79
|
+
if (e?.code === "ESRCH") return false;
|
|
80
|
+
// EPERM means the process exists but belongs to another user. Unknown
|
|
81
|
+
// errors are treated conservatively so a live lock is never reclaimed.
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function existingLockCanBeReclaimed(lockPath) {
|
|
87
|
+
let lockHandle = null;
|
|
88
|
+
try {
|
|
89
|
+
// Inspect one opened inode instead of stat-ing a path and then reading
|
|
90
|
+
// that path. The latter is a TOCTOU window and is also unsafe if a
|
|
91
|
+
// concurrent reclaimer replaces the lock between the two operations.
|
|
92
|
+
lockHandle = await fs.open(lockPath, "r");
|
|
93
|
+
const [stat, raw] = await Promise.all([
|
|
94
|
+
lockHandle.stat(),
|
|
95
|
+
lockHandle.readFile({ encoding: "utf8" }),
|
|
96
|
+
]);
|
|
97
|
+
const owner = parseLockOwner(raw);
|
|
98
|
+
if (owner) {
|
|
99
|
+
if (!isProcessAlive(owner.pid)) return true;
|
|
100
|
+
|
|
101
|
+
// New leases keep a token-specific heartbeat separate from the owner
|
|
102
|
+
// file. This lets a PID that has since been reused be recognized as an
|
|
103
|
+
// abandoned lease without reclaiming a live, long-running sync whose
|
|
104
|
+
// heartbeat is still fresh. Locks from before heartbeat support retain
|
|
105
|
+
// the conservative live-PID behavior.
|
|
106
|
+
let heartbeatHandle = null;
|
|
107
|
+
try {
|
|
108
|
+
heartbeatHandle = await fs.open(heartbeatPathFor(lockPath, owner.token), "r");
|
|
109
|
+
const heartbeat = await heartbeatHandle.stat();
|
|
110
|
+
return Date.now() - heartbeat.mtimeMs > LOCK_STALE_MS;
|
|
111
|
+
} catch (_e) {
|
|
112
|
+
return false;
|
|
113
|
+
} finally {
|
|
114
|
+
await heartbeatHandle?.close().catch(() => {});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return Date.now() - stat.mtimeMs > LOCK_STALE_MS;
|
|
118
|
+
} catch (e) {
|
|
119
|
+
// The lock disappeared between open and inspection, so retry acquisition.
|
|
120
|
+
if (e?.code === "ENOENT") return true;
|
|
121
|
+
return false;
|
|
122
|
+
} finally {
|
|
123
|
+
await lockHandle?.close().catch(() => {});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function startLockHeartbeat(handle, heartbeatHandle) {
|
|
128
|
+
const beat = async () => {
|
|
129
|
+
try {
|
|
130
|
+
const now = new Date();
|
|
131
|
+
await Promise.all([
|
|
132
|
+
handle.utimes(now, now),
|
|
133
|
+
heartbeatHandle.utimes(now, now),
|
|
134
|
+
]);
|
|
135
|
+
} catch (_e) {
|
|
136
|
+
// A failed heartbeat makes the lease eligible for bounded stale
|
|
137
|
+
// reclamation. The owning sync will still finish or release normally.
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
const timer = setInterval(() => {
|
|
141
|
+
void beat();
|
|
142
|
+
}, LOCK_HEARTBEAT_MS);
|
|
143
|
+
timer.unref?.();
|
|
144
|
+
return timer;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function releaseOwnedLock(
|
|
148
|
+
lockPath,
|
|
149
|
+
handle,
|
|
150
|
+
heartbeatHandle,
|
|
151
|
+
heartbeatTimer,
|
|
152
|
+
token,
|
|
153
|
+
{
|
|
154
|
+
beforeReleaseUnlink = null,
|
|
155
|
+
reclaimDepth = 0,
|
|
156
|
+
serializeRelease = true,
|
|
157
|
+
} = {},
|
|
158
|
+
) {
|
|
159
|
+
let transitionGuard = null;
|
|
160
|
+
if (serializeRelease) {
|
|
161
|
+
// Reclaimers use the same guard while moving a stale lease aside. Holding
|
|
162
|
+
// it across token validation and unlink makes that ownership transition
|
|
163
|
+
// indivisible: an old owner cannot delete a replacement installed by a
|
|
164
|
+
// concurrent stale-lock reclaimer.
|
|
165
|
+
for (let attempt = 0; attempt < TRANSITION_GUARD_ATTEMPTS; attempt += 1) {
|
|
166
|
+
transitionGuard = await openLock(`${lockPath}.reclaim`, {
|
|
167
|
+
quietIfLocked: true,
|
|
168
|
+
reclaimDepth: reclaimDepth + 1,
|
|
169
|
+
// This short-lived internal guard is the serialization primitive; its
|
|
170
|
+
// own release must not recursively acquire another transition guard.
|
|
171
|
+
serializeRelease: false,
|
|
172
|
+
});
|
|
173
|
+
if (transitionGuard) break;
|
|
174
|
+
await new Promise((resolve) => setTimeout(resolve, TRANSITION_GUARD_RETRY_MS));
|
|
175
|
+
}
|
|
176
|
+
if (!transitionGuard) {
|
|
177
|
+
clearInterval(heartbeatTimer);
|
|
178
|
+
await handle.close().catch(() => {});
|
|
179
|
+
await heartbeatHandle?.close().catch(() => {});
|
|
180
|
+
await fs.unlink(heartbeatPathFor(lockPath, token)).catch(() => {});
|
|
181
|
+
throw new Error(`Timed out serializing lock release: ${lockPath}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
clearInterval(heartbeatTimer);
|
|
187
|
+
await handle.close().catch(() => {});
|
|
188
|
+
await heartbeatHandle?.close().catch(() => {});
|
|
189
|
+
const owner = parseLockOwner(await fs.readFile(lockPath, "utf8"));
|
|
190
|
+
if (owner?.token === token) {
|
|
191
|
+
if (typeof beforeReleaseUnlink === "function") {
|
|
192
|
+
await beforeReleaseUnlink({ lockPath });
|
|
193
|
+
}
|
|
194
|
+
await fs.unlink(lockPath).catch(() => {});
|
|
195
|
+
await fs.unlink(heartbeatPathFor(lockPath, token)).catch(() => {});
|
|
196
|
+
}
|
|
197
|
+
} catch (_e) {
|
|
198
|
+
// Missing or replaced owner paths are already released from this token's
|
|
199
|
+
// perspective. Other failures retain the conservative no-delete outcome.
|
|
200
|
+
} finally {
|
|
201
|
+
await transitionGuard?.release();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function openLock(
|
|
206
|
+
lockPath,
|
|
207
|
+
{
|
|
208
|
+
quietIfLocked = false,
|
|
209
|
+
beforeReclaim = null,
|
|
210
|
+
beforeReleaseUnlink = null,
|
|
211
|
+
reclaimDepth = 0,
|
|
212
|
+
serializeRelease = true,
|
|
213
|
+
} = {},
|
|
214
|
+
) {
|
|
53
215
|
try {
|
|
54
216
|
const handle = await fs.open(lockPath, "wx");
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
217
|
+
const token = crypto.randomUUID();
|
|
218
|
+
const heartbeatPath = heartbeatPathFor(lockPath, token);
|
|
219
|
+
let heartbeatHandle = null;
|
|
220
|
+
try {
|
|
221
|
+
await handle.writeFile(
|
|
222
|
+
JSON.stringify({ pid: process.pid, token, createdAt: new Date().toISOString() }) + "\n",
|
|
223
|
+
"utf8",
|
|
224
|
+
);
|
|
225
|
+
heartbeatHandle = await fs.open(heartbeatPath, "wx");
|
|
226
|
+
await heartbeatHandle.writeFile(`${token}\n`, "utf8");
|
|
227
|
+
const heartbeatTimer = startLockHeartbeat(handle, heartbeatHandle);
|
|
228
|
+
return {
|
|
229
|
+
async release() {
|
|
230
|
+
await releaseOwnedLock(
|
|
231
|
+
lockPath,
|
|
232
|
+
handle,
|
|
233
|
+
heartbeatHandle,
|
|
234
|
+
heartbeatTimer,
|
|
235
|
+
token,
|
|
236
|
+
{
|
|
237
|
+
beforeReleaseUnlink,
|
|
238
|
+
reclaimDepth,
|
|
239
|
+
serializeRelease,
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
} catch (e) {
|
|
245
|
+
await heartbeatHandle?.close().catch(() => {});
|
|
246
|
+
await fs.unlink(heartbeatPath).catch(() => {});
|
|
247
|
+
await handle.close().catch(() => {});
|
|
248
|
+
await fs.unlink(lockPath).catch(() => {});
|
|
249
|
+
throw e;
|
|
250
|
+
}
|
|
61
251
|
} catch (e) {
|
|
62
252
|
if (e && e.code === "EEXIST") {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
253
|
+
if (await existingLockCanBeReclaimed(lockPath)) {
|
|
254
|
+
if (typeof beforeReclaim === "function") {
|
|
255
|
+
await beforeReclaim({ lockPath });
|
|
256
|
+
}
|
|
257
|
+
if (reclaimDepth >= MAX_RECLAIM_DEPTH) return null;
|
|
258
|
+
|
|
259
|
+
// Serialize all reclaimers before re-checking the target. A stale
|
|
260
|
+
// check can be shared by many contenders; only the holder of this
|
|
261
|
+
// atomic guard may move the old lease out of the way.
|
|
262
|
+
const reclaimGuard = await openLock(`${lockPath}.reclaim`, {
|
|
263
|
+
quietIfLocked: true,
|
|
264
|
+
reclaimDepth: reclaimDepth + 1,
|
|
265
|
+
serializeRelease: false,
|
|
266
|
+
});
|
|
267
|
+
if (!reclaimGuard) return null;
|
|
268
|
+
|
|
269
|
+
let quarantinePath = null;
|
|
270
|
+
try {
|
|
271
|
+
// Re-check while holding the guard. A competing reclaimer may have
|
|
272
|
+
// already replaced the target since our initial stale inspection.
|
|
273
|
+
if (!(await existingLockCanBeReclaimed(lockPath))) return null;
|
|
274
|
+
|
|
275
|
+
quarantinePath = `${lockPath}.stale.${process.pid}.${crypto.randomUUID()}`;
|
|
276
|
+
try {
|
|
277
|
+
await fs.rename(lockPath, quarantinePath);
|
|
278
|
+
} catch (renameError) {
|
|
279
|
+
if (renameError?.code === "ENOENT") {
|
|
280
|
+
return openLock(lockPath, {
|
|
281
|
+
quietIfLocked,
|
|
282
|
+
beforeReleaseUnlink,
|
|
283
|
+
reclaimDepth,
|
|
284
|
+
serializeRelease,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
if (!quietIfLocked) {
|
|
288
|
+
process.stdout.write("Another sync is already running.\n");
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const staleOwner = parseLockOwner(
|
|
294
|
+
await fs.readFile(quarantinePath, "utf8").catch(() => ""),
|
|
295
|
+
);
|
|
296
|
+
if (staleOwner) {
|
|
297
|
+
// The heartbeat name contains the old token, so it cannot belong
|
|
298
|
+
// to a replacement lease created after the atomic rename.
|
|
299
|
+
await fs.unlink(heartbeatPathFor(lockPath, staleOwner.token)).catch(() => {});
|
|
300
|
+
}
|
|
301
|
+
return await openLock(lockPath, {
|
|
302
|
+
quietIfLocked,
|
|
303
|
+
beforeReleaseUnlink,
|
|
304
|
+
reclaimDepth,
|
|
305
|
+
serializeRelease,
|
|
306
|
+
});
|
|
307
|
+
} finally {
|
|
308
|
+
if (quarantinePath) await fs.unlink(quarantinePath).catch(() => {});
|
|
309
|
+
await reclaimGuard.release();
|
|
69
310
|
}
|
|
70
|
-
} catch (_statErr) {
|
|
71
|
-
// Lock file disappeared between checks, retry
|
|
72
|
-
return openLock(lockPath, { quietIfLocked });
|
|
73
311
|
}
|
|
74
312
|
if (!quietIfLocked) {
|
|
75
313
|
process.stdout.write("Another sync is already running.\n");
|
package/src/lib/local-api.js
CHANGED
|
@@ -35,6 +35,7 @@ const {
|
|
|
35
35
|
getModelPricing,
|
|
36
36
|
computeRowCost,
|
|
37
37
|
ensurePricingLoaded,
|
|
38
|
+
getPricingRevision,
|
|
38
39
|
} = require("./pricing");
|
|
39
40
|
|
|
40
41
|
const {
|
|
@@ -66,6 +67,33 @@ const CLAUDE_MEM_OBSERVER_PROJECT_KEY = deriveProjectKeyFromRef(
|
|
|
66
67
|
CLAUDE_MEM_OBSERVER_PROJECT_REF,
|
|
67
68
|
);
|
|
68
69
|
const PROJECT_USAGE_MAX_ENTRIES = 10;
|
|
70
|
+
const MAX_TIME_ZONE_CACHE_ENTRIES = 16;
|
|
71
|
+
|
|
72
|
+
// The native dashboard fans one refresh out across several local endpoints.
|
|
73
|
+
// Keep one immutable, deduped view of the current queue so those endpoints do
|
|
74
|
+
// not all read and JSON.parse the same append-only file. A file identity +
|
|
75
|
+
// nanosecond timestamp signature invalidates the cache immediately on append,
|
|
76
|
+
// in-place rewrite, or atomic replacement.
|
|
77
|
+
let queueDataCache = null;
|
|
78
|
+
const dailyAggregationCache = new WeakMap();
|
|
79
|
+
const zonedPartsFormatters = new Map();
|
|
80
|
+
|
|
81
|
+
function boundedCacheSet(cache, key, value, maxEntries) {
|
|
82
|
+
if (cache.has(key)) cache.delete(key);
|
|
83
|
+
cache.set(key, value);
|
|
84
|
+
while (cache.size > maxEntries) {
|
|
85
|
+
cache.delete(cache.keys().next().value);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function queueFileSignature(queuePath) {
|
|
90
|
+
const stat = fs.statSync(queuePath, { bigint: true });
|
|
91
|
+
return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function timeZoneContextKey({ timeZone, offsetMinutes } = {}) {
|
|
95
|
+
return `${timeZone || ""}|${Number.isFinite(offsetMinutes) ? offsetMinutes : ""}|p${getPricingRevision()}`;
|
|
96
|
+
}
|
|
69
97
|
|
|
70
98
|
// Shared by project-usage-summary and project-usage-detail: reads the
|
|
71
99
|
// deduped project bucket log, drops the claude-mem pseudo project, and
|
|
@@ -160,6 +188,21 @@ function normalizeQueueRow(row) {
|
|
|
160
188
|
}
|
|
161
189
|
|
|
162
190
|
function readQueueData(queuePath) {
|
|
191
|
+
let signature;
|
|
192
|
+
try {
|
|
193
|
+
signature = queueFileSignature(queuePath);
|
|
194
|
+
} catch (e) {
|
|
195
|
+
if (queueDataCache?.queuePath === queuePath) queueDataCache = null;
|
|
196
|
+
if (e?.code !== "ENOENT") {
|
|
197
|
+
console.error("[LocalAPI] readQueueData: failed to stat queue:", e?.message || e);
|
|
198
|
+
}
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (queueDataCache?.queuePath === queuePath && queueDataCache.signature === signature) {
|
|
203
|
+
return queueDataCache.rows;
|
|
204
|
+
}
|
|
205
|
+
|
|
163
206
|
let raw;
|
|
164
207
|
try {
|
|
165
208
|
raw = fs.readFileSync(queuePath, "utf8");
|
|
@@ -196,7 +239,9 @@ function readQueueData(queuePath) {
|
|
|
196
239
|
const key = `${row.source || ""}|${row.model || ""}|${row.hour_start || ""}`;
|
|
197
240
|
seen.set(key, normalizeQueueRow(row));
|
|
198
241
|
}
|
|
199
|
-
|
|
242
|
+
const rows = Array.from(seen.values());
|
|
243
|
+
queueDataCache = { queuePath, signature, rows };
|
|
244
|
+
return rows;
|
|
200
245
|
}
|
|
201
246
|
|
|
202
247
|
function rowDayKey(row, timeZoneContext) {
|
|
@@ -214,8 +259,19 @@ function rowDayKey(row, timeZoneContext) {
|
|
|
214
259
|
}
|
|
215
260
|
|
|
216
261
|
function aggregateByDay(rows, timeZoneContext = null) {
|
|
262
|
+
const normalizedRows = Array.isArray(rows) ? rows : [];
|
|
263
|
+
const cacheKey = timeZoneContextKey(timeZoneContext || {});
|
|
264
|
+
let cachedByTimeZone = dailyAggregationCache.get(normalizedRows);
|
|
265
|
+
if (cachedByTimeZone?.has(cacheKey)) {
|
|
266
|
+
const cached = cachedByTimeZone.get(cacheKey);
|
|
267
|
+
// Touch the entry so the small per-queue map behaves as an LRU.
|
|
268
|
+
cachedByTimeZone.delete(cacheKey);
|
|
269
|
+
cachedByTimeZone.set(cacheKey, cached);
|
|
270
|
+
return cached;
|
|
271
|
+
}
|
|
272
|
+
|
|
217
273
|
const byDay = new Map();
|
|
218
|
-
for (const row of
|
|
274
|
+
for (const row of normalizedRows) {
|
|
219
275
|
if (!row.hour_start) continue;
|
|
220
276
|
const day = rowDayKey(row, timeZoneContext);
|
|
221
277
|
if (!day) continue;
|
|
@@ -250,7 +306,13 @@ function aggregateByDay(rows, timeZoneContext = null) {
|
|
|
250
306
|
const model = row.model || "unknown";
|
|
251
307
|
a.models[model] = (a.models[model] || 0) + (row.total_tokens || 0);
|
|
252
308
|
}
|
|
253
|
-
|
|
309
|
+
const daily = Array.from(byDay.values()).sort((a, b) => a.day.localeCompare(b.day));
|
|
310
|
+
if (!cachedByTimeZone) {
|
|
311
|
+
cachedByTimeZone = new Map();
|
|
312
|
+
dailyAggregationCache.set(normalizedRows, cachedByTimeZone);
|
|
313
|
+
}
|
|
314
|
+
boundedCacheSet(cachedByTimeZone, cacheKey, daily, MAX_TIME_ZONE_CACHE_ENTRIES);
|
|
315
|
+
return daily;
|
|
254
316
|
}
|
|
255
317
|
|
|
256
318
|
function buildCodexCategoryFallbackFromQueue(queueRows, { from, to, timeZoneContext }) {
|
|
@@ -477,17 +539,34 @@ function getZonedParts(date, { timeZone, offsetMinutes } = {}) {
|
|
|
477
539
|
|
|
478
540
|
if (timeZone && typeof Intl !== "undefined" && Intl.DateTimeFormat) {
|
|
479
541
|
try {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
542
|
+
let formatter;
|
|
543
|
+
if (zonedPartsFormatters.has(timeZone)) {
|
|
544
|
+
formatter = zonedPartsFormatters.get(timeZone);
|
|
545
|
+
} else {
|
|
546
|
+
try {
|
|
547
|
+
formatter = new Intl.DateTimeFormat("en-CA", {
|
|
548
|
+
timeZone,
|
|
549
|
+
year: "numeric",
|
|
550
|
+
month: "2-digit",
|
|
551
|
+
day: "2-digit",
|
|
552
|
+
hour: "2-digit",
|
|
553
|
+
minute: "2-digit",
|
|
554
|
+
second: "2-digit",
|
|
555
|
+
hourCycle: "h23",
|
|
556
|
+
});
|
|
557
|
+
} catch {
|
|
558
|
+
// Cache invalid zone ids too; otherwise a bad local query would
|
|
559
|
+
// throw once per queue row before falling back to its fixed offset.
|
|
560
|
+
formatter = null;
|
|
561
|
+
}
|
|
562
|
+
boundedCacheSet(
|
|
563
|
+
zonedPartsFormatters,
|
|
564
|
+
timeZone,
|
|
565
|
+
formatter,
|
|
566
|
+
MAX_TIME_ZONE_CACHE_ENTRIES,
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
const parts = formatter?.formatToParts(dt) || [];
|
|
491
570
|
const values = parts.reduce((acc, part) => {
|
|
492
571
|
if (part.type && part.value) acc[part.type] = part.value;
|
|
493
572
|
return acc;
|
|
@@ -685,6 +764,7 @@ function runSyncCommand(extraEnv = {}, opts = {}) {
|
|
|
685
764
|
const args = [TRACKER_BIN, "sync"];
|
|
686
765
|
if (opts.auto === true) args.push("--auto");
|
|
687
766
|
if (opts.background === true) args.push("--background");
|
|
767
|
+
if (opts.publishAccount === true) args.push("--publish-account");
|
|
688
768
|
if (opts.allLocalSources === true) args.push("--all-local-sources");
|
|
689
769
|
if (opts.drain === true) args.push("--drain");
|
|
690
770
|
const child = spawn(process.execPath, args, {
|
|
@@ -1696,6 +1776,11 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
1696
1776
|
const auto = body.auto === true && !drain;
|
|
1697
1777
|
const background =
|
|
1698
1778
|
auto && (body.background === true || body.lightweight === true);
|
|
1779
|
+
// The local server is the trust boundary for cloud-sync preferences.
|
|
1780
|
+
// A persisted device token must not let a native background request
|
|
1781
|
+
// upload after the user has explicitly disabled cloud sync.
|
|
1782
|
+
const publishAccount =
|
|
1783
|
+
background && body.publishAccount === true && getCloudSyncPref();
|
|
1699
1784
|
const allLocalSources = background && body.allLocalSources === true;
|
|
1700
1785
|
if (typeof body.deviceToken === "string" && body.deviceToken.trim()) {
|
|
1701
1786
|
extraEnv.TOKENTRACKER_DEVICE_TOKEN = body.deviceToken.trim();
|
|
@@ -1710,7 +1795,10 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
1710
1795
|
extraEnv.TOKENTRACKER_INSFORGE_BASE_URL = allowedBaseUrl;
|
|
1711
1796
|
localSyncBaseUrl = allowedBaseUrl;
|
|
1712
1797
|
}
|
|
1713
|
-
if (!background
|
|
1798
|
+
if ((!background || publishAccount) &&
|
|
1799
|
+
!extraEnv.TOKENTRACKER_DEVICE_TOKEN &&
|
|
1800
|
+
getCloudSyncPref() &&
|
|
1801
|
+
getRefreshTokenForCloud()) {
|
|
1714
1802
|
let issuedToken = null;
|
|
1715
1803
|
try {
|
|
1716
1804
|
issuedToken = await issueDeviceTokenForLocalSync(qp, { baseUrl: localSyncBaseUrl });
|
|
@@ -1732,6 +1820,7 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
1732
1820
|
drain,
|
|
1733
1821
|
auto,
|
|
1734
1822
|
background,
|
|
1823
|
+
publishAccount,
|
|
1735
1824
|
allLocalSources,
|
|
1736
1825
|
});
|
|
1737
1826
|
try {
|
|
@@ -1778,7 +1867,8 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
1778
1867
|
const to = url.searchParams.get("to") || "";
|
|
1779
1868
|
const timeZoneContext = getTimeZoneContext(url);
|
|
1780
1869
|
const { rows, scope, excludedSources } = scopedQueueRows(qp, url);
|
|
1781
|
-
const
|
|
1870
|
+
const allDaily = aggregateByDay(rows, timeZoneContext);
|
|
1871
|
+
const daily = allDaily.filter((d) => d.day >= from && d.day <= to);
|
|
1782
1872
|
const totals = daily.reduce(
|
|
1783
1873
|
(acc, r) => {
|
|
1784
1874
|
acc.total_tokens += r.total_tokens;
|
|
@@ -1798,7 +1888,6 @@ function createLocalApiHandler({ queuePath }) {
|
|
|
1798
1888
|
|
|
1799
1889
|
const todayParts = getZonedParts(new Date(), timeZoneContext);
|
|
1800
1890
|
const todayStr = formatPartsDayKey(todayParts) || new Date().toISOString().slice(0, 10);
|
|
1801
|
-
const allDaily = aggregateByDay(rows, timeZoneContext);
|
|
1802
1891
|
|
|
1803
1892
|
const shiftDay = (dayStr, delta) => {
|
|
1804
1893
|
const d = new Date(`${dayStr}T00:00:00Z`);
|
package/src/lib/pricing/index.js
CHANGED
|
@@ -37,6 +37,7 @@ const seedRaw = loadSeedSync();
|
|
|
37
37
|
const state = {
|
|
38
38
|
loaded: false,
|
|
39
39
|
loadingPromise: null,
|
|
40
|
+
revision: 0,
|
|
40
41
|
litellmRawMap: seedRaw, // raw per-token; field shape from LiteLLM JSON
|
|
41
42
|
litellmPerMillionMap: buildLitellmPerMillionMap(seedRaw), // USD/MTok
|
|
42
43
|
source: Object.keys(seedRaw).length ? "seed-snapshot:sync" : null,
|
|
@@ -61,6 +62,7 @@ async function ensurePricingLoaded(opts = {}) {
|
|
|
61
62
|
state.litellmPerMillionMap = buildLitellmPerMillionMap(state.litellmRawMap);
|
|
62
63
|
state.source = source;
|
|
63
64
|
state.loaded = true;
|
|
65
|
+
state.revision += 1;
|
|
64
66
|
state.negativeCache.clear();
|
|
65
67
|
return state;
|
|
66
68
|
} finally {
|
|
@@ -79,9 +81,14 @@ function resetPricingForTests() {
|
|
|
79
81
|
state.litellmRawMap = seedRaw;
|
|
80
82
|
state.litellmPerMillionMap = buildLitellmPerMillionMap(seedRaw);
|
|
81
83
|
state.source = Object.keys(seedRaw).length ? "seed-snapshot:sync" : null;
|
|
84
|
+
state.revision += 1;
|
|
82
85
|
state.negativeCache.clear();
|
|
83
86
|
}
|
|
84
87
|
|
|
88
|
+
function getPricingRevision() {
|
|
89
|
+
return state.revision;
|
|
90
|
+
}
|
|
91
|
+
|
|
85
92
|
function getModelPricing(model, opts = {}) {
|
|
86
93
|
if (!model) return ZERO_PRICING;
|
|
87
94
|
let lookupSource = null;
|
|
@@ -133,6 +140,7 @@ const MODEL_PRICING = curatedOverrides.exact;
|
|
|
133
140
|
|
|
134
141
|
module.exports = {
|
|
135
142
|
ensurePricingLoaded,
|
|
143
|
+
getPricingRevision,
|
|
136
144
|
getModelPricing,
|
|
137
145
|
computeRowCost,
|
|
138
146
|
resetPricingForTests,
|