@prohost/cli 0.10.0 → 0.10.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.
- package/CHANGELOG.md +48 -0
- package/README.md +27 -16
- package/dist/agent/base-url.d.ts +43 -0
- package/dist/agent/base-url.js +72 -0
- package/dist/agent/command.js +21 -6
- package/dist/agent/credentials.d.ts +9 -0
- package/dist/agent/credentials.js +1 -1
- package/dist/agent/pair.js +41 -20
- package/dist/index.js +11 -3
- package/dist/usage/command.d.ts +3 -0
- package/dist/usage/command.js +88 -13
- package/dist/usage/key-input.d.ts +36 -0
- package/dist/usage/key-input.js +79 -0
- package/dist/usage/project.d.ts +30 -0
- package/dist/usage/project.js +138 -0
- package/dist/usage/scanner.d.ts +49 -8
- package/dist/usage/scanner.js +115 -26
- package/dist/usage/service.d.ts +30 -1
- package/dist/usage/service.js +105 -10
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/usage/scanner.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* Token totals from the session logs Claude Code and Codex already write.
|
|
3
3
|
*
|
|
4
4
|
* Aggregates only: tokens per (local day, account, runtime, model, project),
|
|
5
|
-
* where the project is the
|
|
5
|
+
* where the project is the name of the repository the log's working directory
|
|
6
|
+
* belongs to — a worktree or a subdirectory counts as its repository (see
|
|
7
|
+
* `project.ts`).
|
|
6
8
|
* No prompt, reply, tool call or file path ever leaves this module — lines are
|
|
7
9
|
* parsed for their usage counters and nothing else is kept.
|
|
8
10
|
*
|
|
@@ -21,11 +23,22 @@
|
|
|
21
23
|
* Incremental: a cursor per file (byte offset, size, mtime) lives in the scan
|
|
22
24
|
* state, so each pass reads only what was appended. The first pass looks back
|
|
23
25
|
* {@link RETENTION_DAYS} days. Every (day, account) a pass changes is marked dirty and is
|
|
24
|
-
* re-sent whole
|
|
26
|
+
* re-sent whole, and the upload names it as complete: the server replaces that
|
|
27
|
+
* day's rows for the account and deletes any it no longer carries, so a resend
|
|
28
|
+
* is harmless and a renamed project leaves no stale row behind.
|
|
29
|
+
*
|
|
30
|
+
* State version 2 changed project attribution (repository, not directory). A
|
|
31
|
+
* version-1 state is discarded and every log in the window read again; its
|
|
32
|
+
* per-(day, account) totals are kept as {@link ScanState.legacy}. Those pairs'
|
|
33
|
+
* server rows still carry per-directory projects, so the uploader sends them
|
|
34
|
+
* only to a server that replaces a day whole, and only when the rebuild
|
|
35
|
+
* found at least what version 1 had counted (see `service.ts`).
|
|
25
36
|
*/
|
|
26
37
|
import { closeSync, fstatSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
|
|
27
38
|
import path from 'node:path';
|
|
28
39
|
import { writePrivateFile } from './config.js';
|
|
40
|
+
import { projectName } from './project.js';
|
|
41
|
+
export { projectName } from './project.js';
|
|
29
42
|
/** How far back the first scan looks, and how long totals are kept locally. */
|
|
30
43
|
export const RETENTION_DAYS = 30;
|
|
31
44
|
/** Most rows in one `POST /v1/usage/token-totals`. */
|
|
@@ -34,7 +47,6 @@ const DAY_MS = 86_400_000;
|
|
|
34
47
|
const READ_CHUNK_BYTES = 4 * 1024 * 1024;
|
|
35
48
|
/** Deepest directory level walked under `projects/` or `sessions/`. */
|
|
36
49
|
const MAX_WALK_DEPTH = 6;
|
|
37
|
-
const MAX_PROJECT_CHARS = 120;
|
|
38
50
|
const MAX_MODEL_CHARS = 120;
|
|
39
51
|
/** A dirty entry: one account's rows for one day. */
|
|
40
52
|
export function pendingKey(day, accountKey) {
|
|
@@ -44,30 +56,81 @@ export function pendingKey(day, accountKey) {
|
|
|
44
56
|
export function pendingDay(entry) {
|
|
45
57
|
return entry.slice(0, 10);
|
|
46
58
|
}
|
|
59
|
+
/** The account key of a dirty entry. */
|
|
60
|
+
export function pendingAccount(entry) {
|
|
61
|
+
return entry.slice(11);
|
|
62
|
+
}
|
|
47
63
|
/** Days with anything pending, sorted. */
|
|
48
64
|
export function pendingDays(state) {
|
|
49
65
|
return [...new Set(state.dirty.map(pendingDay))].sort();
|
|
50
66
|
}
|
|
51
67
|
export function emptyScanState() {
|
|
52
|
-
return { version:
|
|
68
|
+
return { version: 2, files: {}, buckets: {}, seen: {}, dirty: [], strikes: {}, legacy: {} };
|
|
53
69
|
}
|
|
70
|
+
const pairList = (value) => Array.isArray(value) ? value.filter((d) => typeof d === 'string' && d.includes('|')) : [];
|
|
71
|
+
const isTotals = (value) => Array.isArray(value) && value.length === 4 && value.every((n) => typeof n === 'number' && Number.isFinite(n));
|
|
72
|
+
/** Version-1 buckets summed per (day, account) pair. */
|
|
73
|
+
function legacyTotals(buckets) {
|
|
74
|
+
const out = {};
|
|
75
|
+
if (!buckets || typeof buckets !== 'object')
|
|
76
|
+
return out;
|
|
77
|
+
for (const [key, totals] of Object.entries(buckets)) {
|
|
78
|
+
if (!isTotals(totals))
|
|
79
|
+
continue;
|
|
80
|
+
let day;
|
|
81
|
+
let accountKey;
|
|
82
|
+
try {
|
|
83
|
+
[day, accountKey] = JSON.parse(key);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (typeof day !== 'string' || typeof accountKey !== 'string')
|
|
89
|
+
continue;
|
|
90
|
+
const pair = pendingKey(day, accountKey);
|
|
91
|
+
const sum = out[pair] ?? [0, 0, 0, 0];
|
|
92
|
+
for (let i = 0; i < 4; i += 1)
|
|
93
|
+
sum[i] = sum[i] + totals[i];
|
|
94
|
+
out[pair] = sum;
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Load the scan state. A version-1 state (per-directory projects) is replaced
|
|
100
|
+
* by an empty one that re-reads every log, keeping its per-pair totals as
|
|
101
|
+
* {@link ScanState.legacy}.
|
|
102
|
+
*/
|
|
54
103
|
export function loadScanState(file) {
|
|
104
|
+
let parsed;
|
|
55
105
|
try {
|
|
56
|
-
|
|
57
|
-
if (parsed?.version !== 1)
|
|
58
|
-
return emptyScanState();
|
|
59
|
-
return {
|
|
60
|
-
version: 1,
|
|
61
|
-
files: parsed.files && typeof parsed.files === 'object' ? parsed.files : {},
|
|
62
|
-
buckets: parsed.buckets && typeof parsed.buckets === 'object' ? parsed.buckets : {},
|
|
63
|
-
seen: parsed.seen && typeof parsed.seen === 'object' ? parsed.seen : {},
|
|
64
|
-
dirty: Array.isArray(parsed.dirty) ? parsed.dirty.filter((d) => typeof d === 'string' && d.includes('|')) : [],
|
|
65
|
-
strikes: parsed.strikes && typeof parsed.strikes === 'object' ? parsed.strikes : {},
|
|
66
|
-
};
|
|
106
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
67
107
|
}
|
|
68
108
|
catch {
|
|
69
109
|
return emptyScanState();
|
|
70
110
|
}
|
|
111
|
+
if (parsed?.version === 1)
|
|
112
|
+
return { ...emptyScanState(), legacy: legacyTotals(parsed.buckets) };
|
|
113
|
+
if (parsed?.version !== 2)
|
|
114
|
+
return emptyScanState();
|
|
115
|
+
return {
|
|
116
|
+
version: 2,
|
|
117
|
+
files: parsed.files && typeof parsed.files === 'object' ? parsed.files : {},
|
|
118
|
+
buckets: parsed.buckets && typeof parsed.buckets === 'object' ? parsed.buckets : {},
|
|
119
|
+
seen: parsed.seen && typeof parsed.seen === 'object' ? parsed.seen : {},
|
|
120
|
+
dirty: pairList(parsed.dirty),
|
|
121
|
+
strikes: parsed.strikes && typeof parsed.strikes === 'object' ? parsed.strikes : {},
|
|
122
|
+
legacy: parsed.legacy && typeof parsed.legacy === 'object'
|
|
123
|
+
? Object.fromEntries(Object.entries(parsed.legacy).filter(([pair, t]) => pair.includes('|') && isTotals(t)))
|
|
124
|
+
: {},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Whether a re-attributed pair's rebuild counted less than version 1 had — a
|
|
129
|
+
* log rewritten or removed since, or a file that fell out of the window. Its
|
|
130
|
+
* server rows are then left as they are: mis-attributed, but the right total.
|
|
131
|
+
*/
|
|
132
|
+
export function rebuildFellShort(rebuilt, legacy) {
|
|
133
|
+
return rebuilt.some((n, i) => n < legacy[i]);
|
|
71
134
|
}
|
|
72
135
|
export function saveScanState(file, state) {
|
|
73
136
|
writePrivateFile(file, JSON.stringify(state));
|
|
@@ -97,13 +160,6 @@ export function bucketKey(day, accountKey, runtime, model, project) {
|
|
|
97
160
|
function dayOfBucket(key) {
|
|
98
161
|
return key.slice(2, 12);
|
|
99
162
|
}
|
|
100
|
-
/** Basename of a working directory — the repo or folder name, never the path. */
|
|
101
|
-
export function projectName(cwd) {
|
|
102
|
-
if (typeof cwd !== 'string' || !cwd.trim())
|
|
103
|
-
return null;
|
|
104
|
-
const base = path.basename(cwd.trim().replace(/[\\/]+$/, ''));
|
|
105
|
-
return base ? base.slice(0, MAX_PROJECT_CHARS) : null;
|
|
106
|
-
}
|
|
107
163
|
function asObject(value) {
|
|
108
164
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
109
165
|
}
|
|
@@ -362,6 +418,9 @@ export function scanUsage(accounts, state, options = {}) {
|
|
|
362
418
|
for (const pair of touchedPairs)
|
|
363
419
|
delete state.strikes[pair];
|
|
364
420
|
state.dirty = [...new Set([...state.dirty, ...touchedPairs])].filter((d) => pendingDay(d) >= pass.cutoffDay).sort();
|
|
421
|
+
for (const pair of Object.keys(state.legacy))
|
|
422
|
+
if (pendingDay(pair) < pass.cutoffDay)
|
|
423
|
+
delete state.legacy[pair];
|
|
365
424
|
for (const pair of Object.keys(state.strikes))
|
|
366
425
|
if (!state.dirty.includes(pair))
|
|
367
426
|
delete state.strikes[pair];
|
|
@@ -415,11 +474,41 @@ export function rowsForPending(state, pairs) {
|
|
|
415
474
|
const days = new Set([...wanted].map(pendingDay));
|
|
416
475
|
return rowsForDays(state, days).filter((r) => wanted.has(pendingKey(r.day, r.accountKey)));
|
|
417
476
|
}
|
|
418
|
-
/**
|
|
419
|
-
|
|
477
|
+
/**
|
|
478
|
+
* Split rows into upload batches without splitting a (day, account) pair,
|
|
479
|
+
* so each body carries every row of the pairs it names complete. A pair
|
|
480
|
+
* larger than a batch is sent over consecutive batches: complete in the
|
|
481
|
+
* first (the server clears its rows, then stores these), added to by the rest.
|
|
482
|
+
*/
|
|
483
|
+
export function uploadBatches(rows, size = MAX_ROWS_PER_BATCH) {
|
|
484
|
+
const groups = new Map();
|
|
485
|
+
for (const row of rows) {
|
|
486
|
+
const pair = pendingKey(row.day, row.accountKey);
|
|
487
|
+
const group = groups.get(pair);
|
|
488
|
+
if (group)
|
|
489
|
+
group.push(row);
|
|
490
|
+
else
|
|
491
|
+
groups.set(pair, [row]);
|
|
492
|
+
}
|
|
420
493
|
const out = [];
|
|
421
|
-
|
|
422
|
-
|
|
494
|
+
let current;
|
|
495
|
+
for (const [pair, group] of groups) {
|
|
496
|
+
if (current && current.rows.length + group.length <= size) {
|
|
497
|
+
current.rows.push(...group);
|
|
498
|
+
current.complete.push(pair);
|
|
499
|
+
current.last.push(pair);
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
for (let start = 0; start < group.length; start += size) {
|
|
503
|
+
const chunk = group.slice(start, start + size);
|
|
504
|
+
current = {
|
|
505
|
+
rows: chunk,
|
|
506
|
+
complete: start === 0 ? [pair] : [],
|
|
507
|
+
last: start + size >= group.length ? [pair] : [],
|
|
508
|
+
};
|
|
509
|
+
out.push(current);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
423
512
|
return out;
|
|
424
513
|
}
|
|
425
514
|
/** Local totals per account since ``fromDay`` (inclusive), for `usage status`. */
|
package/dist/usage/service.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export declare const REPORTS_PATH = "/v1/usage/reports";
|
|
|
13
13
|
export declare const TOKEN_TOTALS_PATH = "/v1/usage/token-totals";
|
|
14
14
|
/** Tick cadence. */
|
|
15
15
|
export declare const USAGE_INTERVAL_MS: number;
|
|
16
|
+
/** How long a server that does not replace a day whole is trusted to stay that way before it is asked again. */
|
|
17
|
+
export declare const REPLACE_PROBE_INTERVAL_MS: number;
|
|
16
18
|
/** Partial uploads of one day's rows for one account before they are given up on. */
|
|
17
19
|
export declare const MAX_PARTIAL_STRIKES = 3;
|
|
18
20
|
/**
|
|
@@ -32,6 +34,12 @@ export interface Acceptance {
|
|
|
32
34
|
keys?: Set<string>;
|
|
33
35
|
/** Accounts it skipped, and why (newer servers). */
|
|
34
36
|
skipped?: Map<string, SkipReason>;
|
|
37
|
+
/**
|
|
38
|
+
* Token totals: how many `completeDays` pairs the server replaced whole.
|
|
39
|
+
* Absent from a server that ignores `completeDays` — it only upserts, and
|
|
40
|
+
* leaves rows the body no longer carries.
|
|
41
|
+
*/
|
|
42
|
+
replacedDays?: number;
|
|
35
43
|
}
|
|
36
44
|
/** Read `{accepted, acceptedAccountKeys?, skipped?}` from a 2xx body. */
|
|
37
45
|
export declare function parseAcceptance(body: string | undefined, sent: number): Acceptance;
|
|
@@ -71,7 +79,7 @@ export interface UsageServiceOptions {
|
|
|
71
79
|
export interface TickResult {
|
|
72
80
|
reported: boolean;
|
|
73
81
|
uploadedRows: number;
|
|
74
|
-
skipped?: 'no_key' | 'no_machine_id';
|
|
82
|
+
skipped?: 'no_key' | 'insecure_base_url' | 'no_machine_id';
|
|
75
83
|
}
|
|
76
84
|
/** One process's reporter. Holds the backoff state and the scan cursor in memory. */
|
|
77
85
|
export declare class UsageService {
|
|
@@ -82,6 +90,8 @@ export declare class UsageService {
|
|
|
82
90
|
private readonly prober;
|
|
83
91
|
readonly reportBackoff: Backoff;
|
|
84
92
|
readonly totalsBackoff: Backoff;
|
|
93
|
+
/** The last {@link serverReplacesDays} answer, for this process only. */
|
|
94
|
+
private replaceProbe?;
|
|
85
95
|
private scan;
|
|
86
96
|
private status;
|
|
87
97
|
private warned;
|
|
@@ -110,8 +120,27 @@ export declare class UsageService {
|
|
|
110
120
|
* `not_reported`, costs a strike; after {@link MAX_PARTIAL_STRIKES} the pair
|
|
111
121
|
* is given up on (logged once), so nothing is resent forever. Pairs whose
|
|
112
122
|
* account has never been reported wait without a strike, and are not sent.
|
|
123
|
+
*
|
|
124
|
+
* Every pair goes out as `completeDays`, never split across bodies unless
|
|
125
|
+
* it alone outgrows one: a server that understands it deletes the pair's
|
|
126
|
+
* rows the body no longer carries and says so with `replacedDays`.
|
|
127
|
+
*
|
|
128
|
+
* A pair re-attributed by the version-2 upgrade (`scan.legacy`) still has
|
|
129
|
+
* per-directory rows on the server, so sending it to a server that only
|
|
130
|
+
* upserts would count it twice. It is held until a probe (an empty body)
|
|
131
|
+
* comes back with `replacedDays`, asked again at most every
|
|
132
|
+
* {@link REPLACE_PROBE_INTERVAL_MS}; other pairs go out meanwhile. A pair
|
|
133
|
+
* whose rebuild counted less than version 1 had (a log removed or rewritten
|
|
134
|
+
* since) is not sent at all: its old rows are mis-attributed but total right.
|
|
113
135
|
*/
|
|
114
136
|
private uploadDirty;
|
|
137
|
+
/**
|
|
138
|
+
* Whether the server deletes the rows a complete day no longer carries: an
|
|
139
|
+
* empty body is answered with `replacedDays` only by one that does. A yes
|
|
140
|
+
* is kept for the process; a no (or a failed call) is asked again after
|
|
141
|
+
* {@link REPLACE_PROBE_INTERVAL_MS}.
|
|
142
|
+
*/
|
|
143
|
+
private serverReplacesDays;
|
|
115
144
|
private note;
|
|
116
145
|
private callRecord;
|
|
117
146
|
}
|
package/dist/usage/service.js
CHANGED
|
@@ -12,15 +12,18 @@ import os from 'node:os';
|
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { loadAccounts, machineId as machineIdFor } from '../agent/accounts.js';
|
|
14
14
|
import { postJson } from '../agent/api.js';
|
|
15
|
+
import { baseUrlProblem } from '../agent/base-url.js';
|
|
15
16
|
import { Backoff } from './backoff.js';
|
|
16
17
|
import { resolveUsageKey, usageStateDir, writePrivateFile } from './config.js';
|
|
17
18
|
import { accountConfigDir } from './discovery.js';
|
|
18
19
|
import { buildReportFrame, defaultProber, probeAccounts } from './quota.js';
|
|
19
|
-
import {
|
|
20
|
+
import { loadScanState, pendingAccount, pendingDay, pendingKey, rebuildFellShort, rowsForPending, saveScanState, scanUsage, uploadBatches, } from './scanner.js';
|
|
20
21
|
export const REPORTS_PATH = '/v1/usage/reports';
|
|
21
22
|
export const TOKEN_TOTALS_PATH = '/v1/usage/token-totals';
|
|
22
23
|
/** Tick cadence. */
|
|
23
24
|
export const USAGE_INTERVAL_MS = 5 * 60_000;
|
|
25
|
+
/** How long a server that does not replace a day whole is trusted to stay that way before it is asked again. */
|
|
26
|
+
export const REPLACE_PROBE_INTERVAL_MS = 60 * 60_000;
|
|
24
27
|
/** Partial uploads of one day's rows for one account before they are given up on. */
|
|
25
28
|
export const MAX_PARTIAL_STRIKES = 3;
|
|
26
29
|
/** Skips that will never turn into an acceptance from this machine. */
|
|
@@ -48,7 +51,13 @@ export function parseAcceptance(body, sent) {
|
|
|
48
51
|
}
|
|
49
52
|
}
|
|
50
53
|
}
|
|
51
|
-
|
|
54
|
+
const replacedDays = typeof parsed?.replacedDays === 'number' && Number.isFinite(parsed.replacedDays) ? parsed.replacedDays : undefined;
|
|
55
|
+
return {
|
|
56
|
+
accepted,
|
|
57
|
+
...(keys ? { keys } : {}),
|
|
58
|
+
...(skipped ? { skipped } : {}),
|
|
59
|
+
...(replacedDays !== undefined ? { replacedDays } : {}),
|
|
60
|
+
};
|
|
52
61
|
}
|
|
53
62
|
export function scanStatePath(env = process.env) {
|
|
54
63
|
return path.join(usageStateDir(env), 'scan.json');
|
|
@@ -81,6 +90,8 @@ export class UsageService {
|
|
|
81
90
|
prober;
|
|
82
91
|
reportBackoff;
|
|
83
92
|
totalsBackoff;
|
|
93
|
+
/** The last {@link serverReplacesDays} answer, for this process only. */
|
|
94
|
+
replaceProbe;
|
|
84
95
|
scan;
|
|
85
96
|
status;
|
|
86
97
|
warned = new Set();
|
|
@@ -115,10 +126,17 @@ export class UsageService {
|
|
|
115
126
|
try {
|
|
116
127
|
const key = resolveUsageKey(this.env);
|
|
117
128
|
if (!key) {
|
|
118
|
-
this.warnOnce('no_key', 'Not connected — run `prohost usage connect
|
|
129
|
+
this.warnOnce('no_key', 'Not connected — run `prohost usage connect`. Checking again each tick.');
|
|
119
130
|
return { ...result, skipped: 'no_key' };
|
|
120
131
|
}
|
|
121
132
|
this.warned.delete('no_key');
|
|
133
|
+
// The key travels to this URL; a stored http:// one would send it in plaintext.
|
|
134
|
+
const problem = baseUrlProblem(key.baseUrl);
|
|
135
|
+
if (problem) {
|
|
136
|
+
this.warnOnce('insecure_base_url', `Not sending: the stored API base URL is unusable. ${problem}`);
|
|
137
|
+
return { ...result, skipped: 'insecure_base_url' };
|
|
138
|
+
}
|
|
139
|
+
this.warned.delete('insecure_base_url');
|
|
122
140
|
const machineId = machineIdFor(this.env);
|
|
123
141
|
if (!machineId) {
|
|
124
142
|
this.warnOnce('no_machine_id', 'Could not create this machine\'s id in the accounts root; nothing can be reported.');
|
|
@@ -206,35 +224,89 @@ export class UsageService {
|
|
|
206
224
|
* `not_reported`, costs a strike; after {@link MAX_PARTIAL_STRIKES} the pair
|
|
207
225
|
* is given up on (logged once), so nothing is resent forever. Pairs whose
|
|
208
226
|
* account has never been reported wait without a strike, and are not sent.
|
|
227
|
+
*
|
|
228
|
+
* Every pair goes out as `completeDays`, never split across bodies unless
|
|
229
|
+
* it alone outgrows one: a server that understands it deletes the pair's
|
|
230
|
+
* rows the body no longer carries and says so with `replacedDays`.
|
|
231
|
+
*
|
|
232
|
+
* A pair re-attributed by the version-2 upgrade (`scan.legacy`) still has
|
|
233
|
+
* per-directory rows on the server, so sending it to a server that only
|
|
234
|
+
* upserts would count it twice. It is held until a probe (an empty body)
|
|
235
|
+
* comes back with `replacedDays`, asked again at most every
|
|
236
|
+
* {@link REPLACE_PROBE_INTERVAL_MS}; other pairs go out meanwhile. A pair
|
|
237
|
+
* whose rebuild counted less than version 1 had (a log removed or rewritten
|
|
238
|
+
* since) is not sent at all: its old rows are mis-attributed but total right.
|
|
209
239
|
*/
|
|
210
240
|
async uploadDirty(api, machineId, machine) {
|
|
211
241
|
const scan = this.scan;
|
|
212
242
|
const rows = rowsForPending(scan, scan.dirty);
|
|
213
|
-
|
|
243
|
+
let ready = rows.filter((r) => this.reportedKeys.has(r.accountKey));
|
|
214
244
|
const done = new Set();
|
|
215
245
|
const pendingWithRows = new Set(rows.map((r) => pendingKey(r.day, r.accountKey)));
|
|
216
246
|
// Pending pairs with nothing to send (all-zero) are simply done.
|
|
217
247
|
for (const pair of scan.dirty)
|
|
218
248
|
if (!pendingWithRows.has(pair))
|
|
219
249
|
done.add(pair);
|
|
250
|
+
const legacyReady = new Set(ready.map((r) => pendingKey(r.day, r.accountKey)).filter((pair) => scan.legacy[pair] !== undefined));
|
|
251
|
+
if (legacyReady.size > 0) {
|
|
252
|
+
if (!(await this.serverReplacesDays(api, machineId, machine))) {
|
|
253
|
+
ready = ready.filter((r) => !legacyReady.has(pendingKey(r.day, r.accountKey)));
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
const rebuilt = new Map();
|
|
257
|
+
for (const r of ready) {
|
|
258
|
+
const pair = pendingKey(r.day, r.accountKey);
|
|
259
|
+
if (!legacyReady.has(pair))
|
|
260
|
+
continue;
|
|
261
|
+
const sum = rebuilt.get(pair) ?? [0, 0, 0, 0];
|
|
262
|
+
rebuilt.set(pair, [
|
|
263
|
+
sum[0] + r.inputTokens,
|
|
264
|
+
sum[1] + r.cachedInputTokens,
|
|
265
|
+
sum[2] + r.cacheWriteTokens,
|
|
266
|
+
sum[3] + r.outputTokens,
|
|
267
|
+
]);
|
|
268
|
+
}
|
|
269
|
+
const short = new Set([...rebuilt].filter(([pair, t]) => rebuildFellShort(t, scan.legacy[pair])).map(([p]) => p));
|
|
270
|
+
for (const pair of short) {
|
|
271
|
+
done.add(pair);
|
|
272
|
+
delete scan.legacy[pair];
|
|
273
|
+
}
|
|
274
|
+
if (short.size > 0) {
|
|
275
|
+
ready = ready.filter((r) => !short.has(pendingKey(r.day, r.accountKey)));
|
|
276
|
+
this.warnOnce('legacy_short', `token totals: ${short.size} earlier day(s) read back smaller than first counted (logs removed or ` +
|
|
277
|
+
'rewritten since); they keep their per-folder projects on the server so their totals stay right.');
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
220
281
|
let sent = 0;
|
|
221
282
|
let gaveUp = 0;
|
|
222
|
-
for (const batch of
|
|
223
|
-
const
|
|
283
|
+
for (const batch of uploadBatches(ready)) {
|
|
284
|
+
const body = {
|
|
285
|
+
machineId,
|
|
286
|
+
machine,
|
|
287
|
+
days: batch.rows,
|
|
288
|
+
completeDays: batch.complete.map((pair) => ({ day: pendingDay(pair), accountKey: pendingAccount(pair) })),
|
|
289
|
+
};
|
|
290
|
+
const response = await postJson(api, TOKEN_TOTALS_PATH, body, undefined, true);
|
|
224
291
|
this.note(this.totalsBackoff, response);
|
|
225
|
-
const acceptance = response.ok ? parseAcceptance(response.body, batch.length) : { accepted: 0 };
|
|
226
|
-
this.status.lastTotals = { ...this.callRecord(response, batch.length), accepted: acceptance.accepted };
|
|
292
|
+
const acceptance = response.ok ? parseAcceptance(response.body, batch.rows.length) : { accepted: 0 };
|
|
293
|
+
this.status.lastTotals = { ...this.callRecord(response, batch.rows.length), accepted: acceptance.accepted };
|
|
227
294
|
if (!response.ok)
|
|
228
295
|
break;
|
|
229
296
|
sent += acceptance.accepted;
|
|
297
|
+
const lastHere = new Set(batch.last);
|
|
230
298
|
const pairs = new Map(); // pair → accountKey
|
|
231
|
-
for (const row of batch)
|
|
299
|
+
for (const row of batch.rows)
|
|
232
300
|
pairs.set(pendingKey(row.day, row.accountKey), row.accountKey);
|
|
233
301
|
for (const [pair, accountKey] of pairs) {
|
|
234
|
-
const taken = acceptance.keys ? acceptance.keys.has(accountKey) : acceptance.accepted >= batch.length;
|
|
302
|
+
const taken = acceptance.keys ? acceptance.keys.has(accountKey) : acceptance.accepted >= batch.rows.length;
|
|
235
303
|
if (taken) {
|
|
304
|
+
// A pair spread over several bodies is done with its last one.
|
|
305
|
+
if (!lastHere.has(pair))
|
|
306
|
+
continue;
|
|
236
307
|
done.add(pair);
|
|
237
308
|
delete scan.strikes[pair];
|
|
309
|
+
delete scan.legacy[pair];
|
|
238
310
|
continue;
|
|
239
311
|
}
|
|
240
312
|
const reason = acceptance.skipped?.get(accountKey);
|
|
@@ -243,6 +315,7 @@ export class UsageService {
|
|
|
243
315
|
if (reason && PERMANENT_SKIPS.has(reason)) {
|
|
244
316
|
done.add(pair);
|
|
245
317
|
delete scan.strikes[pair];
|
|
318
|
+
delete scan.legacy[pair];
|
|
246
319
|
this.warnOnce(`totals_skip:${accountKey}:${reason}`, `token totals: account ${accountKey.slice(0, 8)}… skipped (${reason}); those rows will not be resent.`);
|
|
247
320
|
continue;
|
|
248
321
|
}
|
|
@@ -252,6 +325,7 @@ export class UsageService {
|
|
|
252
325
|
if (strikes >= MAX_PARTIAL_STRIKES) {
|
|
253
326
|
done.add(pair);
|
|
254
327
|
delete scan.strikes[pair];
|
|
328
|
+
delete scan.legacy[pair];
|
|
255
329
|
gaveUp += 1;
|
|
256
330
|
}
|
|
257
331
|
else {
|
|
@@ -267,6 +341,27 @@ export class UsageService {
|
|
|
267
341
|
saveScanState(scanStatePath(this.env), scan);
|
|
268
342
|
return sent;
|
|
269
343
|
}
|
|
344
|
+
/**
|
|
345
|
+
* Whether the server deletes the rows a complete day no longer carries: an
|
|
346
|
+
* empty body is answered with `replacedDays` only by one that does. A yes
|
|
347
|
+
* is kept for the process; a no (or a failed call) is asked again after
|
|
348
|
+
* {@link REPLACE_PROBE_INTERVAL_MS}.
|
|
349
|
+
*/
|
|
350
|
+
async serverReplacesDays(api, machineId, machine) {
|
|
351
|
+
if (this.replaceProbe?.supported)
|
|
352
|
+
return true;
|
|
353
|
+
if (this.replaceProbe && this.now() - this.replaceProbe.at < REPLACE_PROBE_INTERVAL_MS)
|
|
354
|
+
return false;
|
|
355
|
+
const response = await postJson(api, TOKEN_TOTALS_PATH, { machineId, machine, days: [], completeDays: [] }, undefined, true);
|
|
356
|
+
this.note(this.totalsBackoff, response);
|
|
357
|
+
const supported = response.ok && parseAcceptance(response.body, 0).replacedDays !== undefined;
|
|
358
|
+
this.replaceProbe = { supported, at: this.now() };
|
|
359
|
+
if (!supported && response.ok) {
|
|
360
|
+
this.warnOnce('replace_unsupported', 'token totals: the server does not replace a day whole yet; days counted under per-folder projects ' +
|
|
361
|
+
'wait until it does, so nothing is counted twice.');
|
|
362
|
+
}
|
|
363
|
+
return supported;
|
|
364
|
+
}
|
|
270
365
|
note(backoff, response) {
|
|
271
366
|
const line = backoff.record(response);
|
|
272
367
|
if (line)
|
package/dist/version.d.ts
CHANGED
|
@@ -7,6 +7,6 @@
|
|
|
7
7
|
* `package.json`, so a release bump that forgets this file fails the suite
|
|
8
8
|
* instead of shipping a `User-Agent` that lies about which build is calling.
|
|
9
9
|
*/
|
|
10
|
-
export declare const CLI_VERSION = "0.10.
|
|
10
|
+
export declare const CLI_VERSION = "0.10.2";
|
|
11
11
|
/** Sent on every HTTP request the CLI makes back into ProhostAI. */
|
|
12
|
-
export declare const USER_AGENT = "prohost-cli/0.10.
|
|
12
|
+
export declare const USER_AGENT = "prohost-cli/0.10.2";
|
package/dist/version.js
CHANGED
|
@@ -7,6 +7,6 @@
|
|
|
7
7
|
* `package.json`, so a release bump that forgets this file fails the suite
|
|
8
8
|
* instead of shipping a `User-Agent` that lies about which build is calling.
|
|
9
9
|
*/
|
|
10
|
-
export const CLI_VERSION = '0.10.
|
|
10
|
+
export const CLI_VERSION = '0.10.2';
|
|
11
11
|
/** Sent on every HTTP request the CLI makes back into ProhostAI. */
|
|
12
12
|
export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;
|