@prohost/cli 0.10.1 → 0.10.3
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 +39 -0
- package/README.md +18 -10
- package/dist/usage/project.d.ts +32 -0
- package/dist/usage/project.js +155 -0
- package/dist/usage/scanner.d.ts +59 -8
- package/dist/usage/scanner.js +164 -28
- package/dist/usage/service.d.ts +41 -0
- package/dist/usage/service.js +142 -10
- 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,45 @@ 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.3
|
|
8
|
+
|
|
9
|
+
Two fixes to the 0.10.2 repository attribution.
|
|
10
|
+
|
|
11
|
+
- **A bare repository is named without `.git`.** A session whose working
|
|
12
|
+
directory is a bare repository (`tools.git`, with `HEAD`, `objects/` and
|
|
13
|
+
`refs/` and no `.git` inside), or a directory within it, counts toward
|
|
14
|
+
`tools`, the same name its linked worktrees get. A plain folder that only
|
|
15
|
+
ends in `.git` keeps its name.
|
|
16
|
+
- **Days 0.10.1 had not uploaded yet are not lost in the upgrade.** The rows
|
|
17
|
+
of any (day, login) still waiting to upload when the scan state was rebuilt
|
|
18
|
+
are sent as 0.10.1 had them, before any rebuilt row. A day whose log has
|
|
19
|
+
since been removed, or reads back smaller, still reaches the server with
|
|
20
|
+
the total 0.10.1 counted.
|
|
21
|
+
|
|
22
|
+
## 0.10.2
|
|
23
|
+
|
|
24
|
+
Token totals are attributed to the repository, not the directory a session ran in.
|
|
25
|
+
|
|
26
|
+
- **Projects are repositories.** A session in a git worktree
|
|
27
|
+
(`.worktrees/backend-service/fix-something`, `.claude/worktrees/agent-…`) or
|
|
28
|
+
a subdirectory (`iosApp`, `Sources`) now counts toward its repository
|
|
29
|
+
(`backend-service`, `mobile`) in "By project". The repository is found from
|
|
30
|
+
the `.git` the directory sits under — a worktree's `gitdir` / `commondir`
|
|
31
|
+
lead back to the main repository — with no `git` process per line, and
|
|
32
|
+
cached per directory. A worktree that has since been removed is recognised by
|
|
33
|
+
its `…/.worktrees/<repo>/<name>` or `…/<repo>/.claude/worktrees/<name>`
|
|
34
|
+
path; anything else keeps its basename.
|
|
35
|
+
- **Days are replaced whole.** Each upload now names the (day, account) pairs
|
|
36
|
+
it carries in full (`completeDays`), and the server deletes that day's other
|
|
37
|
+
rows for the account, so a renamed project never counts twice.
|
|
38
|
+
- **The upgrade re-reads the last 30 days.** The local scan state is rebuilt
|
|
39
|
+
once with the new attribution and every day re-sent whole, which replaces
|
|
40
|
+
the old per-worktree rows. Those days wait until the server confirms it
|
|
41
|
+
replaces a day whole (an empty upload answered with `replacedDays`, asked at
|
|
42
|
+
most hourly), so an older server never counts them twice. A day that reads
|
|
43
|
+
back smaller than first counted (a log removed or rewritten since) is left
|
|
44
|
+
as it is on the server: per-folder, but with the right total.
|
|
45
|
+
|
|
7
46
|
## 0.10.1
|
|
8
47
|
|
|
9
48
|
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,32 @@
|
|
|
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
|
+
* A bare repository (`HEAD`, `objects/`, `refs/` and no `.git`) is its own
|
|
16
|
+
* root, named without a `.git` suffix: `tools.git` is `tools`.
|
|
17
|
+
* 4. A directory that no longer exists (a removed worktree) is recognised by
|
|
18
|
+
* its layout — `…/.worktrees/<repo>/<name>` or `…/<repo>/.claude/worktrees/<name>`
|
|
19
|
+
* — and is otherwise named by its basename.
|
|
20
|
+
*
|
|
21
|
+
* Only the basename ever leaves this module, never the path. Results are
|
|
22
|
+
* cached per directory: the scanner asks once per log line, and the answer is
|
|
23
|
+
* a few `stat`s the first time and a map lookup after — never a `git` child
|
|
24
|
+
* process.
|
|
25
|
+
*
|
|
26
|
+
* The walk does what `git` itself does, so a directory under a dotfiles
|
|
27
|
+
* repository at `~` is attributed to it.
|
|
28
|
+
*/
|
|
29
|
+
/** Forget every cached answer (tests; a long-running process never needs it). */
|
|
30
|
+
export declare function clearProjectCache(): void;
|
|
31
|
+
/** The repository name of a session's working directory, or `null` when it names none. */
|
|
32
|
+
export declare function projectName(cwd: unknown): string | null;
|
|
@@ -0,0 +1,155 @@
|
|
|
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
|
+
* A bare repository (`HEAD`, `objects/`, `refs/` and no `.git`) is its own
|
|
16
|
+
* root, named without a `.git` suffix: `tools.git` is `tools`.
|
|
17
|
+
* 4. A directory that no longer exists (a removed worktree) is recognised by
|
|
18
|
+
* its layout — `…/.worktrees/<repo>/<name>` or `…/<repo>/.claude/worktrees/<name>`
|
|
19
|
+
* — and is otherwise named by its basename.
|
|
20
|
+
*
|
|
21
|
+
* Only the basename ever leaves this module, never the path. Results are
|
|
22
|
+
* cached per directory: the scanner asks once per log line, and the answer is
|
|
23
|
+
* a few `stat`s the first time and a map lookup after — never a `git` child
|
|
24
|
+
* process.
|
|
25
|
+
*
|
|
26
|
+
* The walk does what `git` itself does, so a directory under a dotfiles
|
|
27
|
+
* repository at `~` is attributed to it.
|
|
28
|
+
*/
|
|
29
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
const MAX_PROJECT_CHARS = 120;
|
|
32
|
+
/** Directories remembered; far more than one machine's sessions ever name. */
|
|
33
|
+
const MAX_CACHED = 5000;
|
|
34
|
+
/** Deepest walk up from a directory looking for `.git`. */
|
|
35
|
+
const MAX_ANCESTORS = 64;
|
|
36
|
+
const cache = new Map();
|
|
37
|
+
/** Forget every cached answer (tests; a long-running process never needs it). */
|
|
38
|
+
export function clearProjectCache() {
|
|
39
|
+
cache.clear();
|
|
40
|
+
}
|
|
41
|
+
/** The repository name of a session's working directory, or `null` when it names none. */
|
|
42
|
+
export function projectName(cwd) {
|
|
43
|
+
if (typeof cwd !== 'string' || !cwd.trim())
|
|
44
|
+
return null;
|
|
45
|
+
const dir = cwd.trim().replace(/[\\/]+$/, '');
|
|
46
|
+
if (!dir)
|
|
47
|
+
return null;
|
|
48
|
+
const cached = cache.get(dir);
|
|
49
|
+
if (cached !== undefined)
|
|
50
|
+
return cached;
|
|
51
|
+
const base = path.basename(resolveProjectRoot(dir));
|
|
52
|
+
const name = base ? base.slice(0, MAX_PROJECT_CHARS) : null;
|
|
53
|
+
if (cache.size >= MAX_CACHED)
|
|
54
|
+
cache.clear();
|
|
55
|
+
cache.set(dir, name);
|
|
56
|
+
return name;
|
|
57
|
+
}
|
|
58
|
+
/** The directory whose basename names the project. */
|
|
59
|
+
function resolveProjectRoot(dir) {
|
|
60
|
+
if (!isDirectory(dir))
|
|
61
|
+
return removedWorktreeRepo(dir) ?? dir;
|
|
62
|
+
let current = dir;
|
|
63
|
+
for (let depth = 0; depth < MAX_ANCESTORS; depth += 1) {
|
|
64
|
+
const root = repoRootAt(current);
|
|
65
|
+
if (root)
|
|
66
|
+
return root;
|
|
67
|
+
const parent = path.dirname(current);
|
|
68
|
+
if (parent === current)
|
|
69
|
+
break;
|
|
70
|
+
current = parent;
|
|
71
|
+
}
|
|
72
|
+
return dir;
|
|
73
|
+
}
|
|
74
|
+
/** The main repository root when `dir` holds a `.git` or is a bare repository; `undefined` otherwise. */
|
|
75
|
+
function repoRootAt(dir) {
|
|
76
|
+
const dotGit = path.join(dir, '.git');
|
|
77
|
+
let stat;
|
|
78
|
+
try {
|
|
79
|
+
stat = statSync(dotGit);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return isBareRepo(dir) ? withoutGitSuffix(dir) : undefined;
|
|
83
|
+
}
|
|
84
|
+
if (stat.isDirectory())
|
|
85
|
+
return dir;
|
|
86
|
+
if (!stat.isFile())
|
|
87
|
+
return undefined;
|
|
88
|
+
const gitDir = readGitDir(dotGit);
|
|
89
|
+
return (gitDir && mainRepoOf(gitDir)) ?? dir;
|
|
90
|
+
}
|
|
91
|
+
/** The `gitdir:` a `.git` file points at, resolved against the file's directory. */
|
|
92
|
+
function readGitDir(dotGitFile) {
|
|
93
|
+
try {
|
|
94
|
+
const match = /^gitdir:\s*(.+?)\s*$/m.exec(readFileSync(dotGitFile, 'utf8'));
|
|
95
|
+
return match?.[1] ? path.resolve(path.dirname(dotGitFile), match[1]) : undefined;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** A linked worktree's main repository root, from its gitdir; `undefined` for a submodule. */
|
|
102
|
+
function mainRepoOf(gitDir) {
|
|
103
|
+
let common;
|
|
104
|
+
try {
|
|
105
|
+
const relative = readFileSync(path.join(gitDir, 'commondir'), 'utf8').trim();
|
|
106
|
+
if (relative)
|
|
107
|
+
common = path.resolve(gitDir, relative);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// No commondir: read the conventional `<repo>/.git/worktrees/<name>` layout.
|
|
111
|
+
const match = /^(.*)[\\/]\.git[\\/]worktrees[\\/][^\\/]+$/.exec(gitDir);
|
|
112
|
+
if (match?.[1])
|
|
113
|
+
return match[1];
|
|
114
|
+
}
|
|
115
|
+
if (!common)
|
|
116
|
+
return undefined;
|
|
117
|
+
// `<repo>/.git` → `<repo>`; a bare `<repo>.git` common dir names the repo itself.
|
|
118
|
+
if (path.basename(common) === '.git')
|
|
119
|
+
return path.dirname(common);
|
|
120
|
+
return withoutGitSuffix(common);
|
|
121
|
+
}
|
|
122
|
+
/** `<repo>.git` → `<repo>`; anything else as it is. */
|
|
123
|
+
function withoutGitSuffix(dir) {
|
|
124
|
+
return dir.replace(/\.git$/, '') || dir;
|
|
125
|
+
}
|
|
126
|
+
/** A bare repository's own directory: `HEAD`, `objects/` and `refs/` with no worktree. */
|
|
127
|
+
function isBareRepo(dir) {
|
|
128
|
+
try {
|
|
129
|
+
return (statSync(path.join(dir, 'HEAD')).isFile() &&
|
|
130
|
+
statSync(path.join(dir, 'objects')).isDirectory() &&
|
|
131
|
+
statSync(path.join(dir, 'refs')).isDirectory());
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** The repository a removed worktree path belonged to, from the two layouts agents use. */
|
|
138
|
+
function removedWorktreeRepo(dir) {
|
|
139
|
+
const sep = '[\\\\/]';
|
|
140
|
+
const claude = new RegExp(`^(.*?)${sep}\\.claude${sep}worktrees${sep}[^\\\\/]+(?:${sep}.*)?$`).exec(dir);
|
|
141
|
+
if (claude?.[1])
|
|
142
|
+
return claude[1];
|
|
143
|
+
const sibling = new RegExp(`${sep}\\.worktrees${sep}([^\\\\/]+)${sep}[^\\\\/]+(?:${sep}.*)?$`).exec(dir);
|
|
144
|
+
if (sibling?.[1])
|
|
145
|
+
return sibling[1];
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
function isDirectory(dir) {
|
|
149
|
+
try {
|
|
150
|
+
return statSync(dir).isDirectory();
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
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,26 @@ 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>;
|
|
85
|
+
/**
|
|
86
|
+
* Bucket key → totals: the version-1 rows of pairs version 1 had not
|
|
87
|
+
* uploaded yet (still dirty). The server never took them, so its copy is
|
|
88
|
+
* not the right total; they go out as they were, before any rebuilt row,
|
|
89
|
+
* and an entry goes once the server takes its pair or never will.
|
|
90
|
+
*/
|
|
91
|
+
legacyUnsent: Record<string, Totals>;
|
|
67
92
|
}
|
|
68
93
|
/** A dirty entry: one account's rows for one day. */
|
|
69
94
|
export declare function pendingKey(day: string, accountKey: string): string;
|
|
70
95
|
/** The day of a dirty entry. */
|
|
71
96
|
export declare function pendingDay(entry: string): string;
|
|
97
|
+
/** The account key of a dirty entry. */
|
|
98
|
+
export declare function pendingAccount(entry: string): string;
|
|
72
99
|
/** Days with anything pending, sorted. */
|
|
73
100
|
export declare function pendingDays(state: ScanState): string[];
|
|
74
101
|
export interface TokenRow {
|
|
@@ -93,14 +120,24 @@ export interface ScanSummary {
|
|
|
93
120
|
touchedDays: string[];
|
|
94
121
|
}
|
|
95
122
|
export declare function emptyScanState(): ScanState;
|
|
123
|
+
/**
|
|
124
|
+
* Load the scan state. A version-1 state (per-directory projects) is replaced
|
|
125
|
+
* by an empty one that re-reads every log, keeping its per-pair totals as
|
|
126
|
+
* {@link ScanState.legacy} and the rows it had not uploaded as
|
|
127
|
+
* {@link ScanState.legacyUnsent}.
|
|
128
|
+
*/
|
|
96
129
|
export declare function loadScanState(file: string): ScanState;
|
|
130
|
+
/**
|
|
131
|
+
* Whether a re-attributed pair's rebuild counted less than version 1 had — a
|
|
132
|
+
* log rewritten or removed since, or a file that fell out of the window. Its
|
|
133
|
+
* server rows are then left as they are: mis-attributed, but the right total.
|
|
134
|
+
*/
|
|
135
|
+
export declare function rebuildFellShort(rebuilt: Totals, legacy: Totals): boolean;
|
|
97
136
|
export declare function saveScanState(file: string, state: ScanState): void;
|
|
98
137
|
/** `YYYY-MM-DD` of an instant in a time zone (the machine's by default). */
|
|
99
138
|
export declare function dayKey(ms: number, timeZone?: string): string;
|
|
100
139
|
/** Bucket identity. JSON so it round-trips; the day is first so it can be read cheaply. */
|
|
101
140
|
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
141
|
/**
|
|
105
142
|
* One incremental pass over every account's logs. Mutates and returns
|
|
106
143
|
* ``state``; the caller persists it.
|
|
@@ -108,10 +145,24 @@ export declare function projectName(cwd: unknown): string | null;
|
|
|
108
145
|
export declare function scanUsage(accounts: ScanAccount[], state: ScanState, options?: ScanOptions): ScanSummary;
|
|
109
146
|
/** The rows for the given days, in upload shape. Zero-token buckets are left out. */
|
|
110
147
|
export declare function rowsForDays(state: ScanState, days: Iterable<string>): TokenRow[];
|
|
148
|
+
/** The version-1 rows not yet uploaded ({@link ScanState.legacyUnsent}), in upload shape. */
|
|
149
|
+
export declare function legacyUnsentRows(state: ScanState): TokenRow[];
|
|
111
150
|
/** The rows for the given `day|accountKey` pairs, in upload shape. */
|
|
112
151
|
export declare function rowsForPending(state: ScanState, pairs: Iterable<string>): TokenRow[];
|
|
113
|
-
/**
|
|
114
|
-
export
|
|
152
|
+
/** One `POST /v1/usage/token-totals` body's rows, and what it says about each (day, account) pair. */
|
|
153
|
+
export interface UploadBatch {
|
|
154
|
+
rows: TokenRow[];
|
|
155
|
+
/** Pairs whose first row is here: sent as `completeDays`, so the server replaces each one whole. */
|
|
156
|
+
complete: string[];
|
|
157
|
+
/** Pairs whose last row is here: done once this batch is taken. */
|
|
158
|
+
last: string[];
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Split rows into upload batches without splitting a (day, account) pair,
|
|
162
|
+
* so each body carries every row of the pairs it names complete. A pair
|
|
163
|
+
* larger than a batch is sent over consecutive batches: complete in the
|
|
164
|
+
* first (the server clears its rows, then stores these), added to by the rest.
|
|
165
|
+
*/
|
|
166
|
+
export declare function uploadBatches(rows: TokenRow[], size?: number): UploadBatch[];
|
|
115
167
|
/** Local totals per account since ``fromDay`` (inclusive), for `usage status`. */
|
|
116
168
|
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,117 @@ 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: {}, legacyUnsent: {} };
|
|
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
|
+
/** The version-1 buckets of the pairs it still had dirty. */
|
|
99
|
+
function legacyUnsentBuckets(buckets, dirty) {
|
|
100
|
+
const out = {};
|
|
101
|
+
if (!buckets || typeof buckets !== 'object' || dirty.length === 0)
|
|
102
|
+
return out;
|
|
103
|
+
const wanted = new Set(dirty);
|
|
104
|
+
for (const [key, totals] of Object.entries(buckets)) {
|
|
105
|
+
if (!isTotals(totals) || !isBucketKey(key))
|
|
106
|
+
continue;
|
|
107
|
+
const [day, accountKey] = JSON.parse(key);
|
|
108
|
+
if (wanted.has(pendingKey(day, accountKey)))
|
|
109
|
+
out[key] = totals;
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
/** A {@link bucketKey}: `[day, accountKey, runtime, model, project]`. */
|
|
114
|
+
function isBucketKey(key) {
|
|
115
|
+
try {
|
|
116
|
+
const parts = JSON.parse(key);
|
|
117
|
+
return (Array.isArray(parts) &&
|
|
118
|
+
parts.length === 5 &&
|
|
119
|
+
parts.slice(0, 4).every((part) => typeof part === 'string') &&
|
|
120
|
+
(parts[4] === null || typeof parts[4] === 'string'));
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
53
125
|
}
|
|
126
|
+
const totalsRecord = (value, validKey) => value && typeof value === 'object'
|
|
127
|
+
? Object.fromEntries(Object.entries(value).filter(([key, t]) => validKey(key) && isTotals(t)))
|
|
128
|
+
: {};
|
|
129
|
+
/**
|
|
130
|
+
* Load the scan state. A version-1 state (per-directory projects) is replaced
|
|
131
|
+
* by an empty one that re-reads every log, keeping its per-pair totals as
|
|
132
|
+
* {@link ScanState.legacy} and the rows it had not uploaded as
|
|
133
|
+
* {@link ScanState.legacyUnsent}.
|
|
134
|
+
*/
|
|
54
135
|
export function loadScanState(file) {
|
|
136
|
+
let parsed;
|
|
55
137
|
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
|
-
};
|
|
138
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
67
139
|
}
|
|
68
140
|
catch {
|
|
69
141
|
return emptyScanState();
|
|
70
142
|
}
|
|
143
|
+
if (parsed?.version === 1) {
|
|
144
|
+
return {
|
|
145
|
+
...emptyScanState(),
|
|
146
|
+
legacy: legacyTotals(parsed.buckets),
|
|
147
|
+
legacyUnsent: legacyUnsentBuckets(parsed.buckets, pairList(parsed.dirty)),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (parsed?.version !== 2)
|
|
151
|
+
return emptyScanState();
|
|
152
|
+
return {
|
|
153
|
+
version: 2,
|
|
154
|
+
files: parsed.files && typeof parsed.files === 'object' ? parsed.files : {},
|
|
155
|
+
buckets: parsed.buckets && typeof parsed.buckets === 'object' ? parsed.buckets : {},
|
|
156
|
+
seen: parsed.seen && typeof parsed.seen === 'object' ? parsed.seen : {},
|
|
157
|
+
dirty: pairList(parsed.dirty),
|
|
158
|
+
strikes: parsed.strikes && typeof parsed.strikes === 'object' ? parsed.strikes : {},
|
|
159
|
+
legacy: totalsRecord(parsed.legacy, (pair) => pair.includes('|')),
|
|
160
|
+
legacyUnsent: totalsRecord(parsed.legacyUnsent, isBucketKey),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Whether a re-attributed pair's rebuild counted less than version 1 had — a
|
|
165
|
+
* log rewritten or removed since, or a file that fell out of the window. Its
|
|
166
|
+
* server rows are then left as they are: mis-attributed, but the right total.
|
|
167
|
+
*/
|
|
168
|
+
export function rebuildFellShort(rebuilt, legacy) {
|
|
169
|
+
return rebuilt.some((n, i) => n < legacy[i]);
|
|
71
170
|
}
|
|
72
171
|
export function saveScanState(file, state) {
|
|
73
172
|
writePrivateFile(file, JSON.stringify(state));
|
|
@@ -97,13 +196,6 @@ export function bucketKey(day, accountKey, runtime, model, project) {
|
|
|
97
196
|
function dayOfBucket(key) {
|
|
98
197
|
return key.slice(2, 12);
|
|
99
198
|
}
|
|
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
199
|
function asObject(value) {
|
|
108
200
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
109
201
|
}
|
|
@@ -362,6 +454,13 @@ export function scanUsage(accounts, state, options = {}) {
|
|
|
362
454
|
for (const pair of touchedPairs)
|
|
363
455
|
delete state.strikes[pair];
|
|
364
456
|
state.dirty = [...new Set([...state.dirty, ...touchedPairs])].filter((d) => pendingDay(d) >= pass.cutoffDay).sort();
|
|
457
|
+
for (const pair of Object.keys(state.legacy))
|
|
458
|
+
if (pendingDay(pair) < pass.cutoffDay)
|
|
459
|
+
delete state.legacy[pair];
|
|
460
|
+
for (const key of Object.keys(state.legacyUnsent)) {
|
|
461
|
+
if (dayOfBucket(key) < pass.cutoffDay)
|
|
462
|
+
delete state.legacyUnsent[key];
|
|
463
|
+
}
|
|
365
464
|
for (const pair of Object.keys(state.strikes))
|
|
366
465
|
if (!state.dirty.includes(pair))
|
|
367
466
|
delete state.strikes[pair];
|
|
@@ -390,9 +489,16 @@ function prune(state, cutoffDay, cutoffMs) {
|
|
|
390
489
|
/** The rows for the given days, in upload shape. Zero-token buckets are left out. */
|
|
391
490
|
export function rowsForDays(state, days) {
|
|
392
491
|
const wanted = new Set(days);
|
|
492
|
+
return bucketRows(state.buckets, (key) => wanted.has(dayOfBucket(key)));
|
|
493
|
+
}
|
|
494
|
+
/** The version-1 rows not yet uploaded ({@link ScanState.legacyUnsent}), in upload shape. */
|
|
495
|
+
export function legacyUnsentRows(state) {
|
|
496
|
+
return bucketRows(state.legacyUnsent, () => true);
|
|
497
|
+
}
|
|
498
|
+
function bucketRows(buckets, include) {
|
|
393
499
|
const rows = [];
|
|
394
|
-
for (const [key, totals] of Object.entries(
|
|
395
|
-
if (!
|
|
500
|
+
for (const [key, totals] of Object.entries(buckets)) {
|
|
501
|
+
if (!include(key) || isZero(totals))
|
|
396
502
|
continue;
|
|
397
503
|
const [day, accountKey, runtime, model, project] = JSON.parse(key);
|
|
398
504
|
rows.push({
|
|
@@ -415,11 +521,41 @@ export function rowsForPending(state, pairs) {
|
|
|
415
521
|
const days = new Set([...wanted].map(pendingDay));
|
|
416
522
|
return rowsForDays(state, days).filter((r) => wanted.has(pendingKey(r.day, r.accountKey)));
|
|
417
523
|
}
|
|
418
|
-
/**
|
|
419
|
-
|
|
524
|
+
/**
|
|
525
|
+
* Split rows into upload batches without splitting a (day, account) pair,
|
|
526
|
+
* so each body carries every row of the pairs it names complete. A pair
|
|
527
|
+
* larger than a batch is sent over consecutive batches: complete in the
|
|
528
|
+
* first (the server clears its rows, then stores these), added to by the rest.
|
|
529
|
+
*/
|
|
530
|
+
export function uploadBatches(rows, size = MAX_ROWS_PER_BATCH) {
|
|
531
|
+
const groups = new Map();
|
|
532
|
+
for (const row of rows) {
|
|
533
|
+
const pair = pendingKey(row.day, row.accountKey);
|
|
534
|
+
const group = groups.get(pair);
|
|
535
|
+
if (group)
|
|
536
|
+
group.push(row);
|
|
537
|
+
else
|
|
538
|
+
groups.set(pair, [row]);
|
|
539
|
+
}
|
|
420
540
|
const out = [];
|
|
421
|
-
|
|
422
|
-
|
|
541
|
+
let current;
|
|
542
|
+
for (const [pair, group] of groups) {
|
|
543
|
+
if (current && current.rows.length + group.length <= size) {
|
|
544
|
+
current.rows.push(...group);
|
|
545
|
+
current.complete.push(pair);
|
|
546
|
+
current.last.push(pair);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
for (let start = 0; start < group.length; start += size) {
|
|
550
|
+
const chunk = group.slice(start, start + size);
|
|
551
|
+
current = {
|
|
552
|
+
rows: chunk,
|
|
553
|
+
complete: start === 0 ? [pair] : [],
|
|
554
|
+
last: start + size >= group.length ? [pair] : [],
|
|
555
|
+
};
|
|
556
|
+
out.push(current);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
423
559
|
return out;
|
|
424
560
|
}
|
|
425
561
|
/** 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,39 @@ 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.
|
|
135
|
+
*
|
|
136
|
+
* First, though, the version-1 rows version 1 never uploaded
|
|
137
|
+
* (`scan.legacyUnsent`) go out as they were, without `completeDays` — the
|
|
138
|
+
* server's copy of those pairs is behind, so it is not the right total
|
|
139
|
+
* until they land. A rebuilt complete pair sent after them replaces them.
|
|
113
140
|
*/
|
|
114
141
|
private uploadDirty;
|
|
142
|
+
/**
|
|
143
|
+
* Upload the version-1 rows version 1 had not uploaded, as plain rows (the
|
|
144
|
+
* upsert version 1 would have sent). A pair's rows go once the server takes
|
|
145
|
+
* them or never will (a permanent skip); `not_reported` waits for a report.
|
|
146
|
+
* Returns `false` when a call failed, so nothing else is sent this tick.
|
|
147
|
+
*/
|
|
148
|
+
private uploadLegacyUnsent;
|
|
149
|
+
/**
|
|
150
|
+
* Whether the server deletes the rows a complete day no longer carries: an
|
|
151
|
+
* empty body is answered with `replacedDays` only by one that does. A yes
|
|
152
|
+
* is kept for the process; a no (or a failed call) is asked again after
|
|
153
|
+
* {@link REPLACE_PROBE_INTERVAL_MS}.
|
|
154
|
+
*/
|
|
155
|
+
private serverReplacesDays;
|
|
115
156
|
private note;
|
|
116
157
|
private callRecord;
|
|
117
158
|
}
|
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, legacyUnsentRows, 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();
|
|
@@ -183,7 +193,8 @@ export class UsageService {
|
|
|
183
193
|
// The state can run to megabytes; rewrite it only when a file was read.
|
|
184
194
|
if (scanned.filesRead > 0)
|
|
185
195
|
saveScanState(scanStatePath(this.env), this.scan);
|
|
186
|
-
|
|
196
|
+
const pending = this.scan.dirty.length > 0 || Object.keys(this.scan.legacyUnsent).length > 0;
|
|
197
|
+
if (this.totalsBackoff.ready() && pending) {
|
|
187
198
|
result.uploadedRows = await this.uploadDirty(api, machineId, machine);
|
|
188
199
|
}
|
|
189
200
|
}
|
|
@@ -214,35 +225,98 @@ export class UsageService {
|
|
|
214
225
|
* `not_reported`, costs a strike; after {@link MAX_PARTIAL_STRIKES} the pair
|
|
215
226
|
* is given up on (logged once), so nothing is resent forever. Pairs whose
|
|
216
227
|
* account has never been reported wait without a strike, and are not sent.
|
|
228
|
+
*
|
|
229
|
+
* Every pair goes out as `completeDays`, never split across bodies unless
|
|
230
|
+
* it alone outgrows one: a server that understands it deletes the pair's
|
|
231
|
+
* rows the body no longer carries and says so with `replacedDays`.
|
|
232
|
+
*
|
|
233
|
+
* A pair re-attributed by the version-2 upgrade (`scan.legacy`) still has
|
|
234
|
+
* per-directory rows on the server, so sending it to a server that only
|
|
235
|
+
* upserts would count it twice. It is held until a probe (an empty body)
|
|
236
|
+
* comes back with `replacedDays`, asked again at most every
|
|
237
|
+
* {@link REPLACE_PROBE_INTERVAL_MS}; other pairs go out meanwhile. A pair
|
|
238
|
+
* whose rebuild counted less than version 1 had (a log removed or rewritten
|
|
239
|
+
* since) is not sent at all: its old rows are mis-attributed but total right.
|
|
240
|
+
*
|
|
241
|
+
* First, though, the version-1 rows version 1 never uploaded
|
|
242
|
+
* (`scan.legacyUnsent`) go out as they were, without `completeDays` — the
|
|
243
|
+
* server's copy of those pairs is behind, so it is not the right total
|
|
244
|
+
* until they land. A rebuilt complete pair sent after them replaces them.
|
|
217
245
|
*/
|
|
218
246
|
async uploadDirty(api, machineId, machine) {
|
|
219
247
|
const scan = this.scan;
|
|
220
248
|
const rows = rowsForPending(scan, scan.dirty);
|
|
221
|
-
|
|
249
|
+
let ready = rows.filter((r) => this.reportedKeys.has(r.accountKey));
|
|
222
250
|
const done = new Set();
|
|
223
251
|
const pendingWithRows = new Set(rows.map((r) => pendingKey(r.day, r.accountKey)));
|
|
224
252
|
// Pending pairs with nothing to send (all-zero) are simply done.
|
|
225
253
|
for (const pair of scan.dirty)
|
|
226
254
|
if (!pendingWithRows.has(pair))
|
|
227
255
|
done.add(pair);
|
|
256
|
+
if (!(await this.uploadLegacyUnsent(api, machineId, machine))) {
|
|
257
|
+
saveScanState(scanStatePath(this.env), scan);
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
|
260
|
+
const legacyReady = new Set(ready.map((r) => pendingKey(r.day, r.accountKey)).filter((pair) => scan.legacy[pair] !== undefined));
|
|
261
|
+
if (legacyReady.size > 0) {
|
|
262
|
+
if (!(await this.serverReplacesDays(api, machineId, machine))) {
|
|
263
|
+
ready = ready.filter((r) => !legacyReady.has(pendingKey(r.day, r.accountKey)));
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
const rebuilt = new Map();
|
|
267
|
+
for (const r of ready) {
|
|
268
|
+
const pair = pendingKey(r.day, r.accountKey);
|
|
269
|
+
if (!legacyReady.has(pair))
|
|
270
|
+
continue;
|
|
271
|
+
const sum = rebuilt.get(pair) ?? [0, 0, 0, 0];
|
|
272
|
+
rebuilt.set(pair, [
|
|
273
|
+
sum[0] + r.inputTokens,
|
|
274
|
+
sum[1] + r.cachedInputTokens,
|
|
275
|
+
sum[2] + r.cacheWriteTokens,
|
|
276
|
+
sum[3] + r.outputTokens,
|
|
277
|
+
]);
|
|
278
|
+
}
|
|
279
|
+
const short = new Set([...rebuilt].filter(([pair, t]) => rebuildFellShort(t, scan.legacy[pair])).map(([p]) => p));
|
|
280
|
+
for (const pair of short) {
|
|
281
|
+
done.add(pair);
|
|
282
|
+
delete scan.legacy[pair];
|
|
283
|
+
}
|
|
284
|
+
if (short.size > 0) {
|
|
285
|
+
ready = ready.filter((r) => !short.has(pendingKey(r.day, r.accountKey)));
|
|
286
|
+
this.warnOnce('legacy_short', `token totals: ${short.size} earlier day(s) read back smaller than first counted (logs removed or ` +
|
|
287
|
+
'rewritten since); they keep their per-folder projects on the server so their totals stay right.');
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
228
291
|
let sent = 0;
|
|
229
292
|
let gaveUp = 0;
|
|
230
|
-
for (const batch of
|
|
231
|
-
const
|
|
293
|
+
for (const batch of uploadBatches(ready)) {
|
|
294
|
+
const body = {
|
|
295
|
+
machineId,
|
|
296
|
+
machine,
|
|
297
|
+
days: batch.rows,
|
|
298
|
+
completeDays: batch.complete.map((pair) => ({ day: pendingDay(pair), accountKey: pendingAccount(pair) })),
|
|
299
|
+
};
|
|
300
|
+
const response = await postJson(api, TOKEN_TOTALS_PATH, body, undefined, true);
|
|
232
301
|
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 };
|
|
302
|
+
const acceptance = response.ok ? parseAcceptance(response.body, batch.rows.length) : { accepted: 0 };
|
|
303
|
+
this.status.lastTotals = { ...this.callRecord(response, batch.rows.length), accepted: acceptance.accepted };
|
|
235
304
|
if (!response.ok)
|
|
236
305
|
break;
|
|
237
306
|
sent += acceptance.accepted;
|
|
307
|
+
const lastHere = new Set(batch.last);
|
|
238
308
|
const pairs = new Map(); // pair → accountKey
|
|
239
|
-
for (const row of batch)
|
|
309
|
+
for (const row of batch.rows)
|
|
240
310
|
pairs.set(pendingKey(row.day, row.accountKey), row.accountKey);
|
|
241
311
|
for (const [pair, accountKey] of pairs) {
|
|
242
|
-
const taken = acceptance.keys ? acceptance.keys.has(accountKey) : acceptance.accepted >= batch.length;
|
|
312
|
+
const taken = acceptance.keys ? acceptance.keys.has(accountKey) : acceptance.accepted >= batch.rows.length;
|
|
243
313
|
if (taken) {
|
|
314
|
+
// A pair spread over several bodies is done with its last one.
|
|
315
|
+
if (!lastHere.has(pair))
|
|
316
|
+
continue;
|
|
244
317
|
done.add(pair);
|
|
245
318
|
delete scan.strikes[pair];
|
|
319
|
+
delete scan.legacy[pair];
|
|
246
320
|
continue;
|
|
247
321
|
}
|
|
248
322
|
const reason = acceptance.skipped?.get(accountKey);
|
|
@@ -251,6 +325,7 @@ export class UsageService {
|
|
|
251
325
|
if (reason && PERMANENT_SKIPS.has(reason)) {
|
|
252
326
|
done.add(pair);
|
|
253
327
|
delete scan.strikes[pair];
|
|
328
|
+
delete scan.legacy[pair];
|
|
254
329
|
this.warnOnce(`totals_skip:${accountKey}:${reason}`, `token totals: account ${accountKey.slice(0, 8)}… skipped (${reason}); those rows will not be resent.`);
|
|
255
330
|
continue;
|
|
256
331
|
}
|
|
@@ -260,6 +335,7 @@ export class UsageService {
|
|
|
260
335
|
if (strikes >= MAX_PARTIAL_STRIKES) {
|
|
261
336
|
done.add(pair);
|
|
262
337
|
delete scan.strikes[pair];
|
|
338
|
+
delete scan.legacy[pair];
|
|
263
339
|
gaveUp += 1;
|
|
264
340
|
}
|
|
265
341
|
else {
|
|
@@ -275,6 +351,62 @@ export class UsageService {
|
|
|
275
351
|
saveScanState(scanStatePath(this.env), scan);
|
|
276
352
|
return sent;
|
|
277
353
|
}
|
|
354
|
+
/**
|
|
355
|
+
* Upload the version-1 rows version 1 had not uploaded, as plain rows (the
|
|
356
|
+
* upsert version 1 would have sent). A pair's rows go once the server takes
|
|
357
|
+
* them or never will (a permanent skip); `not_reported` waits for a report.
|
|
358
|
+
* Returns `false` when a call failed, so nothing else is sent this tick.
|
|
359
|
+
*/
|
|
360
|
+
async uploadLegacyUnsent(api, machineId, machine) {
|
|
361
|
+
const scan = this.scan;
|
|
362
|
+
const rows = legacyUnsentRows(scan).filter((r) => this.reportedKeys.has(r.accountKey));
|
|
363
|
+
for (const batch of uploadBatches(rows)) {
|
|
364
|
+
const body = { machineId, machine, days: batch.rows };
|
|
365
|
+
const response = await postJson(api, TOKEN_TOTALS_PATH, body, undefined, true);
|
|
366
|
+
this.note(this.totalsBackoff, response);
|
|
367
|
+
const acceptance = response.ok ? parseAcceptance(response.body, batch.rows.length) : { accepted: 0 };
|
|
368
|
+
this.status.lastTotals = { ...this.callRecord(response, batch.rows.length), accepted: acceptance.accepted };
|
|
369
|
+
if (!response.ok)
|
|
370
|
+
return false;
|
|
371
|
+
const settled = new Set();
|
|
372
|
+
for (const row of batch.rows) {
|
|
373
|
+
const pair = pendingKey(row.day, row.accountKey);
|
|
374
|
+
const taken = acceptance.keys ? acceptance.keys.has(row.accountKey) : acceptance.accepted >= batch.rows.length;
|
|
375
|
+
const reason = acceptance.skipped?.get(row.accountKey);
|
|
376
|
+
if ((taken && batch.last.includes(pair)) || (reason && PERMANENT_SKIPS.has(reason)))
|
|
377
|
+
settled.add(pair);
|
|
378
|
+
else if (!taken && reason !== 'throttled')
|
|
379
|
+
this.reportedKeys.delete(row.accountKey);
|
|
380
|
+
}
|
|
381
|
+
for (const key of Object.keys(scan.legacyUnsent)) {
|
|
382
|
+
const [day, accountKey] = JSON.parse(key);
|
|
383
|
+
if (settled.has(pendingKey(day, accountKey)))
|
|
384
|
+
delete scan.legacyUnsent[key];
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Whether the server deletes the rows a complete day no longer carries: an
|
|
391
|
+
* empty body is answered with `replacedDays` only by one that does. A yes
|
|
392
|
+
* is kept for the process; a no (or a failed call) is asked again after
|
|
393
|
+
* {@link REPLACE_PROBE_INTERVAL_MS}.
|
|
394
|
+
*/
|
|
395
|
+
async serverReplacesDays(api, machineId, machine) {
|
|
396
|
+
if (this.replaceProbe?.supported)
|
|
397
|
+
return true;
|
|
398
|
+
if (this.replaceProbe && this.now() - this.replaceProbe.at < REPLACE_PROBE_INTERVAL_MS)
|
|
399
|
+
return false;
|
|
400
|
+
const response = await postJson(api, TOKEN_TOTALS_PATH, { machineId, machine, days: [], completeDays: [] }, undefined, true);
|
|
401
|
+
this.note(this.totalsBackoff, response);
|
|
402
|
+
const supported = response.ok && parseAcceptance(response.body, 0).replacedDays !== undefined;
|
|
403
|
+
this.replaceProbe = { supported, at: this.now() };
|
|
404
|
+
if (!supported && response.ok) {
|
|
405
|
+
this.warnOnce('replace_unsupported', 'token totals: the server does not replace a day whole yet; days counted under per-folder projects ' +
|
|
406
|
+
'wait until it does, so nothing is counted twice.');
|
|
407
|
+
}
|
|
408
|
+
return supported;
|
|
409
|
+
}
|
|
278
410
|
note(backoff, response) {
|
|
279
411
|
const line = backoff.record(response);
|
|
280
412
|
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.3";
|
|
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.3";
|
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.3';
|
|
11
11
|
/** Sent on every HTTP request the CLI makes back into ProhostAI. */
|
|
12
12
|
export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;
|