@indigoai-us/hq-cli 5.108.17 → 5.108.19
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 +38 -18
- package/dist/commands/sync-manifest.d.ts +213 -0
- package/dist/commands/sync-manifest.js +384 -0
- package/dist/lib/doctor/checks/sync-health.d.ts +46 -1
- package/dist/lib/doctor/checks/sync-health.js +212 -5
- package/dist/lib/hq-cloud-manifest.d.ts +62 -0
- package/dist/lib/hq-cloud-manifest.js +83 -0
- package/dist/main.js +51 -7
- package/dist/register-all.js +2 -0
- package/dist/startup-registration.d.ts +79 -0
- package/dist/startup-registration.js +134 -0
- package/dist/utils/install-tree-torn.d.ts +204 -0
- package/dist/utils/install-tree-torn.js +359 -0
- package/dist/utils/sentry-fingerprint.js +26 -0
- package/package.json +2 -2
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detection and bounded, recovery-safe reporting for the "install tree torn out
|
|
3
|
+
* from under a starting `hq`" condition (Sentry HQ-CLI-1G/1H/1J/1K —
|
|
4
|
+
* 7713386011, 7713482655, 7713783135, 7714501738).
|
|
5
|
+
*
|
|
6
|
+
* hq-cli resolves most of its module graph lazily INSIDE `runCli` (the lazy
|
|
7
|
+
* command module or `import("./register-all.js")`, plus everything those pull in
|
|
8
|
+
* — nested `@aws-sdk/*`, `js-yaml`, `@indigoai-us/hq-cloud`). An EXTERNAL global
|
|
9
|
+
* reinstall — the outpost `hq-cli-update` systemd timer every 6h, the agent
|
|
10
|
+
* boxes' `self_update`, the desktop app — renames the installed package dir
|
|
11
|
+
* aside (`.hq-cli-<rand>`), recreates it empty, and re-extracts it file by file
|
|
12
|
+
* over ~16s. A process that started just before that rename resolves its
|
|
13
|
+
* deferred imports against a directory that no longer holds them and dies with
|
|
14
|
+
* ERR_MODULE_NOT_FOUND / MODULE_NOT_FOUND. None of those writers is the process's
|
|
15
|
+
* OWN updater (the version-gate and self-updater install synchronously under the
|
|
16
|
+
* shared lock and never overlap their own imports), so hq-cli cannot stop them —
|
|
17
|
+
* but it CAN detect the condition at the one seam where it is provably
|
|
18
|
+
* side-effect free (the registration phase, before `parseAsync` and before the
|
|
19
|
+
* preAction hook), wait (bounded) for the install to settle, and re-run itself
|
|
20
|
+
* once on the settled tree.
|
|
21
|
+
*
|
|
22
|
+
* This module is the reusable core of that recovery: a STRUCTURAL, closed
|
|
23
|
+
* classifier for the loader-error dialects Node raises; a readiness probe that
|
|
24
|
+
* re-resolves the missing target exactly as Node's resolver does; a bounded
|
|
25
|
+
* settle wait keyed on the same signals the writer leaves behind; and the typed
|
|
26
|
+
* carrier + boundary helpers (house style of package-root-diagnostics.ts) that
|
|
27
|
+
* keep a genuinely-broken install VISIBLE in Sentry with bounded, hq-derived
|
|
28
|
+
* diagnostics. The recovery seam that drives them lives in
|
|
29
|
+
* ../startup-registration.ts; version-gate.ts / self-update.ts / update-lock.ts
|
|
30
|
+
* are only READ (their exported symbols), never modified.
|
|
31
|
+
*/
|
|
32
|
+
import * as nodeFs from "node:fs";
|
|
33
|
+
import * as path from "node:path";
|
|
34
|
+
import { fileURLToPath } from "node:url";
|
|
35
|
+
import { CLI_NAME } from "../cli-version.js";
|
|
36
|
+
import { boundedDiagnosticValue } from "./package-root-diagnostics.js";
|
|
37
|
+
import { isLockStale } from "./update-lock.js";
|
|
38
|
+
/** Byte caps for the (hq-derived, path-shaped) diagnostic strings. */
|
|
39
|
+
const SPECIFIER_BYTES = 256;
|
|
40
|
+
const IMPORTER_BYTES = 256;
|
|
41
|
+
const PACKAGE_ROOT_BYTES = 256;
|
|
42
|
+
const ESM_PACKAGE_RE = /^Cannot find package '([^']+)' imported from (.+)$/s;
|
|
43
|
+
const CJS_MODULE_RE = /^Cannot find module '([^']+)'/s;
|
|
44
|
+
const ESM_MODULE_IMPORTED_RE = /^Cannot find module '([^']+)' imported from (.+)$/s;
|
|
45
|
+
/**
|
|
46
|
+
* Reduce a bare specifier to its PACKAGE name: `@scope/name/sub` → `@scope/name`,
|
|
47
|
+
* `name/sub` → `name`, `name` → `name`. This is the unit the readiness probe
|
|
48
|
+
* re-resolves (Node looks up `node_modules/<package>/package.json`, never the
|
|
49
|
+
* subpath, when it raises the bare-specifier dialects).
|
|
50
|
+
*/
|
|
51
|
+
export function packageNameOf(specifier) {
|
|
52
|
+
const parts = specifier.split("/");
|
|
53
|
+
if (specifier.startsWith("@"))
|
|
54
|
+
return parts.slice(0, 2).join("/");
|
|
55
|
+
return parts[0] ?? specifier;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Classify a thrown value as a module-resolution failure and extract the missing
|
|
59
|
+
* target, or return `null` for anything that is not one. The decision is
|
|
60
|
+
* STRUCTURAL: `err.code` must be exactly `ERR_MODULE_NOT_FOUND` or
|
|
61
|
+
* `MODULE_NOT_FOUND` — no argv, env, or free text is ever consulted, and any
|
|
62
|
+
* other error (including an import-time throw of another class) returns `null`
|
|
63
|
+
* so it is rethrown to the existing boundary unchanged.
|
|
64
|
+
*/
|
|
65
|
+
export function classifyModuleNotFound(err) {
|
|
66
|
+
if (err === null || typeof err !== "object")
|
|
67
|
+
return null;
|
|
68
|
+
const record = err;
|
|
69
|
+
const code = record.code;
|
|
70
|
+
if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND")
|
|
71
|
+
return null;
|
|
72
|
+
const message = typeof record.message === "string" ? record.message : "";
|
|
73
|
+
// (a) ESM path — `err.url` is a `file:` URL. This is checked first because the
|
|
74
|
+
// ESM path message ALSO matches the imported-from dialect (d); the url is the
|
|
75
|
+
// authoritative resolved target.
|
|
76
|
+
if (typeof record.url === "string" && record.url.startsWith("file:")) {
|
|
77
|
+
const target = fileURLToPath(record.url);
|
|
78
|
+
const importer = ESM_MODULE_IMPORTED_RE.exec(message)?.[2] ?? "";
|
|
79
|
+
return { code, dialect: "esm-path", specifier: target, importer, target: { kind: "path", path: target } };
|
|
80
|
+
}
|
|
81
|
+
// (b) ESM `Cannot find package '<spec>' imported from <importer>`. `<spec>` is
|
|
82
|
+
// a bare name for the ordinary case, or an ABSOLUTE `<dir>/index.js` token for
|
|
83
|
+
// the legacyMainResolve variant (a package dir that exists but has no usable
|
|
84
|
+
// package.json/main) — the latter is a path target.
|
|
85
|
+
const esmPkg = ESM_PACKAGE_RE.exec(message);
|
|
86
|
+
if (esmPkg) {
|
|
87
|
+
const spec = esmPkg[1];
|
|
88
|
+
const importer = esmPkg[2];
|
|
89
|
+
if (path.isAbsolute(spec)) {
|
|
90
|
+
return { code, dialect: "esm-path", specifier: spec, importer, target: { kind: "path", path: spec } };
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
code,
|
|
94
|
+
dialect: "esm-package",
|
|
95
|
+
specifier: spec,
|
|
96
|
+
importer,
|
|
97
|
+
target: { kind: "package", name: spec, from: path.dirname(importer) },
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
// (c) CJS `Cannot find module '<spec>'` (+ Require stack). Absolute → a path
|
|
101
|
+
// target; bare → the package the require named, resolved from requireStack[0].
|
|
102
|
+
if (code === "MODULE_NOT_FOUND") {
|
|
103
|
+
const cjs = CJS_MODULE_RE.exec(message);
|
|
104
|
+
if (cjs) {
|
|
105
|
+
const spec = cjs[1];
|
|
106
|
+
const requireStack = Array.isArray(record.requireStack) ? record.requireStack : [];
|
|
107
|
+
const importer = typeof requireStack[0] === "string" ? requireStack[0] : "";
|
|
108
|
+
if (path.isAbsolute(spec)) {
|
|
109
|
+
return { code, dialect: "cjs-path", specifier: spec, importer, target: { kind: "path", path: spec } };
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
code,
|
|
113
|
+
dialect: "cjs-package",
|
|
114
|
+
specifier: spec,
|
|
115
|
+
importer,
|
|
116
|
+
target: { kind: "package", name: packageNameOf(spec), from: path.dirname(importer) },
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// (d) ESM `Cannot find module '<abs>' imported from <importer>` WITHOUT a url
|
|
121
|
+
// (defensive — the url arm normally wins). The specifier is the resolved
|
|
122
|
+
// absolute path.
|
|
123
|
+
const esmMod = ESM_MODULE_IMPORTED_RE.exec(message);
|
|
124
|
+
if (esmMod) {
|
|
125
|
+
const spec = esmMod[1];
|
|
126
|
+
return { code, dialect: "esm-path", specifier: spec, importer: esmMod[2], target: { kind: "path", path: spec } };
|
|
127
|
+
}
|
|
128
|
+
// (e) A module-not-found whose message did not parse — still recover, keyed on
|
|
129
|
+
// the lock / retired-dir / quiet signals only.
|
|
130
|
+
return { code, dialect: "unknown", specifier: "", importer: "", target: { kind: "unknown" } };
|
|
131
|
+
}
|
|
132
|
+
const nodeInstallTreeFs = {
|
|
133
|
+
existsSync: (p) => nodeFs.existsSync(p),
|
|
134
|
+
statSync: (p) => nodeFs.statSync(p),
|
|
135
|
+
readFileSync: (p, encoding) => nodeFs.readFileSync(p, encoding),
|
|
136
|
+
readdirSync: (p) => nodeFs.readdirSync(p),
|
|
137
|
+
};
|
|
138
|
+
/** Liveness probe with update-lock.ts's semantics (ESRCH dead, EPERM alive). */
|
|
139
|
+
function defaultIsPidAlive(pid) {
|
|
140
|
+
try {
|
|
141
|
+
process.kill(pid, 0);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
return err.code === "EPERM";
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Whether the missing target is now present on disk — the readiness signal the
|
|
150
|
+
* settle wait polls. It re-resolves the SAME step Node's resolver took:
|
|
151
|
+
* - `path`: the file must exist; a directory target must additionally hold
|
|
152
|
+
* a `package.json` (the legacyMainResolve case).
|
|
153
|
+
* - `package`: walk from `from` toward the filesystem root and return true as
|
|
154
|
+
* soon as `<dir>/node_modules/<name>/package.json` exists — the
|
|
155
|
+
* exact ancestor walk `getPackageJSONURL` performs, done WITHOUT
|
|
156
|
+
* `createRequire` so an ESM-only / export-conditioned package
|
|
157
|
+
* cannot false-negative.
|
|
158
|
+
* - `unknown`: true (readiness turns on the other signals).
|
|
159
|
+
* Any filesystem error reads as "not present" rather than throwing.
|
|
160
|
+
*/
|
|
161
|
+
export function installTargetPresent(target, fs = nodeInstallTreeFs) {
|
|
162
|
+
try {
|
|
163
|
+
if (target.kind === "unknown")
|
|
164
|
+
return true;
|
|
165
|
+
if (target.kind === "path") {
|
|
166
|
+
if (!fs.existsSync(target.path))
|
|
167
|
+
return false;
|
|
168
|
+
try {
|
|
169
|
+
if (fs.statSync(target.path).isDirectory()) {
|
|
170
|
+
return fs.existsSync(path.join(target.path, "package.json"));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
// package: ancestor walk from `from`.
|
|
179
|
+
let dir = path.resolve(target.from);
|
|
180
|
+
for (;;) {
|
|
181
|
+
const manifest = path.join(dir, "node_modules", target.name, "package.json");
|
|
182
|
+
if (fs.existsSync(manifest))
|
|
183
|
+
return true;
|
|
184
|
+
const parent = path.dirname(dir);
|
|
185
|
+
if (parent === dir)
|
|
186
|
+
return false;
|
|
187
|
+
dir = parent;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** The scope/leaf split of CLI_NAME, e.g. `@indigoai-us` / `hq-cli`. */
|
|
195
|
+
function cliNameParts() {
|
|
196
|
+
const slash = CLI_NAME.indexOf("/");
|
|
197
|
+
if (slash === -1)
|
|
198
|
+
return { scope: null, leaf: CLI_NAME };
|
|
199
|
+
return { scope: CLI_NAME.slice(0, slash), leaf: CLI_NAME.slice(slash + 1) };
|
|
200
|
+
}
|
|
201
|
+
/** Whether a FRESH update lock is held by a DIFFERENT live pid (blocks readiness). */
|
|
202
|
+
function freshForeignLockHeld(lockPath, nowMs, isPidAlive, fs) {
|
|
203
|
+
let raw;
|
|
204
|
+
try {
|
|
205
|
+
raw = fs.readFileSync(lockPath, "utf-8");
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return false; // no lock (ENOENT) or unreadable — not a fresh foreign holder
|
|
209
|
+
}
|
|
210
|
+
if (isLockStale(raw, nowMs, isPidAlive))
|
|
211
|
+
return false;
|
|
212
|
+
try {
|
|
213
|
+
const info = JSON.parse(raw);
|
|
214
|
+
if (info.pid === process.pid)
|
|
215
|
+
return false; // our own lock never blocks us
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return false; // isLockStale already treats unparseable as stale, unreachable
|
|
219
|
+
}
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
/** Whether an npm retired/staging sibling (`.<leaf>-*`) sits beside packageRoot. */
|
|
223
|
+
function retiredSiblingPresent(packageRoot, fs) {
|
|
224
|
+
const { leaf } = cliNameParts();
|
|
225
|
+
const stagingPrefix = `.${leaf}-`;
|
|
226
|
+
const parent = path.dirname(packageRoot);
|
|
227
|
+
const entries = fs.readdirSync(parent); // caller treats a throw as "not ready"
|
|
228
|
+
return entries.some((entry) => entry.startsWith(stagingPrefix));
|
|
229
|
+
}
|
|
230
|
+
/** Whether packageRoot's own manifest is healthy (`name === CLI_NAME`). */
|
|
231
|
+
function packageRootHealthy(packageRoot, fs) {
|
|
232
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8"));
|
|
233
|
+
return manifest.name === CLI_NAME;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Poll until the install tree has been continuously READY for `quietMs`, or the
|
|
237
|
+
* `deadlineMs` passes. READY means: (i) no FRESH update lock held by another pid,
|
|
238
|
+
* (ii) the missing target is present, and — when `packageRoot` is known —
|
|
239
|
+
* (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and
|
|
240
|
+
* (iv) no `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem
|
|
241
|
+
* error makes its condition "not ready" rather than throwing. `deadlineMs === 0`
|
|
242
|
+
* evaluates readiness exactly once (no waiting); otherwise the whole wait is
|
|
243
|
+
* bounded, so the caller can never hang.
|
|
244
|
+
*/
|
|
245
|
+
export async function waitForInstallTreeSettled(args) {
|
|
246
|
+
const now = args.now ?? Date.now;
|
|
247
|
+
const sleep = args.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
248
|
+
const fs = args.fs ?? nodeInstallTreeFs;
|
|
249
|
+
const isPidAlive = args.isPidAlive ?? defaultIsPidAlive;
|
|
250
|
+
const pollMs = args.pollMs ?? 250;
|
|
251
|
+
const quietMs = args.quietMs ?? 1500;
|
|
252
|
+
const deadlineMs = args.deadlineMs;
|
|
253
|
+
const start = now();
|
|
254
|
+
let readySince = null;
|
|
255
|
+
let sawLock = false;
|
|
256
|
+
let sawRetired = false;
|
|
257
|
+
for (;;) {
|
|
258
|
+
const t = now();
|
|
259
|
+
const foreignLock = freshForeignLockHeld(args.lockPath, t, isPidAlive, fs);
|
|
260
|
+
if (foreignLock)
|
|
261
|
+
sawLock = true;
|
|
262
|
+
let ready = !foreignLock && installTargetPresent(args.target, fs);
|
|
263
|
+
if (ready && args.packageRoot) {
|
|
264
|
+
try {
|
|
265
|
+
if (!packageRootHealthy(args.packageRoot, fs))
|
|
266
|
+
ready = false;
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
ready = false;
|
|
270
|
+
}
|
|
271
|
+
if (ready) {
|
|
272
|
+
try {
|
|
273
|
+
if (retiredSiblingPresent(args.packageRoot, fs)) {
|
|
274
|
+
sawRetired = true;
|
|
275
|
+
ready = false;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
ready = false;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (ready) {
|
|
284
|
+
if (readySince === null)
|
|
285
|
+
readySince = t;
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
readySince = null;
|
|
289
|
+
}
|
|
290
|
+
const waitedMs = t - start;
|
|
291
|
+
if (ready && (deadlineMs === 0 || t - readySince >= quietMs)) {
|
|
292
|
+
return { settled: true, waitedMs, sawLock, sawRetired };
|
|
293
|
+
}
|
|
294
|
+
if (waitedMs >= deadlineMs) {
|
|
295
|
+
return { settled: false, waitedMs, sawLock, sawRetired };
|
|
296
|
+
}
|
|
297
|
+
await sleep(pollMs);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/** The fixed, input-free carrier message (grouping cardinality stays bounded). */
|
|
301
|
+
const INSTALL_TREE_TORN_MESSAGE = "hq-cli install tree was being rewritten while this command started";
|
|
302
|
+
/**
|
|
303
|
+
* A torn-install failure that stays VISIBLE in Sentry with bounded, hq-derived
|
|
304
|
+
* diagnostics. Fixed `name`/`message` (no argv, path, or free text in either),
|
|
305
|
+
* `cause` = the original loader error, and a bounded `diagnostics` object whose
|
|
306
|
+
* only interpolated strings are the hq-derived specifier/importer/packageRoot,
|
|
307
|
+
* each capped. Mirrors PackageRootResolutionError's construction discipline.
|
|
308
|
+
*/
|
|
309
|
+
export class InstallTreeTornError extends Error {
|
|
310
|
+
diagnostics;
|
|
311
|
+
constructor(init) {
|
|
312
|
+
super(INSTALL_TREE_TORN_MESSAGE);
|
|
313
|
+
this.name = "InstallTreeTornError";
|
|
314
|
+
this.cause = init.cause;
|
|
315
|
+
this.diagnostics = {
|
|
316
|
+
dialect: init.classified.dialect,
|
|
317
|
+
code: init.classified.code,
|
|
318
|
+
specifier: boundedDiagnosticValue(init.classified.specifier, SPECIFIER_BYTES),
|
|
319
|
+
importer: boundedDiagnosticValue(init.classified.importer, IMPORTER_BYTES),
|
|
320
|
+
packageRoot: boundedDiagnosticValue(init.packageRoot ?? "", PACKAGE_ROOT_BYTES),
|
|
321
|
+
outcome: init.outcome,
|
|
322
|
+
attempt: init.attempt,
|
|
323
|
+
waitedMs: init.waitedMs,
|
|
324
|
+
sawLock: init.sawLock,
|
|
325
|
+
sawRetired: init.sawRetired,
|
|
326
|
+
node: process.version,
|
|
327
|
+
};
|
|
328
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
export function isInstallTreeTornError(err) {
|
|
332
|
+
return err instanceof InstallTreeTornError;
|
|
333
|
+
}
|
|
334
|
+
/** The exact Sentry `contexts` payload for a captured torn-install failure. */
|
|
335
|
+
export function installTreeTornCaptureContext(err) {
|
|
336
|
+
return { contexts: { install_tree_torn: err.diagnostics } };
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* The fixed, input-free operator remedy. It names the likely writers (so a
|
|
340
|
+
* human knows this was not their command's fault) and the reinstall commands,
|
|
341
|
+
* and interpolates NOTHING — the only per-event value is the bounded specifier
|
|
342
|
+
* appended by {@link installTreeTornStderrLine}.
|
|
343
|
+
*/
|
|
344
|
+
export const INSTALL_TREE_TORN_REMEDY = "the hq-cli install was being updated by another process (the box's hq-cli-update " +
|
|
345
|
+
"timer, the desktop app, or another hq command) while this command started. " +
|
|
346
|
+
"Re-run your command; if it keeps failing, reinstall with " +
|
|
347
|
+
"`npm i -g @indigoai-us/hq-cli` (or `pnpm add -g @indigoai-us/hq-cli`).";
|
|
348
|
+
/**
|
|
349
|
+
* The single actionable stderr line for a captured torn-install failure: the
|
|
350
|
+
* fixed remedy plus the bounded, hq-derived missing specifier — the only
|
|
351
|
+
* interpolated value, never argv.
|
|
352
|
+
*/
|
|
353
|
+
export function installTreeTornStderrLine(err) {
|
|
354
|
+
const specifier = err.diagnostics.specifier;
|
|
355
|
+
return specifier
|
|
356
|
+
? `${INSTALL_TREE_TORN_REMEDY} (missing: ${specifier})`
|
|
357
|
+
: INSTALL_TREE_TORN_REMEDY;
|
|
358
|
+
}
|
|
359
|
+
//# sourceMappingURL=install-tree-torn.js.map
|
|
@@ -91,6 +91,18 @@ const KNOWN_ERROR_NAMES = new Set([
|
|
|
91
91
|
// listed defensively so a future capture path cannot mint an unbounded group
|
|
92
92
|
// (HQ-CLI-1A).
|
|
93
93
|
"QmdWorkdirMissingError",
|
|
94
|
+
// A torn-install failure the bounded settle-wait + single re-exec could not
|
|
95
|
+
// recover (HQ-CLI-1G/1H/1J/1K). Fingerprinted on its own branch below by the
|
|
96
|
+
// bounded `diagnostics.outcome`, so the whole family groups into ≤3 issues.
|
|
97
|
+
"InstallTreeTornError",
|
|
98
|
+
]);
|
|
99
|
+
/**
|
|
100
|
+
* The CLOSED set of InstallTreeTornError recovery outcomes worth their own
|
|
101
|
+
* group. Anything else collapses to `outcome:other`, so the axis stays finite;
|
|
102
|
+
* the carrier's message is fixed, so this outcome IS the only discriminator.
|
|
103
|
+
*/
|
|
104
|
+
const KNOWN_INSTALL_TREE_OUTCOMES = new Set([
|
|
105
|
+
"guarded", "unsettled", "reexec-failed",
|
|
94
106
|
]);
|
|
95
107
|
/** Fixed bucket for any error name outside the closed allowlist. */
|
|
96
108
|
const FALLBACK_ERROR_NAME = "other";
|
|
@@ -200,6 +212,20 @@ export function sentryFingerprintFor(err) {
|
|
|
200
212
|
qmdDispositionToken(record.status, record.signal),
|
|
201
213
|
];
|
|
202
214
|
}
|
|
215
|
+
// A torn-install failure carries neither an rpcCode nor an HTTP status; its
|
|
216
|
+
// only bounded discriminator is the recovery `diagnostics.outcome`. Keyed
|
|
217
|
+
// BEFORE the rpc/status branches so the family groups into ≤3 predictable
|
|
218
|
+
// issues (guarded / unsettled / reexec-failed) rather than one fungible bucket.
|
|
219
|
+
if (rawName === "InstallTreeTornError") {
|
|
220
|
+
const diagnostics = record.diagnostics;
|
|
221
|
+
const outcome = diagnostics !== null && typeof diagnostics === "object"
|
|
222
|
+
? diagnostics.outcome
|
|
223
|
+
: undefined;
|
|
224
|
+
const token = typeof outcome === "string" && KNOWN_INSTALL_TREE_OUTCOMES.has(outcome)
|
|
225
|
+
? `outcome:${outcome}`
|
|
226
|
+
: "outcome:other";
|
|
227
|
+
return [DEFAULT_GROUPING, "InstallTreeTornError", token];
|
|
228
|
+
}
|
|
203
229
|
const name = KNOWN_ERROR_NAMES.has(rawName) ? rawName : FALLBACK_ERROR_NAME;
|
|
204
230
|
const rpcCode = record.rpcCode;
|
|
205
231
|
if (typeof rpcCode === "number" && Number.isInteger(rpcCode)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.108.
|
|
3
|
+
"version": "5.108.19",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@aws-sdk/client-iot-data-plane": "^3.1096.0",
|
|
33
33
|
"@aws-sdk/client-s3": "^3.1049.0",
|
|
34
|
-
"@indigoai-us/hq-cloud": "~6.16.
|
|
34
|
+
"@indigoai-us/hq-cloud": "~6.16.23",
|
|
35
35
|
"@indigoai-us/hq-flags-client": "^0.1.2",
|
|
36
36
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
37
37
|
"@sentry/node": "^10.49.0",
|