@vimoxshah/tokenflow 1.1.0 → 1.1.1

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/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);