@cad0p/pi-usage 0.11.5 → 0.11.6
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 +22 -0
- package/README.md +81 -39
- package/bin/piquota.js +12 -2
- package/extensions/quota-panel.ts +532 -184
- package/package.json +1 -1
- package/src/cache.js +128 -31
- package/src/cli/commands/omarchy.js +21 -3
- package/src/engine.js +98 -7
- package/src/local/pi-sessions.js +28 -1
- package/src/local/stats-worker.js +27 -0
- package/src/local/worker.js +157 -0
- package/src/model.js +39 -5
- package/src/omarchy/publish.js +9 -8
- package/src/overlay-keys.js +257 -0
- package/src/providers/antigravity-oauth.js +43 -3
- package/src/providers/antigravity.js +44 -5
- package/src/refresh.js +4 -1
package/package.json
CHANGED
package/src/cache.js
CHANGED
|
@@ -62,6 +62,59 @@ export function resolveCachePath(options = {}) {
|
|
|
62
62
|
return join(cacheHome, "pi-quota", "usage.json");
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Read the cache envelope, or null when there is not one this version understands.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} path
|
|
69
|
+
* @returns {Record<string, any> | null}
|
|
70
|
+
*/
|
|
71
|
+
function readPayload(path) {
|
|
72
|
+
let raw;
|
|
73
|
+
try {
|
|
74
|
+
raw = readFileSync(path, "utf-8");
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(raw);
|
|
80
|
+
return parsed && typeof parsed === "object" ? /** @type {Record<string, any>} */ (parsed) : null;
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The fetch time of every family a cached report carries.
|
|
88
|
+
*
|
|
89
|
+
* The report is written family by family — the Pi extension repaints and saves
|
|
90
|
+
* as each one lands — so `savedAt` says when the *last* family was written and
|
|
91
|
+
* nothing about the others. Judging the whole file by it served Claude's
|
|
92
|
+
* five-minute-old numbers to a reader a minute later, only because Codex had
|
|
93
|
+
* just been saved. Each family therefore keeps its own clock. A cache written
|
|
94
|
+
* before that field existed carries only `savedAt`, and every family falls
|
|
95
|
+
* back to it.
|
|
96
|
+
*
|
|
97
|
+
* @param {Record<string, any>} parsed
|
|
98
|
+
* @param {string[]} families
|
|
99
|
+
* @returns {Record<string, number>}
|
|
100
|
+
*/
|
|
101
|
+
function payloadClocks(parsed, families) {
|
|
102
|
+
const savedAt = typeof parsed.savedAt === "number" && Number.isFinite(parsed.savedAt) ? parsed.savedAt : null;
|
|
103
|
+
/** @type {Record<string, number>} */
|
|
104
|
+
const clocks = {};
|
|
105
|
+
const raw = parsed.fetchedAt;
|
|
106
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
107
|
+
for (const [family, value] of Object.entries(raw)) {
|
|
108
|
+
if (typeof value === "number" && Number.isFinite(value)) clocks[family] = value;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (const family of families) {
|
|
112
|
+
if (typeof clocks[family] === "number") continue;
|
|
113
|
+
if (savedAt !== null) clocks[family] = savedAt;
|
|
114
|
+
}
|
|
115
|
+
return clocks;
|
|
116
|
+
}
|
|
117
|
+
|
|
65
118
|
/**
|
|
66
119
|
* @param {{
|
|
67
120
|
* path?: string,
|
|
@@ -71,27 +124,20 @@ export function resolveCachePath(options = {}) {
|
|
|
71
124
|
* home?: string,
|
|
72
125
|
* key?: string, Settings fingerprint; a cached report saved under another key is a miss.
|
|
73
126
|
* }} [options]
|
|
74
|
-
* @returns {{
|
|
127
|
+
* @returns {{
|
|
128
|
+
* report: import("./engine.js").PiQuotaReport,
|
|
129
|
+
* ageMs: number,
|
|
130
|
+
* fetchedAt: Record<string, number>,
|
|
131
|
+
* path: string,
|
|
132
|
+
* } | null}
|
|
75
133
|
*/
|
|
76
134
|
export function readCache(options = {}) {
|
|
77
135
|
const path = options.path ?? resolveCachePath(options);
|
|
78
136
|
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
79
137
|
const now = options.now ?? Date.now();
|
|
80
138
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
raw = readFileSync(path, "utf-8");
|
|
84
|
-
} catch {
|
|
85
|
-
return null;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
let parsed;
|
|
89
|
-
try {
|
|
90
|
-
parsed = JSON.parse(raw);
|
|
91
|
-
} catch {
|
|
92
|
-
return null;
|
|
93
|
-
}
|
|
94
|
-
if (!parsed || typeof parsed !== "object") return null;
|
|
139
|
+
const parsed = readPayload(path);
|
|
140
|
+
if (!parsed) return null;
|
|
95
141
|
const report = /** @type {{ report?: import("./engine.js").PiQuotaReport }} */ (parsed).report;
|
|
96
142
|
if (!report || !Array.isArray(report.providers)) return null;
|
|
97
143
|
|
|
@@ -99,7 +145,13 @@ export function readCache(options = {}) {
|
|
|
99
145
|
? /** @type {number} */ (/** @type {{ savedAt?: number }} */ (parsed).savedAt)
|
|
100
146
|
: null;
|
|
101
147
|
if (savedAt === null) return null;
|
|
102
|
-
const
|
|
148
|
+
const families = Array.from(new Set(report.providers.map((provider) => provider.family)));
|
|
149
|
+
const fetchedAt = payloadClocks(parsed, families);
|
|
150
|
+
// The oldest family decides the hit: a report is only as fresh as its stalest
|
|
151
|
+
// part, which is what the single-`savedAt` rule meant before per-family clocks.
|
|
152
|
+
const ageMs = families.length > 0
|
|
153
|
+
? Math.max(...families.map((family) => now - (fetchedAt[family] ?? savedAt)))
|
|
154
|
+
: now - savedAt;
|
|
103
155
|
if (ageMs > ttlMs) return null;
|
|
104
156
|
// A report collected under other settings (e.g. another Claude source) answers a
|
|
105
157
|
// different question, so it is a miss rather than a stale hit.
|
|
@@ -116,7 +168,33 @@ export function readCache(options = {}) {
|
|
|
116
168
|
}
|
|
117
169
|
}
|
|
118
170
|
|
|
119
|
-
return { report, ageMs, path };
|
|
171
|
+
return { report, ageMs, fetchedAt, path };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @param {unknown} value
|
|
176
|
+
* @returns {value is number}
|
|
177
|
+
*/
|
|
178
|
+
function finite(value) {
|
|
179
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The fetch time a family's own results carry: the oldest `updatedAt` among
|
|
184
|
+
* its accounts, or undefined when none parses.
|
|
185
|
+
*
|
|
186
|
+
* @param {import("./engine.js").PiQuotaReport} report
|
|
187
|
+
* @param {string} family
|
|
188
|
+
* @returns {number | undefined}
|
|
189
|
+
*/
|
|
190
|
+
function oldestUpdatedAt(report, family) {
|
|
191
|
+
let oldest;
|
|
192
|
+
for (const provider of report.providers ?? []) {
|
|
193
|
+
if (provider?.family !== family) continue;
|
|
194
|
+
const at = Date.parse(provider.updatedAt ?? "");
|
|
195
|
+
if (Number.isFinite(at) && (oldest === undefined || at < oldest)) oldest = at;
|
|
196
|
+
}
|
|
197
|
+
return oldest;
|
|
120
198
|
}
|
|
121
199
|
|
|
122
200
|
/**
|
|
@@ -128,24 +206,34 @@ export function readCache(options = {}) {
|
|
|
128
206
|
* home?: string,
|
|
129
207
|
* merge?: boolean,
|
|
130
208
|
* key?: string,
|
|
209
|
+
* fetchedAtMs?: Record<string, number>, Each family's own fetch time, when the caller knows it.
|
|
210
|
+
* freshFamilies?: string[], Families this write fetched just now; defaults to every family in `report`.
|
|
131
211
|
* }} [options]
|
|
132
212
|
* @returns {{ ok: boolean, path: string, error?: string }}
|
|
133
213
|
*/
|
|
134
214
|
export function writeCache(report, options = {}) {
|
|
135
215
|
const path = options.path ?? resolveCachePath(options);
|
|
136
216
|
const now = options.now ?? Date.now();
|
|
217
|
+
const existing = readCache({ ...options, path, ttlMs: Number.POSITIVE_INFINITY });
|
|
137
218
|
let finalReport = report;
|
|
138
|
-
if (options.merge) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
219
|
+
if (options.merge && existing?.report) {
|
|
220
|
+
finalReport = mergeReportIntoCache(existing.report, report);
|
|
221
|
+
}
|
|
222
|
+
const fresh = new Set(options.freshFamilies ?? (report.providers ?? []).map((provider) => provider.family));
|
|
223
|
+
const provided = options.fetchedAtMs ?? {};
|
|
224
|
+
/** @type {Record<string, number>} */
|
|
225
|
+
const fetchedAt = {};
|
|
226
|
+
for (const family of new Set((finalReport.providers ?? []).map((provider) => provider.family))) {
|
|
227
|
+
// A family this write fetched is "now" unless the caller knows a better
|
|
228
|
+
// time. Any other family keeps the clock it had; with no clock anywhere it
|
|
229
|
+
// is as old as its own results say, never restamped to now, which would
|
|
230
|
+
// let an hour-old copy pass as fresh merely because a neighbour was saved.
|
|
231
|
+
const own = provided[family];
|
|
232
|
+
const at = finite(own) ? own : fresh.has(family) ? now : finite(existing?.fetchedAt?.[family]) ? existing.fetchedAt[family] : oldestUpdatedAt(finalReport, family);
|
|
233
|
+
fetchedAt[family] = finite(at) ? at : now;
|
|
143
234
|
}
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
null,
|
|
147
|
-
2,
|
|
148
|
-
);
|
|
235
|
+
const envelope = { savedAt: now, ...(options.key === undefined ? {} : { key: options.key }), fetchedAt, report: finalReport };
|
|
236
|
+
const payload = JSON.stringify(envelope, null, 2);
|
|
149
237
|
|
|
150
238
|
try {
|
|
151
239
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
@@ -211,10 +299,16 @@ export function describeCache(options = {}) {
|
|
|
211
299
|
export async function withCache(options, loader) {
|
|
212
300
|
const requested = options.families;
|
|
213
301
|
if (!options.force) {
|
|
214
|
-
|
|
302
|
+
// Freshness is decided per family below, so the read must not apply one
|
|
303
|
+
// TTL to the whole file first.
|
|
304
|
+
const hit = readCache({ ...options, ttlMs: Number.POSITIVE_INFINITY });
|
|
215
305
|
if (hit) {
|
|
216
|
-
const
|
|
217
|
-
|
|
306
|
+
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
307
|
+
const now = options.now ?? Date.now();
|
|
308
|
+
const present = new Set(hit.report.providers.map((provider) => provider.family));
|
|
309
|
+
const wanted = requested ?? Array.from(present);
|
|
310
|
+
const fresh = wanted.every((family) => present.has(family) && now - (hit.fetchedAt[family] ?? 0) <= ttlMs);
|
|
311
|
+
if (fresh) {
|
|
218
312
|
const filteredReport = requested
|
|
219
313
|
? {
|
|
220
314
|
...hit.report,
|
|
@@ -224,7 +318,10 @@ export async function withCache(options, loader) {
|
|
|
224
318
|
),
|
|
225
319
|
}
|
|
226
320
|
: hit.report;
|
|
227
|
-
|
|
321
|
+
const ageMs = wanted.length > 0
|
|
322
|
+
? Math.max(...wanted.map((family) => now - (hit.fetchedAt[family] ?? now)))
|
|
323
|
+
: hit.ageMs;
|
|
324
|
+
return { report: filteredReport, cached: true, ageMs };
|
|
228
325
|
}
|
|
229
326
|
}
|
|
230
327
|
}
|
|
@@ -44,19 +44,37 @@ export function skippedFamilies(env, dir) {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
* @param {{
|
|
47
|
+
* @param {{
|
|
48
|
+
* load: (options?: { onFamily?: (family: string, report: import("../../engine.js").PiQuotaReport) => void | Promise<void> }) => Promise<import("../../engine.js").PiQuotaReport>,
|
|
49
|
+
* dir?: string,
|
|
50
|
+
* env?: Record<string, string | undefined>,
|
|
51
|
+
* quiet?: boolean,
|
|
52
|
+
* }} options
|
|
48
53
|
* @returns {Promise<number>}
|
|
49
54
|
*/
|
|
50
55
|
export async function runOmarchyCommand(options) {
|
|
56
|
+
const skip = skippedFamilies(options.env ?? process.env, options.dir);
|
|
57
|
+
const onFamily = async (family, fragment) => {
|
|
58
|
+
if (skip.has(family)) return;
|
|
59
|
+
const providers = (fragment.providers ?? []).filter((provider) => provider?.family === family && !skip.has(provider.family));
|
|
60
|
+
if (providers.length === 0) return;
|
|
61
|
+
try {
|
|
62
|
+
const familyReport = { ...fragment, providers, byFamily: { [family]: providers } };
|
|
63
|
+
publishRecords(buildRecords(familyReport), { dir: options.dir, env: options.env, prune: false });
|
|
64
|
+
} catch {
|
|
65
|
+
// A per-family write must not prevent later providers from being collected.
|
|
66
|
+
// The final complete publish below reports any persistent write failure.
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
51
70
|
let report;
|
|
52
71
|
try {
|
|
53
|
-
report = await options.load();
|
|
72
|
+
report = await options.load({ onFamily });
|
|
54
73
|
} catch (error) {
|
|
55
74
|
err(`omarchy: could not collect quota: ${redact(/** @type {{ message?: string }} */ (error)?.message ?? error)}`);
|
|
56
75
|
return 1;
|
|
57
76
|
}
|
|
58
77
|
|
|
59
|
-
const skip = skippedFamilies(options.env ?? process.env, options.dir);
|
|
60
78
|
const records = buildRecords(skip.size > 0 ? { ...report, providers: (report.providers ?? []).filter((p) => !skip.has(p?.family)) } : report);
|
|
61
79
|
let result;
|
|
62
80
|
try {
|
package/src/engine.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* degraded result with an actionable message.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { checkFreshness, LABEL_BY_FAMILY, loadPiCredentials, resolveAuthPaths } from "./auth/pi-auth.js";
|
|
9
|
+
import { checkFreshness, LABEL_BY_FAMILY, loadPiCredentials, normalizeCredential, resolveAuthPaths } from "./auth/pi-auth.js";
|
|
10
10
|
import { loadClaudeCodeCredential } from "./auth/claude-code-auth.js";
|
|
11
11
|
import { redact } from "./http.js";
|
|
12
12
|
import { collectLocalStats } from "./local/pi-sessions.js";
|
|
@@ -239,6 +239,12 @@ const PROVIDERS = {
|
|
|
239
239
|
* tests and library callers never read a real home directory; the CLI turns
|
|
240
240
|
* it on with a time budget. Pass an object to inject session roots or a
|
|
241
241
|
* cache path, or false to disable it explicitly.
|
|
242
|
+
* onFamily?: (family: string, report: PiQuotaReport) => void | Promise<void>,
|
|
243
|
+
* Called once a family has settled, before the other families necessarily
|
|
244
|
+
* finish. The report fragment contains only that family's results.
|
|
245
|
+
* authOverrides?: Record<string, { access: string, expiresAtMs?: number | null, accountId?: string | null }>,
|
|
246
|
+
* Optional in-memory OAuth access tokens supplied by the host runtime. Never
|
|
247
|
+
* persisted or included in reports.
|
|
242
248
|
* }} [options]
|
|
243
249
|
* @returns {Promise<PiQuotaReport>}
|
|
244
250
|
*/
|
|
@@ -255,6 +261,37 @@ export async function collectQuota(options = {}) {
|
|
|
255
261
|
paths: options.paths,
|
|
256
262
|
});
|
|
257
263
|
|
|
264
|
+
// Pi's extension host may have resolved/renewed an OAuth token through Pi's
|
|
265
|
+
// own locked auth path. Apply only to the matching provider in memory; the
|
|
266
|
+
// underlying file reader remains read-only and the token never reaches output.
|
|
267
|
+
for (const [provider, auth] of Object.entries(options.authOverrides ?? {})) {
|
|
268
|
+
if (typeof auth?.access !== "string" || auth.access === "") continue;
|
|
269
|
+
let matched = false;
|
|
270
|
+
for (const credential of loaded.credentials) {
|
|
271
|
+
if (credential.provider !== provider) continue;
|
|
272
|
+
credential.access = auth.access;
|
|
273
|
+
credential.expiresAtMs = auth.expiresAtMs ?? null;
|
|
274
|
+
if (typeof auth.accountId === "string" && auth.accountId !== "") credential.accountId = auth.accountId;
|
|
275
|
+
matched = true;
|
|
276
|
+
}
|
|
277
|
+
// Pi may own auth from a non-default agent directory that the standalone
|
|
278
|
+
// read-only path discovery does not know about. A host-resolved OAuth token
|
|
279
|
+
// is sufficient to create the in-memory provider entry; never persist it.
|
|
280
|
+
if (!matched) {
|
|
281
|
+
const credential = normalizeCredential(
|
|
282
|
+
provider,
|
|
283
|
+
{
|
|
284
|
+
type: "oauth",
|
|
285
|
+
access: auth.access,
|
|
286
|
+
expires: auth.expiresAtMs,
|
|
287
|
+
accountId: auth.accountId,
|
|
288
|
+
},
|
|
289
|
+
"Pi runtime auth",
|
|
290
|
+
);
|
|
291
|
+
if (credential) loaded.credentials.push(credential);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
258
295
|
// Claude is the one family with a second, non-Pi source, so it is resolved
|
|
259
296
|
// before the per-family loop and then treated exactly like any other credential.
|
|
260
297
|
const claude = resolveClaudeCredentials({
|
|
@@ -290,6 +327,43 @@ export async function collectQuota(options = {}) {
|
|
|
290
327
|
/** @type {Record<string, string[]>} */
|
|
291
328
|
const familyWarnings = {};
|
|
292
329
|
|
|
330
|
+
/**
|
|
331
|
+
* Publish a family fragment as soon as its provider work is complete. A
|
|
332
|
+
* callback failure is isolated from provider collection and becomes a warning
|
|
333
|
+
* on the final report.
|
|
334
|
+
*
|
|
335
|
+
* @param {string} family
|
|
336
|
+
* @param {import("./model.js").QuotaResult[]} results
|
|
337
|
+
*/
|
|
338
|
+
const emitFamily = async (family, results) => {
|
|
339
|
+
for (const provider of results) {
|
|
340
|
+
provider.primaryWindowId = selectPrimaryWindow(provider.windows ?? [])?.id ?? null;
|
|
341
|
+
}
|
|
342
|
+
byFamily[family] = results;
|
|
343
|
+
familyWarnings[family] ??= [];
|
|
344
|
+
if (!options.onFamily) return;
|
|
345
|
+
const fragment = {
|
|
346
|
+
engine: "pi-quota",
|
|
347
|
+
schemaVersion: 1,
|
|
348
|
+
readOnly: true,
|
|
349
|
+
generatedAt: new Date(now).toISOString(),
|
|
350
|
+
sources: loaded.paths,
|
|
351
|
+
claudeSource: { mode: claude.mode, paths: claude.claudeCodePaths, unavailable: claude.unavailable },
|
|
352
|
+
warnings: [...warnings, ...familyWarnings[family]],
|
|
353
|
+
providers: results,
|
|
354
|
+
byFamily: { [family]: results },
|
|
355
|
+
localStats: {},
|
|
356
|
+
localStatsMeta: null,
|
|
357
|
+
};
|
|
358
|
+
try {
|
|
359
|
+
await options.onFamily(family, fragment);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
familyWarnings[family].push(
|
|
362
|
+
`${family}: family callback failed: ${redact(/** @type {{ message?: string }} */ (error)?.message ?? String(error))}`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
|
|
293
367
|
const hasOpenAi = loaded.rawProviders?.includes("openai") || Boolean(env.OPENAI_API_KEY);
|
|
294
368
|
|
|
295
369
|
// The local scan runs beside the network calls: a first full scan of the
|
|
@@ -310,7 +384,7 @@ export async function collectQuota(options = {}) {
|
|
|
310
384
|
// `notConfigured` is stated rather than inferred: the message now names a
|
|
311
385
|
// second source, and every renderer keys off this flag to avoid painting a
|
|
312
386
|
// failure icon for a provider the user simply does not use.
|
|
313
|
-
|
|
387
|
+
await emitFamily(family, [
|
|
314
388
|
degradedResult({
|
|
315
389
|
family,
|
|
316
390
|
label: LABEL_BY_FAMILY[family] ?? family,
|
|
@@ -319,7 +393,7 @@ export async function collectQuota(options = {}) {
|
|
|
319
393
|
source: loaded.paths[0] ?? resolveAuthPaths({ env, home: options.home })[0] ?? "~/.pi/agent/auth.json",
|
|
320
394
|
now,
|
|
321
395
|
}),
|
|
322
|
-
];
|
|
396
|
+
]);
|
|
323
397
|
return;
|
|
324
398
|
}
|
|
325
399
|
|
|
@@ -340,7 +414,7 @@ export async function collectQuota(options = {}) {
|
|
|
340
414
|
|
|
341
415
|
const throttle = backoffState(family, { now, env, home: options.home });
|
|
342
416
|
if (throttle.active && !options.force) {
|
|
343
|
-
|
|
417
|
+
await emitFamily(family, [
|
|
344
418
|
degradedResult({
|
|
345
419
|
family,
|
|
346
420
|
label: LABEL_BY_FAMILY[family] ?? family,
|
|
@@ -351,7 +425,7 @@ export async function collectQuota(options = {}) {
|
|
|
351
425
|
source: credentials[0].source,
|
|
352
426
|
now,
|
|
353
427
|
}),
|
|
354
|
-
];
|
|
428
|
+
]);
|
|
355
429
|
return;
|
|
356
430
|
}
|
|
357
431
|
|
|
@@ -404,7 +478,11 @@ export async function collectQuota(options = {}) {
|
|
|
404
478
|
const results = [];
|
|
405
479
|
const familyWarnList = [];
|
|
406
480
|
for (const { result, warning, crashed } of outcomes) {
|
|
407
|
-
|
|
481
|
+
// The stored-credential warning is a claim about this run, and a provider
|
|
482
|
+
// that renewed the token in memory for it knows better than the store:
|
|
483
|
+
// "the Pi token expired 271316m ago" next to two healthy Antigravity
|
|
484
|
+
// windows was reading Pi's stale copy, not the token that was used.
|
|
485
|
+
if (warning && result.tokenRenewedInMemory !== true) familyWarnList.push(warning);
|
|
408
486
|
if (!crashed) {
|
|
409
487
|
// Bookkeeping must degrade like the provider call it follows: a
|
|
410
488
|
// failing backoff write is a warning, never a rejected family.
|
|
@@ -426,8 +504,8 @@ export async function collectQuota(options = {}) {
|
|
|
426
504
|
}
|
|
427
505
|
results.push(result);
|
|
428
506
|
}
|
|
429
|
-
byFamily[family] = results;
|
|
430
507
|
familyWarnings[family] = familyWarnList;
|
|
508
|
+
await emitFamily(family, results);
|
|
431
509
|
}),
|
|
432
510
|
);
|
|
433
511
|
|
|
@@ -526,3 +604,16 @@ function refreshHint(credential) {
|
|
|
526
604
|
export function firstForFamily(report, family) {
|
|
527
605
|
return report.byFamily[family]?.[0] ?? null;
|
|
528
606
|
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Collect a single family using the same normalized engine and callback
|
|
610
|
+
* semantics as collectQuota. This is useful to independent refresh schedulers:
|
|
611
|
+
* an in-flight family does not serialize another family's request.
|
|
612
|
+
*
|
|
613
|
+
* @param {string} family
|
|
614
|
+
* @param {Parameters<typeof collectQuota>[0]} [options]
|
|
615
|
+
* @returns {Promise<PiQuotaReport>}
|
|
616
|
+
*/
|
|
617
|
+
export function collectFamily(family, options = {}) {
|
|
618
|
+
return collectQuota({ ...options, families: [family] });
|
|
619
|
+
}
|
package/src/local/pi-sessions.js
CHANGED
|
@@ -145,8 +145,22 @@ const RECENT_DAYS = 7;
|
|
|
145
145
|
* @property {number} [now] Report time; decides which local day is "today".
|
|
146
146
|
* @property {number} [budgetMs] Wall-clock budget for this scan.
|
|
147
147
|
* @property {() => number} [clock] Time source; tests inject one to drive the budget.
|
|
148
|
+
* @property {() => boolean} [shouldStop] The host wants the scan to end now (session shutdown, /reload).
|
|
149
|
+
* @property {(lock: { path: string, token: string | null }) => void} [onLock] Receives the lock
|
|
150
|
+
* this scan holds, so a host that has to kill the worker can still release it.
|
|
148
151
|
*/
|
|
149
152
|
|
|
153
|
+
/**
|
|
154
|
+
* The host asked the scan to stop. Thrown out of the scan loop so the `finally`
|
|
155
|
+
* releases the history lock and nothing after the interruption is written.
|
|
156
|
+
*/
|
|
157
|
+
export class LocalScanAbortedError extends Error {
|
|
158
|
+
constructor() {
|
|
159
|
+
super("local token stats scan stopped");
|
|
160
|
+
this.name = "LocalScanAbortedError";
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
150
164
|
/**
|
|
151
165
|
* One session file's cache entry.
|
|
152
166
|
*
|
|
@@ -1135,6 +1149,10 @@ export function buildLocalStats(input = {}) {
|
|
|
1135
1149
|
* }>}
|
|
1136
1150
|
*/export async function collectLocalStats(options = {}) {
|
|
1137
1151
|
const clock = options.clock ?? Date.now;
|
|
1152
|
+
const hostStopped = options.shouldStop;
|
|
1153
|
+
const stopNow = () => {
|
|
1154
|
+
if (hostStopped?.() === true) throw new LocalScanAbortedError();
|
|
1155
|
+
};
|
|
1138
1156
|
const startedAt = clock();
|
|
1139
1157
|
const now = options.now ?? Date.now();
|
|
1140
1158
|
const today = localDayKey(now);
|
|
@@ -1164,6 +1182,9 @@ export function buildLocalStats(input = {}) {
|
|
|
1164
1182
|
// delta twice. A run that cannot take it still answers, from what is on disk.
|
|
1165
1183
|
const lockPath = `${cachePath}.lock`;
|
|
1166
1184
|
const lock = acquireHistoryLock({ lockPath, now: clock() });
|
|
1185
|
+
// The host gets the token, not just the path: whoever releases this lock must
|
|
1186
|
+
// prove it is this scan's, never another run's.
|
|
1187
|
+
if (lock.acquired) options.onLock?.({ path: lock.path, token: lock.token });
|
|
1167
1188
|
if (!lock.acquired) {
|
|
1168
1189
|
// Read-only path: `canWrite: false` keeps it from rebuilding or repairing
|
|
1169
1190
|
// anything the run that does hold the lock is writing right now.
|
|
@@ -1277,6 +1298,7 @@ export function buildLocalStats(input = {}) {
|
|
|
1277
1298
|
};
|
|
1278
1299
|
|
|
1279
1300
|
for (const path of listen) {
|
|
1301
|
+
stopNow();
|
|
1280
1302
|
if (!keepLock()) break;
|
|
1281
1303
|
if (!partial && overBudget()) partial = true;
|
|
1282
1304
|
if (partial) break;
|
|
@@ -1373,7 +1395,7 @@ export function buildLocalStats(input = {}) {
|
|
|
1373
1395
|
startOffset,
|
|
1374
1396
|
days,
|
|
1375
1397
|
now,
|
|
1376
|
-
shouldStop: overBudget,
|
|
1398
|
+
shouldStop: () => overBudget() || hostStopped?.() === true,
|
|
1377
1399
|
onUsage: (usage) => {
|
|
1378
1400
|
if (lastFrozenDay && usage.day <= lastFrozenDay) {
|
|
1379
1401
|
// Frozen days are never reconstructed on a rescan. Incremental
|
|
@@ -1465,6 +1487,11 @@ export function buildLocalStats(input = {}) {
|
|
|
1465
1487
|
};
|
|
1466
1488
|
}
|
|
1467
1489
|
|
|
1490
|
+
// A stop that arrived while the last file was being read has to end the run
|
|
1491
|
+
// here: freezing days and writing the cache are the writes a shutdown must
|
|
1492
|
+
// not race. Everything before this point is undoable read-only work.
|
|
1493
|
+
stopNow();
|
|
1494
|
+
|
|
1468
1495
|
const openRows = buildOpenRows(next);
|
|
1469
1496
|
const pendingBeforeApply = pendingLate.size;
|
|
1470
1497
|
const applied = applyDurable({
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
2
|
+
|
|
3
|
+
import { LocalScanAbortedError, collectLocalStats } from "./pi-sessions.js";
|
|
4
|
+
|
|
5
|
+
const options = workerData?.options ?? {};
|
|
6
|
+
// A stop request is a flag the scan polls, not a message it has to be idle to
|
|
7
|
+
// receive: a cold scan spends its time inside synchronous reads and hashing.
|
|
8
|
+
const stopFlag = workerData?.stopFlag instanceof Int32Array ? workerData.stopFlag : null;
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
const result = await collectLocalStats({
|
|
12
|
+
...options,
|
|
13
|
+
shouldStop: () => stopFlag !== null && Atomics.load(stopFlag, 0) === 1,
|
|
14
|
+
// The host keeps this handle: if it has to kill this thread, it can release
|
|
15
|
+
// the lock, and only with the token this scan created.
|
|
16
|
+
onLock: (lock) => parentPort?.postMessage({ type: "lock", path: lock.path, token: lock.token }),
|
|
17
|
+
});
|
|
18
|
+
parentPort?.postMessage({ type: "result", ok: true, result });
|
|
19
|
+
} catch (error) {
|
|
20
|
+
// Do not serialize arbitrary error objects from the scanner into the host.
|
|
21
|
+
parentPort?.postMessage({
|
|
22
|
+
type: "result",
|
|
23
|
+
ok: false,
|
|
24
|
+
aborted: error instanceof LocalScanAbortedError,
|
|
25
|
+
error: "the scan failed",
|
|
26
|
+
});
|
|
27
|
+
}
|