tokentracker-cli 0.81.2 → 0.82.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/dashboard/dist/assets/AchievementBadge-D6iPEEbz.js +1 -0
- package/dashboard/dist/assets/AchievementsPage-Jry355YE.js +2 -0
- package/dashboard/dist/assets/AchievementsSection-ah-kG00E.js +1 -0
- package/dashboard/dist/assets/BadgeDetailModal-FyvUrVvF.js +1 -0
- package/dashboard/dist/assets/Card-C3nLScju.js +1 -0
- package/dashboard/dist/assets/ClawdAnimated-CLHhE26z.js +1 -0
- package/dashboard/dist/assets/CommunityStatsModal-DA3PHLV5.js +1 -0
- package/dashboard/dist/assets/ConfirmModal-CTDarLrD.js +1 -0
- package/dashboard/dist/assets/DashboardPage-DZIiWc3Y.js +1 -0
- package/dashboard/dist/assets/DevicePage-CyXjUO8W.js +1 -0
- package/dashboard/dist/assets/DialogDescription-ahxSNUcB.js +1 -0
- package/dashboard/dist/assets/DialogTitle-z86PHHyk.js +1 -0
- package/dashboard/dist/assets/FadeIn-BDWtfIsO.js +1 -0
- package/dashboard/dist/assets/HeaderGithubStar-DE1USQbb.js +1 -0
- package/dashboard/dist/assets/{IpCheckPage-CMf9CHFr.js → IpCheckPage-B2SkgT0P.js} +3 -3
- package/dashboard/dist/assets/{LandingPage-BT61_TqT.js → LandingPage-PU5gxsfY.js} +165 -165
- package/dashboard/dist/assets/LeaderboardAvatar-CaOV_hFE.js +1 -0
- package/dashboard/dist/assets/LeaderboardPage-B0JtEyRP.js +2 -0
- package/dashboard/dist/assets/{LeaderboardProfileModal-C_kxqOMn.js → LeaderboardProfileModal-DtNEbVH8.js} +4 -4
- package/dashboard/dist/assets/{LeaderboardProfilePage-zgNsE0EQ.js → LeaderboardProfilePage-DRklax3-.js} +3 -3
- package/dashboard/dist/assets/LimitsPage-Dg0cJa40.js +4 -0
- package/dashboard/dist/assets/LocalOnlyNotice-si9PMKya.js +1 -0
- package/dashboard/dist/assets/LoginPage-DgLwhXKo.js +1 -0
- package/dashboard/dist/assets/PetPage-DEZbjk99.js +1 -0
- package/dashboard/dist/assets/PopoverPopup-DX7v1XfE.js +1 -0
- package/dashboard/dist/assets/ResetPasswordPage-C6NvRO-Q.js +1 -0
- package/dashboard/dist/assets/Select-CH2xVU8X.js +1 -0
- package/dashboard/dist/assets/SelectItemText-CCW6JeeN.js +1 -0
- package/dashboard/dist/assets/SettingsPage-Cm3uT11D.js +1 -0
- package/dashboard/dist/assets/SkillsPage-CljRL6yF.js +1 -0
- package/dashboard/dist/assets/TrendMonitor-DM1_WNfw.js +19 -0
- package/dashboard/dist/assets/WidgetsPage-mJ1j6hly.js +1 -0
- package/dashboard/dist/assets/WrappedPage-Dz71ln4t.js +1 -0
- package/dashboard/dist/assets/__vite-browser-external-C7JwykID.css +1 -0
- package/dashboard/dist/assets/__vite-browser-external-Eoyj4eG-.js +1341 -0
- package/dashboard/dist/assets/agent-logos-D3K_ZRUj.js +1 -0
- package/dashboard/dist/assets/arrow-up-right-CT4-gmOS.js +1 -0
- package/dashboard/dist/assets/check-B87_qvAg.js +1 -0
- package/dashboard/dist/assets/copy-Dtr68epm.js +1 -0
- package/dashboard/dist/assets/download-DaWsA_Gj.js +1 -0
- package/dashboard/dist/assets/flame-vLBOq0X0.js +1 -0
- package/dashboard/dist/assets/format-tokens-C6Pidz3s.js +1 -0
- package/dashboard/dist/assets/icons-BaaYJxUN.js +1 -0
- package/dashboard/dist/assets/index-lKnLA6Pd.js +1 -0
- package/dashboard/dist/assets/info-B46Qt16h.js +1 -0
- package/dashboard/dist/assets/limits-providers-13af4PEg.js +1 -0
- package/dashboard/dist/assets/link-2-DeN6MJpT.js +1 -0
- package/dashboard/dist/assets/loader-circle-ByEuaaSw.js +1 -0
- package/dashboard/dist/assets/provider-display-CKbOKeV4.js +42 -0
- package/dashboard/dist/assets/terminal-DyicX7lG.js +1 -0
- package/dashboard/dist/assets/trash-2-CVCC3DtQ.js +1 -0
- package/dashboard/dist/assets/use-community-stats-CExk3D6G.js +1 -0
- package/dashboard/dist/assets/use-limits-display-prefs-CRl4uU95.js +1 -0
- package/dashboard/dist/assets/use-native-settings-Bi6E9dsE.js +1 -0
- package/dashboard/dist/assets/{use-ordered-list-DPbiKnPc.js → use-ordered-list-Dk0p2otw.js} +2 -2
- package/dashboard/dist/assets/use-session-efficiency-pref-YAG-OYIh.js +1 -0
- package/dashboard/dist/assets/use-transform-lflyghWN.js +1 -0
- package/dashboard/dist/assets/use-usage-limits-DJXvJ25Q.js +1 -0
- package/dashboard/dist/assets/useCurrency-BtHtcsO5.js +1 -0
- package/dashboard/dist/assets/useScrollLock-C2C-Yazq.js +1 -0
- package/dashboard/dist/assets/useTokenFormat-BdD057ih.js +1 -0
- package/dashboard/dist/assets/zap-DWWma29E.js +1 -0
- package/dashboard/dist/index.html +2 -2
- package/dashboard/dist/share.html +2 -2
- package/package.json +1 -1
- package/src/cli.js +6 -0
- package/src/commands/serve.js +8 -6
- package/src/commands/sessions.js +29 -0
- 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/context-health.js +124 -0
- 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/git-outcomes.js +147 -0
- package/src/lib/local-api.js +151 -20
- package/src/lib/outcomes-engine.js +23 -0
- 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/src/lib/session-analytics.js +612 -0
- package/src/lib/usage-limits.js +21 -0
- 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/LimitsPage-BiQpqxj8.js +0 -4
- 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");
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Conservative, metadata-only Git attribution. A commit is attributed only
|
|
4
|
+
// when exactly one local AI session overlaps its author timestamp.
|
|
5
|
+
|
|
6
|
+
const { execFile } = require("node:child_process");
|
|
7
|
+
const fs = require("node:fs");
|
|
8
|
+
const fsp = require("node:fs/promises");
|
|
9
|
+
const os = require("node:os");
|
|
10
|
+
const path = require("node:path");
|
|
11
|
+
const crypto = require("node:crypto");
|
|
12
|
+
|
|
13
|
+
function resolveAutoOutcomesPath(home = os.homedir()) {
|
|
14
|
+
return path.join(home, ".tokentracker", "tracker", "auto-outcomes.jsonl");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readJsonl(filePath) {
|
|
18
|
+
try { return fs.readFileSync(filePath, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line)); }
|
|
19
|
+
catch { return []; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Async on purpose: this runs inside the local API request path, where a
|
|
23
|
+
// synchronous spawn would freeze every other endpoint for the duration of
|
|
24
|
+
// each git invocation.
|
|
25
|
+
function runGit(cwd, args) {
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
execFile(
|
|
28
|
+
"git",
|
|
29
|
+
args,
|
|
30
|
+
{ cwd, encoding: "utf8", timeout: 15_000, maxBuffer: 32 * 1024 * 1024 },
|
|
31
|
+
(error, stdout) => resolve(error ? "" : String(stdout).trim()),
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function repoRoot(cwd) {
|
|
37
|
+
if (!cwd || !fs.existsSync(cwd)) return null;
|
|
38
|
+
return (await runGit(cwd, ["rev-parse", "--show-toplevel"])) || null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function commitsForWindow(root, from, to) {
|
|
42
|
+
const format = "%H%x1f%aI%x1f%P%x1f%s%x1e";
|
|
43
|
+
const raw = await runGit(root, ["log", "--all", `--since=${from}`, `--until=${to}`, `--format=${format}`]);
|
|
44
|
+
if (!raw) return [];
|
|
45
|
+
return raw.split("\x1e").map((item) => item.trim()).filter(Boolean).map((item) => {
|
|
46
|
+
const [sha, timestamp, parents, subject] = item.split("\x1f");
|
|
47
|
+
return {
|
|
48
|
+
sha,
|
|
49
|
+
timestamp,
|
|
50
|
+
parent_count: String(parents || "").split(/\s+/).filter(Boolean).length,
|
|
51
|
+
reverted: /^revert\b/i.test(subject || ""),
|
|
52
|
+
};
|
|
53
|
+
}).filter((row) => row.sha && Number.isFinite(Date.parse(row.timestamp)));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function writeAtomic(filePath, content) {
|
|
57
|
+
await fsp.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
58
|
+
const temp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
59
|
+
await fsp.writeFile(temp, content, { encoding: "utf8", mode: 0o600 });
|
|
60
|
+
await fsp.rename(temp, filePath);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function buildGitOutcomesInternal(sessions, { home = os.homedir(), force = false, maxAgeDays = 90 } = {}) {
|
|
64
|
+
const groups = new Map();
|
|
65
|
+
const rootCache = new Map();
|
|
66
|
+
const cutoff = Date.now() - Math.max(1, Number(maxAgeDays) || 90) * 86400_000;
|
|
67
|
+
for (const session of sessions || []) {
|
|
68
|
+
if (!session?.project_ref || !session.started_at || !session.ended_at) continue;
|
|
69
|
+
if (Date.parse(session.ended_at) < cutoff) continue;
|
|
70
|
+
let root = rootCache.get(session.project_ref);
|
|
71
|
+
if (root === undefined) {
|
|
72
|
+
root = await repoRoot(session.project_ref);
|
|
73
|
+
rootCache.set(session.project_ref, root);
|
|
74
|
+
}
|
|
75
|
+
if (!root) continue;
|
|
76
|
+
if (!groups.has(root)) groups.set(root, []);
|
|
77
|
+
groups.get(root).push(session);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const outputPath = resolveAutoOutcomesPath(home);
|
|
81
|
+
const metaPath = `${outputPath}.meta.json`;
|
|
82
|
+
const signatureHash = crypto.createHash("sha256");
|
|
83
|
+
for (const [root, repoSessions] of [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
84
|
+
signatureHash.update(`${root}\0${await runGit(root, ["for-each-ref", "--format=%(objectname)"])}\n`);
|
|
85
|
+
for (const session of repoSessions) signatureHash.update(`${session.session_hash}\0${session.ended_at}\n`);
|
|
86
|
+
}
|
|
87
|
+
const signature = signatureHash.digest("hex");
|
|
88
|
+
if (!force) {
|
|
89
|
+
try {
|
|
90
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, "utf8"));
|
|
91
|
+
if (meta.version === 1 && meta.signature === signature) return readJsonl(outputPath);
|
|
92
|
+
} catch { /* first run */ }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const outcomes = [];
|
|
96
|
+
for (const [root, repoSessions] of groups) {
|
|
97
|
+
const start = repoSessions.reduce((min, row) => !min || row.started_at < min ? row.started_at : min, "");
|
|
98
|
+
const endMs = Math.max(...repoSessions.map((row) => Date.parse(row.ended_at))) + 60 * 60 * 1000;
|
|
99
|
+
const commits = await commitsForWindow(root, start, new Date(endMs).toISOString());
|
|
100
|
+
for (const commit of commits) {
|
|
101
|
+
const commitMs = Date.parse(commit.timestamp);
|
|
102
|
+
const candidates = repoSessions.filter((session) => {
|
|
103
|
+
const sessionStart = Date.parse(session.started_at) - 10 * 60 * 1000;
|
|
104
|
+
const sessionEnd = Date.parse(session.ended_at) + 60 * 60 * 1000;
|
|
105
|
+
return commitMs >= sessionStart && commitMs <= sessionEnd;
|
|
106
|
+
});
|
|
107
|
+
if (candidates.length !== 1) continue;
|
|
108
|
+
const session = candidates[0];
|
|
109
|
+
outcomes.push({
|
|
110
|
+
timestamp: commit.timestamp,
|
|
111
|
+
model: session.model || "unknown",
|
|
112
|
+
tool: session.source || "unknown",
|
|
113
|
+
accepted: !commit.reverted,
|
|
114
|
+
task_type: "git_commit",
|
|
115
|
+
status: commit.reverted ? "reverted" : "committed",
|
|
116
|
+
session_hash: session.session_hash,
|
|
117
|
+
commit_hash: commit.sha.slice(0, 12),
|
|
118
|
+
parent_count: commit.parent_count,
|
|
119
|
+
confidence: "heuristic",
|
|
120
|
+
methodology: "single-overlapping-session",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
outcomes.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
125
|
+
await writeAtomic(outputPath, outcomes.map((row) => JSON.stringify(row)).join("\n") + (outcomes.length ? "\n" : ""));
|
|
126
|
+
await writeAtomic(metaPath, `${JSON.stringify({ version: 1, signature, generated_at: new Date().toISOString(), max_age_days: maxAgeDays })}\n`);
|
|
127
|
+
return outcomes;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Overlapping outcomes/session-insights requests must not spawn duplicate git
|
|
131
|
+
// scans nor race the atomic sidecar write; share one build per home.
|
|
132
|
+
const gitOutcomesBuilds = new Map();
|
|
133
|
+
|
|
134
|
+
function buildGitOutcomes(sessions, options = {}) {
|
|
135
|
+
const home = path.resolve(String(options.home || os.homedir()));
|
|
136
|
+
const existing = gitOutcomesBuilds.get(home);
|
|
137
|
+
if (existing) return existing;
|
|
138
|
+
const promise = buildGitOutcomesInternal(sessions, { ...options, home });
|
|
139
|
+
gitOutcomesBuilds.set(home, promise);
|
|
140
|
+
const clear = () => {
|
|
141
|
+
if (gitOutcomesBuilds.get(home) === promise) gitOutcomesBuilds.delete(home);
|
|
142
|
+
};
|
|
143
|
+
promise.then(clear, clear);
|
|
144
|
+
return promise;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = { resolveAutoOutcomesPath, buildGitOutcomes, repoRoot, commitsForWindow };
|