@indigoai-us/hq-cli 5.107.0 → 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 +16 -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/commands/secrets.d.ts +1 -0
- package/dist/commands/secrets.js +28 -2
- 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/run/hq-plugin.js +1 -1
- 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/dist/utils/secrets-cache.d.ts +8 -2
- package/dist/utils/secrets-cache.js +150 -34
- package/package.json +2 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-wide, fail-open bridge to the HQ flag registry.
|
|
3
|
+
*
|
|
4
|
+
* Command gates read an in-memory snapshot synchronously. The first refresh is
|
|
5
|
+
* isolated in an unref'd worker because an unawaited Node fetch can otherwise
|
|
6
|
+
* keep a short-lived, offline CLI alive until the client's network deadline.
|
|
7
|
+
*/
|
|
8
|
+
import { Worker } from "node:worker_threads";
|
|
9
|
+
import { isFlagSnapshot, resolveFlag, } from "@indigoai-us/hq-flags-client";
|
|
10
|
+
/** Dedicated endpoint configuration; this is intentionally not HQ_PRO_API_URL. */
|
|
11
|
+
export const FLAG_REGISTRY_ENDPOINT_ENV = "HQ_FLAGS_API_URL";
|
|
12
|
+
let heldSnapshot = null;
|
|
13
|
+
let processReader = null;
|
|
14
|
+
function processSnapshotReader() {
|
|
15
|
+
return {
|
|
16
|
+
isEnabled(flagKey, lookup = {}) {
|
|
17
|
+
return resolveFlag({
|
|
18
|
+
flagKey,
|
|
19
|
+
env: process.env,
|
|
20
|
+
snapshot: heldSnapshot,
|
|
21
|
+
companyIdentifiers: [],
|
|
22
|
+
lookup,
|
|
23
|
+
}).value;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Begin one registry refresh without awaiting it or retaining the CLI process.
|
|
29
|
+
*
|
|
30
|
+
* The worker owns cached-token I/O, token use, and the potentially slow fetch.
|
|
31
|
+
* Its response is accepted only after the client has validated it as a flag
|
|
32
|
+
* snapshot. Consequently, every command path is an immediate in-memory read:
|
|
33
|
+
* a missing, unauthenticated, slow, or offline worker leaves `heldSnapshot`
|
|
34
|
+
* null and the caller's exact legacy fallback decides the gate.
|
|
35
|
+
*/
|
|
36
|
+
export function kickFlagRegistryReadiness(dependencies = {}) {
|
|
37
|
+
if (processReader)
|
|
38
|
+
return processReader;
|
|
39
|
+
const endpoint = (dependencies.endpoint ?? process.env[FLAG_REGISTRY_ENDPOINT_ENV] ?? "").trim();
|
|
40
|
+
if (!endpoint)
|
|
41
|
+
return null;
|
|
42
|
+
processReader = processSnapshotReader();
|
|
43
|
+
try {
|
|
44
|
+
const worker = (dependencies.createWorker ?? ((filename, options) => new Worker(filename, options)))(new URL("./flag-registry-worker.js", import.meta.url), { workerData: { endpoint } });
|
|
45
|
+
worker.on("message", (value) => {
|
|
46
|
+
if (isFlagSnapshot(value))
|
|
47
|
+
heldSnapshot = value;
|
|
48
|
+
});
|
|
49
|
+
// A failed background refresh must stay invisible to an offline CLI.
|
|
50
|
+
worker.on("error", () => { });
|
|
51
|
+
// Attach listeners first: Worker.on() re-refs the worker, so unref must be
|
|
52
|
+
// last for the parent process to remain free to exit immediately.
|
|
53
|
+
worker.unref();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Construction can fail in restricted Node runtimes. The reader is still
|
|
57
|
+
// safe: it has no snapshot, so it resolves through the legacy vocabulary.
|
|
58
|
+
}
|
|
59
|
+
return processReader;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Evaluate a registry-backed gate without changing its outage default.
|
|
63
|
+
* `isEnabled` itself is synchronous and snapshot-only; any reader failure
|
|
64
|
+
* falls through to the exact legacy predicate owned by the caller.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveFlagGate(reader, flagKey, lookup, legacyFallback) {
|
|
67
|
+
if (!reader)
|
|
68
|
+
return legacyFallback();
|
|
69
|
+
try {
|
|
70
|
+
return reader.isEnabled(flagKey, lookup);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return legacyFallback();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Resolve a process gate against the shared snapshot reader, when configured. */
|
|
77
|
+
export function resolveProcessFlagGate(flagKey, lookup, legacyFallback) {
|
|
78
|
+
return resolveFlagGate(processReader, flagKey, lookup, legacyFallback);
|
|
79
|
+
}
|
|
80
|
+
/** Test-only reset for process-global readiness state. */
|
|
81
|
+
export function _resetFlagRegistryForTests() {
|
|
82
|
+
heldSnapshot = null;
|
|
83
|
+
processReader = null;
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=flag-registry.js.map
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* - the env var `HQ_SYNC_NARROW_HINT=off` is set,
|
|
23
23
|
* - the per-hqRoot CLI config (`<hqRoot>/.hq/config.json`) has
|
|
24
24
|
* `syncNarrowHint: 'off'`,
|
|
25
|
+
* - the synced flag registry disables `sync.narrow-hint`,
|
|
25
26
|
* - or the same `{companyUid, level}` pair has already been shown this
|
|
26
27
|
* process (module-singleton dedupe; the runner imports the same module
|
|
27
28
|
* once per `hq` invocation so a single invocation prints at most one
|
|
@@ -46,6 +47,7 @@
|
|
|
46
47
|
* `syncNarrowHintMinBytes` — see `resolveNarrowHintMinBytes`.
|
|
47
48
|
*/
|
|
48
49
|
import * as fs from "node:fs";
|
|
50
|
+
import { type FlagReader } from "./flag-registry.js";
|
|
49
51
|
export type BannerLevel = "hint" | "warning" | "strict";
|
|
50
52
|
/**
|
|
51
53
|
* Default size gate for the narrow-mode nudge: 5 GiB. A local company folder
|
|
@@ -85,6 +87,8 @@ export interface ShouldShowBannerOpts {
|
|
|
85
87
|
readFile?: (p: string) => string;
|
|
86
88
|
/** Test seam: override `fs.existsSync`. */
|
|
87
89
|
existsFile?: (p: string) => boolean;
|
|
90
|
+
/** Test seam: held registry snapshot reader. */
|
|
91
|
+
flagReader?: FlagReader;
|
|
88
92
|
}
|
|
89
93
|
/**
|
|
90
94
|
* Decides whether a banner should be printed AT ALL — independent of
|
|
@@ -150,7 +154,14 @@ export declare function companyFolderExceedsThreshold(companyDir: string, thresh
|
|
|
150
154
|
* `companyFolderExceedsThreshold`). A strict-level all-mode membership whose
|
|
151
155
|
* folder is under the threshold is never refused.
|
|
152
156
|
*/
|
|
153
|
-
export declare function isStrictRefusal(syncMode: BannerInput["syncMode"], level: BannerLevel): boolean;
|
|
157
|
+
export declare function isStrictRefusal(syncMode: BannerInput["syncMode"], level: BannerLevel, flagReader?: FlagReader): boolean;
|
|
158
|
+
/**
|
|
159
|
+
* Keep the rendered banner honest about the decision that this invocation
|
|
160
|
+
* actually made. Hint and warning remain local presentation choices. `strict`
|
|
161
|
+
* is reserved for a real refusal, so a registry opt-out of an old strict level
|
|
162
|
+
* degrades to the local warning presentation instead of claiming a block.
|
|
163
|
+
*/
|
|
164
|
+
export declare function resolveNarrowHintPresentationLevel(level: BannerLevel, strictRefusal: boolean): BannerLevel;
|
|
154
165
|
/**
|
|
155
166
|
* Emit a one-time-per-{company,level} banner. Writes to `stderr` by
|
|
156
167
|
* default; tests inject a sink. Idempotent — repeated calls with the
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* - the env var `HQ_SYNC_NARROW_HINT=off` is set,
|
|
23
23
|
* - the per-hqRoot CLI config (`<hqRoot>/.hq/config.json`) has
|
|
24
24
|
* `syncNarrowHint: 'off'`,
|
|
25
|
+
* - the synced flag registry disables `sync.narrow-hint`,
|
|
25
26
|
* - or the same `{companyUid, level}` pair has already been shown this
|
|
26
27
|
* process (module-singleton dedupe; the runner imports the same module
|
|
27
28
|
* once per `hq` invocation so a single invocation prints at most one
|
|
@@ -48,6 +49,7 @@
|
|
|
48
49
|
import chalk from "chalk";
|
|
49
50
|
import * as fs from "node:fs";
|
|
50
51
|
import * as path from "node:path";
|
|
52
|
+
import { resolveFlagGate, resolveProcessFlagGate, } from "./flag-registry.js";
|
|
51
53
|
/**
|
|
52
54
|
* Default size gate for the narrow-mode nudge: 5 GiB. A local company folder
|
|
53
55
|
* smaller than this is cheap to keep in full, so all-mode is left alone and no
|
|
@@ -99,7 +101,21 @@ export function shouldShowBanner(opts = {}) {
|
|
|
99
101
|
}
|
|
100
102
|
}
|
|
101
103
|
}
|
|
102
|
-
|
|
104
|
+
const lookup = {
|
|
105
|
+
globalEnvVar: "HQ_SYNC_NARROW_HINT",
|
|
106
|
+
globalValueSemantics: {
|
|
107
|
+
onValues: [],
|
|
108
|
+
offValues: ["off"],
|
|
109
|
+
unrecognizedValue: true,
|
|
110
|
+
unsetValue: true,
|
|
111
|
+
// Deliberately no `trim`: the legacy parser only case-folds.
|
|
112
|
+
caseInsensitive: true,
|
|
113
|
+
},
|
|
114
|
+
fallback: true,
|
|
115
|
+
};
|
|
116
|
+
return opts.flagReader
|
|
117
|
+
? resolveFlagGate(opts.flagReader, "sync.narrow-hint", lookup, () => true)
|
|
118
|
+
: resolveProcessFlagGate("sync.narrow-hint", lookup, () => true);
|
|
103
119
|
}
|
|
104
120
|
/**
|
|
105
121
|
* Resolve the banner level from environment overrides. Defaults to
|
|
@@ -230,8 +246,34 @@ export function companyFolderExceedsThreshold(companyDir, thresholdBytes, deps =
|
|
|
230
246
|
* `companyFolderExceedsThreshold`). A strict-level all-mode membership whose
|
|
231
247
|
* folder is under the threshold is never refused.
|
|
232
248
|
*/
|
|
233
|
-
export function isStrictRefusal(syncMode, level) {
|
|
234
|
-
|
|
249
|
+
export function isStrictRefusal(syncMode, level, flagReader) {
|
|
250
|
+
if (syncMode !== "all")
|
|
251
|
+
return false;
|
|
252
|
+
const lookup = {
|
|
253
|
+
globalEnvVar: "HQ_SYNC_NARROW_HINT_LEVEL",
|
|
254
|
+
globalValueSemantics: {
|
|
255
|
+
onValues: ["strict"],
|
|
256
|
+
offValues: ["hint", "warning"],
|
|
257
|
+
unrecognizedValue: false,
|
|
258
|
+
unsetValue: false,
|
|
259
|
+
caseInsensitive: true,
|
|
260
|
+
},
|
|
261
|
+
fallback: level === "strict",
|
|
262
|
+
};
|
|
263
|
+
return flagReader
|
|
264
|
+
? resolveFlagGate(flagReader, "sync.narrow-hint-strict", lookup, () => level === "strict")
|
|
265
|
+
: resolveProcessFlagGate("sync.narrow-hint-strict", lookup, () => level === "strict");
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Keep the rendered banner honest about the decision that this invocation
|
|
269
|
+
* actually made. Hint and warning remain local presentation choices. `strict`
|
|
270
|
+
* is reserved for a real refusal, so a registry opt-out of an old strict level
|
|
271
|
+
* degrades to the local warning presentation instead of claiming a block.
|
|
272
|
+
*/
|
|
273
|
+
export function resolveNarrowHintPresentationLevel(level, strictRefusal) {
|
|
274
|
+
if (strictRefusal)
|
|
275
|
+
return "strict";
|
|
276
|
+
return level === "strict" ? "warning" : level;
|
|
235
277
|
}
|
|
236
278
|
/**
|
|
237
279
|
* Emit a one-time-per-{company,level} banner. Writes to `stderr` by
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* Additive only — never throws, never touches `process.exitCode`, never
|
|
16
16
|
* writes to stdout. Env off-switch: `HQ_NO_PLAN_LIMIT_NAG=1`.
|
|
17
17
|
*/
|
|
18
|
+
import { type FlagReader } from "./flag-registry.js";
|
|
18
19
|
export declare const PLAN_LIMIT_UPGRADE_URL = "https://app.indigo-hq.com/billing/upgrade";
|
|
19
20
|
export interface PlanLimitEntry {
|
|
20
21
|
used: number;
|
|
@@ -39,6 +40,8 @@ export declare function emitPlanLimitNag(opts?: {
|
|
|
39
40
|
write?: (s: string) => void;
|
|
40
41
|
now?: () => Date;
|
|
41
42
|
statePath?: string;
|
|
43
|
+
/** Test seam: held registry snapshot reader. */
|
|
44
|
+
flagReader?: FlagReader;
|
|
42
45
|
}): void;
|
|
43
46
|
/** Test-only helper — clears last-seen status and session dedupe flags. */
|
|
44
47
|
export declare function _resetForTests(): void;
|
|
@@ -19,6 +19,7 @@ import chalk from "chalk";
|
|
|
19
19
|
import * as fs from "node:fs";
|
|
20
20
|
import * as os from "node:os";
|
|
21
21
|
import * as path from "node:path";
|
|
22
|
+
import { resolveFlagGate, resolveProcessFlagGate, } from "./flag-registry.js";
|
|
22
23
|
export const PLAN_LIMIT_UPGRADE_URL = "https://app.indigo-hq.com/billing/upgrade";
|
|
23
24
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
24
25
|
/** Module-level last-seen cell — overwritten by each successful parse. */
|
|
@@ -33,6 +34,22 @@ function defaultStatePath() {
|
|
|
33
34
|
function isOptedOut() {
|
|
34
35
|
return process.env.HQ_NO_PLAN_LIMIT_NAG === "1";
|
|
35
36
|
}
|
|
37
|
+
function isPlanLimitNagEnabled(flagReader) {
|
|
38
|
+
const lookup = {
|
|
39
|
+
globalEnvVar: "HQ_NO_PLAN_LIMIT_NAG",
|
|
40
|
+
globalValueSemantics: {
|
|
41
|
+
onValues: [],
|
|
42
|
+
offValues: ["1"],
|
|
43
|
+
unrecognizedValue: true,
|
|
44
|
+
unsetValue: true,
|
|
45
|
+
},
|
|
46
|
+
fallback: true,
|
|
47
|
+
};
|
|
48
|
+
const legacyFallback = () => !isOptedOut();
|
|
49
|
+
return flagReader
|
|
50
|
+
? resolveFlagGate(flagReader, "cli.plan-limit-nag", lookup, legacyFallback)
|
|
51
|
+
: resolveProcessFlagGate("cli.plan-limit-nag", lookup, legacyFallback);
|
|
52
|
+
}
|
|
36
53
|
/**
|
|
37
54
|
* Defensively parse a single planLimits entry. Returns null if the shape is
|
|
38
55
|
* not well-formed (non-numeric used/limit, non-boolean over, etc.).
|
|
@@ -164,7 +181,7 @@ function buildOverBox(overEntries) {
|
|
|
164
181
|
*/
|
|
165
182
|
export function emitPlanLimitNag(opts = {}) {
|
|
166
183
|
try {
|
|
167
|
-
if (
|
|
184
|
+
if (!isPlanLimitNagEnabled(opts.flagReader))
|
|
168
185
|
return;
|
|
169
186
|
if (lastSeen === null)
|
|
170
187
|
return;
|
package/dist/main.js
CHANGED
|
@@ -93,6 +93,7 @@ import { emitCliSessionStarted } from "./utils/cli-telemetry.js";
|
|
|
93
93
|
import { reportCliClientHealthInvocation } from "./utils/client-health.js";
|
|
94
94
|
import { settleWithin } from "./utils/settle-with-timeout.js";
|
|
95
95
|
import { emitPlanLimitNag } from "./lib/plan-limit-nag.js";
|
|
96
|
+
import { kickFlagRegistryReadiness } from "./lib/flag-registry.js";
|
|
96
97
|
import { isPackageRootResolutionError, packageRootCaptureContext, } from "./utils/package-root-diagnostics.js";
|
|
97
98
|
import { fallbackOperatorMessage, unexpectedCliErrorMessage } from "./utils/unexpected-cli-error.js";
|
|
98
99
|
/** Hard upper bound for non-user-visible release-health finalization. */
|
|
@@ -290,14 +291,22 @@ registerDoctorCommand(program);
|
|
|
290
291
|
// from `hq doctor` (hook guardrails). Does not start MQTT listen.
|
|
291
292
|
registerMeshCommand(program);
|
|
292
293
|
program.hook("preAction", async () => {
|
|
293
|
-
// Both are best-effort
|
|
294
|
-
//
|
|
294
|
+
// Both are best-effort and fully swallowed: neither can change the command's
|
|
295
|
+
// result or exit code. The 1.2s bound they carry is a TIMER, so it only
|
|
296
|
+
// preempts asynchronous work — synchronous work inside them runs to
|
|
297
|
+
// completion regardless, because the timer cannot be serviced while the
|
|
298
|
+
// event loop is blocked. Keep anything added here asynchronous, or
|
|
299
|
+
// separately cheap: this hook is on the path of EVERY hq command.
|
|
295
300
|
await Promise.all([
|
|
296
301
|
emitCliSessionStarted(),
|
|
297
302
|
reportCliClientHealthInvocation(),
|
|
298
303
|
]);
|
|
299
304
|
});
|
|
300
305
|
export async function runCli() {
|
|
306
|
+
// Begin one registry refresh without awaiting it. Every command gate reads
|
|
307
|
+
// only the client's held snapshot and falls back locally, so an offline or
|
|
308
|
+
// slow registry cannot delay command parsing or alter a command failure.
|
|
309
|
+
void kickFlagRegistryReadiness();
|
|
301
310
|
// Set when a self-update re-exec'd this command on a newer CLI: the child
|
|
302
311
|
// already did the work, so this process only has to carry its exit status
|
|
303
312
|
// out (after the finally block's telemetry, hence not process.exit here).
|
package/dist/run/hq-plugin.js
CHANGED
|
@@ -181,7 +181,7 @@ export async function prewarmHqSecrets(graph /* EnvGraph */, opts, state) {
|
|
|
181
181
|
state.loadedSecretsByName.set(s.name, s.value);
|
|
182
182
|
const cacheTtlMs = normalizeCacheTtlMs(s);
|
|
183
183
|
if (cacheTtlMs > 0) {
|
|
184
|
-
writeCache(uid, s.name, s.value, cacheTtlMs);
|
|
184
|
+
writeCache(uid, s.name, s.value, cacheTtlMs, s.version);
|
|
185
185
|
}
|
|
186
186
|
else {
|
|
187
187
|
removeCacheEntry(uid, s.name);
|
|
@@ -75,6 +75,15 @@ export declare const CLIENT_HEALTH_MAX_STRING_LENGTH = 64;
|
|
|
75
75
|
export declare const CLIENT_HEALTH_MAX_CONSECUTIVE_FAILURES = 100000;
|
|
76
76
|
export declare const CLIENT_HEALTH_MAX_CONFLICT_COUNT = 100000;
|
|
77
77
|
export declare const CLIENT_HEALTH_MAX_CHECKS = 16;
|
|
78
|
+
/**
|
|
79
|
+
* Local-files-overview bounds (client-sync-health-control-plane hq-cli
|
|
80
|
+
* addition, US-016). These cap the closed set of bounded facts support sees
|
|
81
|
+
* about a user's local log/journal FILES — never a path, never file content,
|
|
82
|
+
* never a log line of text. See {@link ClientHealthLocalFilesOverview}.
|
|
83
|
+
*/
|
|
84
|
+
export declare const CLIENT_HEALTH_MAX_ERROR_LINE_COUNT = 50;
|
|
85
|
+
export declare const CLIENT_HEALTH_MAX_FILE_SIZE_BYTES = 1073741824;
|
|
86
|
+
export declare const CLIENT_HEALTH_MAX_FILE_AGE_SECONDS = 315360000;
|
|
78
87
|
/**
|
|
79
88
|
* The four client versions. All optional: a CLI-only installation has no
|
|
80
89
|
* desktop/syncRunner version, and older clients may omit any of them.
|
|
@@ -85,6 +94,25 @@ export interface ClientHealthVersions {
|
|
|
85
94
|
core?: string;
|
|
86
95
|
syncRunner?: string;
|
|
87
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Closed, bounded-fact summary of the user's local log/journal FILES —
|
|
99
|
+
* existence, size, age, and a capped count of recent error-marker lines.
|
|
100
|
+
* NEVER a path, NEVER file content, NEVER a line of log text: every field is
|
|
101
|
+
* a boolean or a bounded integer (see the `CLIENT_HEALTH_MAX_*` bounds).
|
|
102
|
+
* Attached to sync-outcome heartbeats (US-016) so support can see "does this
|
|
103
|
+
* installation even have a log, and does it look like it's erroring" without
|
|
104
|
+
* a customer screenshot.
|
|
105
|
+
*/
|
|
106
|
+
export interface ClientHealthLocalFilesOverview {
|
|
107
|
+
syncLogExists: boolean;
|
|
108
|
+
syncLogSizeBytes: number;
|
|
109
|
+
syncLogAgeSeconds: number;
|
|
110
|
+
journalExists: boolean;
|
|
111
|
+
journalSizeBytes: number;
|
|
112
|
+
journalAgeSeconds: number;
|
|
113
|
+
/** Capped at {@link CLIENT_HEALTH_MAX_ERROR_LINE_COUNT}. */
|
|
114
|
+
recentErrorLineCount: number;
|
|
115
|
+
}
|
|
88
116
|
export interface ClientHealthHeartbeat {
|
|
89
117
|
contractVersion: number;
|
|
90
118
|
/** Stable random installation identity — NOT a hardware fingerprint. */
|
|
@@ -106,6 +134,8 @@ export interface ClientHealthHeartbeat {
|
|
|
106
134
|
conflictCount?: number;
|
|
107
135
|
updaterState?: ClientHealthUpdaterState;
|
|
108
136
|
failureReason?: ClientHealthFailureReason;
|
|
137
|
+
/** Absent on older clients / non-sync-outcome events. See {@link ClientHealthLocalFilesOverview}. */
|
|
138
|
+
localFilesOverview?: ClientHealthLocalFilesOverview;
|
|
109
139
|
}
|
|
110
140
|
export interface ClientHealthCheckResult {
|
|
111
141
|
check: ClientHealthDiagnosticCheck;
|
|
@@ -120,6 +120,15 @@ export const CLIENT_HEALTH_MAX_STRING_LENGTH = 64;
|
|
|
120
120
|
export const CLIENT_HEALTH_MAX_CONSECUTIVE_FAILURES = 100_000;
|
|
121
121
|
export const CLIENT_HEALTH_MAX_CONFLICT_COUNT = 100_000;
|
|
122
122
|
export const CLIENT_HEALTH_MAX_CHECKS = 16;
|
|
123
|
+
/**
|
|
124
|
+
* Local-files-overview bounds (client-sync-health-control-plane hq-cli
|
|
125
|
+
* addition, US-016). These cap the closed set of bounded facts support sees
|
|
126
|
+
* about a user's local log/journal FILES — never a path, never file content,
|
|
127
|
+
* never a log line of text. See {@link ClientHealthLocalFilesOverview}.
|
|
128
|
+
*/
|
|
129
|
+
export const CLIENT_HEALTH_MAX_ERROR_LINE_COUNT = 50;
|
|
130
|
+
export const CLIENT_HEALTH_MAX_FILE_SIZE_BYTES = 1_073_741_824; // 1 GiB
|
|
131
|
+
export const CLIENT_HEALTH_MAX_FILE_AGE_SECONDS = 315_360_000; // 10 years
|
|
123
132
|
export class ClientHealthContractError extends Error {
|
|
124
133
|
code;
|
|
125
134
|
field;
|
|
@@ -194,6 +203,13 @@ function assertBoundedInt(field, value, max) {
|
|
|
194
203
|
throw new ClientHealthContractError("OUT_OF_BOUNDS", field);
|
|
195
204
|
return value;
|
|
196
205
|
}
|
|
206
|
+
function assertBoolean(field, value) {
|
|
207
|
+
if (value === undefined || value === null)
|
|
208
|
+
throw new ClientHealthContractError("MISSING_FIELD", field);
|
|
209
|
+
if (typeof value !== "boolean")
|
|
210
|
+
throw new ClientHealthContractError("INVALID_TYPE", field, "expected boolean");
|
|
211
|
+
return value;
|
|
212
|
+
}
|
|
197
213
|
function asRecord(field, value) {
|
|
198
214
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
199
215
|
throw new ClientHealthContractError("INVALID_TYPE", field, "expected object");
|
|
@@ -246,8 +262,29 @@ export function parseClientHealthHeartbeat(input) {
|
|
|
246
262
|
if (raw.failureReason !== undefined) {
|
|
247
263
|
heartbeat.failureReason = assertEnum("failureReason", raw.failureReason, CLIENT_HEALTH_FAILURE_REASONS);
|
|
248
264
|
}
|
|
265
|
+
if (raw.localFilesOverview !== undefined) {
|
|
266
|
+
heartbeat.localFilesOverview = parseClientHealthLocalFilesOverview("localFilesOverview", raw.localFilesOverview);
|
|
267
|
+
}
|
|
249
268
|
return heartbeat;
|
|
250
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* Parse + validate a {@link ClientHealthLocalFilesOverview}. Every field is a
|
|
272
|
+
* boolean or a bounded integer — there is no string field to be path- or
|
|
273
|
+
* content-shaped, so this fails closed purely on type/bounds, same as any
|
|
274
|
+
* other bounded-fact block in this contract.
|
|
275
|
+
*/
|
|
276
|
+
function parseClientHealthLocalFilesOverview(field, input) {
|
|
277
|
+
const raw = asRecord(field, input);
|
|
278
|
+
return {
|
|
279
|
+
syncLogExists: assertBoolean(`${field}.syncLogExists`, raw.syncLogExists),
|
|
280
|
+
syncLogSizeBytes: assertBoundedInt(`${field}.syncLogSizeBytes`, raw.syncLogSizeBytes, CLIENT_HEALTH_MAX_FILE_SIZE_BYTES),
|
|
281
|
+
syncLogAgeSeconds: assertBoundedInt(`${field}.syncLogAgeSeconds`, raw.syncLogAgeSeconds, CLIENT_HEALTH_MAX_FILE_AGE_SECONDS),
|
|
282
|
+
journalExists: assertBoolean(`${field}.journalExists`, raw.journalExists),
|
|
283
|
+
journalSizeBytes: assertBoundedInt(`${field}.journalSizeBytes`, raw.journalSizeBytes, CLIENT_HEALTH_MAX_FILE_SIZE_BYTES),
|
|
284
|
+
journalAgeSeconds: assertBoundedInt(`${field}.journalAgeSeconds`, raw.journalAgeSeconds, CLIENT_HEALTH_MAX_FILE_AGE_SECONDS),
|
|
285
|
+
recentErrorLineCount: assertBoundedInt(`${field}.recentErrorLineCount`, raw.recentErrorLineCount, CLIENT_HEALTH_MAX_ERROR_LINE_COUNT),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
251
288
|
/** Parse + validate one diagnostic/repair command receipt. Unknown kinds fail closed. */
|
|
252
289
|
export function parseClientHealthCommandReceipt(input) {
|
|
253
290
|
const raw = asRecord("receipt", input);
|
|
@@ -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
|
|
@@ -29,10 +32,42 @@
|
|
|
29
32
|
*/
|
|
30
33
|
import { isExpiring, loadCachedTokens } from "./cognito-session.js";
|
|
31
34
|
import { type VersionInfo } from "./feedback-versions.js";
|
|
32
|
-
import { type ClientHealthArch, type ClientHealthHeartbeat, type ClientHealthPlatform } from "./client-health-contract.js";
|
|
35
|
+
import { type ClientHealthArch, type ClientHealthHeartbeat, type ClientHealthLocalFilesOverview, type ClientHealthPlatform } from "./client-health-contract.js";
|
|
33
36
|
/** Same bound as CLI telemetry: a heartbeat may never stall a command. */
|
|
34
37
|
export declare const CLIENT_HEALTH_TIMEOUT_MS = 1200;
|
|
35
38
|
export declare const CLIENT_HEALTH_STATE_FILE = "cli-client-health.json";
|
|
39
|
+
/**
|
|
40
|
+
* Single-flight marker for the journal observation below. Held only for the
|
|
41
|
+
* duration of one `listJournals()` call.
|
|
42
|
+
*/
|
|
43
|
+
export declare const CLIENT_HEALTH_OBSERVE_LOCK_FILE = "cli-client-health.observe.lock";
|
|
44
|
+
/** Cached journal observation — see {@link JournalObservation}. */
|
|
45
|
+
export declare const CLIENT_HEALTH_OBSERVATION_FILE = "cli-client-health.observation.json";
|
|
46
|
+
/**
|
|
47
|
+
* How long one journal `lastSync` observation stays usable before an
|
|
48
|
+
* `invocation` heartbeat re-reads it from the engine.
|
|
49
|
+
*
|
|
50
|
+
* `listJournals()` is the correct enumeration, but it is NOT cheap: it returns
|
|
51
|
+
* fully materialized journals, so the engine opens every area of the local
|
|
52
|
+
* sync state store, replays each area's write-ahead log, and deep-clones the
|
|
53
|
+
* whole aggregate file table (`AreaLedger.read` → `readCached` →
|
|
54
|
+
* `primeAggregateCache` → `materializeArea`). On a controller with a large,
|
|
55
|
+
* un-compacted store that measured 21 s of CPU per `hq` invocation — for the
|
|
56
|
+
* ONE field this module actually consumes, `journal.lastSync`.
|
|
57
|
+
*
|
|
58
|
+
* Worse, that work is fully SYNCHRONOUS, so the `settleWithin` bound below
|
|
59
|
+
* cannot preempt it: its timer cannot be serviced until the work it is meant
|
|
60
|
+
* to bound has already finished. Every `hq` command awaits the preAction hook,
|
|
61
|
+
* so an expensive store turned a best-effort telemetry heartbeat into a
|
|
62
|
+
* multi-second stall on every single command, secrets fetches included.
|
|
63
|
+
*
|
|
64
|
+
* The heartbeat only needs a coarse "when did this installation last sync"
|
|
65
|
+
* signal, so one observation is reused for this window instead. Sync commands
|
|
66
|
+
* always re-observe — they have opened the store anyway, so it costs nothing.
|
|
67
|
+
*/
|
|
68
|
+
export declare const JOURNAL_OBSERVATION_TTL_MS: number;
|
|
69
|
+
/** An observation lock older than this is treated as abandoned. */
|
|
70
|
+
export declare const JOURNAL_OBSERVATION_LOCK_STALE_MS: number;
|
|
36
71
|
export interface CliClientHealthState {
|
|
37
72
|
/** Stable random installation identity — regenerated only if invalid. */
|
|
38
73
|
installationId: string;
|
|
@@ -43,6 +78,22 @@ export interface CliClientHealthState {
|
|
|
43
78
|
lastSyncAttemptAt?: string;
|
|
44
79
|
lastSyncSuccessAt?: string;
|
|
45
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Cached result of one `listJournals()` read.
|
|
83
|
+
*
|
|
84
|
+
* Deliberately its OWN file rather than a field on {@link CliClientHealthState}:
|
|
85
|
+
* older CLI versions rewrite the state file and drop fields they do not know,
|
|
86
|
+
* so an observation stored there would be erased by any co-installed older
|
|
87
|
+
* `hq` — permanently on a machine that runs a mixed fleet, and throughout any
|
|
88
|
+
* staged rollout. That was observed in practice on the controller this change
|
|
89
|
+
* was measured on.
|
|
90
|
+
*/
|
|
91
|
+
export interface JournalObservation {
|
|
92
|
+
/** Engine-recorded journal `lastSync` at the time of the read. */
|
|
93
|
+
lastSyncAt?: string;
|
|
94
|
+
/** When the read was taken, ISO-8601. */
|
|
95
|
+
observedAt?: string;
|
|
96
|
+
}
|
|
46
97
|
export declare function newInstallationId(): string;
|
|
47
98
|
/**
|
|
48
99
|
* Load (or initialize) the persisted installation state. Any read/parse
|
|
@@ -50,6 +101,10 @@ export declare function newInstallationId(): string;
|
|
|
50
101
|
* never break a CLI command.
|
|
51
102
|
*/
|
|
52
103
|
export declare function loadClientHealthState(stateDir: string): CliClientHealthState;
|
|
104
|
+
/** Load the cached journal observation; any problem reads as "none". */
|
|
105
|
+
export declare function loadJournalObservation(stateDir: string): JournalObservation;
|
|
106
|
+
/** Best-effort persist — a read-only disk must never fail a command. */
|
|
107
|
+
export declare function persistJournalObservation(stateDir: string, observation: JournalObservation): void;
|
|
53
108
|
/** Best-effort persist — a read-only disk must never fail a command. */
|
|
54
109
|
export declare function persistClientHealthState(stateDir: string, state: CliClientHealthState): void;
|
|
55
110
|
/**
|
|
@@ -69,6 +124,16 @@ interface JournalLike {
|
|
|
69
124
|
* contract ISO-UTC. Null when nothing has ever synced.
|
|
70
125
|
*/
|
|
71
126
|
export declare function latestJournalSyncTime(journals: readonly JournalLike[]): string | null;
|
|
127
|
+
/**
|
|
128
|
+
* True when this event kind must pay for a fresh `listJournals()` read.
|
|
129
|
+
*
|
|
130
|
+
* Sync events always do: `hq sync` has already opened the state store, so the
|
|
131
|
+
* marginal cost is nil and the resulting heartbeat is the one that most needs
|
|
132
|
+
* an exact timestamp. Plain invocations reuse the last observation until it
|
|
133
|
+
* ages past `ttlMs`, which is what keeps the enumeration off the hot path of
|
|
134
|
+
* every unrelated command.
|
|
135
|
+
*/
|
|
136
|
+
export declare function shouldObserveJournals(kind: ClientHealthEventKind, observation: JournalObservation, now: Date, ttlMs: number): boolean;
|
|
72
137
|
export type ClientHealthEventKind = "invocation" | "sync_attempt" | "sync_success" | "sync_failure";
|
|
73
138
|
export interface BuildCliHeartbeatInput {
|
|
74
139
|
kind: ClientHealthEventKind;
|
|
@@ -80,6 +145,12 @@ export interface BuildCliHeartbeatInput {
|
|
|
80
145
|
now: Date;
|
|
81
146
|
platform?: ClientHealthPlatform | null;
|
|
82
147
|
arch?: ClientHealthArch | null;
|
|
148
|
+
/**
|
|
149
|
+
* Bounded local log/journal file facts (US-016). Only ever attached for
|
|
150
|
+
* `sync_success` / `sync_failure` kinds — support asked for this on the
|
|
151
|
+
* sync OUTCOME events, not on every invocation heartbeat.
|
|
152
|
+
*/
|
|
153
|
+
localFilesOverview?: ClientHealthLocalFilesOverview | null;
|
|
83
154
|
}
|
|
84
155
|
/**
|
|
85
156
|
* Build one contract-valid CLI heartbeat, or null when this environment
|
|
@@ -98,6 +169,12 @@ export interface ClientHealthDeps {
|
|
|
98
169
|
journals?: () => readonly JournalLike[];
|
|
99
170
|
post?: HeartbeatPoster;
|
|
100
171
|
timeoutMs?: number;
|
|
172
|
+
/** US-016: overridable for tests; defaults to {@link collectLocalFilesOverview}. */
|
|
173
|
+
localFilesOverview?: () => ClientHealthLocalFilesOverview;
|
|
174
|
+
/** Override for {@link JOURNAL_OBSERVATION_TTL_MS}. */
|
|
175
|
+
journalObservationTtlMs?: number;
|
|
176
|
+
/** Override for {@link JOURNAL_OBSERVATION_LOCK_STALE_MS}. */
|
|
177
|
+
journalObservationLockStaleMs?: number;
|
|
101
178
|
}
|
|
102
179
|
/**
|
|
103
180
|
* Apply one health event: update local installation state, then — only when a
|