@vimoxshah/tokenflow 1.1.0 → 1.1.2

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.
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The watcher's single-instance lock.
3
+ *
4
+ * A pidfile alone cannot answer "is my watcher running?". PID numbers restart
5
+ * at boot and the kernel reuses them, so a pidfile that outlives a reboot
6
+ * eventually names somebody else's process. That is not hypothetical: a lock
7
+ * left at pid 810 was inherited by `/usr/libexec/mobilerepaird` after a
8
+ * restart, `kill(810, 0)` kept succeeding, and every `tokenflow watch` — from
9
+ * the launch agent and from the menu bar's play button alike — refused to
10
+ * start with "a watcher is already running" for days. The data silently went
11
+ * stale behind a lock held by a phantom.
12
+ *
13
+ * So the lock records an IDENTITY, not just a number:
14
+ *
15
+ * pid the process to signal
16
+ * boot epoch ms of the boot the pid was issued by
17
+ * startedAt when the watcher took the lock (human-readable diagnostics)
18
+ *
19
+ * A lock is live only when the pid is alive AND its boot stamp matches this
20
+ * boot. A pidfile from an earlier boot is stale by construction, whoever holds
21
+ * that number now.
22
+ *
23
+ * Legacy bare-number pidfiles carry no boot stamp, so they fall back to asking
24
+ * the OS who owns the number: a command line without "tokenflow" in it is
25
+ * somebody else's process and the lock is stale.
26
+ *
27
+ * This module owns the lock so that both the watcher and the read-only status
28
+ * surfaces can consult it without importing each other.
29
+ */
30
+ import fs from 'node:fs';
31
+ import os from 'node:os';
32
+ import { execFileSync } from 'node:child_process';
33
+ import { paths } from './config.js';
34
+
35
+ export const LOCK_VERSION = 2;
36
+
37
+ /**
38
+ * Boot stamps are derived from uptime, which the OS reports in whole seconds,
39
+ * so two readings inside one boot can differ by a second or two. Anything
40
+ * inside this window is the same boot; a reboot moves the stamp by at least
41
+ * the previous session's uptime.
42
+ */
43
+ const BOOT_TOLERANCE_MS = 30000;
44
+
45
+ /** Epoch ms of the last boot. Stable to ~1s for the life of the boot. */
46
+ export function bootTimeMs() {
47
+ return Date.now() - os.uptime() * 1000;
48
+ }
49
+
50
+ /**
51
+ * @typedef {{pid:number, boot:number|null, startedAt:string|null, legacy:boolean}} WatchLock
52
+ */
53
+
54
+ /** Read the lock file. Accepts both the JSON form and the legacy bare number. */
55
+ export function readLock() {
56
+ let raw;
57
+ try {
58
+ raw = fs.readFileSync(paths().watchPid, 'utf8').trim();
59
+ } catch {
60
+ return null;
61
+ }
62
+ if (!raw) return null;
63
+ if (raw.startsWith('{')) {
64
+ try {
65
+ const o = JSON.parse(raw);
66
+ const pid = Number(o.pid);
67
+ if (!Number.isFinite(pid) || pid <= 0) return null;
68
+ const boot = Number(o.boot);
69
+ return {
70
+ pid,
71
+ boot: Number.isFinite(boot) ? boot : null,
72
+ startedAt: typeof o.startedAt === 'string' ? o.startedAt : null,
73
+ legacy: false,
74
+ };
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+ const pid = Number(raw);
80
+ if (!Number.isFinite(pid) || pid <= 0) return null;
81
+ return { pid, boot: null, startedAt: null, legacy: true };
82
+ }
83
+
84
+ /** Just the pid, for callers that only want to signal it. */
85
+ export function readLockPid() {
86
+ return readLock()?.pid ?? null;
87
+ }
88
+
89
+ /** Does SOME process hold this pid? (EPERM = alive, owned by someone else.) */
90
+ export function processAlive(pid) {
91
+ if (!Number.isFinite(pid) || pid <= 0) return false;
92
+ try {
93
+ process.kill(pid, 0);
94
+ return true;
95
+ } catch (err) {
96
+ return err.code !== 'ESRCH';
97
+ }
98
+ }
99
+
100
+ /**
101
+ * The command line behind a pid, or null when the OS will not say.
102
+ * Cheap enough for lock checks; never called in a loop.
103
+ */
104
+ export function processCommand(pid) {
105
+ try {
106
+ if (process.platform === 'linux') {
107
+ const raw = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8');
108
+ return raw.replace(/\0/g, ' ').trim() || null;
109
+ }
110
+ const out = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
111
+ encoding: 'utf8',
112
+ timeout: 2000,
113
+ stdio: ['ignore', 'pipe', 'ignore'],
114
+ });
115
+ return out.trim() || null;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Is this lock held by a live watcher of OURS?
123
+ *
124
+ * @param {WatchLock|null} lock
125
+ */
126
+ export function lockIsLive(lock) {
127
+ if (!lock || !processAlive(lock.pid)) return false;
128
+ if (lock.boot !== null) {
129
+ return Math.abs(lock.boot - bootTimeMs()) <= BOOT_TOLERANCE_MS;
130
+ }
131
+ // No boot stamp to check: ask who owns the number instead. When the OS
132
+ // will not say, keep the lock — refusing to start is safer than two
133
+ // watchers racing on one store.
134
+ const cmd = processCommand(lock.pid);
135
+ if (cmd === null) return true;
136
+ return /tokenflow/i.test(cmd);
137
+ }
138
+
139
+ /** Write this process in as the lock holder. */
140
+ export function writeLock() {
141
+ fs.writeFileSync(
142
+ paths().watchPid,
143
+ `${JSON.stringify({
144
+ v: LOCK_VERSION,
145
+ pid: process.pid,
146
+ boot: Math.round(bootTimeMs()),
147
+ startedAt: new Date().toISOString(),
148
+ })}\n`,
149
+ );
150
+ }
151
+
152
+ /** Remove the lock file. */
153
+ export function clearLock() {
154
+ try {
155
+ fs.unlinkSync(paths().watchPid);
156
+ return true;
157
+ } catch {
158
+ return false; // already gone — that is fine
159
+ }
160
+ }
package/src/core/watch.js CHANGED
@@ -8,8 +8,11 @@
8
8
  *
9
9
  * Design constraints this file takes seriously:
10
10
  *
11
- * Single instance. A pidfile guards against two watchers racing on the same
12
- * store; a stale pidfile from a crashed run is detected and replaced.
11
+ * Single instance. An identity-bearing lock guards against two watchers
12
+ * racing on the same store. It records the boot the pid was issued by, so a
13
+ * lock that outlived a reboot is stale by construction rather than being
14
+ * trusted because the kernel handed its number to something else
15
+ * (see watch-lock.js).
13
16
  * Failure isolation. One bad provider cannot stop the loop: refresh errors
14
17
  * are recorded into the status file and back off exponentially instead.
15
18
  * Sleep/wake. The loop reschedules from wall-clock reality every tick, so a
@@ -18,12 +21,14 @@
18
21
  * Nothing leaves the machine. Refresh reads local logs; notifications go to
19
22
  * the local OS; the status file stays in $TOKENFLOW_HOME.
20
23
  */
21
- import fs from 'node:fs';
22
- import { loadConfig, paths, ensureDirs } from './config.js';
24
+ import { loadConfig, ensureDirs } from './config.js';
23
25
  import { refresh } from './ingest.js';
24
26
  import { listProviders } from './registry.js';
25
27
  import { buildLiveStatus, writeLiveStatus, readLiveStatus } from './live-status.js';
26
28
  import { notify as osNotify } from './notify.js';
29
+ import {
30
+ bootTimeMs, clearLock, lockIsLive, processAlive, readLock, readLockPid, writeLock,
31
+ } from './watch-lock.js';
27
32
 
28
33
  const MAX_BACKOFF_MS = 15 * 60 * 1000;
29
34
 
@@ -100,25 +105,9 @@ function humanize(ms) {
100
105
 
101
106
  // ------------------------------------------------------------- instance ----
102
107
 
103
- function readLockPid() {
104
- try {
105
- const pid = Number(fs.readFileSync(paths().watchPid, 'utf8').trim());
106
- return Number.isFinite(pid) && pid > 0 ? pid : null;
107
- } catch {
108
- return null;
109
- }
110
- }
111
-
112
- function processAlive(pid) {
113
- try {
114
- process.kill(pid, 0);
115
- return true;
116
- } catch (err) {
117
- // ESRCH = no such process; EPERM = alive but owned by someone else.
118
- return err.code !== 'ESRCH';
119
- }
120
- }
121
- export { processAlive };
108
+ // The lock itself lives in watch-lock.js so read-only status surfaces can ask
109
+ // "is a watcher running?" without importing the daemon.
110
+ export { processAlive, readLock, lockIsLive, bootTimeMs };
122
111
 
123
112
  /** Set while THIS process runs a watcher loop — guards same-process double start. */
124
113
  let ownedHere = false;
@@ -133,40 +122,37 @@ export function acquireWatchLock() {
133
122
  throw new Error('a watcher is already running in this process');
134
123
  }
135
124
  ensureDirs();
136
- const existing = readLockPid();
137
- if (existing && processAlive(existing)) {
138
- throw Object.assign(new Error(`a watcher is already running (pid ${existing})`), {
125
+ const existing = readLock();
126
+ if (existing && lockIsLive(existing)) {
127
+ throw Object.assign(new Error(`a watcher is already running (pid ${existing.pid})`), {
139
128
  hint: '`tokenflow watch --status` shows it; `tokenflow watch --stop` stops it.',
140
129
  });
141
130
  }
142
- fs.writeFileSync(paths().watchPid, String(process.pid));
131
+ writeLock();
143
132
  ownedHere = true;
144
133
  }
145
134
 
146
135
  export function releaseWatchLock() {
147
- const p = paths().watchPid;
148
- const pid = readLockPid();
149
- if (pid === process.pid) {
150
- try { fs.unlinkSync(p); } catch { /* already gone — that is fine */ }
151
- }
136
+ if (readLockPid() === process.pid) clearLock();
152
137
  ownedHere = false;
153
138
  }
154
139
 
155
140
  /** Is a watcher running right now (and does it own the lock)? */
156
141
  export function watchIsRunning() {
157
- const pid = readLockPid();
158
- return !!(pid && processAlive(pid));
142
+ return lockIsLive(readLock());
159
143
  }
160
144
 
161
145
  export function stopWatch() {
162
- const pid = readLockPid();
163
- if (!pid || !processAlive(pid)) {
164
- try { fs.unlinkSync(paths().watchPid); } catch { /* nothing to clean */ }
165
- return { stopped: false, reason: 'not running' };
146
+ const lock = readLock();
147
+ if (!lock || !lockIsLive(lock)) {
148
+ // Clearing here is the manual escape hatch for a lock the identity check
149
+ // cannot judge (an unreadable /proc, a pid the OS will not describe).
150
+ const had = lock ? clearLock() : false;
151
+ return { stopped: false, reason: had ? 'not running (cleared a stale lock)' : 'not running' };
166
152
  }
167
153
  try {
168
- process.kill(pid, 'SIGTERM');
169
- return { stopped: true, pid };
154
+ process.kill(lock.pid, 'SIGTERM');
155
+ return { stopped: true, pid: lock.pid };
170
156
  } catch (err) {
171
157
  return { stopped: false, reason: err.message };
172
158
  }
@@ -204,6 +190,9 @@ export async function runCycle(opt = {}) {
204
190
  ? {
205
191
  ...(prev?.watcher || {}),
206
192
  pid: process.pid,
193
+ // The boot this pid was issued by: a reader can tell a live watcher
194
+ // from a pid number the kernel has since handed to somebody else.
195
+ boot: Math.round(bootTimeMs()),
207
196
  mode: 'daemon',
208
197
  intervalSeconds: opt.daemon.intervalSeconds ?? null,
209
198
  startedAt: prev?.watcher?.startedAt || new Date().toISOString(),
@@ -60,6 +60,26 @@
60
60
  * already been emitted, and emit only the DELTA when a row grew. The base
61
61
  * record carries the group's first_seen; a delta carries the last_seen at
62
62
  * which the new usage was observed.
63
+ *
64
+ * ### The tail key MUST be the table's whole primary key
65
+ *
66
+ * session_model_usage is keyed on SIX columns:
67
+ *
68
+ * (session_id, model, billing_provider, billing_base_url, billing_mode, task)
69
+ *
70
+ * An earlier version of this adapter keyed its tails on four of them, leaving
71
+ * out billing_base_url and billing_mode. Two real rows — the same session and
72
+ * model, billed once with mode "" and once with mode "chat_completions" —
73
+ * therefore shared one tail. Each pass, each row computed its delta against
74
+ * the OTHER row's totals and then overwrote the tail, so the pair ping-ponged
75
+ * forever: every refresh cycle emitted the difference between them again, with
76
+ * a fresh emission index that made each one look like a new request. Five
77
+ * colliding sessions produced 37 BILLION phantom tokens in a single day and
78
+ * the totals grew with every cycle, not with usage.
79
+ *
80
+ * Two rules keep that from recurring: the key below is the full primary key,
81
+ * and the tail stores a HIGH-WATER MARK rather than the row's latest numbers,
82
+ * so a total that comes back lower can never manufacture a delta.
63
83
  */
64
84
  import fs from 'node:fs';
65
85
  import path from 'node:path';
@@ -118,6 +138,7 @@ export default createProvider({
118
138
 
119
139
  const rows = db.prepare(
120
140
  `SELECT u.session_id AS session_id, u.model AS model, u.billing_provider AS billing_provider,
141
+ u.billing_base_url AS billing_base_url, u.billing_mode AS billing_mode,
121
142
  u.task AS task, u.api_call_count AS api_call_count,
122
143
  u.input_tokens AS input_tokens, u.output_tokens AS output_tokens,
123
144
  u.cache_read_tokens AS cache_read_tokens, u.cache_write_tokens AS cache_write_tokens,
@@ -159,7 +180,13 @@ export default createProvider({
159
180
  // dashboard can show; skip it rather than emit an empty request.
160
181
  if (FIELDS.every((k) => !base[k]) && measuredCost === null) { skipped++; continue; }
161
182
 
162
- const key = [r.session_id, r.model ?? '', r.billing_provider ?? '', r.task ?? ''].join('|');
183
+ // The table's whole primary key see "The tail key MUST be the
184
+ // table's whole primary key" above. Dropping a column here silently
185
+ // merges distinct rows and makes their deltas oscillate.
186
+ const key = [
187
+ r.session_id, r.model ?? '', r.billing_provider ?? '',
188
+ r.billing_base_url ?? '', r.billing_mode ?? '', r.task ?? '',
189
+ ].join('|');
163
190
  const prev = tails[key];
164
191
  const delta = {};
165
192
  let any = false;
@@ -177,10 +204,18 @@ export default createProvider({
177
204
  // row (not against its current total), so cost never double counts.
178
205
  const costDelta = prev && measuredCost !== null ? Math.max(0, round6(measuredCost - (prev.c || 0))) : measuredCost;
179
206
 
180
- // Remember what this row accounts for. The emission index keeps each
181
- // delta's record id distinct and stable across runs.
207
+ // Remember what this row accounts for, as a HIGH-WATER MARK. These
208
+ // totals only ever grow in the source table, so a lower reading is an
209
+ // anomaly (a source-side reset, a re-count, or two rows this adapter
210
+ // cannot tell apart) — and keeping the maximum means such a reading
211
+ // can never be turned into usage that did not happen.
182
212
  const emissionIndex = prev ? (prev.n || 0) : 0;
183
- tails[key] = { ls: lastSeen ?? (prev?.ls || 0), f: { ...base }, c: measuredCost, n: emissionIndex + 1 };
213
+ const highWater = {};
214
+ for (const k of FIELDS) highWater[k] = Math.max(prev ? (prev.f[k] ?? 0) : 0, base[k] ?? 0);
215
+ const costHighWater = measuredCost === null && (prev?.c ?? null) === null
216
+ ? null
217
+ : Math.max(prev?.c ?? 0, measuredCost ?? 0);
218
+ tails[key] = { ls: lastSeen ?? (prev?.ls || 0), f: highWater, c: costHighWater, n: emissionIndex + 1 };
184
219
  records++;
185
220
 
186
221
  const model = str(r.model);
@@ -239,6 +274,8 @@ export default createProvider({
239
274
  hermes_task: str(r.task),
240
275
  api_calls: intOrNull(r.api_call_count),
241
276
  billing_provider: str(r.billing_provider),
277
+ billing_base_url: str(r.billing_base_url),
278
+ billing_mode: str(r.billing_mode),
242
279
  cost_status: str(r.cost_status),
243
280
  session_open: r.ended_at === null || r.ended_at === undefined ? true : undefined,
244
281
  ...(prev ? { continuation_of: key } : {}),
@@ -232,6 +232,35 @@ export async function startServer({ port = 7799, host = '127.0.0.1', open = fals
232
232
  return { server, url: addr, token: authToken, close: () => new Promise((r) => server.close(r)) };
233
233
  }
234
234
 
235
+ /**
236
+ * Ask whoever is listening on host:port whether they are a TokenFlow server.
237
+ *
238
+ * Used before opening a browser window and before starting a second server:
239
+ * "the port is busy" and "the dashboard is already up" look identical from
240
+ * the outside, and only one of them is good news.
241
+ *
242
+ * @returns {Promise<object|null>} the /api/ping payload, or null when nothing
243
+ * there answers as TokenFlow (closed port, stranger, timeout, garbage).
244
+ */
245
+ export function pingServer({ host = '127.0.0.1', port = 7799, timeoutMs = 1500 } = {}) {
246
+ return new Promise((resolve) => {
247
+ const req = http.get({ host, port, path: '/api/ping', timeout: timeoutMs }, (res) => {
248
+ if (res.statusCode !== 200) { res.resume(); return resolve(null); }
249
+ let body = '';
250
+ res.setEncoding('utf8');
251
+ res.on('data', (c) => { body += c.length > 4096 ? '' : c; });
252
+ res.on('end', () => {
253
+ try {
254
+ const o = JSON.parse(body);
255
+ resolve(o && o.app === 'tokenflow' ? o : null);
256
+ } catch { resolve(null); }
257
+ });
258
+ });
259
+ req.on('timeout', () => { req.destroy(); resolve(null); });
260
+ req.on('error', () => resolve(null));
261
+ });
262
+ }
263
+
235
264
  function send(res, code, type, body) {
236
265
  res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store' });
237
266
  res.end(body);