@indigoai-us/hq-cli 5.345.92 → 5.345.93
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 +14 -0
- package/dist/command-catalog.generated.d.ts +252 -0
- package/dist/command-catalog.generated.js +306 -0
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/capacity.d.ts +71 -0
- package/dist/commands/capacity.js +666 -0
- package/dist/commands/integrations-connect.js +64 -5
- package/dist/commands/lanes-accounts.d.ts +1 -0
- package/dist/commands/lanes-accounts.js +6 -0
- package/dist/lib/capacity/accounts.d.ts +36 -0
- package/dist/lib/capacity/accounts.js +90 -0
- package/dist/lib/capacity/collect.d.ts +33 -0
- package/dist/lib/capacity/collect.js +164 -0
- package/dist/lib/capacity/collectors/claude-oauth.d.ts +145 -0
- package/dist/lib/capacity/collectors/claude-oauth.js +1211 -0
- package/dist/lib/capacity/collectors/claude-statusline.d.ts +72 -0
- package/dist/lib/capacity/collectors/claude-statusline.js +436 -0
- package/dist/lib/capacity/collectors/codex.d.ts +120 -0
- package/dist/lib/capacity/collectors/codex.js +1134 -0
- package/dist/lib/capacity/collectors/grok.d.ts +86 -0
- package/dist/lib/capacity/collectors/grok.js +588 -0
- package/dist/lib/capacity/lane-login.d.ts +29 -0
- package/dist/lib/capacity/lane-login.js +91 -0
- package/dist/lib/capacity/ledger.d.ts +399 -0
- package/dist/lib/capacity/ledger.js +1441 -0
- package/dist/lib/capacity/project.d.ts +12 -0
- package/dist/lib/capacity/project.js +264 -0
- package/dist/lib/capacity/read.d.ts +62 -0
- package/dist/lib/capacity/read.js +264 -0
- package/dist/lib/capacity/refusal.d.ts +30 -0
- package/dist/lib/capacity/refusal.js +183 -0
- package/dist/lib/capacity/scheduler.d.ts +76 -0
- package/dist/lib/capacity/scheduler.js +800 -0
- package/dist/lib/capacity/sign-in.d.ts +103 -0
- package/dist/lib/capacity/sign-in.js +517 -0
- package/dist/lib/capacity/widget.d.ts +22 -0
- package/dist/lib/capacity/widget.js +418 -0
- package/dist/lib/lanes/spawn.js +12 -0
- package/dist/lib/mesh/live/daemon/run.d.ts +5 -0
- package/dist/lib/mesh/live/daemon/run.js +29 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Personal capacity ledger under ~/.hq/capacity/.
|
|
3
|
+
*
|
|
4
|
+
* Daily files events-YYYY-MM-DD.jsonl are the append-only event log (one
|
|
5
|
+
* O_APPEND write under 4 KB). rebuildNow() replays those files, plus a
|
|
6
|
+
* legacy events.jsonl if a fixture dropped one, into now.json. The now.json
|
|
7
|
+
* rewrite holds a TTL lock reclaimed after 30 seconds.
|
|
8
|
+
*
|
|
9
|
+
* Root is HQ_CAPACITY_ROOT, or an explicit option, so tests never touch the
|
|
10
|
+
* real home directory. No network, child process, or provider call.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as os from "node:os";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { CLI_VERSION } from "../../cli-version.js";
|
|
16
|
+
import { parsePidLock } from "../mesh/live/daemon/pid-lock.js";
|
|
17
|
+
export const CAPACITY_DIRNAME = "capacity";
|
|
18
|
+
export const CAPACITY_LOCK_TTL_MS = 30_000;
|
|
19
|
+
export const CAPACITY_LOCK_NAME = "now.lock";
|
|
20
|
+
export const NOW_FILE_NAME = "now.json";
|
|
21
|
+
export const COLLECTOR_FILE_NAME = "collector.json";
|
|
22
|
+
export const LEGACY_EVENTS_NAME = "events.jsonl";
|
|
23
|
+
export const MAX_APPEND_BYTES = 4096;
|
|
24
|
+
export const EVENT_RETENTION_DAYS = 14;
|
|
25
|
+
export const BURN_WINDOW_MS = 6 * 60 * 60 * 1000;
|
|
26
|
+
export const DIR_MODE = 0o700;
|
|
27
|
+
export const FILE_MODE = 0o600;
|
|
28
|
+
/** US-001 redaction shapes: Claude OAuth, and Codex / Grok bearer JWTs. */
|
|
29
|
+
export const PROVIDER_TOKEN_LEAK = /sk-ant-|Bearer\s+\S+|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/i;
|
|
30
|
+
const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
|
|
31
|
+
const DATED_EVENTS = /^events-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
|
|
32
|
+
const DATED_ADVISORIES = /^advisories-(\d{4})-(\d{2})-(\d{2})\.jsonl$/;
|
|
33
|
+
const ORPHAN_NOW_TMP = /^\.now\..+\.tmp$/;
|
|
34
|
+
const SEEN_READINGS_FILE = ".seen-readings.json";
|
|
35
|
+
const REBUILD_CHECKPOINT_FILE = ".rebuild-checkpoint.json";
|
|
36
|
+
export const READING_SOURCES = [
|
|
37
|
+
"statusline",
|
|
38
|
+
"oauth-usage",
|
|
39
|
+
"codex-jsonl",
|
|
40
|
+
"codex-app-server",
|
|
41
|
+
"grok-billing",
|
|
42
|
+
"refusal",
|
|
43
|
+
];
|
|
44
|
+
export const LIMIT_KINDS = [
|
|
45
|
+
"session",
|
|
46
|
+
"weekly_all",
|
|
47
|
+
"weekly_scoped",
|
|
48
|
+
"credits",
|
|
49
|
+
"prepaid",
|
|
50
|
+
"on_demand",
|
|
51
|
+
"unknown",
|
|
52
|
+
];
|
|
53
|
+
export const CONFIDENCE_VALUES = ["observed", "fetched", "inferred", "unknown"];
|
|
54
|
+
export const CAPACITY_STATES = ["ok", "warn", "exhausted", "unknown", "reset-pending"];
|
|
55
|
+
const STATE_RANK = {
|
|
56
|
+
ok: 0,
|
|
57
|
+
"reset-pending": 1,
|
|
58
|
+
warn: 2,
|
|
59
|
+
exhausted: 3,
|
|
60
|
+
unknown: 4,
|
|
61
|
+
};
|
|
62
|
+
export class LedgerMissing extends Error {
|
|
63
|
+
code = "LEDGER_MISSING";
|
|
64
|
+
constructor() {
|
|
65
|
+
super("run hq capacity enable");
|
|
66
|
+
this.name = "LedgerMissing";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export class LedgerCorrupt extends Error {
|
|
70
|
+
code = "LEDGER_CORRUPT";
|
|
71
|
+
constructor() {
|
|
72
|
+
super("ledger corrupt, rebuilding");
|
|
73
|
+
this.name = "LedgerCorrupt";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export class LedgerBusy extends Error {
|
|
77
|
+
code = "LEDGER_BUSY";
|
|
78
|
+
constructor() {
|
|
79
|
+
super("capacity ledger lock held");
|
|
80
|
+
this.name = "LedgerBusy";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export class LedgerRejected extends Error {
|
|
84
|
+
code = "LEDGER_REJECTED";
|
|
85
|
+
constructor(message) {
|
|
86
|
+
super(message);
|
|
87
|
+
this.name = "LedgerRejected";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const limitSchema = {
|
|
91
|
+
type: "object",
|
|
92
|
+
additionalProperties: false,
|
|
93
|
+
required: ["limitId", "kind", "scope", "usedPercent", "windowMinutes", "resetsAt", "balance", "planType"],
|
|
94
|
+
properties: {
|
|
95
|
+
limitId: { type: "string", minLength: 1 },
|
|
96
|
+
kind: { type: "string", enum: [...LIMIT_KINDS] },
|
|
97
|
+
scope: { type: ["string", "null"] },
|
|
98
|
+
usedPercent: { type: ["number", "null"] },
|
|
99
|
+
windowMinutes: { type: ["number", "null"] },
|
|
100
|
+
resetsAt: { type: ["string", "null"] },
|
|
101
|
+
balance: { type: ["string", "number", "null"] },
|
|
102
|
+
planType: { type: ["string", "null"] },
|
|
103
|
+
hasCredits: { type: ["boolean", "null"] },
|
|
104
|
+
unlimited: { type: ["boolean", "null"] },
|
|
105
|
+
allowancePercentOfWeekly: { type: ["number", "null"] },
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
export const readingJsonSchema = {
|
|
109
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
110
|
+
$id: "hq-capacity-reading.v1",
|
|
111
|
+
type: "object",
|
|
112
|
+
additionalProperties: false,
|
|
113
|
+
required: [
|
|
114
|
+
"readingId",
|
|
115
|
+
"observedAt",
|
|
116
|
+
"provider",
|
|
117
|
+
"accountId",
|
|
118
|
+
"source",
|
|
119
|
+
"cliVersion",
|
|
120
|
+
"limit",
|
|
121
|
+
"confidence",
|
|
122
|
+
"parserVersion",
|
|
123
|
+
],
|
|
124
|
+
properties: {
|
|
125
|
+
readingId: { type: "string", minLength: 1 },
|
|
126
|
+
observedAt: { type: "string", pattern: ISO_UTC.source },
|
|
127
|
+
provider: { type: "string", minLength: 1 },
|
|
128
|
+
accountId: { type: "string", minLength: 1 },
|
|
129
|
+
source: { type: "string", enum: [...READING_SOURCES] },
|
|
130
|
+
cliVersion: { type: "string", minLength: 1 },
|
|
131
|
+
limit: limitSchema,
|
|
132
|
+
confidence: { type: "string", enum: [...CONFIDENCE_VALUES] },
|
|
133
|
+
parserVersion: { type: "string", minLength: 1 },
|
|
134
|
+
error: { type: "string" },
|
|
135
|
+
rateLimitReachedType: { type: ["string", "null"] },
|
|
136
|
+
spendControlReached: { type: ["boolean", "null"] },
|
|
137
|
+
accountLabel: { type: "string", minLength: 1 },
|
|
138
|
+
accountLabels: { type: "array", items: { type: "string", minLength: 1 } },
|
|
139
|
+
organization: { type: ["string", "null"] },
|
|
140
|
+
accountHome: { type: "string" },
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
export const nowViewJsonSchema = {
|
|
144
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
145
|
+
$id: "hq-capacity-now-view.v1",
|
|
146
|
+
type: "object",
|
|
147
|
+
additionalProperties: false,
|
|
148
|
+
required: ["machineId", "hqCliVersion", "generatedAt", "providers"],
|
|
149
|
+
properties: {
|
|
150
|
+
machineId: { type: "string", minLength: 1 },
|
|
151
|
+
hqCliVersion: { type: "string", minLength: 1 },
|
|
152
|
+
generatedAt: { type: "string" },
|
|
153
|
+
providers: {
|
|
154
|
+
type: "array",
|
|
155
|
+
items: {
|
|
156
|
+
type: "object",
|
|
157
|
+
additionalProperties: false,
|
|
158
|
+
required: ["provider", "state", "lastError", "limits"],
|
|
159
|
+
properties: {
|
|
160
|
+
provider: { type: "string" },
|
|
161
|
+
state: { type: "string", enum: [...CAPACITY_STATES] },
|
|
162
|
+
lastError: { type: ["string", "null"] },
|
|
163
|
+
limits: { type: "array" },
|
|
164
|
+
ordinaryUsageAllowed: { type: ["boolean", "null"] },
|
|
165
|
+
rateLimitResetCredits: { type: ["object", "null"] },
|
|
166
|
+
accounts: { type: "array" },
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
export function capacityRoot(env = process.env, home = os.homedir()) {
|
|
173
|
+
const override = env.HQ_CAPACITY_ROOT?.trim();
|
|
174
|
+
if (override)
|
|
175
|
+
return override;
|
|
176
|
+
return path.join(home, ".hq", CAPACITY_DIRNAME);
|
|
177
|
+
}
|
|
178
|
+
const SIGNED_OUT_PREFIX = "signed out:";
|
|
179
|
+
const EXPIRED_PREFIX = "expired:";
|
|
180
|
+
/** Ledger text written before expired and signed-out were different states. */
|
|
181
|
+
const LEGACY_EXPIRED_REASON = "signed out: token expired";
|
|
182
|
+
/** Shown for a credential that exists and is past expiry. Never a login. */
|
|
183
|
+
export const EXPIRED_REASON = "expired: refreshes the next time Claude Code is used with this account";
|
|
184
|
+
export function isExpiredReason(reason) {
|
|
185
|
+
if (!reason)
|
|
186
|
+
return false;
|
|
187
|
+
return reason === LEGACY_EXPIRED_REASON
|
|
188
|
+
|| reason === "unavailable: token_expired"
|
|
189
|
+
|| reason.startsWith(EXPIRED_PREFIX);
|
|
190
|
+
}
|
|
191
|
+
export function isAccountFailureReason(reason) {
|
|
192
|
+
return reason.startsWith(SIGNED_OUT_PREFIX) || reason.startsWith(EXPIRED_PREFIX);
|
|
193
|
+
}
|
|
194
|
+
/** Exact command for an account that has no usable credential. */
|
|
195
|
+
export function capacityLoginCommand(provider, label) {
|
|
196
|
+
const safe = /^[A-Za-z0-9._-]+$/.test(label) ? label : `'${label.replace(/'/g, `'\\''`)}'`;
|
|
197
|
+
return `hq capacity login ${provider} --account ${safe}`;
|
|
198
|
+
}
|
|
199
|
+
/** Open the vendor CLI so it can refresh its own token. Does not refresh anything. */
|
|
200
|
+
export function claudeOpenCommand(home, userHome = os.homedir()) {
|
|
201
|
+
const fallback = path.resolve(userHome, ".claude");
|
|
202
|
+
const resolved = home ? path.resolve(home) : fallback;
|
|
203
|
+
if (resolved === fallback)
|
|
204
|
+
return "claude";
|
|
205
|
+
const quoted = /^[A-Za-z0-9._@%+:,/~-]+$/.test(resolved)
|
|
206
|
+
? resolved
|
|
207
|
+
: `'${resolved.replace(/'/g, `'\\''`)}'`;
|
|
208
|
+
return `CLAUDE_CONFIG_DIR=${quoted} claude`;
|
|
209
|
+
}
|
|
210
|
+
export function redactProviderTokens(text) {
|
|
211
|
+
return text.replace(/sk-ant-\S+|Bearer\s+\S+|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?/gi, "[REDACTED]");
|
|
212
|
+
}
|
|
213
|
+
export function ledgerSentryPayload(error) {
|
|
214
|
+
const err = error instanceof Error ? error : new Error("capacity ledger error");
|
|
215
|
+
return {
|
|
216
|
+
name: err.name || "Error",
|
|
217
|
+
message: redactProviderTokens(err.message),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function assertNoProviderToken(text) {
|
|
221
|
+
if (PROVIDER_TOKEN_LEAK.test(text)) {
|
|
222
|
+
throw new LedgerRejected("reading refused: provider token shape");
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
export function appendReading(input, options) {
|
|
226
|
+
return appendReadings([input], [], null, options);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Write every reading, parser failure, and optional provider meta, then
|
|
230
|
+
* rebuild now.json once. appendReading stays the single-reading path.
|
|
231
|
+
*/
|
|
232
|
+
export function appendReadings(readings, failures = [], meta = null, options) {
|
|
233
|
+
const ctx = resolve(options);
|
|
234
|
+
const known = knownReadingIds(ctx.root);
|
|
235
|
+
const fresh = readings.filter((reading) => !known.has(reading.readingId));
|
|
236
|
+
if (fresh.length === 0 && failures.length === 0 && !meta) {
|
|
237
|
+
try {
|
|
238
|
+
return readNow({ ...options, root: ctx.root });
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
if (!(err instanceof LedgerMissing))
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const events = [];
|
|
246
|
+
for (const reading of fresh)
|
|
247
|
+
events.push(validateReading(reading));
|
|
248
|
+
for (const failure of failures)
|
|
249
|
+
events.push(validateParserFailure(failure));
|
|
250
|
+
if (meta)
|
|
251
|
+
events.push(validateProviderMeta(meta));
|
|
252
|
+
for (const event of events)
|
|
253
|
+
writeEventLine(event, { ...options, root: ctx.root });
|
|
254
|
+
rememberReadingIds(ctx.root, fresh.map((reading) => reading.readingId));
|
|
255
|
+
return rebuildNow({ ...options, root: ctx.root });
|
|
256
|
+
}
|
|
257
|
+
export function appendParserFailure(input, options) {
|
|
258
|
+
const event = validateParserFailure(input);
|
|
259
|
+
writeEventLine(event, options);
|
|
260
|
+
return rebuildNow(options);
|
|
261
|
+
}
|
|
262
|
+
export function appendProviderMeta(input, options) {
|
|
263
|
+
const event = validateProviderMeta(input);
|
|
264
|
+
writeEventLine(event, options);
|
|
265
|
+
return rebuildNow(options);
|
|
266
|
+
}
|
|
267
|
+
export function readNow(options) {
|
|
268
|
+
const root = resolve(options).root;
|
|
269
|
+
const dest = path.join(root, NOW_FILE_NAME);
|
|
270
|
+
if (!fs.existsSync(dest))
|
|
271
|
+
throw new LedgerMissing();
|
|
272
|
+
let parsed;
|
|
273
|
+
try {
|
|
274
|
+
parsed = JSON.parse(fs.readFileSync(dest, "utf8"));
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
throw new LedgerCorrupt();
|
|
278
|
+
}
|
|
279
|
+
if (!isNowView(parsed))
|
|
280
|
+
throw new LedgerCorrupt();
|
|
281
|
+
return parsed;
|
|
282
|
+
}
|
|
283
|
+
export function readLedgerEvents(options) {
|
|
284
|
+
const root = resolve(options).root;
|
|
285
|
+
if (!fs.existsSync(root))
|
|
286
|
+
throw new LedgerMissing();
|
|
287
|
+
return readEvents(root);
|
|
288
|
+
}
|
|
289
|
+
export function readCollectorMark(options) {
|
|
290
|
+
const dest = path.join(resolve(options).root, COLLECTOR_FILE_NAME);
|
|
291
|
+
if (!fs.existsSync(dest))
|
|
292
|
+
return null;
|
|
293
|
+
let parsed;
|
|
294
|
+
try {
|
|
295
|
+
parsed = JSON.parse(fs.readFileSync(dest, "utf8"));
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
if (!parsed || typeof parsed !== "object")
|
|
301
|
+
return null;
|
|
302
|
+
const mark = parsed;
|
|
303
|
+
if (typeof mark.enabled !== "boolean" || typeof mark.updatedAt !== "string")
|
|
304
|
+
return null;
|
|
305
|
+
return { enabled: mark.enabled, updatedAt: mark.updatedAt };
|
|
306
|
+
}
|
|
307
|
+
export function setCollectorEnabled(enabled, options) {
|
|
308
|
+
const ctx = resolve(options);
|
|
309
|
+
const mark = { enabled, updatedAt: ctx.now.toISOString() };
|
|
310
|
+
if (!enabled && !fs.existsSync(ctx.root))
|
|
311
|
+
return mark;
|
|
312
|
+
ensureDir(ctx.root);
|
|
313
|
+
const dest = path.join(ctx.root, COLLECTOR_FILE_NAME);
|
|
314
|
+
fs.writeFileSync(dest, `${JSON.stringify(mark, null, 2)}\n`, { mode: FILE_MODE });
|
|
315
|
+
fs.chmodSync(dest, FILE_MODE);
|
|
316
|
+
return mark;
|
|
317
|
+
}
|
|
318
|
+
export function rebuildNow(options) {
|
|
319
|
+
const ctx = resolve(options);
|
|
320
|
+
ensureDir(ctx.root);
|
|
321
|
+
acquireNowLock(ctx.root, ctx);
|
|
322
|
+
try {
|
|
323
|
+
deleteExpiredEvents(ctx.root, ctx.now, ctx.lockTtlSec * 1000);
|
|
324
|
+
const events = eventsForRebuild(ctx.root, ctx.nowMs);
|
|
325
|
+
const view = materialize(events, ctx);
|
|
326
|
+
writeNow(ctx.root, view, ctx.pid);
|
|
327
|
+
return view;
|
|
328
|
+
}
|
|
329
|
+
finally {
|
|
330
|
+
releaseNowLock(ctx.root, ctx);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function resolve(options = {}) {
|
|
334
|
+
const env = options.env ?? process.env;
|
|
335
|
+
const now = options.now ? options.now() : new Date();
|
|
336
|
+
const nowMs = now.getTime();
|
|
337
|
+
const ttlMs = options.lockTtlMs ?? lockTtlFromEnv(env);
|
|
338
|
+
const machineId = options.machineId ?? os.hostname();
|
|
339
|
+
const hqCliVersion = options.hqCliVersion ?? CLI_VERSION;
|
|
340
|
+
assertNoProviderToken(`${machineId}\n${hqCliVersion}`);
|
|
341
|
+
return {
|
|
342
|
+
root: options.root ?? capacityRoot(env, options.home),
|
|
343
|
+
now,
|
|
344
|
+
nowMs,
|
|
345
|
+
nowSec: Math.floor(nowMs / 1000),
|
|
346
|
+
machineId,
|
|
347
|
+
hqCliVersion,
|
|
348
|
+
pid: options.pid ?? process.pid,
|
|
349
|
+
lockTtlSec: Math.max(1, Math.ceil(ttlMs / 1000)),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
function lockTtlFromEnv(env) {
|
|
353
|
+
const raw = env.HQ_CAPACITY_LOCK_TTL_MS?.trim();
|
|
354
|
+
if (!raw)
|
|
355
|
+
return CAPACITY_LOCK_TTL_MS;
|
|
356
|
+
const parsed = Number(raw);
|
|
357
|
+
if (!Number.isFinite(parsed) || parsed < 1000)
|
|
358
|
+
return CAPACITY_LOCK_TTL_MS;
|
|
359
|
+
return parsed;
|
|
360
|
+
}
|
|
361
|
+
function ensureDir(dir) {
|
|
362
|
+
fs.mkdirSync(dir, { recursive: true, mode: DIR_MODE });
|
|
363
|
+
fs.chmodSync(dir, DIR_MODE);
|
|
364
|
+
}
|
|
365
|
+
function eventsFileName(observedAt) {
|
|
366
|
+
return `events-${observedAt.slice(0, 10)}.jsonl`;
|
|
367
|
+
}
|
|
368
|
+
function writeEventLine(event, options) {
|
|
369
|
+
const line = JSON.stringify(event);
|
|
370
|
+
assertNoProviderToken(line);
|
|
371
|
+
const payload = Buffer.from(`${line}\n`, "utf8");
|
|
372
|
+
if (payload.length >= MAX_APPEND_BYTES) {
|
|
373
|
+
throw new LedgerRejected("reading exceeds 4 KB");
|
|
374
|
+
}
|
|
375
|
+
const ctx = resolve(options);
|
|
376
|
+
ensureDir(ctx.root);
|
|
377
|
+
const file = path.join(ctx.root, eventsFileName(event.observedAt));
|
|
378
|
+
const fd = fs.openSync(file, fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_WRONLY, FILE_MODE);
|
|
379
|
+
try {
|
|
380
|
+
const written = fs.writeSync(fd, payload);
|
|
381
|
+
if (written !== payload.length) {
|
|
382
|
+
throw new Error("capacity ledger append was truncated");
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
finally {
|
|
386
|
+
fs.closeSync(fd);
|
|
387
|
+
}
|
|
388
|
+
fs.chmodSync(file, FILE_MODE);
|
|
389
|
+
}
|
|
390
|
+
function wallClockSec() {
|
|
391
|
+
return Math.floor(Date.now() / 1000);
|
|
392
|
+
}
|
|
393
|
+
function acquireNowLock(dir, ctx) {
|
|
394
|
+
const lockPath = path.join(dir, CAPACITY_LOCK_NAME);
|
|
395
|
+
const ts = wallClockSec();
|
|
396
|
+
ctx.lockTs = ts;
|
|
397
|
+
const body = `pid=${ctx.pid}\nts=${ts}\n`;
|
|
398
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
399
|
+
try {
|
|
400
|
+
fs.writeFileSync(lockPath, body, { flag: "wx", mode: FILE_MODE });
|
|
401
|
+
fs.chmodSync(lockPath, FILE_MODE);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
const code = err.code;
|
|
406
|
+
if (code !== "EEXIST")
|
|
407
|
+
throw err;
|
|
408
|
+
if (!reclaimStaleLock(lockPath, ctx))
|
|
409
|
+
throw new LedgerBusy();
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
throw new LedgerBusy();
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Rename the lock aside, then re-check its age. A fresh or unparseable lock
|
|
416
|
+
* with a fresh mtime is renamed back and stays held. Only a stale claim is
|
|
417
|
+
* removed. ENOENT means another reclaimer already took it.
|
|
418
|
+
*/
|
|
419
|
+
function reclaimStaleLock(lockPath, ctx) {
|
|
420
|
+
let stat;
|
|
421
|
+
try {
|
|
422
|
+
stat = fs.statSync(lockPath);
|
|
423
|
+
}
|
|
424
|
+
catch (err) {
|
|
425
|
+
const code = err.code;
|
|
426
|
+
if (code === "ENOENT")
|
|
427
|
+
return true;
|
|
428
|
+
throw err;
|
|
429
|
+
}
|
|
430
|
+
if (lockAgeSec(readLock(lockPath), stat) < ctx.lockTtlSec)
|
|
431
|
+
return false;
|
|
432
|
+
const claim = `${lockPath}.${ctx.pid}.${Date.now()}.reclaim`;
|
|
433
|
+
try {
|
|
434
|
+
fs.renameSync(lockPath, claim);
|
|
435
|
+
}
|
|
436
|
+
catch (err) {
|
|
437
|
+
const code = err.code;
|
|
438
|
+
if (code === "ENOENT")
|
|
439
|
+
return true;
|
|
440
|
+
throw err;
|
|
441
|
+
}
|
|
442
|
+
let claimStat;
|
|
443
|
+
try {
|
|
444
|
+
claimStat = fs.statSync(claim);
|
|
445
|
+
}
|
|
446
|
+
catch (err) {
|
|
447
|
+
const code = err.code;
|
|
448
|
+
if (code === "ENOENT")
|
|
449
|
+
return true;
|
|
450
|
+
throw err;
|
|
451
|
+
}
|
|
452
|
+
if (lockAgeSec(readLock(claim), claimStat) < ctx.lockTtlSec) {
|
|
453
|
+
try {
|
|
454
|
+
fs.renameSync(claim, lockPath);
|
|
455
|
+
}
|
|
456
|
+
catch (err) {
|
|
457
|
+
const code = err.code;
|
|
458
|
+
if (code !== "ENOENT")
|
|
459
|
+
throw err;
|
|
460
|
+
}
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
try {
|
|
464
|
+
fs.unlinkSync(claim);
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
const code = err.code;
|
|
468
|
+
if (code !== "ENOENT")
|
|
469
|
+
throw err;
|
|
470
|
+
}
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
function lockAgeSec(record, stat) {
|
|
474
|
+
const now = wallClockSec();
|
|
475
|
+
if (record)
|
|
476
|
+
return now - record.ts;
|
|
477
|
+
return now - Math.floor(stat.mtimeMs / 1000);
|
|
478
|
+
}
|
|
479
|
+
function releaseNowLock(dir, ctx) {
|
|
480
|
+
const lockPath = path.join(dir, CAPACITY_LOCK_NAME);
|
|
481
|
+
const record = readLock(lockPath);
|
|
482
|
+
if (!record || record.pid !== ctx.pid || record.ts !== ctx.lockTs)
|
|
483
|
+
return;
|
|
484
|
+
try {
|
|
485
|
+
fs.unlinkSync(lockPath);
|
|
486
|
+
}
|
|
487
|
+
catch (err) {
|
|
488
|
+
const code = err.code;
|
|
489
|
+
if (code !== "ENOENT")
|
|
490
|
+
throw err;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
function readLock(lockPath) {
|
|
494
|
+
if (!fs.existsSync(lockPath))
|
|
495
|
+
return null;
|
|
496
|
+
try {
|
|
497
|
+
return parsePidLock(fs.readFileSync(lockPath, "utf8"));
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
function deleteExpiredEvents(dir, now, lockTtlMs = CAPACITY_LOCK_TTL_MS) {
|
|
504
|
+
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
|
505
|
+
const cutoff = today - EVENT_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
|
506
|
+
const wallNow = Date.now();
|
|
507
|
+
for (const name of fs.readdirSync(dir)) {
|
|
508
|
+
const full = path.join(dir, name);
|
|
509
|
+
const advisory = DATED_ADVISORIES.exec(name);
|
|
510
|
+
if (advisory) {
|
|
511
|
+
const fileDay = Date.UTC(Number(advisory[1]), Number(advisory[2]) - 1, Number(advisory[3]));
|
|
512
|
+
if (fileDay < cutoff)
|
|
513
|
+
unlinkIfExists(full);
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
if (ORPHAN_NOW_TMP.test(name) || name.endsWith(".reclaim")) {
|
|
517
|
+
let mtimeMs;
|
|
518
|
+
try {
|
|
519
|
+
mtimeMs = fs.statSync(full).mtimeMs;
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (wallNow - mtimeMs > lockTtlMs)
|
|
525
|
+
unlinkIfExists(full);
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
// now.json is written via a unique `.now.<pid>.<n>.tmp`. Retention runs
|
|
529
|
+
// on every rebuild and must not unlink that in-flight temp.
|
|
530
|
+
if (name.startsWith(".now.") || name.endsWith(".tmp"))
|
|
531
|
+
continue;
|
|
532
|
+
const match = DATED_EVENTS.exec(name);
|
|
533
|
+
if (!match)
|
|
534
|
+
continue;
|
|
535
|
+
const fileDay = Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
|
536
|
+
if (fileDay < cutoff)
|
|
537
|
+
unlinkIfExists(full);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
function unlinkIfExists(file) {
|
|
541
|
+
try {
|
|
542
|
+
fs.unlinkSync(file);
|
|
543
|
+
}
|
|
544
|
+
catch (err) {
|
|
545
|
+
const code = err.code;
|
|
546
|
+
if (code !== "ENOENT")
|
|
547
|
+
throw err;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
function knownReadingIds(root) {
|
|
551
|
+
const seenPath = path.join(root, SEEN_READINGS_FILE);
|
|
552
|
+
if (fs.existsSync(seenPath)) {
|
|
553
|
+
const parsed = readJsonFile(seenPath);
|
|
554
|
+
const ids = parsed && typeof parsed === "object" && Array.isArray(parsed.ids)
|
|
555
|
+
? parsed.ids.filter((id) => typeof id === "string")
|
|
556
|
+
: [];
|
|
557
|
+
return new Set(ids);
|
|
558
|
+
}
|
|
559
|
+
const ids = new Set();
|
|
560
|
+
const checkpoint = loadCheckpoint(root);
|
|
561
|
+
if (!checkpoint)
|
|
562
|
+
return ids;
|
|
563
|
+
for (const event of checkpoint.events) {
|
|
564
|
+
if (isReadingShape(event))
|
|
565
|
+
ids.add(event.readingId);
|
|
566
|
+
}
|
|
567
|
+
return ids;
|
|
568
|
+
}
|
|
569
|
+
function rememberReadingIds(root, ids) {
|
|
570
|
+
if (ids.length === 0)
|
|
571
|
+
return;
|
|
572
|
+
const known = knownReadingIds(root);
|
|
573
|
+
for (const id of ids)
|
|
574
|
+
known.add(id);
|
|
575
|
+
atomicWriteText(root, SEEN_READINGS_FILE, `${JSON.stringify({ ids: [...known] })}\n`);
|
|
576
|
+
}
|
|
577
|
+
function eventsForRebuild(dir, nowMs) {
|
|
578
|
+
const names = eventFileNames(dir);
|
|
579
|
+
const statSizes = new Map();
|
|
580
|
+
for (const name of names)
|
|
581
|
+
statSizes.set(name, fs.statSync(path.join(dir, name)).size);
|
|
582
|
+
const checkpoint = loadCheckpoint(dir);
|
|
583
|
+
const incremental = checkpoint !== null && checkpointUsable(checkpoint, names, statSizes);
|
|
584
|
+
const events = incremental && checkpoint ? checkpoint.events.slice() : [];
|
|
585
|
+
const files = {};
|
|
586
|
+
for (const name of names) {
|
|
587
|
+
const prev = incremental && checkpoint ? checkpoint.files[name]?.size ?? 0 : 0;
|
|
588
|
+
const slice = readCompleteSuffix(path.join(dir, name), prev);
|
|
589
|
+
events.push(...slice.events);
|
|
590
|
+
files[name] = { size: slice.end };
|
|
591
|
+
}
|
|
592
|
+
const reduced = reduceEvents(events, nowMs);
|
|
593
|
+
atomicWriteText(dir, REBUILD_CHECKPOINT_FILE, `${JSON.stringify({ files, events: reduced })}\n`);
|
|
594
|
+
return reduced;
|
|
595
|
+
}
|
|
596
|
+
function eventFileNames(dir) {
|
|
597
|
+
if (!fs.existsSync(dir))
|
|
598
|
+
return [];
|
|
599
|
+
const names = fs.readdirSync(dir);
|
|
600
|
+
const files = [];
|
|
601
|
+
if (names.includes(LEGACY_EVENTS_NAME))
|
|
602
|
+
files.push(LEGACY_EVENTS_NAME);
|
|
603
|
+
for (const name of names.filter((item) => DATED_EVENTS.test(item)).sort())
|
|
604
|
+
files.push(name);
|
|
605
|
+
return files;
|
|
606
|
+
}
|
|
607
|
+
function checkpointUsable(checkpoint, names, statSizes) {
|
|
608
|
+
for (const name of Object.keys(checkpoint.files)) {
|
|
609
|
+
if (!names.includes(name))
|
|
610
|
+
return false;
|
|
611
|
+
if ((statSizes.get(name) ?? 0) < checkpoint.files[name].size)
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
return true;
|
|
615
|
+
}
|
|
616
|
+
/** Read only complete lines appended after `from`. A trailing partial line stays uncommitted. */
|
|
617
|
+
function readCompleteSuffix(filePath, from) {
|
|
618
|
+
const size = fs.statSync(filePath).size;
|
|
619
|
+
if (size <= from)
|
|
620
|
+
return { events: [], end: from };
|
|
621
|
+
const fd = fs.openSync(filePath, "r");
|
|
622
|
+
try {
|
|
623
|
+
const len = size - from;
|
|
624
|
+
const buf = Buffer.alloc(len);
|
|
625
|
+
const n = fs.readSync(fd, buf, 0, len, from);
|
|
626
|
+
const text = buf.subarray(0, n).toString("utf8");
|
|
627
|
+
const lastNl = text.lastIndexOf("\n");
|
|
628
|
+
if (lastNl < 0)
|
|
629
|
+
return { events: [], end: from };
|
|
630
|
+
const events = [];
|
|
631
|
+
for (const line of text.slice(0, lastNl).split("\n")) {
|
|
632
|
+
const event = parseStoredLine(line);
|
|
633
|
+
if (event)
|
|
634
|
+
events.push(event);
|
|
635
|
+
}
|
|
636
|
+
return { events, end: from + Buffer.byteLength(text.slice(0, lastNl + 1), "utf8") };
|
|
637
|
+
}
|
|
638
|
+
finally {
|
|
639
|
+
fs.closeSync(fd);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
function reduceEvents(events, nowMs) {
|
|
643
|
+
const windowStart = nowMs - BURN_WINDOW_MS;
|
|
644
|
+
const latestReading = new Map();
|
|
645
|
+
const inWindow = [];
|
|
646
|
+
const latestFailure = new Map();
|
|
647
|
+
const latestMeta = new Map();
|
|
648
|
+
for (const event of events) {
|
|
649
|
+
const at = Date.parse(event.observedAt);
|
|
650
|
+
if (Number.isNaN(at))
|
|
651
|
+
continue;
|
|
652
|
+
if (isProviderMeta(event)) {
|
|
653
|
+
const prev = latestMeta.get(event.provider);
|
|
654
|
+
if (!prev || at >= Date.parse(prev.observedAt))
|
|
655
|
+
latestMeta.set(event.provider, event);
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
if (isParserFailure(event)) {
|
|
659
|
+
const key = `${event.provider}\0${event.accountId ?? ""}`;
|
|
660
|
+
const prev = latestFailure.get(key);
|
|
661
|
+
if (!prev || at >= Date.parse(prev.observedAt))
|
|
662
|
+
latestFailure.set(key, event);
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
const key = `${event.provider}\0${event.accountId}\0${event.limit.limitId}`;
|
|
666
|
+
const prev = latestReading.get(key);
|
|
667
|
+
if (!prev || at >= Date.parse(prev.observedAt))
|
|
668
|
+
latestReading.set(key, event);
|
|
669
|
+
if (at >= windowStart && at <= nowMs)
|
|
670
|
+
inWindow.push(event);
|
|
671
|
+
}
|
|
672
|
+
const kept = new Set();
|
|
673
|
+
for (const event of inWindow)
|
|
674
|
+
kept.add(event);
|
|
675
|
+
for (const event of latestReading.values())
|
|
676
|
+
kept.add(event);
|
|
677
|
+
for (const event of latestFailure.values())
|
|
678
|
+
kept.add(event);
|
|
679
|
+
for (const event of latestMeta.values())
|
|
680
|
+
kept.add(event);
|
|
681
|
+
return [...kept].sort((a, b) => Date.parse(a.observedAt) - Date.parse(b.observedAt));
|
|
682
|
+
}
|
|
683
|
+
function loadCheckpoint(dir) {
|
|
684
|
+
const parsed = readJsonFile(path.join(dir, REBUILD_CHECKPOINT_FILE));
|
|
685
|
+
if (!parsed || typeof parsed !== "object")
|
|
686
|
+
return null;
|
|
687
|
+
const body = parsed;
|
|
688
|
+
if (!body.files || typeof body.files !== "object" || Array.isArray(body.files) || !Array.isArray(body.events)) {
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
const files = {};
|
|
692
|
+
for (const [name, value] of Object.entries(body.files)) {
|
|
693
|
+
if (!value || typeof value !== "object")
|
|
694
|
+
return null;
|
|
695
|
+
const size = value.size;
|
|
696
|
+
if (typeof size !== "number" || !Number.isFinite(size) || size < 0)
|
|
697
|
+
return null;
|
|
698
|
+
files[name] = { size };
|
|
699
|
+
}
|
|
700
|
+
const events = [];
|
|
701
|
+
for (const item of body.events) {
|
|
702
|
+
const event = parseStoredLine(JSON.stringify(item));
|
|
703
|
+
if (!event)
|
|
704
|
+
return null;
|
|
705
|
+
events.push(event);
|
|
706
|
+
}
|
|
707
|
+
return { files, events };
|
|
708
|
+
}
|
|
709
|
+
function readJsonFile(file) {
|
|
710
|
+
try {
|
|
711
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
function atomicWriteText(dir, name, body) {
|
|
718
|
+
ensureDir(dir);
|
|
719
|
+
const dest = path.join(dir, name);
|
|
720
|
+
const tmp = path.join(dir, `.${name}.${process.pid}.${Date.now()}.tmp`);
|
|
721
|
+
fs.writeFileSync(tmp, body, { mode: FILE_MODE });
|
|
722
|
+
try {
|
|
723
|
+
fs.renameSync(tmp, dest);
|
|
724
|
+
}
|
|
725
|
+
catch (err) {
|
|
726
|
+
unlinkIfExists(tmp);
|
|
727
|
+
throw err;
|
|
728
|
+
}
|
|
729
|
+
fs.chmodSync(dest, FILE_MODE);
|
|
730
|
+
}
|
|
731
|
+
function readEvents(dir) {
|
|
732
|
+
const names = fs.readdirSync(dir);
|
|
733
|
+
const files = [];
|
|
734
|
+
if (names.includes(LEGACY_EVENTS_NAME))
|
|
735
|
+
files.push(LEGACY_EVENTS_NAME);
|
|
736
|
+
for (const name of names.filter((item) => DATED_EVENTS.test(item)).sort())
|
|
737
|
+
files.push(name);
|
|
738
|
+
const events = [];
|
|
739
|
+
for (const name of files) {
|
|
740
|
+
const text = fs.readFileSync(path.join(dir, name), "utf8");
|
|
741
|
+
for (const line of text.split("\n")) {
|
|
742
|
+
const event = parseStoredLine(line);
|
|
743
|
+
if (event)
|
|
744
|
+
events.push(event);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
events.sort((a, b) => Date.parse(a.observedAt) - Date.parse(b.observedAt));
|
|
748
|
+
return events;
|
|
749
|
+
}
|
|
750
|
+
function parseStoredLine(line) {
|
|
751
|
+
const trimmed = line.trim();
|
|
752
|
+
if (!trimmed)
|
|
753
|
+
return null;
|
|
754
|
+
if (PROVIDER_TOKEN_LEAK.test(trimmed))
|
|
755
|
+
return null;
|
|
756
|
+
try {
|
|
757
|
+
const value = JSON.parse(trimmed);
|
|
758
|
+
if (isParserFailure(value))
|
|
759
|
+
return validateParserFailure(value);
|
|
760
|
+
if (isProviderMeta(value))
|
|
761
|
+
return validateProviderMeta(value);
|
|
762
|
+
if (isReadingShape(value))
|
|
763
|
+
return validateReading(value);
|
|
764
|
+
return null;
|
|
765
|
+
}
|
|
766
|
+
catch (err) {
|
|
767
|
+
if (err instanceof LedgerRejected)
|
|
768
|
+
return null;
|
|
769
|
+
return null;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
function materialize(events, ctx) {
|
|
773
|
+
const providers = new Map();
|
|
774
|
+
const bucketFor = (provider) => {
|
|
775
|
+
let bucket = providers.get(provider);
|
|
776
|
+
if (!bucket) {
|
|
777
|
+
bucket = { limits: new Map(), latestReadingAt: Number.NEGATIVE_INFINITY, failure: null, meta: null };
|
|
778
|
+
providers.set(provider, bucket);
|
|
779
|
+
}
|
|
780
|
+
return bucket;
|
|
781
|
+
};
|
|
782
|
+
for (const event of events) {
|
|
783
|
+
const at = Date.parse(event.observedAt);
|
|
784
|
+
if (Number.isNaN(at))
|
|
785
|
+
continue;
|
|
786
|
+
if (isProviderMeta(event)) {
|
|
787
|
+
const bucket = bucketFor(event.provider);
|
|
788
|
+
bucket.meta = {
|
|
789
|
+
ordinaryUsageAllowed: event.ordinaryUsageAllowed,
|
|
790
|
+
rateLimitResetCredits: event.rateLimitResetCredits,
|
|
791
|
+
};
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
if (isParserFailure(event)) {
|
|
795
|
+
const bucket = bucketFor(event.provider);
|
|
796
|
+
// An account-scoped failure must not mark the whole provider unknown.
|
|
797
|
+
if (!isAccountScopedFailure(event))
|
|
798
|
+
bucket.failure = { at, error: event.error };
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
const bucket = bucketFor(event.provider);
|
|
802
|
+
if (bucket.failure && at >= bucket.failure.at)
|
|
803
|
+
bucket.failure = null;
|
|
804
|
+
const key = `${event.accountId}\0${event.limit.limitId}`;
|
|
805
|
+
let limit = bucket.limits.get(key);
|
|
806
|
+
if (!limit) {
|
|
807
|
+
limit = { reading: event, history: [] };
|
|
808
|
+
bucket.limits.set(key, limit);
|
|
809
|
+
}
|
|
810
|
+
else if (at >= Date.parse(limit.reading.observedAt)) {
|
|
811
|
+
// Greatest observedAt wins, independent of append / file order.
|
|
812
|
+
limit.reading = event;
|
|
813
|
+
}
|
|
814
|
+
if (event.limit.usedPercent !== null && Number.isFinite(event.limit.usedPercent)) {
|
|
815
|
+
limit.history.push({ at, used: event.limit.usedPercent });
|
|
816
|
+
}
|
|
817
|
+
if (at > bucket.latestReadingAt)
|
|
818
|
+
bucket.latestReadingAt = at;
|
|
819
|
+
}
|
|
820
|
+
const viewProviders = [...providers.entries()]
|
|
821
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
822
|
+
.map(([provider, bucket]) => {
|
|
823
|
+
const limits = [...bucket.limits.values()]
|
|
824
|
+
.sort((a, b) => {
|
|
825
|
+
const account = a.reading.accountId.localeCompare(b.reading.accountId);
|
|
826
|
+
if (account !== 0)
|
|
827
|
+
return account;
|
|
828
|
+
return a.reading.limit.limitId.localeCompare(b.reading.limit.limitId);
|
|
829
|
+
})
|
|
830
|
+
.map((limit) => toNowLimit(limit, ctx.nowMs));
|
|
831
|
+
const failed = bucket.failure !== null && bucket.failure.at >= bucket.latestReadingAt;
|
|
832
|
+
let state = "ok";
|
|
833
|
+
for (const limit of limits) {
|
|
834
|
+
if (STATE_RANK[limit.state] > STATE_RANK[state])
|
|
835
|
+
state = limit.state;
|
|
836
|
+
}
|
|
837
|
+
if (failed)
|
|
838
|
+
state = "unknown";
|
|
839
|
+
if (limits.length === 0 && failed)
|
|
840
|
+
state = "unknown";
|
|
841
|
+
const unknownLimit = limits.find((limit) => limit.state === "unknown" && limit.lastError);
|
|
842
|
+
const lastError = failed
|
|
843
|
+
? bucket.failure?.error ?? null
|
|
844
|
+
: unknownLimit?.lastError ?? null;
|
|
845
|
+
const entry = { provider, state, lastError, limits };
|
|
846
|
+
if (bucket.meta) {
|
|
847
|
+
entry.ordinaryUsageAllowed = bucket.meta.ordinaryUsageAllowed;
|
|
848
|
+
entry.rateLimitResetCredits = bucket.meta.rateLimitResetCredits;
|
|
849
|
+
}
|
|
850
|
+
const unscopedFailure = bucket.failure !== null && bucket.failure.at >= bucket.latestReadingAt;
|
|
851
|
+
attachAccounts(entry, events, unscopedFailure);
|
|
852
|
+
return entry;
|
|
853
|
+
});
|
|
854
|
+
return {
|
|
855
|
+
machineId: ctx.machineId,
|
|
856
|
+
hqCliVersion: ctx.hqCliVersion,
|
|
857
|
+
generatedAt: ctx.now.toISOString(),
|
|
858
|
+
providers: viewProviders,
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
function isAccountScopedFailure(event) {
|
|
862
|
+
return Boolean(event.accountId)
|
|
863
|
+
&& (Boolean(event.accountLabel) || isAccountFailureReason(event.error) || isExpiredReason(event.error));
|
|
864
|
+
}
|
|
865
|
+
function eventAnnotated(event) {
|
|
866
|
+
if ("event" in event && event.event === "provider_meta")
|
|
867
|
+
return false;
|
|
868
|
+
if ("event" in event && event.event === "parser_failure") {
|
|
869
|
+
return Boolean(event.accountLabel) || isAccountFailureReason(event.error) || isExpiredReason(event.error);
|
|
870
|
+
}
|
|
871
|
+
const reading = event;
|
|
872
|
+
return Boolean(reading.accountLabel) || Boolean(reading.accountLabels && reading.accountLabels.length > 0);
|
|
873
|
+
}
|
|
874
|
+
function attachAccounts(provider, events, unscopedFailure) {
|
|
875
|
+
const relevant = events.filter((event) => event.provider === provider.provider && eventAnnotated(event));
|
|
876
|
+
if (relevant.length === 0)
|
|
877
|
+
return;
|
|
878
|
+
const accounts = clusterAccounts(provider, relevant);
|
|
879
|
+
if (accounts.length === 0)
|
|
880
|
+
return;
|
|
881
|
+
provider.accounts = accounts;
|
|
882
|
+
if (unscopedFailure)
|
|
883
|
+
return;
|
|
884
|
+
const live = accounts.filter((account) => account.authState !== "expired" && account.authState !== "signed_out" && !account.signedOut);
|
|
885
|
+
if (live.length === 0) {
|
|
886
|
+
provider.state = "unknown";
|
|
887
|
+
provider.lastError = accounts.find((account) => account.reason)?.reason ?? provider.lastError;
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
if (accounts.some((account) => account.signedOut || account.authState === "expired")) {
|
|
891
|
+
let state = "ok";
|
|
892
|
+
let anyLimit = false;
|
|
893
|
+
for (const account of live) {
|
|
894
|
+
for (const limit of account.limits) {
|
|
895
|
+
anyLimit = true;
|
|
896
|
+
if (STATE_RANK[limit.state] > STATE_RANK[state])
|
|
897
|
+
state = limit.state;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
provider.state = anyLimit ? state : "unknown";
|
|
901
|
+
if (provider.lastError?.startsWith(SIGNED_OUT_PREFIX))
|
|
902
|
+
provider.lastError = null;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
function clusterAccounts(provider, events) {
|
|
906
|
+
const seeds = [];
|
|
907
|
+
for (const event of events) {
|
|
908
|
+
const at = Date.parse(event.observedAt);
|
|
909
|
+
if (Number.isNaN(at))
|
|
910
|
+
continue;
|
|
911
|
+
if ("event" in event && event.event === "parser_failure") {
|
|
912
|
+
if (!event.accountId)
|
|
913
|
+
continue;
|
|
914
|
+
seeds.push({
|
|
915
|
+
accountId: event.accountId,
|
|
916
|
+
labels: eventLabels(event),
|
|
917
|
+
home: event.accountHome ?? null,
|
|
918
|
+
organization: cleanOrganization(event.organization),
|
|
919
|
+
at,
|
|
920
|
+
problem: problemFor(event.error),
|
|
921
|
+
});
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
if ("event" in event)
|
|
925
|
+
continue;
|
|
926
|
+
const reading = event;
|
|
927
|
+
seeds.push({
|
|
928
|
+
accountId: reading.accountId,
|
|
929
|
+
labels: eventLabels(reading),
|
|
930
|
+
home: reading.accountHome ?? null,
|
|
931
|
+
organization: cleanOrganization(reading.organization),
|
|
932
|
+
at,
|
|
933
|
+
problem: null,
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
if (seeds.length === 0)
|
|
937
|
+
return [];
|
|
938
|
+
const parent = seeds.map((_, index) => index);
|
|
939
|
+
const find = (index) => {
|
|
940
|
+
let cursor = index;
|
|
941
|
+
while (parent[cursor] !== cursor)
|
|
942
|
+
cursor = parent[cursor] ?? cursor;
|
|
943
|
+
return cursor;
|
|
944
|
+
};
|
|
945
|
+
const union = (left, right) => {
|
|
946
|
+
const a = find(left);
|
|
947
|
+
const b = find(right);
|
|
948
|
+
if (a !== b)
|
|
949
|
+
parent[b] = a;
|
|
950
|
+
};
|
|
951
|
+
const byId = new Map();
|
|
952
|
+
const byLabel = new Map();
|
|
953
|
+
seeds.forEach((seed, index) => {
|
|
954
|
+
const seenId = byId.get(seed.accountId);
|
|
955
|
+
if (seenId === undefined)
|
|
956
|
+
byId.set(seed.accountId, index);
|
|
957
|
+
else
|
|
958
|
+
union(seenId, index);
|
|
959
|
+
for (const label of seed.labels) {
|
|
960
|
+
const seen = byLabel.get(label);
|
|
961
|
+
if (seen === undefined)
|
|
962
|
+
byLabel.set(label, index);
|
|
963
|
+
else
|
|
964
|
+
union(seen, index);
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
const groups = new Map();
|
|
968
|
+
seeds.forEach((_, index) => {
|
|
969
|
+
const root = find(index);
|
|
970
|
+
const list = groups.get(root) ?? [];
|
|
971
|
+
list.push(index);
|
|
972
|
+
groups.set(root, list);
|
|
973
|
+
});
|
|
974
|
+
const accounts = [];
|
|
975
|
+
for (const indexes of groups.values()) {
|
|
976
|
+
const members = indexes.map((index) => seeds[index]).filter((seed) => Boolean(seed));
|
|
977
|
+
const ids = [...new Set(members.map((seed) => seed.accountId))];
|
|
978
|
+
const labels = [];
|
|
979
|
+
const homes = [];
|
|
980
|
+
let organization = null;
|
|
981
|
+
let problem = null;
|
|
982
|
+
let latestReadingAt = Number.NEGATIVE_INFINITY;
|
|
983
|
+
let latestAt = Number.NEGATIVE_INFINITY;
|
|
984
|
+
for (const seed of members) {
|
|
985
|
+
for (const label of seed.labels) {
|
|
986
|
+
if (!labels.includes(label))
|
|
987
|
+
labels.push(label);
|
|
988
|
+
}
|
|
989
|
+
if (seed.home && !homes.includes(seed.home))
|
|
990
|
+
homes.push(seed.home);
|
|
991
|
+
if (!organization || seed.at >= organization.at)
|
|
992
|
+
organization = { value: seed.organization, at: seed.at };
|
|
993
|
+
if (seed.problem && (!problem || seed.at >= problem.at)) {
|
|
994
|
+
problem = { ...seed.problem, at: seed.at };
|
|
995
|
+
}
|
|
996
|
+
if (!seed.problem && seed.at > latestReadingAt)
|
|
997
|
+
latestReadingAt = seed.at;
|
|
998
|
+
if (seed.at > latestAt)
|
|
999
|
+
latestAt = seed.at;
|
|
1000
|
+
}
|
|
1001
|
+
const ordered = orderLabels(labels);
|
|
1002
|
+
const primary = ordered.find((label) => label !== "default") ?? ordered[0] ?? "default";
|
|
1003
|
+
const limitedIds = ids.filter((id) => provider.limits.some((limit) => limit.accountId === id));
|
|
1004
|
+
const accountId = limitedIds[0] ?? ids[0] ?? primary;
|
|
1005
|
+
const limits = provider.limits.filter((limit) => ids.includes(limit.accountId));
|
|
1006
|
+
const blocked = problem !== null && problem.at >= latestReadingAt;
|
|
1007
|
+
const expired = blocked && problem?.kind === "expired";
|
|
1008
|
+
const isSignedOut = blocked && problem?.kind === "signed_out";
|
|
1009
|
+
let state = "unknown";
|
|
1010
|
+
if (!blocked) {
|
|
1011
|
+
state = "ok";
|
|
1012
|
+
for (const limit of limits) {
|
|
1013
|
+
if (STATE_RANK[limit.state] > STATE_RANK[state])
|
|
1014
|
+
state = limit.state;
|
|
1015
|
+
}
|
|
1016
|
+
if (limits.length === 0)
|
|
1017
|
+
state = "unknown";
|
|
1018
|
+
}
|
|
1019
|
+
const authState = expired
|
|
1020
|
+
? "expired"
|
|
1021
|
+
: isSignedOut
|
|
1022
|
+
? "signed_out"
|
|
1023
|
+
: limits.length > 0
|
|
1024
|
+
? "ok"
|
|
1025
|
+
: "unknown";
|
|
1026
|
+
const home = homes[0] ?? null;
|
|
1027
|
+
accounts.push({
|
|
1028
|
+
accountId,
|
|
1029
|
+
labels: ordered.length > 0 ? ordered : [primary],
|
|
1030
|
+
organization: organization?.value ?? null,
|
|
1031
|
+
homes,
|
|
1032
|
+
state,
|
|
1033
|
+
signedOut: isSignedOut,
|
|
1034
|
+
authState,
|
|
1035
|
+
observedAt: Number.isFinite(latestAt) ? new Date(latestAt).toISOString() : null,
|
|
1036
|
+
reason: expired ? EXPIRED_REASON : isSignedOut ? problem?.reason ?? null : null,
|
|
1037
|
+
fixCommand: expired
|
|
1038
|
+
? (provider.provider === "claude" ? claudeOpenCommand(home) : null)
|
|
1039
|
+
: isSignedOut
|
|
1040
|
+
? capacityLoginCommand(provider.provider, primary)
|
|
1041
|
+
: null,
|
|
1042
|
+
limits,
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
accounts.sort((a, b) => (a.labels[0] ?? "").localeCompare(b.labels[0] ?? ""));
|
|
1046
|
+
return accounts;
|
|
1047
|
+
}
|
|
1048
|
+
function problemFor(error) {
|
|
1049
|
+
if (isExpiredReason(error))
|
|
1050
|
+
return { kind: "expired", reason: EXPIRED_REASON };
|
|
1051
|
+
if (error.startsWith(SIGNED_OUT_PREFIX) || error === "unavailable: auth" || error === "unavailable: missing_scope") {
|
|
1052
|
+
return { kind: "signed_out", reason: error };
|
|
1053
|
+
}
|
|
1054
|
+
return null;
|
|
1055
|
+
}
|
|
1056
|
+
function eventLabels(event) {
|
|
1057
|
+
const labels = [...(event.accountLabels ?? [])];
|
|
1058
|
+
if (event.accountLabel && !labels.includes(event.accountLabel))
|
|
1059
|
+
labels.unshift(event.accountLabel);
|
|
1060
|
+
return labels.filter((label) => label.trim().length > 0);
|
|
1061
|
+
}
|
|
1062
|
+
function orderLabels(labels) {
|
|
1063
|
+
const rest = labels.filter((label) => label !== "default");
|
|
1064
|
+
return labels.includes("default") ? [...rest, "default"] : rest;
|
|
1065
|
+
}
|
|
1066
|
+
function cleanOrganization(value) {
|
|
1067
|
+
if (typeof value !== "string")
|
|
1068
|
+
return null;
|
|
1069
|
+
const trimmed = value.trim();
|
|
1070
|
+
if (!trimmed || trimmed.includes("@"))
|
|
1071
|
+
return null;
|
|
1072
|
+
return trimmed;
|
|
1073
|
+
}
|
|
1074
|
+
function copyAccountFields(target, source) {
|
|
1075
|
+
if (source.accountLabel !== undefined)
|
|
1076
|
+
target.accountLabel = source.accountLabel;
|
|
1077
|
+
if (source.accountLabels !== undefined)
|
|
1078
|
+
target.accountLabels = [...source.accountLabels];
|
|
1079
|
+
if (source.organization !== undefined)
|
|
1080
|
+
target.organization = cleanOrganization(source.organization);
|
|
1081
|
+
if (source.accountHome !== undefined)
|
|
1082
|
+
target.accountHome = source.accountHome;
|
|
1083
|
+
}
|
|
1084
|
+
function validAccountFields(value) {
|
|
1085
|
+
if (value.accountLabel !== undefined && !nonEmpty(value.accountLabel))
|
|
1086
|
+
return false;
|
|
1087
|
+
if (value.accountLabels !== undefined) {
|
|
1088
|
+
if (!Array.isArray(value.accountLabels) || !value.accountLabels.every((label) => nonEmpty(label)))
|
|
1089
|
+
return false;
|
|
1090
|
+
}
|
|
1091
|
+
if (value.organization !== undefined && value.organization !== null && typeof value.organization !== "string") {
|
|
1092
|
+
return false;
|
|
1093
|
+
}
|
|
1094
|
+
if (value.accountHome !== undefined && typeof value.accountHome !== "string")
|
|
1095
|
+
return false;
|
|
1096
|
+
return true;
|
|
1097
|
+
}
|
|
1098
|
+
function toNowLimit(bucket, nowMs) {
|
|
1099
|
+
const reading = bucket.reading;
|
|
1100
|
+
const derived = deriveLimit(reading, nowMs);
|
|
1101
|
+
const burn = derived.state === "ok" || derived.state === "warn" || derived.state === "exhausted"
|
|
1102
|
+
? burnRate(bucket.history, nowMs)
|
|
1103
|
+
: null;
|
|
1104
|
+
const nowLimit = {
|
|
1105
|
+
limitId: reading.limit.limitId,
|
|
1106
|
+
accountId: reading.accountId,
|
|
1107
|
+
kind: reading.limit.kind,
|
|
1108
|
+
scope: reading.limit.scope,
|
|
1109
|
+
usedPercent: derived.usedPercent,
|
|
1110
|
+
windowMinutes: reading.limit.windowMinutes,
|
|
1111
|
+
resetsAt: reading.limit.resetsAt,
|
|
1112
|
+
secondsToReset: derived.secondsToReset,
|
|
1113
|
+
burnRatePercentPerHour: burn,
|
|
1114
|
+
balance: reading.limit.balance,
|
|
1115
|
+
planType: reading.limit.planType,
|
|
1116
|
+
state: derived.state,
|
|
1117
|
+
source: reading.source,
|
|
1118
|
+
confidence: reading.confidence,
|
|
1119
|
+
observedAt: reading.observedAt,
|
|
1120
|
+
readingId: reading.readingId,
|
|
1121
|
+
cliVersion: reading.cliVersion,
|
|
1122
|
+
parserVersion: reading.parserVersion,
|
|
1123
|
+
lastError: derived.lastError,
|
|
1124
|
+
};
|
|
1125
|
+
if (reading.limit.hasCredits !== undefined)
|
|
1126
|
+
nowLimit.hasCredits = reading.limit.hasCredits;
|
|
1127
|
+
if (reading.limit.unlimited !== undefined)
|
|
1128
|
+
nowLimit.unlimited = reading.limit.unlimited;
|
|
1129
|
+
if (reading.limit.allowancePercentOfWeekly !== undefined) {
|
|
1130
|
+
nowLimit.allowancePercentOfWeekly = reading.limit.allowancePercentOfWeekly;
|
|
1131
|
+
}
|
|
1132
|
+
if (reading.rateLimitReachedType !== undefined)
|
|
1133
|
+
nowLimit.rateLimitReachedType = reading.rateLimitReachedType;
|
|
1134
|
+
if (reading.spendControlReached !== undefined)
|
|
1135
|
+
nowLimit.spendControlReached = reading.spendControlReached;
|
|
1136
|
+
return nowLimit;
|
|
1137
|
+
}
|
|
1138
|
+
function deriveLimit(reading, nowMs) {
|
|
1139
|
+
const resetsAtMs = reading.limit.resetsAt ? Date.parse(reading.limit.resetsAt) : Number.NaN;
|
|
1140
|
+
const seconds = secondsUntil(reading.limit.resetsAt, nowMs);
|
|
1141
|
+
if (reading.confidence === "unknown") {
|
|
1142
|
+
return {
|
|
1143
|
+
state: "unknown",
|
|
1144
|
+
usedPercent: null,
|
|
1145
|
+
secondsToReset: seconds,
|
|
1146
|
+
lastError: reading.error?.trim() ? reading.error : "confidence unknown",
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
// A passed reset beats both a refusal and a 100% reading. The newest
|
|
1150
|
+
// observedAt wins in materialize, so a later fetched or observed percent
|
|
1151
|
+
// below 100 replaces this refusal before the reset.
|
|
1152
|
+
if (!Number.isNaN(resetsAtMs) && resetsAtMs < nowMs) {
|
|
1153
|
+
return { state: "reset-pending", usedPercent: null, secondsToReset: null, lastError: null };
|
|
1154
|
+
}
|
|
1155
|
+
const percent = reading.limit.usedPercent;
|
|
1156
|
+
if (reading.source === "refusal" || (percent !== null && percent >= 100)) {
|
|
1157
|
+
return { state: "exhausted", usedPercent: percent, secondsToReset: seconds, lastError: null };
|
|
1158
|
+
}
|
|
1159
|
+
if (percent !== null && percent >= 80) {
|
|
1160
|
+
return { state: "warn", usedPercent: percent, secondsToReset: seconds, lastError: null };
|
|
1161
|
+
}
|
|
1162
|
+
return { state: "ok", usedPercent: percent, secondsToReset: seconds, lastError: null };
|
|
1163
|
+
}
|
|
1164
|
+
function secondsUntil(resetsAt, nowMs) {
|
|
1165
|
+
if (!resetsAt)
|
|
1166
|
+
return null;
|
|
1167
|
+
const at = Date.parse(resetsAt);
|
|
1168
|
+
if (Number.isNaN(at) || at < nowMs)
|
|
1169
|
+
return null;
|
|
1170
|
+
return Math.floor((at - nowMs) / 1000);
|
|
1171
|
+
}
|
|
1172
|
+
function burnRate(history, nowMs) {
|
|
1173
|
+
const windowStart = nowMs - BURN_WINDOW_MS;
|
|
1174
|
+
const points = history.filter((point) => point.at >= windowStart && point.at <= nowMs);
|
|
1175
|
+
if (points.length < 2)
|
|
1176
|
+
return null;
|
|
1177
|
+
const first = points[0];
|
|
1178
|
+
const last = points[points.length - 1];
|
|
1179
|
+
if (!first || !last)
|
|
1180
|
+
return null;
|
|
1181
|
+
const hours = (last.at - first.at) / 3_600_000;
|
|
1182
|
+
if (hours <= 0)
|
|
1183
|
+
return null;
|
|
1184
|
+
const delta = last.used - first.used;
|
|
1185
|
+
if (delta < 0)
|
|
1186
|
+
return null;
|
|
1187
|
+
return delta / hours;
|
|
1188
|
+
}
|
|
1189
|
+
let nowWriteSerial = 0;
|
|
1190
|
+
function writeNow(dir, view, pid) {
|
|
1191
|
+
const body = `${JSON.stringify(view, null, 2)}\n`;
|
|
1192
|
+
assertNoProviderToken(body);
|
|
1193
|
+
const dest = path.join(dir, NOW_FILE_NAME);
|
|
1194
|
+
// A fixed `.now.<pid>.tmp` disappeared before rename when rebuild ran in a
|
|
1195
|
+
// tight loop in one process (ENOENT on rename). Each write gets its own name.
|
|
1196
|
+
const tmp = path.join(dir, `.now.${pid}.${++nowWriteSerial}.${process.hrtime.bigint().toString(36)}.tmp`);
|
|
1197
|
+
const fd = fs.openSync(tmp, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, FILE_MODE);
|
|
1198
|
+
try {
|
|
1199
|
+
const payload = Buffer.from(body, "utf8");
|
|
1200
|
+
const written = fs.writeSync(fd, payload);
|
|
1201
|
+
if (written !== payload.length)
|
|
1202
|
+
throw new Error("capacity now.json temp write was truncated");
|
|
1203
|
+
}
|
|
1204
|
+
finally {
|
|
1205
|
+
fs.closeSync(fd);
|
|
1206
|
+
}
|
|
1207
|
+
try {
|
|
1208
|
+
fs.renameSync(tmp, dest);
|
|
1209
|
+
}
|
|
1210
|
+
catch (err) {
|
|
1211
|
+
try {
|
|
1212
|
+
fs.unlinkSync(tmp);
|
|
1213
|
+
}
|
|
1214
|
+
catch {
|
|
1215
|
+
// Cleanup must not hide the rename failure. The lock is still released.
|
|
1216
|
+
}
|
|
1217
|
+
throw err;
|
|
1218
|
+
}
|
|
1219
|
+
fs.chmodSync(dest, FILE_MODE);
|
|
1220
|
+
}
|
|
1221
|
+
function isNowView(value) {
|
|
1222
|
+
if (!value || typeof value !== "object")
|
|
1223
|
+
return false;
|
|
1224
|
+
const view = value;
|
|
1225
|
+
return typeof view.machineId === "string"
|
|
1226
|
+
&& view.machineId.length > 0
|
|
1227
|
+
&& typeof view.hqCliVersion === "string"
|
|
1228
|
+
&& typeof view.generatedAt === "string"
|
|
1229
|
+
&& Array.isArray(view.providers);
|
|
1230
|
+
}
|
|
1231
|
+
function validateReading(value) {
|
|
1232
|
+
if (!isReadingShape(value))
|
|
1233
|
+
throw new LedgerRejected("reading shape is invalid");
|
|
1234
|
+
const limit = value.limit;
|
|
1235
|
+
const usedPercent = value.confidence === "unknown" ? null : limit.usedPercent;
|
|
1236
|
+
const reading = {
|
|
1237
|
+
readingId: value.readingId,
|
|
1238
|
+
observedAt: value.observedAt,
|
|
1239
|
+
provider: value.provider,
|
|
1240
|
+
accountId: value.accountId,
|
|
1241
|
+
source: value.source,
|
|
1242
|
+
cliVersion: value.cliVersion,
|
|
1243
|
+
limit: {
|
|
1244
|
+
limitId: limit.limitId,
|
|
1245
|
+
kind: limit.kind,
|
|
1246
|
+
scope: limit.scope,
|
|
1247
|
+
usedPercent,
|
|
1248
|
+
windowMinutes: limit.windowMinutes,
|
|
1249
|
+
resetsAt: normalizeResetsAt(limit.resetsAt),
|
|
1250
|
+
balance: limit.balance,
|
|
1251
|
+
planType: limit.planType,
|
|
1252
|
+
},
|
|
1253
|
+
confidence: value.confidence,
|
|
1254
|
+
parserVersion: value.parserVersion,
|
|
1255
|
+
};
|
|
1256
|
+
if (value.error !== undefined)
|
|
1257
|
+
reading.error = value.error;
|
|
1258
|
+
copyAccountFields(reading, value);
|
|
1259
|
+
if (limit.hasCredits !== undefined)
|
|
1260
|
+
reading.limit.hasCredits = limit.hasCredits;
|
|
1261
|
+
if (limit.unlimited !== undefined)
|
|
1262
|
+
reading.limit.unlimited = limit.unlimited;
|
|
1263
|
+
if (limit.allowancePercentOfWeekly !== undefined) {
|
|
1264
|
+
reading.limit.allowancePercentOfWeekly = limit.allowancePercentOfWeekly;
|
|
1265
|
+
}
|
|
1266
|
+
if (value.rateLimitReachedType !== undefined)
|
|
1267
|
+
reading.rateLimitReachedType = value.rateLimitReachedType;
|
|
1268
|
+
if (value.spendControlReached !== undefined)
|
|
1269
|
+
reading.spendControlReached = value.spendControlReached;
|
|
1270
|
+
return reading;
|
|
1271
|
+
}
|
|
1272
|
+
function validateProviderMeta(value) {
|
|
1273
|
+
if (!isProviderMeta(value))
|
|
1274
|
+
throw new LedgerRejected("provider_meta shape is invalid");
|
|
1275
|
+
const event = {
|
|
1276
|
+
event: "provider_meta",
|
|
1277
|
+
observedAt: value.observedAt,
|
|
1278
|
+
provider: value.provider,
|
|
1279
|
+
source: value.source,
|
|
1280
|
+
ordinaryUsageAllowed: value.ordinaryUsageAllowed,
|
|
1281
|
+
rateLimitResetCredits: value.rateLimitResetCredits === null
|
|
1282
|
+
? null
|
|
1283
|
+
: {
|
|
1284
|
+
availableCount: value.rateLimitResetCredits.availableCount,
|
|
1285
|
+
credits: value.rateLimitResetCredits.credits.map((row) => ({
|
|
1286
|
+
id: row.id,
|
|
1287
|
+
resetType: row.resetType,
|
|
1288
|
+
status: row.status,
|
|
1289
|
+
grantedAt: row.grantedAt,
|
|
1290
|
+
expiresAt: row.expiresAt,
|
|
1291
|
+
title: row.title === null ? null : redactProviderTokens(row.title),
|
|
1292
|
+
description: row.description === null ? null : redactProviderTokens(row.description),
|
|
1293
|
+
})),
|
|
1294
|
+
},
|
|
1295
|
+
};
|
|
1296
|
+
if (value.accountId !== undefined)
|
|
1297
|
+
event.accountId = value.accountId;
|
|
1298
|
+
if (value.cliVersion !== undefined)
|
|
1299
|
+
event.cliVersion = value.cliVersion;
|
|
1300
|
+
if (value.parserVersion !== undefined)
|
|
1301
|
+
event.parserVersion = value.parserVersion;
|
|
1302
|
+
return event;
|
|
1303
|
+
}
|
|
1304
|
+
function validateParserFailure(value) {
|
|
1305
|
+
if (!isParserFailure(value))
|
|
1306
|
+
throw new LedgerRejected("parser_failure shape is invalid");
|
|
1307
|
+
const event = {
|
|
1308
|
+
event: "parser_failure",
|
|
1309
|
+
observedAt: value.observedAt,
|
|
1310
|
+
provider: value.provider,
|
|
1311
|
+
source: value.source,
|
|
1312
|
+
error: value.error,
|
|
1313
|
+
};
|
|
1314
|
+
if (value.accountId !== undefined)
|
|
1315
|
+
event.accountId = value.accountId;
|
|
1316
|
+
if (value.cliVersion !== undefined)
|
|
1317
|
+
event.cliVersion = value.cliVersion;
|
|
1318
|
+
if (value.parserVersion !== undefined)
|
|
1319
|
+
event.parserVersion = value.parserVersion;
|
|
1320
|
+
if (value.limitId !== undefined)
|
|
1321
|
+
event.limitId = value.limitId;
|
|
1322
|
+
copyAccountFields(event, value);
|
|
1323
|
+
return event;
|
|
1324
|
+
}
|
|
1325
|
+
function isReadingShape(value) {
|
|
1326
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1327
|
+
return false;
|
|
1328
|
+
const reading = value;
|
|
1329
|
+
if (reading.event === "parser_failure")
|
|
1330
|
+
return false;
|
|
1331
|
+
return nonEmpty(reading.readingId)
|
|
1332
|
+
&& isoUtc(reading.observedAt)
|
|
1333
|
+
&& nonEmpty(reading.provider)
|
|
1334
|
+
&& nonEmpty(reading.accountId)
|
|
1335
|
+
&& isEnum(reading.source, READING_SOURCES)
|
|
1336
|
+
&& nonEmpty(reading.cliVersion)
|
|
1337
|
+
&& isEnum(reading.confidence, CONFIDENCE_VALUES)
|
|
1338
|
+
&& nonEmpty(reading.parserVersion)
|
|
1339
|
+
&& (reading.error === undefined || typeof reading.error === "string")
|
|
1340
|
+
&& (reading.rateLimitReachedType === undefined
|
|
1341
|
+
|| reading.rateLimitReachedType === null
|
|
1342
|
+
|| typeof reading.rateLimitReachedType === "string")
|
|
1343
|
+
&& (reading.spendControlReached === undefined
|
|
1344
|
+
|| reading.spendControlReached === null
|
|
1345
|
+
|| typeof reading.spendControlReached === "boolean")
|
|
1346
|
+
&& validAccountFields(reading)
|
|
1347
|
+
&& isLimit(reading.limit);
|
|
1348
|
+
}
|
|
1349
|
+
function isLimit(value) {
|
|
1350
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1351
|
+
return false;
|
|
1352
|
+
const limit = value;
|
|
1353
|
+
return nonEmpty(limit.limitId)
|
|
1354
|
+
&& isEnum(limit.kind, LIMIT_KINDS)
|
|
1355
|
+
&& (limit.scope === null || typeof limit.scope === "string")
|
|
1356
|
+
&& (limit.usedPercent === null || finiteNumber(limit.usedPercent))
|
|
1357
|
+
&& (limit.windowMinutes === null || finiteNumber(limit.windowMinutes))
|
|
1358
|
+
&& (limit.resetsAt === null || typeof limit.resetsAt === "string" || finiteNumber(limit.resetsAt))
|
|
1359
|
+
&& (limit.balance === null || typeof limit.balance === "string" || finiteNumber(limit.balance))
|
|
1360
|
+
&& (limit.planType === null || typeof limit.planType === "string")
|
|
1361
|
+
&& (limit.hasCredits === undefined || limit.hasCredits === null || typeof limit.hasCredits === "boolean")
|
|
1362
|
+
&& (limit.unlimited === undefined || limit.unlimited === null || typeof limit.unlimited === "boolean")
|
|
1363
|
+
&& (limit.allowancePercentOfWeekly === undefined
|
|
1364
|
+
|| limit.allowancePercentOfWeekly === null
|
|
1365
|
+
|| finiteNumber(limit.allowancePercentOfWeekly));
|
|
1366
|
+
}
|
|
1367
|
+
function isProviderMeta(value) {
|
|
1368
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1369
|
+
return false;
|
|
1370
|
+
const event = value;
|
|
1371
|
+
return event.event === "provider_meta"
|
|
1372
|
+
&& isoUtc(event.observedAt)
|
|
1373
|
+
&& nonEmpty(event.provider)
|
|
1374
|
+
&& isEnum(event.source, READING_SOURCES)
|
|
1375
|
+
&& (event.ordinaryUsageAllowed === null || typeof event.ordinaryUsageAllowed === "boolean")
|
|
1376
|
+
&& isResetCredits(event.rateLimitResetCredits)
|
|
1377
|
+
&& (event.accountId === undefined || nonEmpty(event.accountId))
|
|
1378
|
+
&& (event.cliVersion === undefined || typeof event.cliVersion === "string")
|
|
1379
|
+
&& (event.parserVersion === undefined || typeof event.parserVersion === "string");
|
|
1380
|
+
}
|
|
1381
|
+
function isResetCredits(value) {
|
|
1382
|
+
if (value === null)
|
|
1383
|
+
return true;
|
|
1384
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1385
|
+
return false;
|
|
1386
|
+
const credits = value;
|
|
1387
|
+
if (!(credits.availableCount === null || finiteNumber(credits.availableCount)))
|
|
1388
|
+
return false;
|
|
1389
|
+
if (!Array.isArray(credits.credits))
|
|
1390
|
+
return false;
|
|
1391
|
+
return credits.credits.every((row) => {
|
|
1392
|
+
if (!row || typeof row !== "object")
|
|
1393
|
+
return false;
|
|
1394
|
+
return nonEmpty(row.id)
|
|
1395
|
+
&& (row.resetType === null || typeof row.resetType === "string")
|
|
1396
|
+
&& (row.status === null || typeof row.status === "string")
|
|
1397
|
+
&& (row.grantedAt === null || finiteNumber(row.grantedAt))
|
|
1398
|
+
&& (row.expiresAt === null || finiteNumber(row.expiresAt))
|
|
1399
|
+
&& (row.title === null || typeof row.title === "string")
|
|
1400
|
+
&& (row.description === null || typeof row.description === "string");
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
function isParserFailure(value) {
|
|
1404
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1405
|
+
return false;
|
|
1406
|
+
const event = value;
|
|
1407
|
+
return event.event === "parser_failure"
|
|
1408
|
+
&& isoUtc(event.observedAt)
|
|
1409
|
+
&& nonEmpty(event.provider)
|
|
1410
|
+
&& isEnum(event.source, READING_SOURCES)
|
|
1411
|
+
&& nonEmpty(event.error)
|
|
1412
|
+
&& (event.accountId === undefined || nonEmpty(event.accountId))
|
|
1413
|
+
&& (event.cliVersion === undefined || typeof event.cliVersion === "string")
|
|
1414
|
+
&& (event.parserVersion === undefined || typeof event.parserVersion === "string")
|
|
1415
|
+
&& (event.limitId === undefined || nonEmpty(event.limitId))
|
|
1416
|
+
&& validAccountFields(event);
|
|
1417
|
+
}
|
|
1418
|
+
function normalizeResetsAt(value) {
|
|
1419
|
+
if (value === null)
|
|
1420
|
+
return null;
|
|
1421
|
+
if (typeof value === "number") {
|
|
1422
|
+
const ms = value < 1e12 ? value * 1000 : value;
|
|
1423
|
+
return new Date(ms).toISOString();
|
|
1424
|
+
}
|
|
1425
|
+
if (!isoUtc(value))
|
|
1426
|
+
throw new LedgerRejected("resetsAt must be UTC ISO-8601 or epoch seconds");
|
|
1427
|
+
return value;
|
|
1428
|
+
}
|
|
1429
|
+
function nonEmpty(value) {
|
|
1430
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
1431
|
+
}
|
|
1432
|
+
function isoUtc(value) {
|
|
1433
|
+
return typeof value === "string" && ISO_UTC.test(value) && !Number.isNaN(Date.parse(value));
|
|
1434
|
+
}
|
|
1435
|
+
function finiteNumber(value) {
|
|
1436
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
1437
|
+
}
|
|
1438
|
+
function isEnum(value, allowed) {
|
|
1439
|
+
return typeof value === "string" && allowed.includes(value);
|
|
1440
|
+
}
|
|
1441
|
+
//# sourceMappingURL=ledger.js.map
|