@prohost/cli 0.10.1 → 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 +24 -0
- package/README.md +18 -10
- 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 +29 -0
- package/dist/usage/service.js +96 -9
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,30 @@ Versions follow [semver](https://semver.org/). Publishing is automated: merging
|
|
|
4
4
|
a version bump to `main` triggers `.github/workflows/npm-publish-cli.yml`, which
|
|
5
5
|
builds via `prepack`, runs the suite, publishes, and tags `cli-v<version>`.
|
|
6
6
|
|
|
7
|
+
## 0.10.2
|
|
8
|
+
|
|
9
|
+
Token totals are attributed to the repository, not the directory a session ran in.
|
|
10
|
+
|
|
11
|
+
- **Projects are repositories.** A session in a git worktree
|
|
12
|
+
(`.worktrees/backend-service/fix-something`, `.claude/worktrees/agent-…`) or
|
|
13
|
+
a subdirectory (`iosApp`, `Sources`) now counts toward its repository
|
|
14
|
+
(`backend-service`, `mobile`) in "By project". The repository is found from
|
|
15
|
+
the `.git` the directory sits under — a worktree's `gitdir` / `commondir`
|
|
16
|
+
lead back to the main repository — with no `git` process per line, and
|
|
17
|
+
cached per directory. A worktree that has since been removed is recognised by
|
|
18
|
+
its `…/.worktrees/<repo>/<name>` or `…/<repo>/.claude/worktrees/<name>`
|
|
19
|
+
path; anything else keeps its basename.
|
|
20
|
+
- **Days are replaced whole.** Each upload now names the (day, account) pairs
|
|
21
|
+
it carries in full (`completeDays`), and the server deletes that day's other
|
|
22
|
+
rows for the account, so a renamed project never counts twice.
|
|
23
|
+
- **The upgrade re-reads the last 30 days.** The local scan state is rebuilt
|
|
24
|
+
once with the new attribution and every day re-sent whole, which replaces
|
|
25
|
+
the old per-worktree rows. Those days wait until the server confirms it
|
|
26
|
+
replaces a day whole (an empty upload answered with `replacedDays`, asked at
|
|
27
|
+
most hourly), so an older server never counts them twice. A day that reads
|
|
28
|
+
back smaller than first counted (a log removed or rewritten since) is left
|
|
29
|
+
as it is on the server: per-folder, but with the right total.
|
|
30
|
+
|
|
7
31
|
## 0.10.1
|
|
8
32
|
|
|
9
33
|
Safer key handling for `prohost usage connect`, and no credential ever goes to
|
package/README.md
CHANGED
|
@@ -24,8 +24,9 @@ npx @prohost/cli --version # the version you're running
|
|
|
24
24
|
Pair once, then leave the agent running:
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
|
-
# 1. In ProhostAI:
|
|
28
|
-
# Codes are single-use and expire 15
|
|
27
|
+
# 1. In ProhostAI: AI Employees → Computers → Pair a computer. It shows this
|
|
28
|
+
# command with your code filled in. Codes are single-use and expire 15
|
|
29
|
+
# minutes after they're generated.
|
|
29
30
|
npx @prohost/cli agent pair --code <pairing-code>
|
|
30
31
|
|
|
31
32
|
# 2. Point it at whatever agent you already use.
|
|
@@ -48,8 +49,9 @@ doesn't know or care which one it is.
|
|
|
48
49
|
|
|
49
50
|
### 1. Get a pairing code
|
|
50
51
|
|
|
51
|
-
In ProhostAI: **
|
|
52
|
-
one-time code
|
|
52
|
+
In ProhostAI: **AI Employees → Computers → Pair a computer**. You'll get a
|
|
53
|
+
one-time code, already filled into the command it shows you. It is single-use
|
|
54
|
+
and expires 15 minutes after it's generated.
|
|
53
55
|
|
|
54
56
|
### 2. Pair
|
|
55
57
|
|
|
@@ -735,7 +737,7 @@ through ProhostAI's own approval path, not around it via an API key.
|
|
|
735
737
|
|
|
736
738
|
`prohost usage` reports, for every Claude Code and Codex login on this machine,
|
|
737
739
|
how much of its subscription is left (the 5-hour and weekly windows, with their
|
|
738
|
-
reset times) and how many tokens it used per day, model and project
|
|
740
|
+
reset times) and how many tokens it used per day, model and project (repository).
|
|
739
741
|
ProhostAI shows it in the workspace's Usage Monitor app. No agent pairing needed.
|
|
740
742
|
|
|
741
743
|
```bash
|
|
@@ -773,11 +775,17 @@ spends a model turn. Sign-in state comes from `claude auth status` /
|
|
|
773
775
|
**Tokens.** Read from the session logs the CLIs already write —
|
|
774
776
|
`<config dir>/projects/**/*.jsonl` for Claude Code, `sessions/**/rollout-*.jsonl`
|
|
775
777
|
for Codex — and summed per local day, login, model and project, where the
|
|
776
|
-
project is the **name** of the working directory
|
|
777
|
-
path).
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
is
|
|
778
|
+
project is the **name** of the repository the working directory belongs to
|
|
779
|
+
(`backend-service`, never its path). A git worktree or a subdirectory counts as
|
|
780
|
+
its main repository: `.worktrees/backend-service/fix-x` and
|
|
781
|
+
`backend-service/web/src` are both `backend-service`. A directory outside any
|
|
782
|
+
repository is its own name; a removed worktree is recognised by its
|
|
783
|
+
`…/.worktrees/<repo>/<name>` or `…/<repo>/.claude/worktrees/<name>` path. Only
|
|
784
|
+
usage counters are read: no prompt, reply, tool call or file content is kept or
|
|
785
|
+
sent. The first run looks back 30 days; after that only what was appended is
|
|
786
|
+
read, using a cursor in `$PROHOST_HOME/usage/`. A day that changes is re-sent
|
|
787
|
+
whole, marked complete, and the server replaces that day's rows for the login —
|
|
788
|
+
including deleting any project the day no longer has.
|
|
781
789
|
Totals for a login go only after the server has accepted a capacity report
|
|
782
790
|
for it (the server ignores totals for logins it hasn't seen), so each pass
|
|
783
791
|
reports first. When the server says why it skipped a login's rows, the reason
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project a session's working directory belongs to: its repository's name.
|
|
3
|
+
*
|
|
4
|
+
* A session can run in the repository root, a subdirectory (`iosApp`,
|
|
5
|
+
* `Sources`) or a git worktree with a branch-like name
|
|
6
|
+
* (`.worktrees/backend-service/fix-agent-delete-memory-aware`). All of them
|
|
7
|
+
* are the one repository, so the project is the basename of the MAIN
|
|
8
|
+
* repository's root:
|
|
9
|
+
*
|
|
10
|
+
* 1. Walk up from the directory to the nearest ancestor holding `.git`.
|
|
11
|
+
* 2. A `.git` directory: that ancestor is the root.
|
|
12
|
+
* 3. A `.git` file is a linked worktree (or a submodule): its `gitdir:` names
|
|
13
|
+
* `<repo>/.git/worktrees/<name>`, whose `commondir` leads back to
|
|
14
|
+
* `<repo>/.git`. A submodule's gitdir has neither, so it is its own project.
|
|
15
|
+
* 4. A directory that no longer exists (a removed worktree) is recognised by
|
|
16
|
+
* its layout — `…/.worktrees/<repo>/<name>` or `…/<repo>/.claude/worktrees/<name>`
|
|
17
|
+
* — and is otherwise named by its basename.
|
|
18
|
+
*
|
|
19
|
+
* Only the basename ever leaves this module, never the path. Results are
|
|
20
|
+
* cached per directory: the scanner asks once per log line, and the answer is
|
|
21
|
+
* a few `stat`s the first time and a map lookup after — never a `git` child
|
|
22
|
+
* process.
|
|
23
|
+
*
|
|
24
|
+
* The walk does what `git` itself does, so a directory under a dotfiles
|
|
25
|
+
* repository at `~` is attributed to it.
|
|
26
|
+
*/
|
|
27
|
+
/** Forget every cached answer (tests; a long-running process never needs it). */
|
|
28
|
+
export declare function clearProjectCache(): void;
|
|
29
|
+
/** The repository name of a session's working directory, or `null` when it names none. */
|
|
30
|
+
export declare function projectName(cwd: unknown): string | null;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project a session's working directory belongs to: its repository's name.
|
|
3
|
+
*
|
|
4
|
+
* A session can run in the repository root, a subdirectory (`iosApp`,
|
|
5
|
+
* `Sources`) or a git worktree with a branch-like name
|
|
6
|
+
* (`.worktrees/backend-service/fix-agent-delete-memory-aware`). All of them
|
|
7
|
+
* are the one repository, so the project is the basename of the MAIN
|
|
8
|
+
* repository's root:
|
|
9
|
+
*
|
|
10
|
+
* 1. Walk up from the directory to the nearest ancestor holding `.git`.
|
|
11
|
+
* 2. A `.git` directory: that ancestor is the root.
|
|
12
|
+
* 3. A `.git` file is a linked worktree (or a submodule): its `gitdir:` names
|
|
13
|
+
* `<repo>/.git/worktrees/<name>`, whose `commondir` leads back to
|
|
14
|
+
* `<repo>/.git`. A submodule's gitdir has neither, so it is its own project.
|
|
15
|
+
* 4. A directory that no longer exists (a removed worktree) is recognised by
|
|
16
|
+
* its layout — `…/.worktrees/<repo>/<name>` or `…/<repo>/.claude/worktrees/<name>`
|
|
17
|
+
* — and is otherwise named by its basename.
|
|
18
|
+
*
|
|
19
|
+
* Only the basename ever leaves this module, never the path. Results are
|
|
20
|
+
* cached per directory: the scanner asks once per log line, and the answer is
|
|
21
|
+
* a few `stat`s the first time and a map lookup after — never a `git` child
|
|
22
|
+
* process.
|
|
23
|
+
*
|
|
24
|
+
* The walk does what `git` itself does, so a directory under a dotfiles
|
|
25
|
+
* repository at `~` is attributed to it.
|
|
26
|
+
*/
|
|
27
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
const MAX_PROJECT_CHARS = 120;
|
|
30
|
+
/** Directories remembered; far more than one machine's sessions ever name. */
|
|
31
|
+
const MAX_CACHED = 5000;
|
|
32
|
+
/** Deepest walk up from a directory looking for `.git`. */
|
|
33
|
+
const MAX_ANCESTORS = 64;
|
|
34
|
+
const cache = new Map();
|
|
35
|
+
/** Forget every cached answer (tests; a long-running process never needs it). */
|
|
36
|
+
export function clearProjectCache() {
|
|
37
|
+
cache.clear();
|
|
38
|
+
}
|
|
39
|
+
/** The repository name of a session's working directory, or `null` when it names none. */
|
|
40
|
+
export function projectName(cwd) {
|
|
41
|
+
if (typeof cwd !== 'string' || !cwd.trim())
|
|
42
|
+
return null;
|
|
43
|
+
const dir = cwd.trim().replace(/[\\/]+$/, '');
|
|
44
|
+
if (!dir)
|
|
45
|
+
return null;
|
|
46
|
+
const cached = cache.get(dir);
|
|
47
|
+
if (cached !== undefined)
|
|
48
|
+
return cached;
|
|
49
|
+
const base = path.basename(resolveProjectRoot(dir));
|
|
50
|
+
const name = base ? base.slice(0, MAX_PROJECT_CHARS) : null;
|
|
51
|
+
if (cache.size >= MAX_CACHED)
|
|
52
|
+
cache.clear();
|
|
53
|
+
cache.set(dir, name);
|
|
54
|
+
return name;
|
|
55
|
+
}
|
|
56
|
+
/** The directory whose basename names the project. */
|
|
57
|
+
function resolveProjectRoot(dir) {
|
|
58
|
+
if (!isDirectory(dir))
|
|
59
|
+
return removedWorktreeRepo(dir) ?? dir;
|
|
60
|
+
let current = dir;
|
|
61
|
+
for (let depth = 0; depth < MAX_ANCESTORS; depth += 1) {
|
|
62
|
+
const root = repoRootAt(current);
|
|
63
|
+
if (root)
|
|
64
|
+
return root;
|
|
65
|
+
const parent = path.dirname(current);
|
|
66
|
+
if (parent === current)
|
|
67
|
+
break;
|
|
68
|
+
current = parent;
|
|
69
|
+
}
|
|
70
|
+
return dir;
|
|
71
|
+
}
|
|
72
|
+
/** The main repository root when `dir` holds a `.git`; `undefined` when it holds none. */
|
|
73
|
+
function repoRootAt(dir) {
|
|
74
|
+
const dotGit = path.join(dir, '.git');
|
|
75
|
+
let stat;
|
|
76
|
+
try {
|
|
77
|
+
stat = statSync(dotGit);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
if (stat.isDirectory())
|
|
83
|
+
return dir;
|
|
84
|
+
if (!stat.isFile())
|
|
85
|
+
return undefined;
|
|
86
|
+
const gitDir = readGitDir(dotGit);
|
|
87
|
+
return (gitDir && mainRepoOf(gitDir)) ?? dir;
|
|
88
|
+
}
|
|
89
|
+
/** The `gitdir:` a `.git` file points at, resolved against the file's directory. */
|
|
90
|
+
function readGitDir(dotGitFile) {
|
|
91
|
+
try {
|
|
92
|
+
const match = /^gitdir:\s*(.+?)\s*$/m.exec(readFileSync(dotGitFile, 'utf8'));
|
|
93
|
+
return match?.[1] ? path.resolve(path.dirname(dotGitFile), match[1]) : undefined;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** A linked worktree's main repository root, from its gitdir; `undefined` for a submodule. */
|
|
100
|
+
function mainRepoOf(gitDir) {
|
|
101
|
+
let common;
|
|
102
|
+
try {
|
|
103
|
+
const relative = readFileSync(path.join(gitDir, 'commondir'), 'utf8').trim();
|
|
104
|
+
if (relative)
|
|
105
|
+
common = path.resolve(gitDir, relative);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// No commondir: read the conventional `<repo>/.git/worktrees/<name>` layout.
|
|
109
|
+
const match = /^(.*)[\\/]\.git[\\/]worktrees[\\/][^\\/]+$/.exec(gitDir);
|
|
110
|
+
if (match?.[1])
|
|
111
|
+
return match[1];
|
|
112
|
+
}
|
|
113
|
+
if (!common)
|
|
114
|
+
return undefined;
|
|
115
|
+
// `<repo>/.git` → `<repo>`; a bare `<repo>.git` common dir names the repo itself.
|
|
116
|
+
if (path.basename(common) === '.git')
|
|
117
|
+
return path.dirname(common);
|
|
118
|
+
return common.replace(/\.git$/, '') || common;
|
|
119
|
+
}
|
|
120
|
+
/** The repository a removed worktree path belonged to, from the two layouts agents use. */
|
|
121
|
+
function removedWorktreeRepo(dir) {
|
|
122
|
+
const sep = '[\\\\/]';
|
|
123
|
+
const claude = new RegExp(`^(.*?)${sep}\\.claude${sep}worktrees${sep}[^\\\\/]+(?:${sep}.*)?$`).exec(dir);
|
|
124
|
+
if (claude?.[1])
|
|
125
|
+
return claude[1];
|
|
126
|
+
const sibling = new RegExp(`${sep}\\.worktrees${sep}([^\\\\/]+)${sep}[^\\\\/]+(?:${sep}.*)?$`).exec(dir);
|
|
127
|
+
if (sibling?.[1])
|
|
128
|
+
return sibling[1];
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
function isDirectory(dir) {
|
|
132
|
+
try {
|
|
133
|
+
return statSync(dir).isDirectory();
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
package/dist/usage/scanner.d.ts
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,9 +23,19 @@
|
|
|
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 type { AccountRuntime } from '../agent/accounts.js';
|
|
38
|
+
export { projectName } from './project.js';
|
|
27
39
|
/** How far back the first scan looks, and how long totals are kept locally. */
|
|
28
40
|
export declare const RETENTION_DAYS = 30;
|
|
29
41
|
/** Most rows in one `POST /v1/usage/token-totals`. */
|
|
@@ -54,7 +66,7 @@ interface CodexCursor {
|
|
|
54
66
|
}
|
|
55
67
|
type Cursor = ClaudeCursor | CodexCursor;
|
|
56
68
|
export interface ScanState {
|
|
57
|
-
version:
|
|
69
|
+
version: 2;
|
|
58
70
|
files: Record<string, Cursor>;
|
|
59
71
|
/** Bucket key ({@link bucketKey}) → totals. */
|
|
60
72
|
buckets: Record<string, Totals>;
|
|
@@ -64,11 +76,19 @@ export interface ScanState {
|
|
|
64
76
|
dirty: string[];
|
|
65
77
|
/** Pending pair → consecutive uploads the server only partly accepted. */
|
|
66
78
|
strikes: Record<string, number>;
|
|
79
|
+
/**
|
|
80
|
+
* Pair → what the version-1 state had counted for it, for pairs whose
|
|
81
|
+
* server rows may still carry per-directory projects. An entry goes once
|
|
82
|
+
* the pair is replaced whole on the server, or is given up on.
|
|
83
|
+
*/
|
|
84
|
+
legacy: Record<string, Totals>;
|
|
67
85
|
}
|
|
68
86
|
/** A dirty entry: one account's rows for one day. */
|
|
69
87
|
export declare function pendingKey(day: string, accountKey: string): string;
|
|
70
88
|
/** The day of a dirty entry. */
|
|
71
89
|
export declare function pendingDay(entry: string): string;
|
|
90
|
+
/** The account key of a dirty entry. */
|
|
91
|
+
export declare function pendingAccount(entry: string): string;
|
|
72
92
|
/** Days with anything pending, sorted. */
|
|
73
93
|
export declare function pendingDays(state: ScanState): string[];
|
|
74
94
|
export interface TokenRow {
|
|
@@ -93,14 +113,23 @@ export interface ScanSummary {
|
|
|
93
113
|
touchedDays: string[];
|
|
94
114
|
}
|
|
95
115
|
export declare function emptyScanState(): ScanState;
|
|
116
|
+
/**
|
|
117
|
+
* Load the scan state. A version-1 state (per-directory projects) is replaced
|
|
118
|
+
* by an empty one that re-reads every log, keeping its per-pair totals as
|
|
119
|
+
* {@link ScanState.legacy}.
|
|
120
|
+
*/
|
|
96
121
|
export declare function loadScanState(file: string): ScanState;
|
|
122
|
+
/**
|
|
123
|
+
* Whether a re-attributed pair's rebuild counted less than version 1 had — a
|
|
124
|
+
* log rewritten or removed since, or a file that fell out of the window. Its
|
|
125
|
+
* server rows are then left as they are: mis-attributed, but the right total.
|
|
126
|
+
*/
|
|
127
|
+
export declare function rebuildFellShort(rebuilt: Totals, legacy: Totals): boolean;
|
|
97
128
|
export declare function saveScanState(file: string, state: ScanState): void;
|
|
98
129
|
/** `YYYY-MM-DD` of an instant in a time zone (the machine's by default). */
|
|
99
130
|
export declare function dayKey(ms: number, timeZone?: string): string;
|
|
100
131
|
/** Bucket identity. JSON so it round-trips; the day is first so it can be read cheaply. */
|
|
101
132
|
export declare function bucketKey(day: string, accountKey: string, runtime: AccountRuntime, model: string, project: string | null): string;
|
|
102
|
-
/** Basename of a working directory — the repo or folder name, never the path. */
|
|
103
|
-
export declare function projectName(cwd: unknown): string | null;
|
|
104
133
|
/**
|
|
105
134
|
* One incremental pass over every account's logs. Mutates and returns
|
|
106
135
|
* ``state``; the caller persists it.
|
|
@@ -110,8 +139,20 @@ export declare function scanUsage(accounts: ScanAccount[], state: ScanState, opt
|
|
|
110
139
|
export declare function rowsForDays(state: ScanState, days: Iterable<string>): TokenRow[];
|
|
111
140
|
/** The rows for the given `day|accountKey` pairs, in upload shape. */
|
|
112
141
|
export declare function rowsForPending(state: ScanState, pairs: Iterable<string>): TokenRow[];
|
|
113
|
-
/**
|
|
114
|
-
export
|
|
142
|
+
/** One `POST /v1/usage/token-totals` body's rows, and what it says about each (day, account) pair. */
|
|
143
|
+
export interface UploadBatch {
|
|
144
|
+
rows: TokenRow[];
|
|
145
|
+
/** Pairs whose first row is here: sent as `completeDays`, so the server replaces each one whole. */
|
|
146
|
+
complete: string[];
|
|
147
|
+
/** Pairs whose last row is here: done once this batch is taken. */
|
|
148
|
+
last: string[];
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Split rows into upload batches without splitting a (day, account) pair,
|
|
152
|
+
* so each body carries every row of the pairs it names complete. A pair
|
|
153
|
+
* larger than a batch is sent over consecutive batches: complete in the
|
|
154
|
+
* first (the server clears its rows, then stores these), added to by the rest.
|
|
155
|
+
*/
|
|
156
|
+
export declare function uploadBatches(rows: TokenRow[], size?: number): UploadBatch[];
|
|
115
157
|
/** Local totals per account since ``fromDay`` (inclusive), for `usage status`. */
|
|
116
158
|
export declare function localTotals(state: ScanState, fromDay: string): Map<string, Totals>;
|
|
117
|
-
export {};
|
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;
|
|
@@ -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
|
@@ -17,11 +17,13 @@ import { Backoff } from './backoff.js';
|
|
|
17
17
|
import { resolveUsageKey, usageStateDir, writePrivateFile } from './config.js';
|
|
18
18
|
import { accountConfigDir } from './discovery.js';
|
|
19
19
|
import { buildReportFrame, defaultProber, probeAccounts } from './quota.js';
|
|
20
|
-
import {
|
|
20
|
+
import { loadScanState, pendingAccount, pendingDay, pendingKey, rebuildFellShort, rowsForPending, saveScanState, scanUsage, uploadBatches, } from './scanner.js';
|
|
21
21
|
export const REPORTS_PATH = '/v1/usage/reports';
|
|
22
22
|
export const TOKEN_TOTALS_PATH = '/v1/usage/token-totals';
|
|
23
23
|
/** Tick cadence. */
|
|
24
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;
|
|
25
27
|
/** Partial uploads of one day's rows for one account before they are given up on. */
|
|
26
28
|
export const MAX_PARTIAL_STRIKES = 3;
|
|
27
29
|
/** Skips that will never turn into an acceptance from this machine. */
|
|
@@ -49,7 +51,13 @@ export function parseAcceptance(body, sent) {
|
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
}
|
|
52
|
-
|
|
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
|
+
};
|
|
53
61
|
}
|
|
54
62
|
export function scanStatePath(env = process.env) {
|
|
55
63
|
return path.join(usageStateDir(env), 'scan.json');
|
|
@@ -82,6 +90,8 @@ export class UsageService {
|
|
|
82
90
|
prober;
|
|
83
91
|
reportBackoff;
|
|
84
92
|
totalsBackoff;
|
|
93
|
+
/** The last {@link serverReplacesDays} answer, for this process only. */
|
|
94
|
+
replaceProbe;
|
|
85
95
|
scan;
|
|
86
96
|
status;
|
|
87
97
|
warned = new Set();
|
|
@@ -214,35 +224,89 @@ export class UsageService {
|
|
|
214
224
|
* `not_reported`, costs a strike; after {@link MAX_PARTIAL_STRIKES} the pair
|
|
215
225
|
* is given up on (logged once), so nothing is resent forever. Pairs whose
|
|
216
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.
|
|
217
239
|
*/
|
|
218
240
|
async uploadDirty(api, machineId, machine) {
|
|
219
241
|
const scan = this.scan;
|
|
220
242
|
const rows = rowsForPending(scan, scan.dirty);
|
|
221
|
-
|
|
243
|
+
let ready = rows.filter((r) => this.reportedKeys.has(r.accountKey));
|
|
222
244
|
const done = new Set();
|
|
223
245
|
const pendingWithRows = new Set(rows.map((r) => pendingKey(r.day, r.accountKey)));
|
|
224
246
|
// Pending pairs with nothing to send (all-zero) are simply done.
|
|
225
247
|
for (const pair of scan.dirty)
|
|
226
248
|
if (!pendingWithRows.has(pair))
|
|
227
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
|
+
}
|
|
228
281
|
let sent = 0;
|
|
229
282
|
let gaveUp = 0;
|
|
230
|
-
for (const batch of
|
|
231
|
-
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);
|
|
232
291
|
this.note(this.totalsBackoff, response);
|
|
233
|
-
const acceptance = response.ok ? parseAcceptance(response.body, batch.length) : { accepted: 0 };
|
|
234
|
-
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 };
|
|
235
294
|
if (!response.ok)
|
|
236
295
|
break;
|
|
237
296
|
sent += acceptance.accepted;
|
|
297
|
+
const lastHere = new Set(batch.last);
|
|
238
298
|
const pairs = new Map(); // pair → accountKey
|
|
239
|
-
for (const row of batch)
|
|
299
|
+
for (const row of batch.rows)
|
|
240
300
|
pairs.set(pendingKey(row.day, row.accountKey), row.accountKey);
|
|
241
301
|
for (const [pair, accountKey] of pairs) {
|
|
242
|
-
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;
|
|
243
303
|
if (taken) {
|
|
304
|
+
// A pair spread over several bodies is done with its last one.
|
|
305
|
+
if (!lastHere.has(pair))
|
|
306
|
+
continue;
|
|
244
307
|
done.add(pair);
|
|
245
308
|
delete scan.strikes[pair];
|
|
309
|
+
delete scan.legacy[pair];
|
|
246
310
|
continue;
|
|
247
311
|
}
|
|
248
312
|
const reason = acceptance.skipped?.get(accountKey);
|
|
@@ -251,6 +315,7 @@ export class UsageService {
|
|
|
251
315
|
if (reason && PERMANENT_SKIPS.has(reason)) {
|
|
252
316
|
done.add(pair);
|
|
253
317
|
delete scan.strikes[pair];
|
|
318
|
+
delete scan.legacy[pair];
|
|
254
319
|
this.warnOnce(`totals_skip:${accountKey}:${reason}`, `token totals: account ${accountKey.slice(0, 8)}… skipped (${reason}); those rows will not be resent.`);
|
|
255
320
|
continue;
|
|
256
321
|
}
|
|
@@ -260,6 +325,7 @@ export class UsageService {
|
|
|
260
325
|
if (strikes >= MAX_PARTIAL_STRIKES) {
|
|
261
326
|
done.add(pair);
|
|
262
327
|
delete scan.strikes[pair];
|
|
328
|
+
delete scan.legacy[pair];
|
|
263
329
|
gaveUp += 1;
|
|
264
330
|
}
|
|
265
331
|
else {
|
|
@@ -275,6 +341,27 @@ export class UsageService {
|
|
|
275
341
|
saveScanState(scanStatePath(this.env), scan);
|
|
276
342
|
return sent;
|
|
277
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
|
+
}
|
|
278
365
|
note(backoff, response) {
|
|
279
366
|
const line = backoff.record(response);
|
|
280
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}`;
|