@indigoai-us/hq-cli 5.107.1 → 5.108.0
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/commands/cloud.js +24 -15
- package/dist/commands/mcp-registration.d.ts +8 -0
- package/dist/commands/mcp-registration.js +29 -4
- package/dist/commands/pack-install.js +11 -3
- package/dist/commands/reindex.d.ts +15 -0
- package/dist/commands/reindex.js +120 -0
- package/dist/lib/core-utils/timeout-guard.d.ts +4 -1
- package/dist/lib/core-utils/timeout-guard.js +12 -3
- package/dist/lib/flag-registry-worker.d.ts +3 -0
- package/dist/lib/flag-registry-worker.js +26 -0
- package/dist/lib/flag-registry.d.ts +44 -0
- package/dist/lib/flag-registry.js +85 -0
- package/dist/lib/narrow-hint-banner.d.ts +12 -1
- package/dist/lib/narrow-hint-banner.js +45 -3
- package/dist/lib/plan-limit-nag.d.ts +3 -0
- package/dist/lib/plan-limit-nag.js +18 -1
- package/dist/main.js +11 -2
- package/dist/utils/client-health-contract.d.ts +30 -0
- package/dist/utils/client-health-contract.js +37 -0
- package/dist/utils/client-health.d.ts +79 -2
- package/dist/utils/client-health.js +208 -6
- package/dist/utils/local-files-overview.d.ts +58 -0
- package/dist/utils/local-files-overview.js +170 -0
- package/package.json +2 -1
|
@@ -20,7 +20,10 @@
|
|
|
20
20
|
* caller-supplied identity outright).
|
|
21
21
|
* - Sync state is read through the engine's `listJournals()` — the ONLY
|
|
22
22
|
* correct enumeration of per-scope journals (single-path reconstruction
|
|
23
|
-
* regressed before: feedback_9fbf1f82 / feedback_46288b7b).
|
|
23
|
+
* regressed before: feedback_9fbf1f82 / feedback_46288b7b). It stays the
|
|
24
|
+
* only enumeration, but it is now rate-limited rather than run on every
|
|
25
|
+
* command: it is expensive and synchronous, so it could not be bounded on
|
|
26
|
+
* the command path (see {@link JOURNAL_OBSERVATION_TTL_MS}).
|
|
24
27
|
*
|
|
25
28
|
* Local state (installation identity + monotonic sequence + sync outcome
|
|
26
29
|
* counters) lives at `{stateDir}/cli-client-health.json` next to the sync
|
|
@@ -35,9 +38,42 @@ import { isExpiring, isMachineIdentity, loadCachedTokens, } from "./cognito-sess
|
|
|
35
38
|
import { vaultApiFetch } from "./vault-api.js";
|
|
36
39
|
import { collectVersions } from "./feedback-versions.js";
|
|
37
40
|
import { CLIENT_HEALTH_CONTRACT_VERSION, parseClientHealthHeartbeat, } from "./client-health-contract.js";
|
|
41
|
+
import { collectLocalFilesOverview } from "./local-files-overview.js";
|
|
38
42
|
/** Same bound as CLI telemetry: a heartbeat may never stall a command. */
|
|
39
43
|
export const CLIENT_HEALTH_TIMEOUT_MS = 1_200;
|
|
40
44
|
export const CLIENT_HEALTH_STATE_FILE = "cli-client-health.json";
|
|
45
|
+
/**
|
|
46
|
+
* Single-flight marker for the journal observation below. Held only for the
|
|
47
|
+
* duration of one `listJournals()` call.
|
|
48
|
+
*/
|
|
49
|
+
export const CLIENT_HEALTH_OBSERVE_LOCK_FILE = "cli-client-health.observe.lock";
|
|
50
|
+
/** Cached journal observation — see {@link JournalObservation}. */
|
|
51
|
+
export const CLIENT_HEALTH_OBSERVATION_FILE = "cli-client-health.observation.json";
|
|
52
|
+
/**
|
|
53
|
+
* How long one journal `lastSync` observation stays usable before an
|
|
54
|
+
* `invocation` heartbeat re-reads it from the engine.
|
|
55
|
+
*
|
|
56
|
+
* `listJournals()` is the correct enumeration, but it is NOT cheap: it returns
|
|
57
|
+
* fully materialized journals, so the engine opens every area of the local
|
|
58
|
+
* sync state store, replays each area's write-ahead log, and deep-clones the
|
|
59
|
+
* whole aggregate file table (`AreaLedger.read` → `readCached` →
|
|
60
|
+
* `primeAggregateCache` → `materializeArea`). On a controller with a large,
|
|
61
|
+
* un-compacted store that measured 21 s of CPU per `hq` invocation — for the
|
|
62
|
+
* ONE field this module actually consumes, `journal.lastSync`.
|
|
63
|
+
*
|
|
64
|
+
* Worse, that work is fully SYNCHRONOUS, so the `settleWithin` bound below
|
|
65
|
+
* cannot preempt it: its timer cannot be serviced until the work it is meant
|
|
66
|
+
* to bound has already finished. Every `hq` command awaits the preAction hook,
|
|
67
|
+
* so an expensive store turned a best-effort telemetry heartbeat into a
|
|
68
|
+
* multi-second stall on every single command, secrets fetches included.
|
|
69
|
+
*
|
|
70
|
+
* The heartbeat only needs a coarse "when did this installation last sync"
|
|
71
|
+
* signal, so one observation is reused for this window instead. Sync commands
|
|
72
|
+
* always re-observe — they have opened the store anyway, so it costs nothing.
|
|
73
|
+
*/
|
|
74
|
+
export const JOURNAL_OBSERVATION_TTL_MS = 15 * 60_000;
|
|
75
|
+
/** An observation lock older than this is treated as abandoned. */
|
|
76
|
+
export const JOURNAL_OBSERVATION_LOCK_STALE_MS = 5 * 60_000;
|
|
41
77
|
/**
|
|
42
78
|
* Pre-filter mirror of the contract's SemVer shape (the contract keeps its
|
|
43
79
|
* validators private). A version failing this is DROPPED individually so one
|
|
@@ -107,6 +143,38 @@ export function loadClientHealthState(stateDir) {
|
|
|
107
143
|
}
|
|
108
144
|
return state;
|
|
109
145
|
}
|
|
146
|
+
function observationFilePath(stateDir) {
|
|
147
|
+
return path.join(stateDir, CLIENT_HEALTH_OBSERVATION_FILE);
|
|
148
|
+
}
|
|
149
|
+
/** Load the cached journal observation; any problem reads as "none". */
|
|
150
|
+
export function loadJournalObservation(stateDir) {
|
|
151
|
+
let raw = {};
|
|
152
|
+
try {
|
|
153
|
+
const parsed = JSON.parse(fs.readFileSync(observationFilePath(stateDir), "utf-8"));
|
|
154
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
155
|
+
raw = parsed;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// Missing or corrupt: no usable observation.
|
|
160
|
+
}
|
|
161
|
+
const observation = {};
|
|
162
|
+
if (typeof raw.lastSyncAt === "string")
|
|
163
|
+
observation.lastSyncAt = raw.lastSyncAt;
|
|
164
|
+
if (typeof raw.observedAt === "string")
|
|
165
|
+
observation.observedAt = raw.observedAt;
|
|
166
|
+
return observation;
|
|
167
|
+
}
|
|
168
|
+
/** Best-effort persist — a read-only disk must never fail a command. */
|
|
169
|
+
export function persistJournalObservation(stateDir, observation) {
|
|
170
|
+
try {
|
|
171
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
172
|
+
fs.writeFileSync(observationFilePath(stateDir), `${JSON.stringify(observation, null, 2)}\n`, "utf-8");
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Best effort only.
|
|
176
|
+
}
|
|
177
|
+
}
|
|
110
178
|
/** Best-effort persist — a read-only disk must never fail a command. */
|
|
111
179
|
export function persistClientHealthState(stateDir, state) {
|
|
112
180
|
try {
|
|
@@ -165,6 +233,87 @@ export function latestJournalSyncTime(journals) {
|
|
|
165
233
|
}
|
|
166
234
|
return latest === null ? null : new Date(latest).toISOString();
|
|
167
235
|
}
|
|
236
|
+
/**
|
|
237
|
+
* True when this event kind must pay for a fresh `listJournals()` read.
|
|
238
|
+
*
|
|
239
|
+
* Sync events always do: `hq sync` has already opened the state store, so the
|
|
240
|
+
* marginal cost is nil and the resulting heartbeat is the one that most needs
|
|
241
|
+
* an exact timestamp. Plain invocations reuse the last observation until it
|
|
242
|
+
* ages past `ttlMs`, which is what keeps the enumeration off the hot path of
|
|
243
|
+
* every unrelated command.
|
|
244
|
+
*/
|
|
245
|
+
export function shouldObserveJournals(kind, observation, now, ttlMs) {
|
|
246
|
+
if (kind !== "invocation")
|
|
247
|
+
return true;
|
|
248
|
+
const observed = typeof observation.observedAt === "string"
|
|
249
|
+
? Date.parse(observation.observedAt)
|
|
250
|
+
: NaN;
|
|
251
|
+
if (!Number.isFinite(observed))
|
|
252
|
+
return true;
|
|
253
|
+
const age = now.getTime() - observed;
|
|
254
|
+
// A stamp from the future (clock step, shared home directory) is not
|
|
255
|
+
// evidence of a recent read — re-observe rather than trust it forever.
|
|
256
|
+
return age < 0 || age >= ttlMs;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Best-effort exclusive claim on the journal observation, returning a release
|
|
260
|
+
* function, or null when another process already holds it.
|
|
261
|
+
*
|
|
262
|
+
* Fleet workers spawn many `hq` processes at once. Without this, a burst that
|
|
263
|
+
* all see the same expired observation would all pay the full enumeration
|
|
264
|
+
* simultaneously — precisely the CPU storm this change exists to remove. Any
|
|
265
|
+
* failure here resolves toward "do not observe", which is the cheap and safe
|
|
266
|
+
* direction for best-effort telemetry.
|
|
267
|
+
*/
|
|
268
|
+
function readObservationLockOwner(lockPath) {
|
|
269
|
+
try {
|
|
270
|
+
return fs.readFileSync(lockPath, "utf-8");
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function acquireJournalObservationLock(stateDir, nowMs, staleMs) {
|
|
277
|
+
const lockPath = path.join(stateDir, CLIENT_HEALTH_OBSERVE_LOCK_FILE);
|
|
278
|
+
// Reclaim an abandoned lock — but only the EXACT one just inspected. The
|
|
279
|
+
// owner token is re-read immediately before the unlink and the removal is
|
|
280
|
+
// dropped if it changed. Unfenced, two processes arriving together on the
|
|
281
|
+
// same aged lock could both decide to remove it, the second deleting the
|
|
282
|
+
// first's freshly created lock, and both would then run the enumeration this
|
|
283
|
+
// lock exists to serialise.
|
|
284
|
+
const abandoned = readObservationLockOwner(lockPath);
|
|
285
|
+
if (abandoned !== null) {
|
|
286
|
+
try {
|
|
287
|
+
const age = nowMs - fs.statSync(lockPath).mtimeMs;
|
|
288
|
+
const stale = age >= staleMs || age < -staleMs;
|
|
289
|
+
if (stale && readObservationLockOwner(lockPath) === abandoned) {
|
|
290
|
+
fs.rmSync(lockPath, { force: true });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
// Vanished or unreadable mid-inspection: the exclusive create below is
|
|
295
|
+
// the real single-flight, so losing this race is safe.
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const owner = `${process.pid}:${randomBytes(8).toString("hex")}`;
|
|
299
|
+
try {
|
|
300
|
+
fs.writeFileSync(lockPath, owner, { flag: "wx" });
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
return () => {
|
|
306
|
+
try {
|
|
307
|
+
// Never remove a lock this process no longer owns.
|
|
308
|
+
if (readObservationLockOwner(lockPath) === owner) {
|
|
309
|
+
fs.rmSync(lockPath, { force: true });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
// Best effort; a leaked lock ages out via `staleMs`.
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
}
|
|
168
317
|
/**
|
|
169
318
|
* Build one contract-valid CLI heartbeat, or null when this environment
|
|
170
319
|
* cannot be represented in the closed contract enums (unknown platform/arch)
|
|
@@ -217,6 +366,10 @@ export function buildCliHeartbeat(input) {
|
|
|
217
366
|
if (lastSyncSuccessAt !== null) {
|
|
218
367
|
heartbeat.lastSyncSuccessAt = lastSyncSuccessAt;
|
|
219
368
|
}
|
|
369
|
+
if ((input.kind === "sync_success" || input.kind === "sync_failure") &&
|
|
370
|
+
input.localFilesOverview) {
|
|
371
|
+
heartbeat.localFilesOverview = input.localFilesOverview;
|
|
372
|
+
}
|
|
220
373
|
return heartbeat;
|
|
221
374
|
}
|
|
222
375
|
function latestIso(a, b) {
|
|
@@ -310,12 +463,60 @@ async function contributeClientHealthUnbounded(kind, deps) {
|
|
|
310
463
|
const sequence = nextHeartbeatSequence(state, now.getTime());
|
|
311
464
|
state.sequence = sequence;
|
|
312
465
|
persistClientHealthState(stateDir, state);
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
466
|
+
// Journal enumeration is the single most expensive thing this module can
|
|
467
|
+
// do — see JOURNAL_OBSERVATION_TTL_MS. Reuse the last observation unless
|
|
468
|
+
// this event kind requires a fresh one and no other process is taking it.
|
|
469
|
+
const observation = loadJournalObservation(stateDir);
|
|
470
|
+
let journalLastSyncAt = observation.lastSyncAt ?? null;
|
|
471
|
+
const ttlMs = deps.journalObservationTtlMs ?? JOURNAL_OBSERVATION_TTL_MS;
|
|
472
|
+
if (shouldObserveJournals(kind, observation, now, ttlMs)) {
|
|
473
|
+
const release = acquireJournalObservationLock(stateDir, now.getTime(), deps.journalObservationLockStaleMs ?? JOURNAL_OBSERVATION_LOCK_STALE_MS);
|
|
474
|
+
if (!release) {
|
|
475
|
+
// Another process is producing the authoritative observation right now.
|
|
476
|
+
// Emitting an invocation heartbeat here would carry a value already
|
|
477
|
+
// known to be stale under a HIGHER wall-clock sequence than the
|
|
478
|
+
// in-flight accurate one, and the server's monotonic gate would then
|
|
479
|
+
// discard the good heartbeat. Withhold instead — invocation heartbeats
|
|
480
|
+
// are best effort, and the next one reports the refreshed value. Sync
|
|
481
|
+
// OUTCOME heartbeats are never withheld; they carry a result.
|
|
482
|
+
if (kind === "invocation")
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
try {
|
|
487
|
+
const observed = latestJournalSyncTime((deps.journals ?? listJournals)());
|
|
488
|
+
if (observed !== null) {
|
|
489
|
+
journalLastSyncAt = observed;
|
|
490
|
+
observation.lastSyncAt = observed;
|
|
491
|
+
}
|
|
492
|
+
// Publish freshness only once the value behind it is ready. Stamping
|
|
493
|
+
// first would let a process that arrives mid-read treat the previous
|
|
494
|
+
// (or absent) value as current.
|
|
495
|
+
//
|
|
496
|
+
// A process that dies inside the engine therefore leaves no stamp —
|
|
497
|
+
// it leaves its LOCK, which holds contenders off until
|
|
498
|
+
// `journalObservationLockStaleMs`. That is what bounds retries of an
|
|
499
|
+
// enumeration already known to be pathological on this machine.
|
|
500
|
+
observation.observedAt = now.toISOString();
|
|
501
|
+
persistJournalObservation(stateDir, observation);
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
// Journal enumeration is best effort.
|
|
505
|
+
}
|
|
506
|
+
finally {
|
|
507
|
+
release();
|
|
508
|
+
}
|
|
509
|
+
}
|
|
316
510
|
}
|
|
317
|
-
|
|
318
|
-
|
|
511
|
+
let localFilesOverview = null;
|
|
512
|
+
if (kind === "sync_success" || kind === "sync_failure") {
|
|
513
|
+
try {
|
|
514
|
+
localFilesOverview = (deps.localFilesOverview ?? collectLocalFilesOverview)();
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
// Collection is best effort — never blocks or drops the heartbeat.
|
|
518
|
+
localFilesOverview = null;
|
|
519
|
+
}
|
|
319
520
|
}
|
|
320
521
|
const heartbeat = buildCliHeartbeat({
|
|
321
522
|
kind,
|
|
@@ -324,6 +525,7 @@ async function contributeClientHealthUnbounded(kind, deps) {
|
|
|
324
525
|
versions: (deps.versions ?? collectVersions)(),
|
|
325
526
|
journalLastSyncAt,
|
|
326
527
|
now,
|
|
528
|
+
localFilesOverview,
|
|
327
529
|
});
|
|
328
530
|
if (!heartbeat)
|
|
329
531
|
return;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local log/journal FILE METADATA overview (client-sync-health-control-plane
|
|
3
|
+
* US-016, hq-cli only).
|
|
4
|
+
*
|
|
5
|
+
* Support asked for a bounded overview of a user's local log/journal files
|
|
6
|
+
* so they can debug sync issues without a screenshot. This module collects
|
|
7
|
+
* ONLY closed-enum booleans and bounded integers — existence, size, age, and
|
|
8
|
+
* a capped count of recent error-marker lines — and NEVER a path, NEVER file
|
|
9
|
+
* content, NEVER a line of log text. The returned
|
|
10
|
+
* {@link ClientHealthLocalFilesOverview} is the entire surface that can ever
|
|
11
|
+
* cross the wire; there is no string field on it for anything sensitive to
|
|
12
|
+
* hide inside.
|
|
13
|
+
*
|
|
14
|
+
* Sources (metadata only, via `fs.stat` — full content is read only for the
|
|
15
|
+
* bounded error-line count below, and even then only a capped tail window,
|
|
16
|
+
* and even then only an integer count is ever returned):
|
|
17
|
+
* - the hq-sync desktop app's log file, `~/.hq/logs/hq-sync.log`
|
|
18
|
+
* - the CLI's own per-company sync journals, enumerated via `listJournals()`
|
|
19
|
+
* (never a hand-reconstructed path — see client-health.ts's note on the
|
|
20
|
+
* single-path-reconstruction regression this mirrors).
|
|
21
|
+
*
|
|
22
|
+
* Every fs operation is independently best-effort: a missing file, a
|
|
23
|
+
* permission-denied stat, or a file that disappears mid-read (TOCTOU race)
|
|
24
|
+
* degrades that one field to its "absent" value and never throws. Collection
|
|
25
|
+
* must never break a heartbeat, let alone a CLI command.
|
|
26
|
+
*/
|
|
27
|
+
import { type ClientHealthLocalFilesOverview } from "./client-health-contract.js";
|
|
28
|
+
/**
|
|
29
|
+
* Only the last N bytes of the sync log are read for error-line counting —
|
|
30
|
+
* this bounds memory/CPU regardless of how large the on-disk log has grown.
|
|
31
|
+
* Metadata (size/age) still reflects the FULL file via `fs.stat`.
|
|
32
|
+
*/
|
|
33
|
+
export declare const LOCAL_FILES_OVERVIEW_TAIL_READ_BYTES: number;
|
|
34
|
+
/**
|
|
35
|
+
* Count lines that look like an error marker in the last
|
|
36
|
+
* {@link LOCAL_FILES_OVERVIEW_TAIL_READ_BYTES} of `filePath`, capped at
|
|
37
|
+
* {@link CLIENT_HEALTH_MAX_ERROR_LINE_COUNT}. Returns 0 on any read failure
|
|
38
|
+
* (missing file, permission denied, race). Only the resulting integer is
|
|
39
|
+
* ever returned — the read buffer never leaves this function.
|
|
40
|
+
*/
|
|
41
|
+
export declare function countRecentErrorLines(filePath: string, maxBytes?: number, cap?: number): number;
|
|
42
|
+
export interface CollectLocalFilesOverviewDeps {
|
|
43
|
+
now?: () => Date;
|
|
44
|
+
homeDir?: () => string;
|
|
45
|
+
journals?: () => readonly {
|
|
46
|
+
path: string;
|
|
47
|
+
}[];
|
|
48
|
+
countErrorLines?: (filePath: string) => number;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Collect the bounded local-files overview for the current installation.
|
|
52
|
+
* NEVER throws. When per-company journals exist, `journalExists` is true if
|
|
53
|
+
* ANY journal file is present, sizes are summed (capped), and the age
|
|
54
|
+
* reflects the FRESHEST journal (smallest age) — the signal support cares
|
|
55
|
+
* about is "is this installation still writing journals at all."
|
|
56
|
+
*/
|
|
57
|
+
export declare function collectLocalFilesOverview(deps?: CollectLocalFilesOverviewDeps): ClientHealthLocalFilesOverview;
|
|
58
|
+
//# sourceMappingURL=local-files-overview.d.ts.map
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local log/journal FILE METADATA overview (client-sync-health-control-plane
|
|
3
|
+
* US-016, hq-cli only).
|
|
4
|
+
*
|
|
5
|
+
* Support asked for a bounded overview of a user's local log/journal files
|
|
6
|
+
* so they can debug sync issues without a screenshot. This module collects
|
|
7
|
+
* ONLY closed-enum booleans and bounded integers — existence, size, age, and
|
|
8
|
+
* a capped count of recent error-marker lines — and NEVER a path, NEVER file
|
|
9
|
+
* content, NEVER a line of log text. The returned
|
|
10
|
+
* {@link ClientHealthLocalFilesOverview} is the entire surface that can ever
|
|
11
|
+
* cross the wire; there is no string field on it for anything sensitive to
|
|
12
|
+
* hide inside.
|
|
13
|
+
*
|
|
14
|
+
* Sources (metadata only, via `fs.stat` — full content is read only for the
|
|
15
|
+
* bounded error-line count below, and even then only a capped tail window,
|
|
16
|
+
* and even then only an integer count is ever returned):
|
|
17
|
+
* - the hq-sync desktop app's log file, `~/.hq/logs/hq-sync.log`
|
|
18
|
+
* - the CLI's own per-company sync journals, enumerated via `listJournals()`
|
|
19
|
+
* (never a hand-reconstructed path — see client-health.ts's note on the
|
|
20
|
+
* single-path-reconstruction regression this mirrors).
|
|
21
|
+
*
|
|
22
|
+
* Every fs operation is independently best-effort: a missing file, a
|
|
23
|
+
* permission-denied stat, or a file that disappears mid-read (TOCTOU race)
|
|
24
|
+
* degrades that one field to its "absent" value and never throws. Collection
|
|
25
|
+
* must never break a heartbeat, let alone a CLI command.
|
|
26
|
+
*/
|
|
27
|
+
import * as fs from "node:fs";
|
|
28
|
+
import * as os from "node:os";
|
|
29
|
+
import * as path from "node:path";
|
|
30
|
+
import { listJournals } from "@indigoai-us/hq-cloud";
|
|
31
|
+
import { CLIENT_HEALTH_MAX_ERROR_LINE_COUNT, CLIENT_HEALTH_MAX_FILE_AGE_SECONDS, CLIENT_HEALTH_MAX_FILE_SIZE_BYTES, } from "./client-health-contract.js";
|
|
32
|
+
/**
|
|
33
|
+
* Only the last N bytes of the sync log are read for error-line counting —
|
|
34
|
+
* this bounds memory/CPU regardless of how large the on-disk log has grown.
|
|
35
|
+
* Metadata (size/age) still reflects the FULL file via `fs.stat`.
|
|
36
|
+
*/
|
|
37
|
+
export const LOCAL_FILES_OVERVIEW_TAIL_READ_BYTES = 2 * 1024 * 1024; // 2 MiB
|
|
38
|
+
/** Case-insensitive marker matched against each line (or JSON-lines `level`). */
|
|
39
|
+
const ERROR_MARKER = /error/i;
|
|
40
|
+
const JSON_LEVEL_ERROR = /"level"\s*:\s*"error"/i;
|
|
41
|
+
const ABSENT = { exists: false, sizeBytes: 0, ageSeconds: 0 };
|
|
42
|
+
function clampInt(value, max) {
|
|
43
|
+
if (!Number.isFinite(value) || value < 0)
|
|
44
|
+
return 0;
|
|
45
|
+
return Math.min(Math.floor(value), max);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Best-effort `fs.lstat`. Never throws: missing/denied/raced degrades to
|
|
49
|
+
* absent. Uses `lstat` (not `stat`) and rejects symlinks outright — a
|
|
50
|
+
* symlinked log/journal path could otherwise point anywhere on disk, and
|
|
51
|
+
* this module has no business following it (defense in depth: even though
|
|
52
|
+
* only bounded numbers ever leave this module, nothing here should read an
|
|
53
|
+
* arbitrary attacker- or misconfiguration-controlled file at all).
|
|
54
|
+
*/
|
|
55
|
+
function statFacts(filePath, nowMs) {
|
|
56
|
+
try {
|
|
57
|
+
const stat = fs.lstatSync(filePath);
|
|
58
|
+
if (!stat.isFile())
|
|
59
|
+
return ABSENT;
|
|
60
|
+
const ageSeconds = (nowMs - stat.mtimeMs) / 1000;
|
|
61
|
+
return {
|
|
62
|
+
exists: true,
|
|
63
|
+
sizeBytes: clampInt(stat.size, CLIENT_HEALTH_MAX_FILE_SIZE_BYTES),
|
|
64
|
+
ageSeconds: clampInt(ageSeconds, CLIENT_HEALTH_MAX_FILE_AGE_SECONDS),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return ABSENT;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** True only when `filePath` exists, is not a symlink, and is a regular file. */
|
|
72
|
+
function isSafeRegularFile(filePath) {
|
|
73
|
+
try {
|
|
74
|
+
return fs.lstatSync(filePath).isFile();
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Count lines that look like an error marker in the last
|
|
82
|
+
* {@link LOCAL_FILES_OVERVIEW_TAIL_READ_BYTES} of `filePath`, capped at
|
|
83
|
+
* {@link CLIENT_HEALTH_MAX_ERROR_LINE_COUNT}. Returns 0 on any read failure
|
|
84
|
+
* (missing file, permission denied, race). Only the resulting integer is
|
|
85
|
+
* ever returned — the read buffer never leaves this function.
|
|
86
|
+
*/
|
|
87
|
+
export function countRecentErrorLines(filePath, maxBytes = LOCAL_FILES_OVERVIEW_TAIL_READ_BYTES, cap = CLIENT_HEALTH_MAX_ERROR_LINE_COUNT) {
|
|
88
|
+
if (!isSafeRegularFile(filePath))
|
|
89
|
+
return 0;
|
|
90
|
+
let fd;
|
|
91
|
+
try {
|
|
92
|
+
fd = fs.openSync(filePath, "r");
|
|
93
|
+
const size = fs.fstatSync(fd).size;
|
|
94
|
+
const readSize = Math.min(size, maxBytes);
|
|
95
|
+
if (readSize <= 0)
|
|
96
|
+
return 0;
|
|
97
|
+
const start = size - readSize;
|
|
98
|
+
const buffer = Buffer.alloc(readSize);
|
|
99
|
+
fs.readSync(fd, buffer, 0, readSize, start);
|
|
100
|
+
const text = buffer.toString("utf-8");
|
|
101
|
+
let count = 0;
|
|
102
|
+
for (const line of text.split("\n")) {
|
|
103
|
+
if (count >= cap)
|
|
104
|
+
break;
|
|
105
|
+
if (ERROR_MARKER.test(line) || JSON_LEVEL_ERROR.test(line))
|
|
106
|
+
count += 1;
|
|
107
|
+
}
|
|
108
|
+
return Math.min(count, cap);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
if (fd !== undefined) {
|
|
115
|
+
try {
|
|
116
|
+
fs.closeSync(fd);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// Best effort only.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Collect the bounded local-files overview for the current installation.
|
|
126
|
+
* NEVER throws. When per-company journals exist, `journalExists` is true if
|
|
127
|
+
* ANY journal file is present, sizes are summed (capped), and the age
|
|
128
|
+
* reflects the FRESHEST journal (smallest age) — the signal support cares
|
|
129
|
+
* about is "is this installation still writing journals at all."
|
|
130
|
+
*/
|
|
131
|
+
export function collectLocalFilesOverview(deps = {}) {
|
|
132
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
133
|
+
const nowMs = now.getTime();
|
|
134
|
+
const homeDir = (deps.homeDir ?? os.homedir)();
|
|
135
|
+
const countErrorLines = deps.countErrorLines ?? countRecentErrorLines;
|
|
136
|
+
const syncLogPath = path.join(homeDir, ".hq", "logs", "hq-sync.log");
|
|
137
|
+
const syncLogFacts = statFacts(syncLogPath, nowMs);
|
|
138
|
+
const recentErrorLineCount = syncLogFacts.exists ? countErrorLines(syncLogPath) : 0;
|
|
139
|
+
let journalEntries;
|
|
140
|
+
try {
|
|
141
|
+
journalEntries = (deps.journals ?? listJournals)();
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// Journal enumeration is best effort — mirrors client-health.ts.
|
|
145
|
+
journalEntries = [];
|
|
146
|
+
}
|
|
147
|
+
let journalExists = false;
|
|
148
|
+
let journalSizeBytesTotal = 0;
|
|
149
|
+
let journalAgeSecondsMin;
|
|
150
|
+
for (const entry of journalEntries) {
|
|
151
|
+
const facts = statFacts(entry.path, nowMs);
|
|
152
|
+
if (!facts.exists)
|
|
153
|
+
continue;
|
|
154
|
+
journalExists = true;
|
|
155
|
+
journalSizeBytesTotal += facts.sizeBytes;
|
|
156
|
+
if (journalAgeSecondsMin === undefined || facts.ageSeconds < journalAgeSecondsMin) {
|
|
157
|
+
journalAgeSecondsMin = facts.ageSeconds;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
syncLogExists: syncLogFacts.exists,
|
|
162
|
+
syncLogSizeBytes: syncLogFacts.sizeBytes,
|
|
163
|
+
syncLogAgeSeconds: syncLogFacts.ageSeconds,
|
|
164
|
+
journalExists,
|
|
165
|
+
journalSizeBytes: clampInt(journalSizeBytesTotal, CLIENT_HEALTH_MAX_FILE_SIZE_BYTES),
|
|
166
|
+
journalAgeSeconds: clampInt(journalAgeSecondsMin ?? 0, CLIENT_HEALTH_MAX_FILE_AGE_SECONDS),
|
|
167
|
+
recentErrorLineCount: clampInt(recentErrorLineCount, CLIENT_HEALTH_MAX_ERROR_LINE_COUNT),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
//# sourceMappingURL=local-files-overview.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.108.0",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"@aws-sdk/client-iot-data-plane": "^3.1096.0",
|
|
32
32
|
"@aws-sdk/client-s3": "^3.1049.0",
|
|
33
33
|
"@indigoai-us/hq-cloud": "~6.16.6",
|
|
34
|
+
"@indigoai-us/hq-flags-client": "^0.1.2",
|
|
34
35
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
35
36
|
"@sentry/node": "^10.49.0",
|
|
36
37
|
"@tobilu/qmd": "2.5.3",
|