@indigoai-us/hq-cli 5.205.0 → 5.207.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 +15 -0
- package/dist/command-catalog.generated.d.ts +9 -0
- package/dist/command-catalog.generated.js +12 -0
- package/dist/commands/doctor.js +74 -0
- package/dist/lib/core-utils/sentry-report.d.ts +19 -0
- package/dist/lib/core-utils/sentry-report.js +61 -0
- package/dist/lib/doctor/client-health-sentry-flag.d.ts +22 -0
- package/dist/lib/doctor/client-health-sentry-flag.js +88 -0
- package/dist/lib/doctor/client-health-sentry.d.ts +71 -0
- package/dist/lib/doctor/client-health-sentry.js +279 -0
- package/dist/sentry-before-send.js +80 -0
- package/dist/startup-registration.js +2 -0
- package/dist/utils/install-tree-torn.d.ts +22 -7
- package/dist/utils/install-tree-torn.js +108 -20
- package/dist/utils/sentry-fingerprint.js +9 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.207.0] — 2026-09-26
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- The SessionStart hook can send client health reports straight to Sentry when
|
|
10
|
+
the company enables `client-health.sentry-direct`. Reports exclude doctor
|
|
11
|
+
text and company names. With the flag off, the hook keeps using the existing
|
|
12
|
+
feedback route.
|
|
13
|
+
|
|
14
|
+
## [5.206.0] — 2026-09-26
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Startup recovery no longer waits the full install deadline when an unchanged retired hq-cli package remains beside the install. Node 24 ESM loader file-read `ENOENT` errors also enter the existing recovery path and receive a report tag.
|
|
19
|
+
|
|
5
20
|
## [5.205.0] — 2026-09-26
|
|
6
21
|
|
|
7
22
|
### Added
|
|
@@ -5552,6 +5552,15 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
5552
5552
|
}, {
|
|
5553
5553
|
readonly flags: "--client-health-report";
|
|
5554
5554
|
readonly description: "Resolve the opt-in hq-flags gate used by the client-health report hook.";
|
|
5555
|
+
}, {
|
|
5556
|
+
readonly flags: "--client-health-sentry";
|
|
5557
|
+
readonly description: "Send the opt-in client-health Sentry report from a doctor JSON document.";
|
|
5558
|
+
}, {
|
|
5559
|
+
readonly flags: "--json-input <file>";
|
|
5560
|
+
readonly description: "Doctor JSON input for the client-health report.";
|
|
5561
|
+
}, {
|
|
5562
|
+
readonly flags: "--client-health-fix-outcome <outcome>";
|
|
5563
|
+
readonly description: "Safe fix outcome label for the client-health report.";
|
|
5555
5564
|
}, {
|
|
5556
5565
|
readonly flags: "--yes";
|
|
5557
5566
|
readonly description: "Skip the interactive --fix confirmation (non-interactive use).";
|
|
@@ -7163,6 +7163,18 @@ export const COMMAND_CATALOG = [
|
|
|
7163
7163
|
"flags": "--client-health-report",
|
|
7164
7164
|
"description": "Resolve the opt-in hq-flags gate used by the client-health report hook."
|
|
7165
7165
|
},
|
|
7166
|
+
{
|
|
7167
|
+
"flags": "--client-health-sentry",
|
|
7168
|
+
"description": "Send the opt-in client-health Sentry report from a doctor JSON document."
|
|
7169
|
+
},
|
|
7170
|
+
{
|
|
7171
|
+
"flags": "--json-input <file>",
|
|
7172
|
+
"description": "Doctor JSON input for the client-health report."
|
|
7173
|
+
},
|
|
7174
|
+
{
|
|
7175
|
+
"flags": "--client-health-fix-outcome <outcome>",
|
|
7176
|
+
"description": "Safe fix outcome label for the client-health report."
|
|
7177
|
+
},
|
|
7166
7178
|
{
|
|
7167
7179
|
"flags": "--yes",
|
|
7168
7180
|
"description": "Skip the interactive --fix confirmation (non-interactive use)."
|
package/dist/commands/doctor.js
CHANGED
|
@@ -33,6 +33,9 @@ import { PARITY_FAMILY_ID, PARITY_FAMILY_TITLE, runParityReplay, } from "../lib/
|
|
|
33
33
|
import { applyFixes } from "../lib/doctor/fix/apply.js";
|
|
34
34
|
import { renderDoctorFixJson } from "../lib/doctor/fix/json-output.js";
|
|
35
35
|
import { clientHealthReasonCodesEnabled as isActiveCompanyClientHealthReasonCodesEnabled, clientHealthReasonCodesEnabledForCompany, } from "../lib/doctor/client-health-reason-codes-flag.js";
|
|
36
|
+
import { CLIENT_HEALTH_SENTRY_MAX_INPUT_BYTES, CLIENT_HEALTH_SENTRY_NOT_HANDLED_EXIT_CODE, handleClientHealthSentryReport, } from "../lib/doctor/client-health-sentry.js";
|
|
37
|
+
import { clientHealthSentryDirectEnabled, clientHealthSentryDirectEnabledForCompany, } from "../lib/doctor/client-health-sentry-flag.js";
|
|
38
|
+
import { reportClientHealthEvents } from "../lib/core-utils/sentry-report.js";
|
|
36
39
|
import { isCompanyJournalSlug } from "../lib/doctor/checks/sync-health.js";
|
|
37
40
|
import { readCompaniesManifestMap } from "../lib/work-context/repo-remote.js";
|
|
38
41
|
import * as readline from "node:readline";
|
|
@@ -241,6 +244,26 @@ function createClientHealthReasonCodeCompanyResolver(hqRoot) {
|
|
|
241
244
|
});
|
|
242
245
|
};
|
|
243
246
|
}
|
|
247
|
+
function createClientHealthSentryCompanyResolver(hqRoot) {
|
|
248
|
+
const companies = readCompaniesManifestMap(hqRoot);
|
|
249
|
+
return async (companySlug) => {
|
|
250
|
+
if (companySlug === null)
|
|
251
|
+
return clientHealthSentryDirectEnabled();
|
|
252
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(companySlug))
|
|
253
|
+
return false;
|
|
254
|
+
const manifestUid = companies?.[companySlug]?.cloud_uid;
|
|
255
|
+
const activeSlug = process.env.HQ_COMPANY_SLUG?.trim() || "";
|
|
256
|
+
const activeUid = process.env.HQ_COMPANY_UID?.trim() || "";
|
|
257
|
+
const companyUid = typeof manifestUid === "string" && manifestUid.trim()
|
|
258
|
+
? manifestUid.trim()
|
|
259
|
+
: companySlug === activeSlug
|
|
260
|
+
? activeUid
|
|
261
|
+
: "";
|
|
262
|
+
if (!/^cmp_[A-Za-z0-9]{3,128}$/.test(companyUid))
|
|
263
|
+
return false;
|
|
264
|
+
return clientHealthSentryDirectEnabledForCompany({ companyUid, companySlug });
|
|
265
|
+
};
|
|
266
|
+
}
|
|
244
267
|
/** Register the top-level `hq doctor` command so it appears in `hq --help`. */
|
|
245
268
|
export function registerDoctorCommand(program) {
|
|
246
269
|
program
|
|
@@ -256,6 +279,9 @@ export function registerDoctorCommand(program) {
|
|
|
256
279
|
.option("--only <families>", "Run only these comma-separated check families (e.g. hooks). Unknown names are rejected; --json then records the scope.")
|
|
257
280
|
.option("--fix", "Apply the allowlisted safe repairs (backs up first; read-only without this flag).")
|
|
258
281
|
.addOption(new Option("--client-health-report", "Resolve the opt-in hq-flags gate used by the client-health report hook.").hideHelp())
|
|
282
|
+
.addOption(new Option("--client-health-sentry", "Send the opt-in client-health Sentry report from a doctor JSON document.").hideHelp())
|
|
283
|
+
.addOption(new Option("--json-input <file>", "Doctor JSON input for the client-health report.").hideHelp())
|
|
284
|
+
.addOption(new Option("--client-health-fix-outcome <outcome>", "Safe fix outcome label for the client-health report.").hideHelp())
|
|
259
285
|
.option("--yes", "Skip the interactive --fix confirmation (non-interactive use).")
|
|
260
286
|
.option("--force", "Let --fix run despite uncommitted changes under .claude/, .codex/, or .grok/.")
|
|
261
287
|
.action(async (opts) => {
|
|
@@ -272,6 +298,54 @@ export function registerDoctorCommand(program) {
|
|
|
272
298
|
process.exitCode = 1;
|
|
273
299
|
return;
|
|
274
300
|
}
|
|
301
|
+
if (opts.clientHealthSentry === true) {
|
|
302
|
+
const hqRoot = resolveHqRoot();
|
|
303
|
+
if (!hqRoot || !opts.jsonInput) {
|
|
304
|
+
process.stderr.write("hq doctor --client-health-sentry requires an HQ root and --json-input.\n");
|
|
305
|
+
process.exitCode = 1;
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
const workspacePath = path.join(hqRoot, "workspace");
|
|
310
|
+
const workspaceStat = fs.lstatSync(workspacePath);
|
|
311
|
+
if (!workspaceStat.isDirectory() || workspaceStat.isSymbolicLink()) {
|
|
312
|
+
throw new Error("workspace is not a local directory");
|
|
313
|
+
}
|
|
314
|
+
const statePath = path.join(hqRoot, "workspace", ".hq-client-health");
|
|
315
|
+
const stateStat = fs.lstatSync(statePath);
|
|
316
|
+
if (!stateStat.isDirectory() || stateStat.isSymbolicLink()) {
|
|
317
|
+
throw new Error("client-health state is not a local directory");
|
|
318
|
+
}
|
|
319
|
+
const stateRoot = fs.realpathSync(statePath);
|
|
320
|
+
const inputPath = fs.realpathSync(opts.jsonInput);
|
|
321
|
+
const relativeInput = path.relative(stateRoot, inputPath);
|
|
322
|
+
if (relativeInput === ".." || relativeInput.startsWith(`..${path.sep}`) || path.isAbsolute(relativeInput)) {
|
|
323
|
+
throw new Error("input path is outside client-health state");
|
|
324
|
+
}
|
|
325
|
+
if (fs.statSync(inputPath).size > CLIENT_HEALTH_SENTRY_MAX_INPUT_BYTES) {
|
|
326
|
+
throw new Error("input exceeds client-health size limit");
|
|
327
|
+
}
|
|
328
|
+
const result = await handleClientHealthSentryReport({
|
|
329
|
+
hqRoot,
|
|
330
|
+
doctorJson: fs.readFileSync(inputPath, "utf8"),
|
|
331
|
+
fixOutcome: opts.clientHealthFixOutcome ?? "not_auto_fixable",
|
|
332
|
+
}, {
|
|
333
|
+
resolveFlagForCompany: createClientHealthSentryCompanyResolver(hqRoot),
|
|
334
|
+
sendEvents: async (events) => (await reportClientHealthEvents(events)).code === 0,
|
|
335
|
+
});
|
|
336
|
+
process.exitCode =
|
|
337
|
+
result === "not_handled"
|
|
338
|
+
? CLIENT_HEALTH_SENTRY_NOT_HANDLED_EXIT_CODE
|
|
339
|
+
: result === "send_failed"
|
|
340
|
+
? 1
|
|
341
|
+
: 0;
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
process.stderr.write("hq doctor --client-health-sentry could not handle the report.\n");
|
|
345
|
+
process.exitCode = 1;
|
|
346
|
+
}
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
275
349
|
// `--fix` is the only write path. It resolves the tree, applies the
|
|
276
350
|
// allowlisted repairs behind a backup + confirmation, and returns its own
|
|
277
351
|
// exit code; the read-only report below never runs in this branch.
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import * as Sentry from "@sentry/node";
|
|
19
19
|
import type { CaptureContext, SeverityLevel } from "@sentry/node";
|
|
20
|
+
import type { ClientHealthSentryEvent } from "../doctor/client-health-sentry.js";
|
|
20
21
|
export declare const DEFAULT_SENTRY_REPORT_TIMEOUT_MS = 750;
|
|
21
22
|
export declare const MAX_SENTRY_REPORT_TIMEOUT_MS = 5000;
|
|
22
23
|
export declare const MAX_SENTRY_REPORT_PAYLOAD_BYTES = 2048;
|
|
@@ -95,6 +96,17 @@ export type SentryReportResult = {
|
|
|
95
96
|
event?: SentryReportEvent;
|
|
96
97
|
droppedMetadataKeys?: number;
|
|
97
98
|
};
|
|
99
|
+
export interface ClientHealthSentryScope {
|
|
100
|
+
clear(): void;
|
|
101
|
+
setLevel(level: SeverityLevel): void;
|
|
102
|
+
setFingerprint(fingerprint: string[]): void;
|
|
103
|
+
setTags(tags: Record<string, string>): void;
|
|
104
|
+
setUser(user: {
|
|
105
|
+
id: string;
|
|
106
|
+
ip_address: null;
|
|
107
|
+
}): void;
|
|
108
|
+
}
|
|
109
|
+
export type ClientHealthSentryScopeRunner = (callback: (scope: ClientHealthSentryScope) => void) => void;
|
|
98
110
|
/**
|
|
99
111
|
* Read no more than the accepted event size. Returning early keeps a malformed
|
|
100
112
|
* pipe from making a watchdog retain an unbounded hook payload in memory.
|
|
@@ -117,4 +129,11 @@ export declare function parseSentryReportEvent(stdin: string): SentryReportResul
|
|
|
117
129
|
* transport errors cannot echo event data back to a hook.
|
|
118
130
|
*/
|
|
119
131
|
export declare function reportSentryEvent(stdin: string, options?: SentryReportOptions, dependencies?: SentryReportDependencies): Promise<SentryReportResult>;
|
|
132
|
+
/** Send the client-health projection with a clean Sentry scope and no metadata payload. */
|
|
133
|
+
export declare function reportClientHealthEvents(events: readonly ClientHealthSentryEvent[], options?: {
|
|
134
|
+
timeoutMs?: number;
|
|
135
|
+
scopeRunner?: ClientHealthSentryScopeRunner;
|
|
136
|
+
}, dependencies?: SentryReportDependencies): Promise<{
|
|
137
|
+
code: 0 | 1;
|
|
138
|
+
}>;
|
|
120
139
|
//# sourceMappingURL=sentry-report.d.ts.map
|
|
@@ -1033,4 +1033,65 @@ export async function reportSentryEvent(stdin, options = {}, dependencies = defa
|
|
|
1033
1033
|
dependencies.stderr.write("Sentry report failed.\n");
|
|
1034
1034
|
return invalidInput();
|
|
1035
1035
|
}
|
|
1036
|
+
function isSafeClientHealthEvent(event) {
|
|
1037
|
+
const tags = event.tags;
|
|
1038
|
+
const checkClass = tags.check_class;
|
|
1039
|
+
const reasonCode = tags.reason_code;
|
|
1040
|
+
const classPattern = /^sync\.(?:update\.core|journal\.(?:personal|personal_vault|company)|manifest\.(?:personal|personal_vault|company))$|^other$/;
|
|
1041
|
+
const reasonPattern = /^(?:none|core-version-unavailable|core-update-available|no-local-tree|never-synced|invalid-last-sync|stale-threshold|manifest-unavailable|manifest-state-unavailable|no-manifest-scopes|never-uploaded|invalid-upload-time|unresolved-company-uid)$/;
|
|
1042
|
+
const versionPattern = /^(?:unknown|\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?(?:\+[A-Za-z0-9.-]+)?)$/;
|
|
1043
|
+
const safeTagKeys = [
|
|
1044
|
+
"check_class", "reason_code", "fix_outcome", "status", "failing_check_count",
|
|
1045
|
+
"cli_version", "core_version", "sync_version", "platform", "arch",
|
|
1046
|
+
];
|
|
1047
|
+
return (event.level === "warning" &&
|
|
1048
|
+
classPattern.test(checkClass) &&
|
|
1049
|
+
reasonPattern.test(reasonCode) &&
|
|
1050
|
+
event.message === `Client health: ${checkClass} unresolved after hq doctor --fix` &&
|
|
1051
|
+
event.fingerprint.length === 3 &&
|
|
1052
|
+
event.fingerprint[0] === "client-health" &&
|
|
1053
|
+
event.fingerprint[1] === checkClass &&
|
|
1054
|
+
event.fingerprint[2] === reasonCode &&
|
|
1055
|
+
(tags.fix_outcome === "not_auto_fixable" || tags.fix_outcome === "fix_failed" || tags.fix_outcome === "fix_timed_out") &&
|
|
1056
|
+
(tags.status === "FAIL" || tags.status === "WARN") &&
|
|
1057
|
+
/^\d{1,3}$/.test(tags.failing_check_count) &&
|
|
1058
|
+
safeTagKeys.every((key) => Object.hasOwn(tags, key)) &&
|
|
1059
|
+
Object.keys(tags).length === safeTagKeys.length &&
|
|
1060
|
+
[tags.cli_version, tags.core_version, tags.sync_version].every((value) => versionPattern.test(value)) &&
|
|
1061
|
+
/^[a-z0-9]+$/.test(tags.platform) &&
|
|
1062
|
+
/^[a-z0-9]+$/.test(tags.arch) &&
|
|
1063
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(event.user.id) &&
|
|
1064
|
+
event.user.ip_address === null);
|
|
1065
|
+
}
|
|
1066
|
+
/** Send the client-health projection with a clean Sentry scope and no metadata payload. */
|
|
1067
|
+
export async function reportClientHealthEvents(events, options = {}, dependencies = defaultDependencies) {
|
|
1068
|
+
const timeoutMs = resolveTimeout(options.timeoutMs);
|
|
1069
|
+
if (timeoutMs === null ||
|
|
1070
|
+
events.length === 0 ||
|
|
1071
|
+
events.length > 250 ||
|
|
1072
|
+
events.some((event) => !isSafeClientHealthEvent(event))) {
|
|
1073
|
+
dependencies.stderr.write("Invalid client health Sentry event.\n");
|
|
1074
|
+
return { code: 1 };
|
|
1075
|
+
}
|
|
1076
|
+
const withScope = options.scopeRunner ?? ((callback) => Sentry.withScope((scope) => callback(scope)));
|
|
1077
|
+
try {
|
|
1078
|
+
for (const event of events) {
|
|
1079
|
+
withScope((scope) => {
|
|
1080
|
+
scope.clear();
|
|
1081
|
+
scope.setLevel(event.level);
|
|
1082
|
+
scope.setFingerprint([...event.fingerprint]);
|
|
1083
|
+
scope.setTags({ sentry_event_type: "client_health", ...event.tags });
|
|
1084
|
+
scope.setUser({ id: event.user.id, ip_address: null });
|
|
1085
|
+
dependencies.sentry.captureMessage(event.message);
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
if (await flushWithin(dependencies.sentry, timeoutMs))
|
|
1089
|
+
return { code: 0 };
|
|
1090
|
+
}
|
|
1091
|
+
catch {
|
|
1092
|
+
// Keep the event and transport details out of hook stderr.
|
|
1093
|
+
}
|
|
1094
|
+
dependencies.stderr.write("Client health Sentry report failed.\n");
|
|
1095
|
+
return { code: 1 };
|
|
1096
|
+
}
|
|
1036
1097
|
//# sourceMappingURL=sentry-report.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type FlagClient, type FlagClientOptions } from "@indigoai-us/hq-flags-client";
|
|
2
|
+
export declare const CLIENT_HEALTH_SENTRY_DIRECT_FLAG = "client-health.sentry-direct";
|
|
3
|
+
export declare const CLIENT_HEALTH_SENTRY_DIRECT_FALLBACK = false;
|
|
4
|
+
export declare const CLIENT_HEALTH_SENTRY_DIRECT_TIMEOUT_MS = 150;
|
|
5
|
+
type ClientHealthFlagClient = Pick<FlagClient, "ready" | "isEnabled" | "close">;
|
|
6
|
+
export interface ClientHealthSentryFlagDependencies {
|
|
7
|
+
env?: NodeJS.ProcessEnv;
|
|
8
|
+
createClient?: (options: FlagClientOptions) => ClientHealthFlagClient;
|
|
9
|
+
getToken?: FlagClientOptions["getToken"];
|
|
10
|
+
fetch?: typeof fetch;
|
|
11
|
+
reportFailure?: (error: unknown) => void;
|
|
12
|
+
}
|
|
13
|
+
export interface ClientHealthSentryCompany {
|
|
14
|
+
companyUid: string;
|
|
15
|
+
companySlug?: string;
|
|
16
|
+
}
|
|
17
|
+
/** Resolve the active company gate with a bounded hq-flags read. */
|
|
18
|
+
export declare function clientHealthSentryDirectEnabled(dependencies?: ClientHealthSentryFlagDependencies, callerSignal?: AbortSignal): Promise<boolean>;
|
|
19
|
+
/** Resolve the default-off gate for one explicitly identified company. */
|
|
20
|
+
export declare function clientHealthSentryDirectEnabledForCompany(company: ClientHealthSentryCompany, dependencies?: ClientHealthSentryFlagDependencies, callerSignal?: AbortSignal): Promise<boolean>;
|
|
21
|
+
export {};
|
|
22
|
+
//# sourceMappingURL=client-health-sentry-flag.d.ts.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { loadCachedTokens } from "@indigoai-us/hq-cloud";
|
|
2
|
+
import { createFlagClient, } from "@indigoai-us/hq-flags-client";
|
|
3
|
+
import { resolveFlagRegistryEndpoint } from "../flag-registry-endpoint.js";
|
|
4
|
+
export const CLIENT_HEALTH_SENTRY_DIRECT_FLAG = "client-health.sentry-direct";
|
|
5
|
+
export const CLIENT_HEALTH_SENTRY_DIRECT_FALLBACK = false;
|
|
6
|
+
export const CLIENT_HEALTH_SENTRY_DIRECT_TIMEOUT_MS = 150;
|
|
7
|
+
function reportFlagFailure(error) {
|
|
8
|
+
const name = error instanceof Error ? error.name : "UnknownError";
|
|
9
|
+
const safeName = /^[A-Za-z][A-Za-z0-9]{0,63}$/.test(name) ? name : "UnknownError";
|
|
10
|
+
process.stderr.write(`HQ client-health Sentry flag lookup failed (${safeName}); using default off.\n`);
|
|
11
|
+
}
|
|
12
|
+
function combineSignals(first, second) {
|
|
13
|
+
return first ? AbortSignal.any([first, second]) : second;
|
|
14
|
+
}
|
|
15
|
+
/** Resolve the active company gate with a bounded hq-flags read. */
|
|
16
|
+
export async function clientHealthSentryDirectEnabled(dependencies = {}, callerSignal) {
|
|
17
|
+
const env = dependencies.env ?? process.env;
|
|
18
|
+
const companyUid = env.HQ_COMPANY_UID?.trim() || "";
|
|
19
|
+
if (!/^cmp_[A-Za-z0-9]{3,128}$/.test(companyUid)) {
|
|
20
|
+
return CLIENT_HEALTH_SENTRY_DIRECT_FALLBACK;
|
|
21
|
+
}
|
|
22
|
+
return clientHealthSentryDirectEnabledForCompany({
|
|
23
|
+
companyUid,
|
|
24
|
+
companySlug: env.HQ_COMPANY_SLUG?.trim() || undefined,
|
|
25
|
+
}, dependencies, callerSignal);
|
|
26
|
+
}
|
|
27
|
+
/** Resolve the default-off gate for one explicitly identified company. */
|
|
28
|
+
export async function clientHealthSentryDirectEnabledForCompany(company, dependencies = {}, callerSignal) {
|
|
29
|
+
const env = dependencies.env ?? process.env;
|
|
30
|
+
const companyUid = company.companyUid.trim();
|
|
31
|
+
if (!/^cmp_[A-Za-z0-9]{3,128}$/.test(companyUid)) {
|
|
32
|
+
return CLIENT_HEALTH_SENTRY_DIRECT_FALLBACK;
|
|
33
|
+
}
|
|
34
|
+
const companySlug = company.companySlug?.trim() || "";
|
|
35
|
+
const deadline = AbortSignal.timeout(CLIENT_HEALTH_SENTRY_DIRECT_TIMEOUT_MS);
|
|
36
|
+
const signal = callerSignal ? AbortSignal.any([callerSignal, deadline]) : deadline;
|
|
37
|
+
const doFetch = dependencies.fetch ?? globalThis.fetch;
|
|
38
|
+
let client;
|
|
39
|
+
let failed = false;
|
|
40
|
+
let failureReported = false;
|
|
41
|
+
let enabled = CLIENT_HEALTH_SENTRY_DIRECT_FALLBACK;
|
|
42
|
+
const reportFailureOnce = (error) => {
|
|
43
|
+
failed = true;
|
|
44
|
+
if (failureReported)
|
|
45
|
+
return;
|
|
46
|
+
failureReported = true;
|
|
47
|
+
(dependencies.reportFailure ?? reportFlagFailure)(error);
|
|
48
|
+
};
|
|
49
|
+
try {
|
|
50
|
+
client = (dependencies.createClient ?? createFlagClient)({
|
|
51
|
+
endpoint: resolveFlagRegistryEndpoint(undefined, env),
|
|
52
|
+
companyUid,
|
|
53
|
+
...(companySlug && /^[A-Za-z0-9][A-Za-z0-9-]{0,63}$/.test(companySlug)
|
|
54
|
+
? { companyIdentifiers: [companySlug] }
|
|
55
|
+
: {}),
|
|
56
|
+
env: {},
|
|
57
|
+
getToken: dependencies.getToken ?? (() => loadCachedTokens()?.idToken ?? ""),
|
|
58
|
+
fetch: (input, init) => doFetch(input, {
|
|
59
|
+
...init,
|
|
60
|
+
signal: combineSignals(init?.signal ?? undefined, signal),
|
|
61
|
+
}),
|
|
62
|
+
refreshIntervalMs: 0,
|
|
63
|
+
requestTimeoutMs: CLIENT_HEALTH_SENTRY_DIRECT_TIMEOUT_MS,
|
|
64
|
+
onError: reportFailureOnce,
|
|
65
|
+
});
|
|
66
|
+
await client.ready();
|
|
67
|
+
if (!failed && !signal.aborted) {
|
|
68
|
+
enabled = client.isEnabled(CLIENT_HEALTH_SENTRY_DIRECT_FLAG, {
|
|
69
|
+
fallback: CLIENT_HEALTH_SENTRY_DIRECT_FALLBACK,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
reportFailureOnce(error);
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
try {
|
|
78
|
+
client?.close();
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
reportFailureOnce(error);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return failed || signal.aborted
|
|
85
|
+
? CLIENT_HEALTH_SENTRY_DIRECT_FALLBACK
|
|
86
|
+
: enabled;
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=client-health-sentry-flag.js.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export declare const CLIENT_HEALTH_SENTRY_NOT_HANDLED_EXIT_CODE = 10;
|
|
2
|
+
export declare const CLIENT_HEALTH_SENTRY_MAX_INPUT_BYTES: number;
|
|
3
|
+
export declare const CLIENT_HEALTH_SENTRY_MAX_FINDINGS = 250;
|
|
4
|
+
export declare const CLIENT_HEALTH_INSTALL_ID_FILENAME = "install-id";
|
|
5
|
+
export declare const CLIENT_HEALTH_LAST_RESULT_FILENAME = "last-result.json";
|
|
6
|
+
export type ClientHealthFixOutcome = "not_auto_fixable" | "fix_failed" | "fix_timed_out";
|
|
7
|
+
export type ClientHealthStatus = "FAIL" | "WARN";
|
|
8
|
+
export type ClientHealthCheckClass = "sync.update.core" | "sync.journal.personal" | "sync.journal.personal_vault" | "sync.journal.company" | "sync.manifest.personal" | "sync.manifest.personal_vault" | "sync.manifest.company" | "other";
|
|
9
|
+
export interface ClientHealthSentryEvent {
|
|
10
|
+
message: string;
|
|
11
|
+
level: "warning";
|
|
12
|
+
fingerprint: ["client-health", ClientHealthCheckClass, string];
|
|
13
|
+
tags: {
|
|
14
|
+
check_class: ClientHealthCheckClass;
|
|
15
|
+
reason_code: string;
|
|
16
|
+
fix_outcome: ClientHealthFixOutcome;
|
|
17
|
+
status: ClientHealthStatus;
|
|
18
|
+
failing_check_count: string;
|
|
19
|
+
cli_version: string;
|
|
20
|
+
core_version: string;
|
|
21
|
+
sync_version: string;
|
|
22
|
+
platform: string;
|
|
23
|
+
arch: string;
|
|
24
|
+
};
|
|
25
|
+
user: {
|
|
26
|
+
id: string;
|
|
27
|
+
ip_address: null;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
interface ClientHealthFinding {
|
|
31
|
+
checkClass: ClientHealthCheckClass;
|
|
32
|
+
companySlug: string | null;
|
|
33
|
+
reasonCode: string;
|
|
34
|
+
status: ClientHealthStatus;
|
|
35
|
+
}
|
|
36
|
+
export interface ClientHealthLastResult {
|
|
37
|
+
check_class: ClientHealthCheckClass;
|
|
38
|
+
outcome: ClientHealthFixOutcome;
|
|
39
|
+
next_step: string;
|
|
40
|
+
result_id: string;
|
|
41
|
+
}
|
|
42
|
+
export interface ClientHealthReportVersions {
|
|
43
|
+
cli: string | null;
|
|
44
|
+
core: string | null;
|
|
45
|
+
sync: string | null;
|
|
46
|
+
}
|
|
47
|
+
export interface ClientHealthSentryReportDependencies {
|
|
48
|
+
resolveFlagForCompany: (companySlug: string | null) => Promise<boolean>;
|
|
49
|
+
sendEvents: (events: readonly ClientHealthSentryEvent[]) => Promise<boolean>;
|
|
50
|
+
randomUUID?: () => string;
|
|
51
|
+
versions?: (hqRoot: string) => ClientHealthReportVersions;
|
|
52
|
+
platform?: () => string;
|
|
53
|
+
arch?: () => string;
|
|
54
|
+
}
|
|
55
|
+
/** Collapse company-specific doctor ids before any value can enter a Sentry event. */
|
|
56
|
+
export declare function normalizeClientHealthCheckId(checkId: string): ClientHealthCheckClass;
|
|
57
|
+
/** Keep only the doctor fields needed for safe health reporting. */
|
|
58
|
+
export declare function parseClientHealthDoctorFindings(input: string): ClientHealthFinding[];
|
|
59
|
+
/** Store a random UUID once per local HQ install, without using host identity. */
|
|
60
|
+
export declare function getOrCreateClientHealthInstallId(hqRoot: string, randomUUID?: () => string): string;
|
|
61
|
+
/**
|
|
62
|
+
* Report only after every finding's company has opted in. A mixed rollout
|
|
63
|
+
* falls back as one batch so the old path cannot duplicate partially sent rows.
|
|
64
|
+
*/
|
|
65
|
+
export declare function handleClientHealthSentryReport(input: {
|
|
66
|
+
hqRoot: string;
|
|
67
|
+
doctorJson: string;
|
|
68
|
+
fixOutcome: string;
|
|
69
|
+
}, dependencies: ClientHealthSentryReportDependencies): Promise<"handled" | "not_handled" | "send_failed">;
|
|
70
|
+
export {};
|
|
71
|
+
//# sourceMappingURL=client-health-sentry.d.ts.map
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { randomUUID as cryptoRandomUUID } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { CLI_VERSION } from "../../cli-version.js";
|
|
6
|
+
import { readSyncVersion } from "../../utils/feedback-versions.js";
|
|
7
|
+
import { readHqVersion } from "../../utils/pack-contributions.js";
|
|
8
|
+
export const CLIENT_HEALTH_SENTRY_NOT_HANDLED_EXIT_CODE = 10;
|
|
9
|
+
export const CLIENT_HEALTH_SENTRY_MAX_INPUT_BYTES = 2 * 1024 * 1024;
|
|
10
|
+
export const CLIENT_HEALTH_SENTRY_MAX_FINDINGS = 250;
|
|
11
|
+
export const CLIENT_HEALTH_INSTALL_ID_FILENAME = "install-id";
|
|
12
|
+
export const CLIENT_HEALTH_LAST_RESULT_FILENAME = "last-result.json";
|
|
13
|
+
const STATE_DIRECTORY = path.join("workspace", ".hq-client-health");
|
|
14
|
+
const REASON_CODES = new Set([
|
|
15
|
+
"core-version-unavailable",
|
|
16
|
+
"core-update-available",
|
|
17
|
+
"no-local-tree",
|
|
18
|
+
"never-synced",
|
|
19
|
+
"invalid-last-sync",
|
|
20
|
+
"stale-threshold",
|
|
21
|
+
"manifest-unavailable",
|
|
22
|
+
"manifest-state-unavailable",
|
|
23
|
+
"no-manifest-scopes",
|
|
24
|
+
"never-uploaded",
|
|
25
|
+
"invalid-upload-time",
|
|
26
|
+
"unresolved-company-uid",
|
|
27
|
+
]);
|
|
28
|
+
const FIX_OUTCOMES = new Set(["not_auto_fixable", "fix_failed", "fix_timed_out"]);
|
|
29
|
+
const INSTALL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
30
|
+
const VERSION = /^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?(?:\+[A-Za-z0-9.-]+)?$/;
|
|
31
|
+
const PLATFORMS = new Set([
|
|
32
|
+
"aix", "android", "darwin", "freebsd", "haiku", "linux", "openbsd",
|
|
33
|
+
"sunos", "win32", "cygwin",
|
|
34
|
+
]);
|
|
35
|
+
const ARCHITECTURES = new Set([
|
|
36
|
+
"arm", "arm64", "ia32", "loong64", "mips", "mipsel", "ppc", "ppc64",
|
|
37
|
+
"riscv64", "s390x", "x64",
|
|
38
|
+
]);
|
|
39
|
+
function isRecord(value) {
|
|
40
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
41
|
+
}
|
|
42
|
+
function cleanVersion(value) {
|
|
43
|
+
return value && VERSION.test(value) ? value : "unknown";
|
|
44
|
+
}
|
|
45
|
+
function cleanPlatform(value) {
|
|
46
|
+
return PLATFORMS.has(value) ? value : "unknown";
|
|
47
|
+
}
|
|
48
|
+
function cleanArchitecture(value) {
|
|
49
|
+
return ARCHITECTURES.has(value) ? value : "unknown";
|
|
50
|
+
}
|
|
51
|
+
/** Collapse company-specific doctor ids before any value can enter a Sentry event. */
|
|
52
|
+
export function normalizeClientHealthCheckId(checkId) {
|
|
53
|
+
if (checkId === "sync.update.core")
|
|
54
|
+
return checkId;
|
|
55
|
+
if (checkId === "sync.journal.__hq_personal_vault__")
|
|
56
|
+
return "sync.journal.personal_vault";
|
|
57
|
+
if (checkId === "sync.manifest.__hq_personal_vault__")
|
|
58
|
+
return "sync.manifest.personal_vault";
|
|
59
|
+
const match = /^sync\.(journal|manifest)\.([A-Za-z0-9][A-Za-z0-9._-]{0,127})$/.exec(checkId);
|
|
60
|
+
if (!match)
|
|
61
|
+
return "other";
|
|
62
|
+
const family = match[1];
|
|
63
|
+
const scope = match[2];
|
|
64
|
+
if (scope === "personal")
|
|
65
|
+
return `sync.${family}.personal`;
|
|
66
|
+
if (family === "manifest" && scope.startsWith("unresolved."))
|
|
67
|
+
return "other";
|
|
68
|
+
return `sync.${family}.company`;
|
|
69
|
+
}
|
|
70
|
+
function companySlugForCheck(checkId, checkClass) {
|
|
71
|
+
if (checkClass !== "sync.journal.company" && checkClass !== "sync.manifest.company") {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const match = /^sync\.(?:journal|manifest)\.([A-Za-z0-9][A-Za-z0-9._-]{0,127})$/.exec(checkId);
|
|
75
|
+
return match?.[1] ?? null;
|
|
76
|
+
}
|
|
77
|
+
function safeReasonCode(value) {
|
|
78
|
+
return typeof value === "string" && REASON_CODES.has(value) ? value : "none";
|
|
79
|
+
}
|
|
80
|
+
function safeFixOutcome(value) {
|
|
81
|
+
return FIX_OUTCOMES.has(value) ? value : "not_auto_fixable";
|
|
82
|
+
}
|
|
83
|
+
/** Keep only the doctor fields needed for safe health reporting. */
|
|
84
|
+
export function parseClientHealthDoctorFindings(input) {
|
|
85
|
+
if (Buffer.byteLength(input, "utf8") > CLIENT_HEALTH_SENTRY_MAX_INPUT_BYTES) {
|
|
86
|
+
throw new Error("Client health doctor input exceeds its size limit");
|
|
87
|
+
}
|
|
88
|
+
let parsed;
|
|
89
|
+
try {
|
|
90
|
+
parsed = JSON.parse(input);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
throw new Error("Client health doctor input is invalid JSON");
|
|
94
|
+
}
|
|
95
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.results)) {
|
|
96
|
+
throw new Error("Client health doctor input has no results array");
|
|
97
|
+
}
|
|
98
|
+
const findings = [];
|
|
99
|
+
for (const value of parsed.results) {
|
|
100
|
+
if (!isRecord(value) || value.family !== "sync")
|
|
101
|
+
continue;
|
|
102
|
+
if (value.status !== "FAIL" && value.status !== "WARN")
|
|
103
|
+
continue;
|
|
104
|
+
if (value.checkId === "sync.update.core" && value.status === "WARN")
|
|
105
|
+
continue;
|
|
106
|
+
const checkId = typeof value.checkId === "string" &&
|
|
107
|
+
/^sync\.[A-Za-z0-9._-]{1,160}$/.test(value.checkId)
|
|
108
|
+
? value.checkId
|
|
109
|
+
: "unknown";
|
|
110
|
+
const checkClass = normalizeClientHealthCheckId(checkId);
|
|
111
|
+
findings.push({
|
|
112
|
+
checkClass,
|
|
113
|
+
companySlug: companySlugForCheck(checkId, checkClass),
|
|
114
|
+
reasonCode: safeReasonCode(value.reasonCode),
|
|
115
|
+
status: value.status,
|
|
116
|
+
});
|
|
117
|
+
if (findings.length > CLIENT_HEALTH_SENTRY_MAX_FINDINGS) {
|
|
118
|
+
throw new Error("Client health doctor input has too many sync findings");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return findings;
|
|
122
|
+
}
|
|
123
|
+
function defaultVersions(hqRoot) {
|
|
124
|
+
let core;
|
|
125
|
+
try {
|
|
126
|
+
core = readHqVersion(hqRoot);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
core = null;
|
|
130
|
+
}
|
|
131
|
+
let sync;
|
|
132
|
+
try {
|
|
133
|
+
sync = readSyncVersion();
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
sync = null;
|
|
137
|
+
}
|
|
138
|
+
return { cli: CLI_VERSION, core, sync };
|
|
139
|
+
}
|
|
140
|
+
function stateDirectory(hqRoot) {
|
|
141
|
+
const root = fs.realpathSync(hqRoot);
|
|
142
|
+
const workspacePath = path.join(root, "workspace");
|
|
143
|
+
fs.mkdirSync(workspacePath, { recursive: true, mode: 0o700 });
|
|
144
|
+
const workspaceStat = fs.lstatSync(workspacePath);
|
|
145
|
+
if (!workspaceStat.isDirectory() || workspaceStat.isSymbolicLink()) {
|
|
146
|
+
throw new Error("Client health state workspace is not a local directory");
|
|
147
|
+
}
|
|
148
|
+
const directory = path.join(root, STATE_DIRECTORY);
|
|
149
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
150
|
+
const directoryStat = fs.lstatSync(directory);
|
|
151
|
+
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) {
|
|
152
|
+
throw new Error("Client health state directory is not a local directory");
|
|
153
|
+
}
|
|
154
|
+
fs.chmodSync(directory, 0o700);
|
|
155
|
+
return directory;
|
|
156
|
+
}
|
|
157
|
+
function readInstallId(filePath) {
|
|
158
|
+
try {
|
|
159
|
+
const fileStat = fs.lstatSync(filePath);
|
|
160
|
+
if (!fileStat.isFile() || fileStat.isSymbolicLink()) {
|
|
161
|
+
throw new Error("Client health install id is not a regular local file");
|
|
162
|
+
}
|
|
163
|
+
const value = fs.readFileSync(filePath, "utf8").trim();
|
|
164
|
+
if (!INSTALL_ID.test(value))
|
|
165
|
+
throw new Error("Client health install id is invalid");
|
|
166
|
+
fs.chmodSync(filePath, 0o600);
|
|
167
|
+
return value;
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
if (isRecord(error) && error.code === "ENOENT")
|
|
171
|
+
return null;
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** Store a random UUID once per local HQ install, without using host identity. */
|
|
176
|
+
export function getOrCreateClientHealthInstallId(hqRoot, randomUUID = cryptoRandomUUID) {
|
|
177
|
+
const directory = stateDirectory(hqRoot);
|
|
178
|
+
const filePath = path.join(directory, CLIENT_HEALTH_INSTALL_ID_FILENAME);
|
|
179
|
+
const current = readInstallId(filePath);
|
|
180
|
+
if (current)
|
|
181
|
+
return current;
|
|
182
|
+
const candidate = randomUUID();
|
|
183
|
+
if (!INSTALL_ID.test(candidate))
|
|
184
|
+
throw new Error("Client health random id source returned an invalid id");
|
|
185
|
+
try {
|
|
186
|
+
fs.writeFileSync(filePath, `${candidate}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
187
|
+
return candidate;
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
if (!isRecord(error) || error.code !== "EEXIST")
|
|
191
|
+
throw error;
|
|
192
|
+
const raced = readInstallId(filePath);
|
|
193
|
+
if (!raced)
|
|
194
|
+
throw new Error("Client health install id was not created");
|
|
195
|
+
return raced;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function nextStep(checkClass) {
|
|
199
|
+
switch (checkClass) {
|
|
200
|
+
case "sync.update.core":
|
|
201
|
+
return "HQ couldn't update itself automatically. Run `hq update` or `/update-hq`, then restart this session.";
|
|
202
|
+
case "sync.journal.personal":
|
|
203
|
+
case "sync.journal.personal_vault":
|
|
204
|
+
return "HQ hasn't synced your personal files in over 7 days and the automatic fix did not work. Run `hq sync` and check the result. If it fails, run `hq doctor` and send the output to support.";
|
|
205
|
+
case "sync.journal.company":
|
|
206
|
+
return "HQ hasn't synced this company's files in over 7 days and the automatic fix did not work. Run `hq sync` and check the result. If it fails, run `hq doctor` and send the output to support.";
|
|
207
|
+
case "sync.manifest.personal":
|
|
208
|
+
case "sync.manifest.personal_vault":
|
|
209
|
+
return "HQ couldn't read your personal sync list. Run `hq doctor --fix`. If it still fails, run `hq login` again.";
|
|
210
|
+
case "sync.manifest.company":
|
|
211
|
+
return "HQ couldn't read a company sync manifest. Run `hq doctor --fix`. If it still fails, run `hq login` again.";
|
|
212
|
+
default:
|
|
213
|
+
return "HQ still reports a sync health issue after its automatic fix. Run `hq doctor` and send the output to support.";
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function writeLastResult(hqRoot, finding, outcome, randomUUID) {
|
|
217
|
+
const directory = stateDirectory(hqRoot);
|
|
218
|
+
const result = {
|
|
219
|
+
check_class: finding.checkClass,
|
|
220
|
+
outcome,
|
|
221
|
+
next_step: nextStep(finding.checkClass),
|
|
222
|
+
result_id: randomUUID(),
|
|
223
|
+
};
|
|
224
|
+
if (!INSTALL_ID.test(result.result_id))
|
|
225
|
+
throw new Error("Client health result id source returned an invalid id");
|
|
226
|
+
const filePath = path.join(directory, CLIENT_HEALTH_LAST_RESULT_FILENAME);
|
|
227
|
+
const tempPath = path.join(directory, `${CLIENT_HEALTH_LAST_RESULT_FILENAME}.${result.result_id}.tmp`);
|
|
228
|
+
fs.writeFileSync(tempPath, `${JSON.stringify(result, null, 2)}\n`, {
|
|
229
|
+
encoding: "utf8",
|
|
230
|
+
flag: "wx",
|
|
231
|
+
mode: 0o600,
|
|
232
|
+
});
|
|
233
|
+
fs.renameSync(tempPath, filePath);
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
236
|
+
function buildEvent(finding, installId, outcome, failingCount, versions, platform, arch) {
|
|
237
|
+
const reasonCode = finding.reasonCode;
|
|
238
|
+
return {
|
|
239
|
+
message: `Client health: ${finding.checkClass} unresolved after hq doctor --fix`,
|
|
240
|
+
level: "warning",
|
|
241
|
+
fingerprint: ["client-health", finding.checkClass, reasonCode],
|
|
242
|
+
tags: {
|
|
243
|
+
check_class: finding.checkClass,
|
|
244
|
+
reason_code: reasonCode,
|
|
245
|
+
fix_outcome: outcome,
|
|
246
|
+
status: finding.status,
|
|
247
|
+
failing_check_count: String(failingCount),
|
|
248
|
+
cli_version: cleanVersion(versions.cli),
|
|
249
|
+
core_version: cleanVersion(versions.core),
|
|
250
|
+
sync_version: cleanVersion(versions.sync),
|
|
251
|
+
platform: cleanPlatform(platform),
|
|
252
|
+
arch: cleanArchitecture(arch),
|
|
253
|
+
},
|
|
254
|
+
user: { id: installId, ip_address: null },
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Report only after every finding's company has opted in. A mixed rollout
|
|
259
|
+
* falls back as one batch so the old path cannot duplicate partially sent rows.
|
|
260
|
+
*/
|
|
261
|
+
export async function handleClientHealthSentryReport(input, dependencies) {
|
|
262
|
+
const findings = parseClientHealthDoctorFindings(input.doctorJson);
|
|
263
|
+
if (findings.length === 0)
|
|
264
|
+
return "handled";
|
|
265
|
+
const outcome = safeFixOutcome(input.fixOutcome);
|
|
266
|
+
const companySlugs = [...new Set(findings.map((finding) => finding.companySlug))];
|
|
267
|
+
const enabled = await Promise.all(companySlugs.map((companySlug) => dependencies.resolveFlagForCompany(companySlug)));
|
|
268
|
+
if (enabled.some((value) => value !== true))
|
|
269
|
+
return "not_handled";
|
|
270
|
+
const randomUUID = dependencies.randomUUID ?? cryptoRandomUUID;
|
|
271
|
+
const installId = getOrCreateClientHealthInstallId(input.hqRoot, randomUUID);
|
|
272
|
+
const versions = (dependencies.versions ?? defaultVersions)(input.hqRoot);
|
|
273
|
+
const events = findings.map((finding) => buildEvent(finding, installId, outcome, findings.length, versions, cleanPlatform((dependencies.platform ?? (() => process.platform))()), cleanArchitecture((dependencies.arch ?? os.arch)())));
|
|
274
|
+
writeLastResult(input.hqRoot, findings[0], outcome, randomUUID);
|
|
275
|
+
if (!(await dependencies.sendEvents(events)))
|
|
276
|
+
return "send_failed";
|
|
277
|
+
return "handled";
|
|
278
|
+
}
|
|
279
|
+
//# sourceMappingURL=client-health-sentry.js.map
|
|
@@ -175,6 +175,86 @@ function scrubFields(value, memo = new Map()) {
|
|
|
175
175
|
return out;
|
|
176
176
|
}
|
|
177
177
|
export function beforeSend(event, _hint) {
|
|
178
|
+
if (event.tags?.sentry_event_type === "client_health") {
|
|
179
|
+
const sourceTags = (event.tags ?? {});
|
|
180
|
+
const checkClasses = new Set([
|
|
181
|
+
"sync.update.core",
|
|
182
|
+
"sync.journal.personal",
|
|
183
|
+
"sync.journal.personal_vault",
|
|
184
|
+
"sync.journal.company",
|
|
185
|
+
"sync.manifest.personal",
|
|
186
|
+
"sync.manifest.personal_vault",
|
|
187
|
+
"sync.manifest.company",
|
|
188
|
+
"other",
|
|
189
|
+
]);
|
|
190
|
+
const reasonCodes = new Set([
|
|
191
|
+
"none",
|
|
192
|
+
"core-version-unavailable",
|
|
193
|
+
"core-update-available",
|
|
194
|
+
"no-local-tree",
|
|
195
|
+
"never-synced",
|
|
196
|
+
"invalid-last-sync",
|
|
197
|
+
"stale-threshold",
|
|
198
|
+
"manifest-unavailable",
|
|
199
|
+
"manifest-state-unavailable",
|
|
200
|
+
"no-manifest-scopes",
|
|
201
|
+
"never-uploaded",
|
|
202
|
+
"invalid-upload-time",
|
|
203
|
+
"unresolved-company-uid",
|
|
204
|
+
]);
|
|
205
|
+
const platforms = new Set([
|
|
206
|
+
"aix", "android", "darwin", "freebsd", "haiku", "linux", "openbsd",
|
|
207
|
+
"sunos", "win32", "cygwin",
|
|
208
|
+
]);
|
|
209
|
+
const architectures = new Set([
|
|
210
|
+
"arm", "arm64", "ia32", "loong64", "mips", "mipsel", "ppc", "ppc64",
|
|
211
|
+
"riscv64", "s390x", "x64",
|
|
212
|
+
]);
|
|
213
|
+
const versionPattern = /^(?:unknown|\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?(?:\+[A-Za-z0-9.-]+)?)$/;
|
|
214
|
+
const id = typeof event.user?.id === "string" &&
|
|
215
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(event.user.id)
|
|
216
|
+
? event.user.id
|
|
217
|
+
: undefined;
|
|
218
|
+
const checkClass = typeof sourceTags.check_class === "string" && checkClasses.has(sourceTags.check_class)
|
|
219
|
+
? sourceTags.check_class
|
|
220
|
+
: "other";
|
|
221
|
+
const reasonCode = typeof sourceTags.reason_code === "string" && reasonCodes.has(sourceTags.reason_code)
|
|
222
|
+
? sourceTags.reason_code
|
|
223
|
+
: "none";
|
|
224
|
+
const fixOutcome = ["not_auto_fixable", "fix_failed", "fix_timed_out"].includes(String(sourceTags.fix_outcome)) ? String(sourceTags.fix_outcome) : "not_auto_fixable";
|
|
225
|
+
const status = sourceTags.status === "WARN" ? "WARN" : "FAIL";
|
|
226
|
+
const count = typeof sourceTags.failing_check_count === "string" &&
|
|
227
|
+
/^[1-9]\d{0,2}$/.test(sourceTags.failing_check_count) &&
|
|
228
|
+
Number(sourceTags.failing_check_count) <= 250
|
|
229
|
+
? sourceTags.failing_check_count
|
|
230
|
+
: "1";
|
|
231
|
+
const safeVersion = (value) => typeof value === "string" && versionPattern.test(value) ? value : "unknown";
|
|
232
|
+
const safePlatform = (value) => typeof value === "string" && platforms.has(value) ? value : "unknown";
|
|
233
|
+
const safeArchitecture = (value) => typeof value === "string" && architectures.has(value) ? value : "unknown";
|
|
234
|
+
return {
|
|
235
|
+
type: undefined,
|
|
236
|
+
...(typeof event.event_id === "string" ? { event_id: event.event_id } : {}),
|
|
237
|
+
...(typeof event.timestamp === "number" ? { timestamp: event.timestamp } : {}),
|
|
238
|
+
platform: "node",
|
|
239
|
+
level: "warning",
|
|
240
|
+
message: `Client health: ${checkClass} unresolved after hq doctor --fix`,
|
|
241
|
+
fingerprint: ["client-health", checkClass, reasonCode],
|
|
242
|
+
tags: {
|
|
243
|
+
sentry_event_type: "client_health",
|
|
244
|
+
check_class: checkClass,
|
|
245
|
+
reason_code: reasonCode,
|
|
246
|
+
fix_outcome: fixOutcome,
|
|
247
|
+
status,
|
|
248
|
+
failing_check_count: count,
|
|
249
|
+
cli_version: safeVersion(sourceTags.cli_version),
|
|
250
|
+
core_version: safeVersion(sourceTags.core_version),
|
|
251
|
+
sync_version: safeVersion(sourceTags.sync_version),
|
|
252
|
+
platform: safePlatform(sourceTags.platform),
|
|
253
|
+
arch: safeArchitecture(sourceTags.arch),
|
|
254
|
+
},
|
|
255
|
+
user: { ...(id ? { id } : {}), ip_address: null },
|
|
256
|
+
};
|
|
257
|
+
}
|
|
178
258
|
if (event.request) {
|
|
179
259
|
const req = event.request;
|
|
180
260
|
event.request = {
|
|
@@ -158,6 +158,7 @@ export async function registerCommandsWithRecovery(args) {
|
|
|
158
158
|
waitedMs: result.waitedMs,
|
|
159
159
|
sawLock: result.sawLock,
|
|
160
160
|
sawRetired: result.sawRetired,
|
|
161
|
+
retiredSiblingPresent: result.retiredSiblingPresent,
|
|
161
162
|
sawTreeChange: result.sawTreeChange,
|
|
162
163
|
lockHeldAtEnd: result.lockHeldAtEnd,
|
|
163
164
|
lockedMs: result.lockedMs,
|
|
@@ -188,6 +189,7 @@ export async function registerCommandsWithRecovery(args) {
|
|
|
188
189
|
waitedMs: result.waitedMs,
|
|
189
190
|
sawLock: result.sawLock,
|
|
190
191
|
sawRetired: result.sawRetired,
|
|
192
|
+
retiredSiblingPresent: result.retiredSiblingPresent,
|
|
191
193
|
sawTreeChange: result.sawTreeChange,
|
|
192
194
|
lockTool: result.lockTool,
|
|
193
195
|
});
|
|
@@ -39,8 +39,9 @@
|
|
|
39
39
|
*/
|
|
40
40
|
export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND" | "ENOENT" | "EVAL_THROW";
|
|
41
41
|
/**
|
|
42
|
-
* The closed set of resolution-failure dialects, verified on Node v22.23.1
|
|
43
|
-
*
|
|
42
|
+
* The closed set of resolution-failure dialects, verified on Node v22.23.1 and
|
|
43
|
+
* the observed Node 24 synchronous ESM loader shape (the @sentry/node
|
|
44
|
+
* import-in-the-middle hook does not change the stack transfer):
|
|
44
45
|
* - `esm-path`: ESM importer, an absolute file (or legacyMainResolve dir)
|
|
45
46
|
* target — `err.url` set, or `Cannot find package '<abs>'…`.
|
|
46
47
|
* - `esm-package`: ESM importer, a bare specifier — `Cannot find package
|
|
@@ -62,7 +63,7 @@ export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND" | "
|
|
|
62
63
|
* held in Module._pathCache) and gone at READ, so the CJS
|
|
63
64
|
* loader's source read (defaultLoadImpl/loadSource, or the older
|
|
64
65
|
* Module._extensions..js handler) raises ENOENT with syscall
|
|
65
|
-
* 'open' and a cjs/loader frame but NO
|
|
66
|
+
* 'open' and a cjs/loader frame but NO ESM source-read frame. `err.path`
|
|
66
67
|
* is the vanished file.
|
|
67
68
|
* - `resolve-enoent`: ESM/CJS RESOLVE-time ENOENT (HQ-CLI-1S) — a path component
|
|
68
69
|
* stat'ed OK, then vanished before realpathSync's lstat, so the
|
|
@@ -179,6 +180,13 @@ export interface InstallTreeFs {
|
|
|
179
180
|
existsSync(p: string): boolean;
|
|
180
181
|
statSync(p: string): {
|
|
181
182
|
isDirectory(): boolean;
|
|
183
|
+
isSymbolicLink?(): boolean;
|
|
184
|
+
size?: number;
|
|
185
|
+
mtimeMs?: number;
|
|
186
|
+
};
|
|
187
|
+
lstatSync?(p: string): {
|
|
188
|
+
isDirectory(): boolean;
|
|
189
|
+
isSymbolicLink?(): boolean;
|
|
182
190
|
size?: number;
|
|
183
191
|
mtimeMs?: number;
|
|
184
192
|
};
|
|
@@ -264,6 +272,8 @@ export interface WaitForInstallTreeSettledResult {
|
|
|
264
272
|
sawTreeChange?: boolean;
|
|
265
273
|
/** True when the lock-free tree stayed unchanged for `staleProbeMs`. */
|
|
266
274
|
staleTree?: boolean;
|
|
275
|
+
/** Presence on the final poll; null means the sibling directory was unreadable. */
|
|
276
|
+
retiredSiblingPresent?: boolean | null;
|
|
267
277
|
}
|
|
268
278
|
export declare const DEFAULT_INSTALL_TREE_STALE_PROBE_MS = 1500;
|
|
269
279
|
/**
|
|
@@ -278,10 +288,11 @@ export declare const DEFAULT_INSTALL_TREE_STALE_PROBE_MS = 1500;
|
|
|
278
288
|
* app, the box's update timer, another `hq`) cannot make a legitimately-in-progress
|
|
279
289
|
* reinstall look stale:
|
|
280
290
|
* - UNLOCKED time — wall time observed with no fresh foreign lock — is bounded
|
|
281
|
-
* by `deadlineMs`. If the target is still missing
|
|
282
|
-
*
|
|
283
|
-
* wait returns `staleTree` early. Any observed tree
|
|
284
|
-
* settle deadline in force. `deadlineMs === 0`
|
|
291
|
+
* by `deadlineMs`. If the target is still missing and the observed tree,
|
|
292
|
+
* including retired sibling names and mtimes, stays unchanged for
|
|
293
|
+
* `staleProbeMs`, the wait returns `staleTree` early. Any observed tree
|
|
294
|
+
* change keeps the ordinary settle deadline in force. `deadlineMs === 0`
|
|
295
|
+
* evaluates readiness once.
|
|
285
296
|
* - LOCKED time — wall time observed under a fresh foreign lock held by a live
|
|
286
297
|
* pid — is bounded SEPARATELY by `lockWaitMs` (default the lock contract's own
|
|
287
298
|
* 10-minute staleness window) and does NOT consume the settle budget. When
|
|
@@ -342,6 +353,8 @@ export type InstallTreeTornDiagnostics = {
|
|
|
342
353
|
/** The bounded `tool` of the last fresh foreign lock seen, or "". */
|
|
343
354
|
lockTool: string;
|
|
344
355
|
node: string;
|
|
356
|
+
/** Presence on the final poll when the scope directory could be observed. */
|
|
357
|
+
retiredSiblingPresent?: boolean;
|
|
345
358
|
/** The thrown error's `name` for the eval-throw dialect (HQ-CLI-1T), else "". */
|
|
346
359
|
errorName: string;
|
|
347
360
|
};
|
|
@@ -363,6 +376,8 @@ export interface InstallTreeTornInit {
|
|
|
363
376
|
lockedMs?: number;
|
|
364
377
|
/** The last fresh foreign lock's `tool`, bounded on construction (default ""). */
|
|
365
378
|
lockTool?: string;
|
|
379
|
+
/** Final retired sibling presence, or null when the scope directory was unreadable. */
|
|
380
|
+
retiredSiblingPresent?: boolean | null;
|
|
366
381
|
}
|
|
367
382
|
/**
|
|
368
383
|
* A torn-install failure that stays VISIBLE in Sentry with bounded, hq-derived
|
|
@@ -48,15 +48,33 @@ const ESM_PACKAGE_RE = /^Cannot find package '([^']+)' imported from (.+)$/s;
|
|
|
48
48
|
const CJS_MODULE_RE = /^Cannot find module '([^']+)'/s;
|
|
49
49
|
const ESM_MODULE_IMPORTED_RE = /^Cannot find module '([^']+)' imported from (.+)$/s;
|
|
50
50
|
/**
|
|
51
|
-
* The Node ESM loader's SOURCE-READ
|
|
52
|
-
* `node:internal/modules/esm/
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* never `esm/load` — is NOT misclassified as a torn install and does not trigger
|
|
57
|
-
* the settle wait. `load\b` also excludes the sibling `esm/loader` module.
|
|
51
|
+
* The Node ESM loader's SOURCE-READ helper in `node:internal/modules/esm/load`
|
|
52
|
+
* (Node 22) or `node:internal/modules/esm/loader` (Node 24's sync loader).
|
|
53
|
+
* `getSourceSync` must be the first non-`node:fs` caller after the source read;
|
|
54
|
+
* an ordinary fs ENOENT that escapes module evaluation therefore stays outside
|
|
55
|
+
* recovery even when an outer ModuleLoader frame exists.
|
|
58
56
|
*/
|
|
59
|
-
const
|
|
57
|
+
const ESM_SOURCE_READ_FRAME = /^at getSourceSync \(node:internal[\\/]modules[\\/]esm[\\/](?:load|loader)\b/;
|
|
58
|
+
const NODE_FS_FRAME = /^at .* \(node:fs\b/;
|
|
59
|
+
/** True only when Node's ESM source-read helper called the failing fs read. */
|
|
60
|
+
function hasEsmLoaderSourceReadFrame(stack) {
|
|
61
|
+
const frames = stack
|
|
62
|
+
.split("\n")
|
|
63
|
+
.map((line) => line.trim())
|
|
64
|
+
.filter((line) => line.startsWith("at "));
|
|
65
|
+
let sawFsRead = false;
|
|
66
|
+
for (const frame of frames) {
|
|
67
|
+
if (!sawFsRead) {
|
|
68
|
+
if (NODE_FS_READ_FRAME.test(frame))
|
|
69
|
+
sawFsRead = true;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (NODE_FS_FRAME.test(frame))
|
|
73
|
+
continue;
|
|
74
|
+
return ESM_SOURCE_READ_FRAME.test(frame);
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
60
78
|
/**
|
|
61
79
|
* The Node module RESOLVER frame — `finalizeResolution` in
|
|
62
80
|
* `node:internal/modules/esm/resolve` (the observed HQ-CLI-1S route, where
|
|
@@ -85,12 +103,12 @@ function hasModuleEvalFrame(stack) {
|
|
|
85
103
|
}
|
|
86
104
|
/**
|
|
87
105
|
* The Node CJS loader's SOURCE-READ position (HQ-CLI-20) — the CJS twin of
|
|
88
|
-
* {@link
|
|
106
|
+
* {@link hasEsmLoaderSourceReadFrame}. A CJS require whose target was present at RESOLVE (its
|
|
89
107
|
* filename cached in Module._pathCache) and gone at READ fails inside the loader's
|
|
90
108
|
* source read: `readFileSync (node:fs)` called by `defaultLoadImpl`/`loadSource`
|
|
91
109
|
* (Node ≥22) or, on older Node, the `Module._extensions..js` extension handler —
|
|
92
110
|
* all frames in `node:internal/modules/cjs/loader`, and (unlike HQ-CLI-1M) with NO
|
|
93
|
-
*
|
|
111
|
+
* ESM source-read frame.
|
|
94
112
|
*
|
|
95
113
|
* The discriminator is POSITIONAL: the innermost `node:fs` read frame's
|
|
96
114
|
* IMMEDIATELY-following (caller) frame must be a cjs/loader frame that is NOT a
|
|
@@ -251,7 +269,7 @@ export function classifyModuleNotFound(err) {
|
|
|
251
269
|
if (record.syscall === "open" &&
|
|
252
270
|
typeof record.path === "string" &&
|
|
253
271
|
typeof record.stack === "string" &&
|
|
254
|
-
|
|
272
|
+
hasEsmLoaderSourceReadFrame(record.stack)) {
|
|
255
273
|
return {
|
|
256
274
|
code,
|
|
257
275
|
dialect: "esm-enoent",
|
|
@@ -263,7 +281,7 @@ export function classifyModuleNotFound(err) {
|
|
|
263
281
|
// cjs-loader source-read ENOENT (HQ-CLI-20): the CJS twin of esm-enoent — a
|
|
264
282
|
// require() whose target was present at RESOLVE (its filename held in
|
|
265
283
|
// Module._pathCache) and gone at READ, so the CJS loader's source read raises
|
|
266
|
-
// ENOENT with syscall 'open' and a cjs/loader frame but NO
|
|
284
|
+
// ENOENT with syscall 'open' and a cjs/loader frame but NO ESM source-read frame
|
|
267
285
|
// (which is why arm (0) missed it and classifyModuleNotFound returned null,
|
|
268
286
|
// filing the bare HQ-CLI-20 capture). Gated on the FULL conjunction — code
|
|
269
287
|
// ENOENT AND syscall 'open' AND a string path AND the POSITIONAL CJS
|
|
@@ -449,6 +467,7 @@ export function classifyEvalThrow(err, packageRoot) {
|
|
|
449
467
|
const nodeInstallTreeFs = {
|
|
450
468
|
existsSync: (p) => nodeFs.existsSync(p),
|
|
451
469
|
statSync: (p) => nodeFs.statSync(p),
|
|
470
|
+
lstatSync: (p) => nodeFs.lstatSync(p),
|
|
452
471
|
readFileSync: (p, encoding) => nodeFs.readFileSync(p, encoding),
|
|
453
472
|
readdirSync: (p) => nodeFs.readdirSync(p),
|
|
454
473
|
};
|
|
@@ -633,6 +652,56 @@ function retiredSiblingPresent(packageRoot, fs) {
|
|
|
633
652
|
const entries = fs.readdirSync(parent); // caller treats a throw as "not ready"
|
|
634
653
|
return entries.some((entry) => entry.startsWith(stagingPrefix));
|
|
635
654
|
}
|
|
655
|
+
/** Snapshot retired sibling trees so changes below their root stay observable. */
|
|
656
|
+
function retiredSiblingActivitySignature(packageRoot, fs) {
|
|
657
|
+
const { leaf } = cliNameParts();
|
|
658
|
+
const stagingPrefix = `.${leaf}-`;
|
|
659
|
+
const parent = path.dirname(packageRoot);
|
|
660
|
+
const siblings = fs.readdirSync(parent).filter((entry) => entry.startsWith(stagingPrefix)).sort();
|
|
661
|
+
const snapshots = [["names", siblings]];
|
|
662
|
+
let observed = true;
|
|
663
|
+
let visited = 0;
|
|
664
|
+
const maxEntries = 20_000;
|
|
665
|
+
const maxDepth = 64;
|
|
666
|
+
const visit = (absolutePath, relativePath, depth) => {
|
|
667
|
+
visited += 1;
|
|
668
|
+
if (visited > maxEntries) {
|
|
669
|
+
observed = false;
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
try {
|
|
673
|
+
const stat = fs.lstatSync ? fs.lstatSync(absolutePath) : fs.statSync(absolutePath);
|
|
674
|
+
const symbolicLink = stat.isSymbolicLink?.() ?? false;
|
|
675
|
+
const directory = !symbolicLink && stat.isDirectory();
|
|
676
|
+
const mtimeMs = stat.mtimeMs;
|
|
677
|
+
if (typeof mtimeMs !== "number" || !Number.isFinite(mtimeMs))
|
|
678
|
+
observed = false;
|
|
679
|
+
snapshots.push([
|
|
680
|
+
relativePath,
|
|
681
|
+
symbolicLink ? "symlink" : directory ? "directory" : "file",
|
|
682
|
+
stat.size ?? null,
|
|
683
|
+
typeof mtimeMs === "number" && Number.isFinite(mtimeMs) ? mtimeMs : null,
|
|
684
|
+
]);
|
|
685
|
+
if (!directory)
|
|
686
|
+
return;
|
|
687
|
+
if (depth >= maxDepth) {
|
|
688
|
+
observed = false;
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
for (const child of fs.readdirSync(absolutePath).sort()) {
|
|
692
|
+
visit(path.join(absolutePath, child), path.join(relativePath, child), depth + 1);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
catch {
|
|
696
|
+
observed = false;
|
|
697
|
+
snapshots.push([relativePath, "unreadable"]);
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
for (const sibling of siblings) {
|
|
701
|
+
visit(path.join(parent, sibling), sibling, 0);
|
|
702
|
+
}
|
|
703
|
+
return { value: JSON.stringify(snapshots), observed };
|
|
704
|
+
}
|
|
636
705
|
/** Whether packageRoot's own manifest is healthy (`name === CLI_NAME`). */
|
|
637
706
|
function packageRootHealthy(packageRoot, fs) {
|
|
638
707
|
const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8"));
|
|
@@ -754,6 +823,18 @@ function installTreeActivitySignature(target, packageRoot, fs) {
|
|
|
754
823
|
snapshots.push([directory, `entries-unreadable:${errorCode(err)}`]);
|
|
755
824
|
}
|
|
756
825
|
}
|
|
826
|
+
if (packageRoot) {
|
|
827
|
+
try {
|
|
828
|
+
const retired = retiredSiblingActivitySignature(packageRoot, fs);
|
|
829
|
+
snapshots.push(["retired-siblings", retired.value]);
|
|
830
|
+
if (!retired.observed)
|
|
831
|
+
observed = false;
|
|
832
|
+
}
|
|
833
|
+
catch (err) {
|
|
834
|
+
observed = false;
|
|
835
|
+
snapshots.push(["retired-siblings-unreadable", errorCode(err)]);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
757
838
|
return { value: JSON.stringify(snapshots), observed };
|
|
758
839
|
}
|
|
759
840
|
export const DEFAULT_INSTALL_TREE_STALE_PROBE_MS = 1_500;
|
|
@@ -769,10 +850,11 @@ export const DEFAULT_INSTALL_TREE_STALE_PROBE_MS = 1_500;
|
|
|
769
850
|
* app, the box's update timer, another `hq`) cannot make a legitimately-in-progress
|
|
770
851
|
* reinstall look stale:
|
|
771
852
|
* - UNLOCKED time — wall time observed with no fresh foreign lock — is bounded
|
|
772
|
-
* by `deadlineMs`. If the target is still missing
|
|
773
|
-
*
|
|
774
|
-
* wait returns `staleTree` early. Any observed tree
|
|
775
|
-
* settle deadline in force. `deadlineMs === 0`
|
|
853
|
+
* by `deadlineMs`. If the target is still missing and the observed tree,
|
|
854
|
+
* including retired sibling names and mtimes, stays unchanged for
|
|
855
|
+
* `staleProbeMs`, the wait returns `staleTree` early. Any observed tree
|
|
856
|
+
* change keeps the ordinary settle deadline in force. `deadlineMs === 0`
|
|
857
|
+
* evaluates readiness once.
|
|
776
858
|
* - LOCKED time — wall time observed under a fresh foreign lock held by a live
|
|
777
859
|
* pid — is bounded SEPARATELY by `lockWaitMs` (default the lock contract's own
|
|
778
860
|
* 10-minute staleness window) and does NOT consume the settle budget. When
|
|
@@ -839,7 +921,7 @@ export async function waitForInstallTreeSettled(args) {
|
|
|
839
921
|
}
|
|
840
922
|
lastActivitySnapshot = activitySnapshot;
|
|
841
923
|
}
|
|
842
|
-
let retiredPresent =
|
|
924
|
+
let retiredPresent = null;
|
|
843
925
|
let retiredCheckFailed = false;
|
|
844
926
|
if (args.packageRoot) {
|
|
845
927
|
try {
|
|
@@ -848,6 +930,7 @@ export async function waitForInstallTreeSettled(args) {
|
|
|
848
930
|
sawRetired = true;
|
|
849
931
|
}
|
|
850
932
|
catch {
|
|
933
|
+
retiredPresent = null;
|
|
851
934
|
retiredCheckFailed = true;
|
|
852
935
|
}
|
|
853
936
|
}
|
|
@@ -883,14 +966,13 @@ export async function waitForInstallTreeSettled(args) {
|
|
|
883
966
|
lockTool,
|
|
884
967
|
sawTreeChange,
|
|
885
968
|
staleTree: false,
|
|
969
|
+
retiredSiblingPresent: retiredPresent,
|
|
886
970
|
};
|
|
887
971
|
}
|
|
888
972
|
if (!ready &&
|
|
889
973
|
!foreignLockHeld &&
|
|
890
|
-
!retiredPresent &&
|
|
891
974
|
!retiredCheckFailed &&
|
|
892
975
|
!sawLock &&
|
|
893
|
-
!sawRetired &&
|
|
894
976
|
!sawTreeChange &&
|
|
895
977
|
staleObservationStartedAt !== null &&
|
|
896
978
|
t - staleObservationStartedAt >= staleProbeMs) {
|
|
@@ -904,6 +986,7 @@ export async function waitForInstallTreeSettled(args) {
|
|
|
904
986
|
lockTool,
|
|
905
987
|
sawTreeChange,
|
|
906
988
|
staleTree: true,
|
|
989
|
+
retiredSiblingPresent: retiredPresent,
|
|
907
990
|
};
|
|
908
991
|
}
|
|
909
992
|
// Give up when the TREE has had a full unlocked budget with no writer visible,
|
|
@@ -925,6 +1008,7 @@ export async function waitForInstallTreeSettled(args) {
|
|
|
925
1008
|
lockTool,
|
|
926
1009
|
sawTreeChange,
|
|
927
1010
|
staleTree: false,
|
|
1011
|
+
retiredSiblingPresent: retiredPresent,
|
|
928
1012
|
};
|
|
929
1013
|
}
|
|
930
1014
|
// The first poll at which the wait has reached `deadlineMs` while a lock is
|
|
@@ -1007,7 +1091,7 @@ export class InstallTreeTornError extends Error {
|
|
|
1007
1091
|
this.cause = init.cause;
|
|
1008
1092
|
const sawTreeChange = init.sawTreeChange ?? false;
|
|
1009
1093
|
const treeState = init.treeState ?? (init.outcome === "stale_tree" ? "stale" : "in_flight");
|
|
1010
|
-
|
|
1094
|
+
const diagnostics = {
|
|
1011
1095
|
dialect: init.classified.dialect,
|
|
1012
1096
|
code: init.classified.code,
|
|
1013
1097
|
specifier: boundedDiagnosticValue(init.classified.specifier, SPECIFIER_BYTES),
|
|
@@ -1026,6 +1110,10 @@ export class InstallTreeTornError extends Error {
|
|
|
1026
1110
|
node: process.version,
|
|
1027
1111
|
errorName: boundedDiagnosticValue(init.classified.errorName ?? "", ERRORNAME_BYTES),
|
|
1028
1112
|
};
|
|
1113
|
+
if (typeof init.retiredSiblingPresent === "boolean") {
|
|
1114
|
+
diagnostics.retiredSiblingPresent = init.retiredSiblingPresent;
|
|
1115
|
+
}
|
|
1116
|
+
this.diagnostics = diagnostics;
|
|
1029
1117
|
Object.setPrototypeOf(this, new.target.prototype);
|
|
1030
1118
|
}
|
|
1031
1119
|
}
|
|
@@ -240,6 +240,15 @@ export function installTreeTornSentryTagsFor(err) {
|
|
|
240
240
|
tree_state: installTreeStateTag(diagnostics.treeState),
|
|
241
241
|
os: installTreeOsTag(process.platform),
|
|
242
242
|
};
|
|
243
|
+
if (diagnostics.code === "ERR_MODULE_NOT_FOUND" || diagnostics.code === "MODULE_NOT_FOUND") {
|
|
244
|
+
tags.error_shape = "module_not_found";
|
|
245
|
+
}
|
|
246
|
+
else if (diagnostics.code === "ENOENT") {
|
|
247
|
+
tags.error_shape = "loader_enoent";
|
|
248
|
+
}
|
|
249
|
+
if (typeof diagnostics.retiredSiblingPresent === "boolean") {
|
|
250
|
+
tags.retired_sibling = diagnostics.retiredSiblingPresent ? "present" : "absent";
|
|
251
|
+
}
|
|
243
252
|
if (recoveryOutcome)
|
|
244
253
|
tags.recovery_outcome = recoveryOutcome;
|
|
245
254
|
const lockTool = diagnostics.lockTool;
|