@indigoai-us/hq-cli 5.109.8 → 5.109.11
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 +35 -0
- package/dist/commands/agents.d.ts +26 -2
- package/dist/commands/agents.js +139 -14
- package/dist/lib/workers-registry/index.js +27 -1
- package/dist/startup-registration.d.ts +18 -0
- package/dist/startup-registration.js +37 -2
- package/dist/utils/install-tree-torn.d.ts +106 -15
- package/dist/utils/install-tree-torn.js +220 -28
- package/dist/utils/sentry-fingerprint.js +5 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.109.11] — 2026-09-12
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- A torn-install recovery no longer gives up after 90 seconds while another hq
|
|
10
|
+
updater is still legitimately installing hq-cli. When a command starts just as
|
|
11
|
+
the desktop app (or the box's `hq-cli-update` timer, or another `hq`) is
|
|
12
|
+
rewriting the install, the recovery waits for that writer to finish — bounded by
|
|
13
|
+
the shared install lock's own 10-minute staleness window rather than the 90-second
|
|
14
|
+
settle deadline — and then re-runs once on the healed tree, so the common case
|
|
15
|
+
(an install that finishes within the window) no longer fails at all. A short
|
|
16
|
+
second line explains the pause while it waits. If a writer holds the lock past
|
|
17
|
+
that ceiling, the command still fails once, but with a "let the update finish,
|
|
18
|
+
then re-run — do not reinstall while it is running" remedy and a distinct
|
|
19
|
+
`lock-held` outcome, instead of advice to reinstall over a live installer
|
|
20
|
+
(Sentry HQ-CLI-1Y, HQ-CLI-23). The ceiling is overridable with
|
|
21
|
+
`HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS`.
|
|
22
|
+
|
|
23
|
+
## [5.109.10] — 2026-09-12
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- A global reinstall that tears the install tree out mid-startup now recovers
|
|
28
|
+
even when the module that vanished is a RELATIVE sibling (for example
|
|
29
|
+
`@babel/runtime`'s `./typeof.js`, reached through the mqtt → worker-timers
|
|
30
|
+
chain). The torn-install classifier reduced a relative CJS specifier to the
|
|
31
|
+
package name `.`, producing a readiness probe that could never turn true — so
|
|
32
|
+
an affected command burned the entire 90-second settle budget, never re-ran on
|
|
33
|
+
the settled tree, and reported an error instead of recovering. Relative
|
|
34
|
+
specifiers are now re-resolved the way Node's CJS resolver does — from the
|
|
35
|
+
importer's directory, trying the file, its `.js`/`.json`/`.node` forms, and the
|
|
36
|
+
directory forms — so the command waits only as long as the reinstall actually
|
|
37
|
+
takes and then re-execs once on the healed tree. A genuinely missing sibling
|
|
38
|
+
still surfaces exactly one error naming the resolved path (Sentry HQ-CLI-1R).
|
|
39
|
+
|
|
5
40
|
## [5.109.8] — 2026-09-11
|
|
6
41
|
|
|
7
42
|
### Fixed
|
|
@@ -37,6 +37,28 @@ export declare const VALID_PROVIDERS: Set<string>;
|
|
|
37
37
|
export declare const VALID_AUTH_MODES: Set<string>;
|
|
38
38
|
/** Customer-facing agent size keys served by hq-pro's authoritative catalog. */
|
|
39
39
|
export declare const VALID_AGENT_SIZE_KEYS: Set<string>;
|
|
40
|
+
/**
|
|
41
|
+
* `hq agents create` exits with this distinct status when payment is required.
|
|
42
|
+
* Scripts can distinguish the expected upgrade state from an ordinary command
|
|
43
|
+
* failure without parsing human output.
|
|
44
|
+
*/
|
|
45
|
+
export declare const AGENT_CREATE_PAYMENT_REQUIRED_EXIT_CODE = 3;
|
|
46
|
+
export type AgentCreatePlanLimit = Readonly<{
|
|
47
|
+
requiredPlan: "agents-500";
|
|
48
|
+
amountMinor: number;
|
|
49
|
+
currency: string;
|
|
50
|
+
checkoutUrl?: string;
|
|
51
|
+
}>;
|
|
52
|
+
/**
|
|
53
|
+
* Decode only the priced response defined by the agent-create contract. A
|
|
54
|
+
* lookalike 403 must stay an ordinary API error rather than opening a browser
|
|
55
|
+
* or guessing at a price.
|
|
56
|
+
*/
|
|
57
|
+
export declare function agentCreatePlanLimitFromPayload(status: number, body: unknown): AgentCreatePlanLimit | null;
|
|
58
|
+
/** Print a server-priced plan block without minting or rewriting its URL. */
|
|
59
|
+
export declare function surfaceAgentCreatePlanLimit(planLimit: AgentCreatePlanLimit, opts?: {
|
|
60
|
+
json?: boolean;
|
|
61
|
+
}): Promise<void>;
|
|
40
62
|
/**
|
|
41
63
|
* Resolve a closed-set option value, or exit(1) with a message naming the
|
|
42
64
|
* offending input and the legal set.
|
|
@@ -82,7 +104,9 @@ export declare class AgentsHttpError extends Error {
|
|
|
82
104
|
code?: string;
|
|
83
105
|
/** hq-pro's billing envelope on a `402 billing_required` provision block. */
|
|
84
106
|
billing?: BillingErrorPayload;
|
|
85
|
-
|
|
107
|
+
/** Strictly decoded priced `403 AGENT_PLAN_LIMIT` create response. */
|
|
108
|
+
agentCreatePlanLimit?: AgentCreatePlanLimit;
|
|
109
|
+
constructor(status: number, message: string, code?: string, billing?: BillingErrorPayload, agentCreatePlanLimit?: AgentCreatePlanLimit);
|
|
86
110
|
}
|
|
87
111
|
/** Roster row from `GET /v1/agents` — a superset is returned; we keep what we render. */
|
|
88
112
|
export interface CompanyAgentView {
|
|
@@ -194,7 +218,7 @@ export declare function formatAgentCreateSize(option: AgentCreateSizeOption): st
|
|
|
194
218
|
/** Ask a TTY user to choose one of hq-pro's currently selectable quotes. */
|
|
195
219
|
export declare function promptForAgentCreateSize(view: AgentCreateOptionsView): Promise<QuotedAgentCreateSizeOption>;
|
|
196
220
|
/** Confirm creation using the server quote, preserving $0 as a real answer. */
|
|
197
|
-
export declare function confirmAgentCreateQuoteOrExit(option: QuotedAgentCreateSizeOption, yes?: boolean): void;
|
|
221
|
+
export declare function confirmAgentCreateQuoteOrExit(option: QuotedAgentCreateSizeOption, yes?: boolean, quiet?: boolean): void;
|
|
198
222
|
export declare function provisionAgent(token: string, input: ProvisionAgentInput): Promise<{
|
|
199
223
|
uid?: string;
|
|
200
224
|
slug?: string;
|
package/dist/commands/agents.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* the caller's single active membership (same as `members.ts`).
|
|
26
26
|
*/
|
|
27
27
|
import chalk from "chalk";
|
|
28
|
+
import open from "open";
|
|
28
29
|
import { randomUUID } from "node:crypto";
|
|
29
30
|
import * as readline from "node:readline";
|
|
30
31
|
import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
|
|
@@ -50,6 +51,96 @@ export const VALID_PROVIDERS = new Set(["codex", "grok", "claude", "agents-v2"])
|
|
|
50
51
|
export const VALID_AUTH_MODES = new Set(["subscription", "apiKey"]);
|
|
51
52
|
/** Customer-facing agent size keys served by hq-pro's authoritative catalog. */
|
|
52
53
|
export const VALID_AGENT_SIZE_KEYS = new Set(["basic", "power", "dev"]);
|
|
54
|
+
/**
|
|
55
|
+
* `hq agents create` exits with this distinct status when payment is required.
|
|
56
|
+
* Scripts can distinguish the expected upgrade state from an ordinary command
|
|
57
|
+
* failure without parsing human output.
|
|
58
|
+
*/
|
|
59
|
+
export const AGENT_CREATE_PAYMENT_REQUIRED_EXIT_CODE = 3;
|
|
60
|
+
function isCheckoutUrl(value) {
|
|
61
|
+
if (typeof value !== "string" || !value.trim())
|
|
62
|
+
return false;
|
|
63
|
+
try {
|
|
64
|
+
const url = new URL(value);
|
|
65
|
+
return url.protocol === "https:" && !url.username && !url.password;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Decode only the priced response defined by the agent-create contract. A
|
|
73
|
+
* lookalike 403 must stay an ordinary API error rather than opening a browser
|
|
74
|
+
* or guessing at a price.
|
|
75
|
+
*/
|
|
76
|
+
export function agentCreatePlanLimitFromPayload(status, body) {
|
|
77
|
+
if (status !== 403 || !body || typeof body !== "object")
|
|
78
|
+
return null;
|
|
79
|
+
const payload = body;
|
|
80
|
+
if (payload.code !== "AGENT_PLAN_LIMIT" ||
|
|
81
|
+
payload.reasonCode !== "plan_limit" ||
|
|
82
|
+
payload.requiredPlan !== "agents-500" ||
|
|
83
|
+
typeof payload.amountMinor !== "number" ||
|
|
84
|
+
!Number.isSafeInteger(payload.amountMinor) ||
|
|
85
|
+
payload.amountMinor < 0 ||
|
|
86
|
+
typeof payload.currency !== "string" ||
|
|
87
|
+
!/^[A-Za-z]{3}$/.test(payload.currency)) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
if (payload.checkoutUrl !== undefined && !isCheckoutUrl(payload.checkoutUrl)) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
requiredPlan: "agents-500",
|
|
95
|
+
amountMinor: payload.amountMinor,
|
|
96
|
+
currency: payload.currency.toUpperCase(),
|
|
97
|
+
...(typeof payload.checkoutUrl === "string"
|
|
98
|
+
? { checkoutUrl: payload.checkoutUrl }
|
|
99
|
+
: {}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function formatServerPrice(amountMinor, currency) {
|
|
103
|
+
return new Intl.NumberFormat("en-US", {
|
|
104
|
+
style: "currency",
|
|
105
|
+
currency,
|
|
106
|
+
}).format(amountMinor / 100);
|
|
107
|
+
}
|
|
108
|
+
function canOfferAgentCheckout(json) {
|
|
109
|
+
return json !== true &&
|
|
110
|
+
process.stdin.isTTY === true &&
|
|
111
|
+
process.stdout.isTTY === true &&
|
|
112
|
+
!process.env.CI;
|
|
113
|
+
}
|
|
114
|
+
async function offerAgentCheckout(url) {
|
|
115
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
116
|
+
try {
|
|
117
|
+
const answer = await new Promise((resolve) => rl.question("Open the payment page now? [y/N] ", resolve));
|
|
118
|
+
if (!/^y(?:es)?$/i.test(answer.trim()))
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
rl.close();
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
await open(url);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
console.error("Could not open the payment page automatically. Use the URL above.");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Print a server-priced plan block without minting or rewriting its URL. */
|
|
132
|
+
export async function surfaceAgentCreatePlanLimit(planLimit, opts = {}) {
|
|
133
|
+
const price = `${formatServerPrice(planLimit.amountMinor, planLimit.currency)}/month`;
|
|
134
|
+
console.error(`Payment required: your plan does not include agents. Upgrade to ${planLimit.requiredPlan} costs ${price}.`);
|
|
135
|
+
if (!planLimit.checkoutUrl) {
|
|
136
|
+
console.error("Ask a company owner to upgrade before creating an agent.");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// Deliberately plain and on its own line so terminals make this clickable.
|
|
140
|
+
console.log(planLimit.checkoutUrl);
|
|
141
|
+
if (canOfferAgentCheckout(opts.json))
|
|
142
|
+
await offerAgentCheckout(planLimit.checkoutUrl);
|
|
143
|
+
}
|
|
53
144
|
/**
|
|
54
145
|
* Resolve a closed-set option value, or exit(1) with a message naming the
|
|
55
146
|
* offending input and the legal set.
|
|
@@ -112,12 +203,15 @@ export class AgentsHttpError extends Error {
|
|
|
112
203
|
code;
|
|
113
204
|
/** hq-pro's billing envelope on a `402 billing_required` provision block. */
|
|
114
205
|
billing;
|
|
115
|
-
|
|
206
|
+
/** Strictly decoded priced `403 AGENT_PLAN_LIMIT` create response. */
|
|
207
|
+
agentCreatePlanLimit;
|
|
208
|
+
constructor(status, message, code, billing, agentCreatePlanLimit) {
|
|
116
209
|
super(message);
|
|
117
210
|
this.name = "AgentsHttpError";
|
|
118
211
|
this.status = status;
|
|
119
212
|
this.code = code;
|
|
120
213
|
this.billing = billing;
|
|
214
|
+
this.agentCreatePlanLimit = agentCreatePlanLimit;
|
|
121
215
|
}
|
|
122
216
|
}
|
|
123
217
|
/**
|
|
@@ -134,7 +228,7 @@ export async function agentsRequest(opts) {
|
|
|
134
228
|
// decline copy ("Your card was declined…") while `error` is the generic
|
|
135
229
|
// "payment required" — error-first would feed surfaceBillingBlocked the
|
|
136
230
|
// generic string and lose the decline reason (mirrors outpostRequest).
|
|
137
|
-
body.message ?? body.error ?? res.statusText, body.code, parseBillingPayload(body));
|
|
231
|
+
body.message ?? body.error ?? res.statusText, body.code, parseBillingPayload(body), agentCreatePlanLimitFromPayload(res.status, body) ?? undefined);
|
|
138
232
|
}
|
|
139
233
|
return (await res.json());
|
|
140
234
|
}
|
|
@@ -242,7 +336,7 @@ export async function promptForAgentCreateSize(view) {
|
|
|
242
336
|
}
|
|
243
337
|
}
|
|
244
338
|
/** Confirm creation using the server quote, preserving $0 as a real answer. */
|
|
245
|
-
export function confirmAgentCreateQuoteOrExit(option, yes) {
|
|
339
|
+
export function confirmAgentCreateQuoteOrExit(option, yes, quiet = false) {
|
|
246
340
|
if (option.notBilled || option.netMonthlyCents === 0) {
|
|
247
341
|
const message = `${option.productName} is ${formatUsd(0)}/month for this company — ` +
|
|
248
342
|
"there is no per-agent charge.";
|
|
@@ -250,14 +344,26 @@ export function confirmAgentCreateQuoteOrExit(option, yes) {
|
|
|
250
344
|
console.error(chalk.yellow(`${message}\nRe-run with --yes to confirm agent provisioning.`));
|
|
251
345
|
process.exit(1);
|
|
252
346
|
}
|
|
253
|
-
|
|
347
|
+
if (!quiet) {
|
|
348
|
+
console.log(chalk.dim(`${message} Provisioning…`));
|
|
349
|
+
}
|
|
254
350
|
return;
|
|
255
351
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
352
|
+
if (!quiet) {
|
|
353
|
+
confirmChargeOrExit({
|
|
354
|
+
resource: "agent",
|
|
355
|
+
unitCents: option.netMonthlyCents,
|
|
356
|
+
yes,
|
|
357
|
+
});
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (!yes) {
|
|
361
|
+
confirmChargeOrExit({
|
|
362
|
+
resource: "agent",
|
|
363
|
+
unitCents: option.netMonthlyCents,
|
|
364
|
+
yes,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
261
367
|
}
|
|
262
368
|
export async function provisionAgent(token, input) {
|
|
263
369
|
return agentsRequest({
|
|
@@ -764,7 +870,7 @@ export function registerAgentsCommand(program) {
|
|
|
764
870
|
});
|
|
765
871
|
agents
|
|
766
872
|
.command("provision <name>")
|
|
767
|
-
.
|
|
873
|
+
.aliases(["new", "create"])
|
|
768
874
|
.description("Provision a new cloud agent (company-specific monthly price shown before creation)")
|
|
769
875
|
.option("--company <slug>", "Company slug (resolves to companyUid)")
|
|
770
876
|
.option("--slug <slug>", "Agent slug (defaults to a slug of <name>)")
|
|
@@ -775,6 +881,7 @@ export function registerAgentsCommand(program) {
|
|
|
775
881
|
.option("--description <text>", "Short description / bio")
|
|
776
882
|
.option("--size <size>", "Agent box size: basic | power | dev (omitted keeps the current default)")
|
|
777
883
|
.option("--yes", "Confirm the quoted monthly cost and provision the agent")
|
|
884
|
+
.option("--json", "Emit machine-readable output and never open a browser")
|
|
778
885
|
.action(async function (name, opts) {
|
|
779
886
|
try {
|
|
780
887
|
// Both of these previously fell back to their default on an
|
|
@@ -818,12 +925,12 @@ export function registerAgentsCommand(program) {
|
|
|
818
925
|
: shouldChooseInteractively
|
|
819
926
|
? await promptForAgentCreateSize(createOptions)
|
|
820
927
|
: requireDefaultAgentCreateSize(createOptions);
|
|
821
|
-
if (!shouldChooseInteractively) {
|
|
928
|
+
if (!shouldChooseInteractively && !opts.json) {
|
|
822
929
|
console.log(chalk.dim(formatAgentCreateSize(quotedSize)));
|
|
823
930
|
}
|
|
824
931
|
// Every path confirms hq-pro's company-specific quote. Omission still
|
|
825
932
|
// leaves the POST size implicit, preserving the server-side default.
|
|
826
|
-
confirmAgentCreateQuoteOrExit(quotedSize, opts.yes);
|
|
933
|
+
confirmAgentCreateQuoteOrExit(quotedSize, opts.yes, opts.json);
|
|
827
934
|
const slug = opts.slug ?? slugifyAgentName(name);
|
|
828
935
|
try {
|
|
829
936
|
const result = await provisionAgent(token, {
|
|
@@ -849,10 +956,28 @@ export function registerAgentsCommand(program) {
|
|
|
849
956
|
surface: CLI_AGENT_CREATE_SURFACE,
|
|
850
957
|
});
|
|
851
958
|
const uid = typeof result.uid === "string" ? result.uid : slug;
|
|
852
|
-
|
|
853
|
-
|
|
959
|
+
if (opts.json) {
|
|
960
|
+
console.log(JSON.stringify(result));
|
|
961
|
+
}
|
|
962
|
+
else {
|
|
963
|
+
console.log(chalk.green(`Provisioning started for agent "${name}".`));
|
|
964
|
+
console.log(chalk.dim(`Track setup: hq agents status ${uid} --company <slug>`));
|
|
965
|
+
}
|
|
854
966
|
}
|
|
855
967
|
catch (err) {
|
|
968
|
+
if (err instanceof AgentsHttpError &&
|
|
969
|
+
err.status === 403 &&
|
|
970
|
+
err.code === "AGENT_PLAN_LIMIT") {
|
|
971
|
+
if (!err.agentCreatePlanLimit) {
|
|
972
|
+
throw new Error("HQ Pro returned an unrecognized agent payment-required response.");
|
|
973
|
+
}
|
|
974
|
+
await surfaceAgentCreatePlanLimit(err.agentCreatePlanLimit, opts);
|
|
975
|
+
// Keep the payment-required status through normal CLI teardown.
|
|
976
|
+
// Unlike process.exit(), this is testable and cannot be caught by
|
|
977
|
+
// this action's outer error boundary as a generic failure.
|
|
978
|
+
process.exitCode = AGENT_CREATE_PAYMENT_REQUIRED_EXIT_CODE;
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
856
981
|
// No card on file → surface the shareable payment link instead of an
|
|
857
982
|
// opaque 402, then exit non-zero so scripts can react.
|
|
858
983
|
if (err instanceof AgentsHttpError &&
|
|
@@ -124,6 +124,32 @@ function registryText(now, entries) {
|
|
|
124
124
|
function withoutTimestamp(content) {
|
|
125
125
|
return content.split('\n').filter((line) => !line.startsWith('generated_at:')).join('\n');
|
|
126
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Worker roots hold two kinds of `worker.yaml` that must never reach the
|
|
129
|
+
* registry.
|
|
130
|
+
*
|
|
131
|
+
* The `_template` company and any `_overrides` directory are scaffolding —
|
|
132
|
+
* copies kept so a new tenant or a pack override can be stamped out from them.
|
|
133
|
+
*
|
|
134
|
+
* A checkout nested under a tenant's `repos/` is a source repository that tenant
|
|
135
|
+
* happens to keep inside HQ, and when that repository is HQ itself, its own
|
|
136
|
+
* `core/workers` and template trees are full of worker.yaml files. Indexing them
|
|
137
|
+
* treats product source as deployed workers: they claim ids real workers already
|
|
138
|
+
* own, and because duplicates resolve by path order — a tenant path sorts before
|
|
139
|
+
* both `core` and `personal` — the checkout wins. The operator's actual worker
|
|
140
|
+
* then silently vanishes from the registry every skill reads.
|
|
141
|
+
*/
|
|
142
|
+
function isExcludedWorkerPath(relativeFile) {
|
|
143
|
+
const normalized = relativeFile.replaceAll('\\', '/');
|
|
144
|
+
const [root, tenant, nested] = normalized.split('/');
|
|
145
|
+
if (root !== 'companies')
|
|
146
|
+
return /(?:^|\/)_overrides(?:\/|$)/.test(normalized);
|
|
147
|
+
if (tenant === '_template')
|
|
148
|
+
return true;
|
|
149
|
+
if (nested === 'repos')
|
|
150
|
+
return true;
|
|
151
|
+
return /(?:^|\/)_overrides(?:\/|$)/.test(normalized);
|
|
152
|
+
}
|
|
127
153
|
/**
|
|
128
154
|
* Generate core/workers/registry.yaml from worker.yaml files. Invalid workers
|
|
129
155
|
* are quarantined but do not prevent all valid workers from being registered.
|
|
@@ -138,7 +164,7 @@ export function generateWorkersRegistry(hqRoot, options = {}) {
|
|
|
138
164
|
...workerYamlFiles(hqRoot, 'personal/workers'),
|
|
139
165
|
].sort();
|
|
140
166
|
for (const relativeFile of files) {
|
|
141
|
-
if (
|
|
167
|
+
if (isExcludedWorkerPath(relativeFile))
|
|
142
168
|
continue;
|
|
143
169
|
const fields = readWorkerFields(path.join(hqRoot, relativeFile));
|
|
144
170
|
const missing = ['id', 'type', 'description'].filter((key) => !fields[key]);
|
|
@@ -35,6 +35,24 @@ export declare const DEFAULT_INSTALL_SETTLE_TIMEOUT_MS = 90000;
|
|
|
35
35
|
export declare const INSTALL_TREE_WAIT_NOTICE = "hq: the hq-cli install is being updated underneath this command; waiting for it to finish\u2026";
|
|
36
36
|
/** Resolve the settle deadline from the environment (0 = evaluate once; default on invalid). */
|
|
37
37
|
export declare function resolveSettleTimeoutMs(env: NodeJS.ProcessEnv): number;
|
|
38
|
+
/** Operator override for the settle wait's LOCK ceiling (time under a live writer). */
|
|
39
|
+
export declare const INSTALL_SETTLE_LOCK_TIMEOUT_ENV = "HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS";
|
|
40
|
+
/**
|
|
41
|
+
* Default ceiling (ms) on the time the settle wait spends under a fresh foreign
|
|
42
|
+
* lock held by a live cooperating writer — the update-lock contract's own
|
|
43
|
+
* staleness window. Overridable by {@link INSTALL_SETTLE_LOCK_TIMEOUT_ENV}.
|
|
44
|
+
*/
|
|
45
|
+
export declare const DEFAULT_INSTALL_SETTLE_LOCK_TIMEOUT_MS: number;
|
|
46
|
+
/**
|
|
47
|
+
* The single dim line emitted once when the wait continues PAST the settle
|
|
48
|
+
* deadline because a live updater still holds the shared install lock — so a human
|
|
49
|
+
* knows the pause is a running update, not a hang. Never a remedy, never an exit
|
|
50
|
+
* code; worded distinctly from {@link INSTALL_TREE_WAIT_NOTICE} so the e2e can
|
|
51
|
+
* synchronize on it.
|
|
52
|
+
*/
|
|
53
|
+
export declare const INSTALL_TREE_LOCK_WAIT_NOTICE = "hq: another hq updater is still installing hq-cli; waiting for it to finish\u2026";
|
|
54
|
+
/** Resolve the lock ceiling from the environment (0 = do not extend; default on invalid). */
|
|
55
|
+
export declare function resolveSettleLockTimeoutMs(env: NodeJS.ProcessEnv): number;
|
|
38
56
|
/** Minimal view of a `spawnSync` result the seam relies on. */
|
|
39
57
|
export interface RecoverySpawnResult {
|
|
40
58
|
status: number | null;
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { spawnSync } from "node:child_process";
|
|
20
20
|
import { resolveRunningInstall } from "./utils/version-gate.js";
|
|
21
21
|
import { stringDerivedPackageRoot } from "./utils/hq-roots.js";
|
|
22
|
-
import { updateLockPath } from "./utils/update-lock.js";
|
|
22
|
+
import { updateLockPath, UPDATE_LOCK_STALE_MS } from "./utils/update-lock.js";
|
|
23
23
|
import { classifyModuleNotFound, InstallTreeTornError, waitForInstallTreeSettled, } from "./utils/install-tree-torn.js";
|
|
24
24
|
/**
|
|
25
25
|
* Set on the re-exec'd child so it can NEVER wait or re-exec again — distinct
|
|
@@ -46,6 +46,31 @@ export function resolveSettleTimeoutMs(env) {
|
|
|
46
46
|
return DEFAULT_INSTALL_SETTLE_TIMEOUT_MS;
|
|
47
47
|
return Number.parseInt(raw.trim(), 10);
|
|
48
48
|
}
|
|
49
|
+
/** Operator override for the settle wait's LOCK ceiling (time under a live writer). */
|
|
50
|
+
export const INSTALL_SETTLE_LOCK_TIMEOUT_ENV = "HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS";
|
|
51
|
+
/**
|
|
52
|
+
* Default ceiling (ms) on the time the settle wait spends under a fresh foreign
|
|
53
|
+
* lock held by a live cooperating writer — the update-lock contract's own
|
|
54
|
+
* staleness window. Overridable by {@link INSTALL_SETTLE_LOCK_TIMEOUT_ENV}.
|
|
55
|
+
*/
|
|
56
|
+
export const DEFAULT_INSTALL_SETTLE_LOCK_TIMEOUT_MS = UPDATE_LOCK_STALE_MS;
|
|
57
|
+
/**
|
|
58
|
+
* The single dim line emitted once when the wait continues PAST the settle
|
|
59
|
+
* deadline because a live updater still holds the shared install lock — so a human
|
|
60
|
+
* knows the pause is a running update, not a hang. Never a remedy, never an exit
|
|
61
|
+
* code; worded distinctly from {@link INSTALL_TREE_WAIT_NOTICE} so the e2e can
|
|
62
|
+
* synchronize on it.
|
|
63
|
+
*/
|
|
64
|
+
export const INSTALL_TREE_LOCK_WAIT_NOTICE = "hq: another hq updater is still installing hq-cli; waiting for it to finish…";
|
|
65
|
+
/** Resolve the lock ceiling from the environment (0 = do not extend; default on invalid). */
|
|
66
|
+
export function resolveSettleLockTimeoutMs(env) {
|
|
67
|
+
const raw = env[INSTALL_SETTLE_LOCK_TIMEOUT_ENV];
|
|
68
|
+
if (typeof raw !== "string" || raw.trim() === "")
|
|
69
|
+
return DEFAULT_INSTALL_SETTLE_LOCK_TIMEOUT_MS;
|
|
70
|
+
if (!/^\d+$/.test(raw.trim()))
|
|
71
|
+
return DEFAULT_INSTALL_SETTLE_LOCK_TIMEOUT_MS;
|
|
72
|
+
return Number.parseInt(raw.trim(), 10);
|
|
73
|
+
}
|
|
49
74
|
/**
|
|
50
75
|
* Register commands, recovering once from a torn-install module-resolution
|
|
51
76
|
* failure. On a clean registration this returns `{}` and touches nothing else.
|
|
@@ -90,17 +115,27 @@ export async function registerCommandsWithRecovery(args) {
|
|
|
90
115
|
packageRoot,
|
|
91
116
|
lockPath,
|
|
92
117
|
deadlineMs: resolveSettleTimeoutMs(env),
|
|
118
|
+
lockWaitMs: resolveSettleLockTimeoutMs(env),
|
|
119
|
+
onLockExtended: () => stderr.write(`${INSTALL_TREE_LOCK_WAIT_NOTICE}\n`),
|
|
93
120
|
});
|
|
94
121
|
if (!result.settled) {
|
|
95
122
|
throw new InstallTreeTornError({
|
|
96
123
|
cause: err,
|
|
97
124
|
classified,
|
|
98
125
|
packageRoot,
|
|
99
|
-
|
|
126
|
+
// A fresh foreign lock still held at the end means a live cooperating
|
|
127
|
+
// writer held the shared lock past its own ceiling — report 'lock-held'
|
|
128
|
+
// (its remedy tells the user to let the update finish, never to reinstall
|
|
129
|
+
// over it). Otherwise the tree genuinely never settled with no writer
|
|
130
|
+
// visible, which keeps meaning 'unsettled'.
|
|
131
|
+
outcome: result.lockHeldAtEnd ? "lock-held" : "unsettled",
|
|
100
132
|
attempt: 1,
|
|
101
133
|
waitedMs: result.waitedMs,
|
|
102
134
|
sawLock: result.sawLock,
|
|
103
135
|
sawRetired: result.sawRetired,
|
|
136
|
+
lockHeldAtEnd: result.lockHeldAtEnd,
|
|
137
|
+
lockedMs: result.lockedMs,
|
|
138
|
+
lockTool: result.lockTool,
|
|
104
139
|
});
|
|
105
140
|
}
|
|
106
141
|
const spawn = deps.spawn ?? spawnSync;
|
|
@@ -46,19 +46,28 @@ export type ModuleNotFoundCode = "ERR_MODULE_NOT_FOUND" | "MODULE_NOT_FOUND" | "
|
|
|
46
46
|
* '<abs>'` + `requireStack`.
|
|
47
47
|
* - `cjs-package`: CJS require of a bare specifier — `Cannot find module
|
|
48
48
|
* '<name>'` + `requireStack`.
|
|
49
|
+
* - `cjs-relative`: CJS require of a RELATIVE specifier — `Cannot find module
|
|
50
|
+
* './x'` (or `../x`) + `requireStack`. Node reports the RAW
|
|
51
|
+
* relative token, never a resolved path, so it is re-resolved
|
|
52
|
+
* from the importer's directory rather than reduced by
|
|
53
|
+
* packageNameOf (which would yield the un-probeable name `.`).
|
|
49
54
|
* - `esm-enoent`: ESM loader ENOENT (HQ-CLI-1M) — a module present at RESOLVE
|
|
50
55
|
* and gone at READ, so getSourceSync/openSync raises ENOENT
|
|
51
56
|
* (not ERR_MODULE_NOT_FOUND). `err.path` is the vanished file.
|
|
52
57
|
* - `unknown`: a module-not-found whose message did not parse; recovery
|
|
53
58
|
* still waits on the lock / retired-dir / quiet signals.
|
|
54
59
|
*/
|
|
55
|
-
export type ModuleErrorDialect = "esm-path" | "esm-package" | "cjs-path" | "cjs-package" | "esm-enoent" | "unknown";
|
|
60
|
+
export type ModuleErrorDialect = "esm-path" | "esm-package" | "cjs-path" | "cjs-package" | "cjs-relative" | "esm-enoent" | "unknown";
|
|
56
61
|
/**
|
|
57
62
|
* The missing thing, re-resolvable by the readiness probe:
|
|
58
|
-
* - `path`:
|
|
59
|
-
* - `package`:
|
|
60
|
-
* - `
|
|
61
|
-
*
|
|
63
|
+
* - `path`: an absolute filesystem path (a `.js` file, or a package dir).
|
|
64
|
+
* - `package`: a bare package `name` resolvable from directory `from` upward.
|
|
65
|
+
* - `relative`: a RELATIVE `specifier` (`./x`, `../x`) re-resolved the way
|
|
66
|
+
* Node's CJS resolver does from directory `from` —
|
|
67
|
+
* `path.resolve(from, specifier)` plus the `.js`/`.json`/`.node`
|
|
68
|
+
* and directory forms.
|
|
69
|
+
* - `unknown`: the message did not parse; treated as "present" by the probe so
|
|
70
|
+
* readiness turns only on the lock / retired-dir / quiet signals.
|
|
62
71
|
*/
|
|
63
72
|
export type ModuleErrorTarget = {
|
|
64
73
|
kind: "path";
|
|
@@ -67,6 +76,10 @@ export type ModuleErrorTarget = {
|
|
|
67
76
|
kind: "package";
|
|
68
77
|
name: string;
|
|
69
78
|
from: string;
|
|
79
|
+
} | {
|
|
80
|
+
kind: "relative";
|
|
81
|
+
specifier: string;
|
|
82
|
+
from: string;
|
|
70
83
|
} | {
|
|
71
84
|
kind: "unknown";
|
|
72
85
|
};
|
|
@@ -115,10 +128,24 @@ export interface InstallTreeFs {
|
|
|
115
128
|
* exact ancestor walk `getPackageJSONURL` performs, done WITHOUT
|
|
116
129
|
* `createRequire` so an ESM-only / export-conditioned package
|
|
117
130
|
* cannot false-negative.
|
|
131
|
+
* - `relative`: re-resolve `path.resolve(from, specifier)` as Node's CJS
|
|
132
|
+
* require would — a real file (exact / `.js` / `.json` / `.node`),
|
|
133
|
+
* or a directory whose package.json `main` (or that main's index)
|
|
134
|
+
* or own `index.*` is a real file. A bare or partially-extracted
|
|
135
|
+
* directory is NOT loadable and reads as not-present.
|
|
118
136
|
* - `unknown`: true (readiness turns on the other signals).
|
|
119
137
|
* Any filesystem error reads as "not present" rather than throwing.
|
|
120
138
|
*/
|
|
121
139
|
export declare function installTargetPresent(target: ModuleErrorTarget, fs?: InstallTreeFs): boolean;
|
|
140
|
+
/**
|
|
141
|
+
* Default ceiling on the CUMULATIVE time the wait may spend observing a FRESH
|
|
142
|
+
* foreign lock held by a live pid. Equal to the shared update-lock contract's own
|
|
143
|
+
* staleness window ({@link UPDATE_LOCK_STALE_MS} — "no healthy npm install -g of
|
|
144
|
+
* this package runs 10 minutes"), so the wait trusts a live cooperating writer for
|
|
145
|
+
* exactly as long as the contract legitimises holding the lock, and never longer.
|
|
146
|
+
* Overridable by the seam (env HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS).
|
|
147
|
+
*/
|
|
148
|
+
export declare const DEFAULT_INSTALL_SETTLE_LOCK_WAIT_MS: number;
|
|
122
149
|
export interface WaitForInstallTreeSettledArgs {
|
|
123
150
|
target: ModuleErrorTarget;
|
|
124
151
|
/** The running install's package dir (from resolveRunningInstall), or null. */
|
|
@@ -132,6 +159,22 @@ export interface WaitForInstallTreeSettledArgs {
|
|
|
132
159
|
pollMs?: number;
|
|
133
160
|
quietMs?: number;
|
|
134
161
|
deadlineMs: number;
|
|
162
|
+
/**
|
|
163
|
+
* Ceiling on the CUMULATIVE time observed under a FRESH foreign lock held by a
|
|
164
|
+
* live pid. Locked time does NOT consume `deadlineMs` (the budget for the tree
|
|
165
|
+
* to settle once no cooperating writer is visible); it is bounded separately by
|
|
166
|
+
* this ceiling so the wait can never hang. Defaults to
|
|
167
|
+
* {@link DEFAULT_INSTALL_SETTLE_LOCK_WAIT_MS}.
|
|
168
|
+
*/
|
|
169
|
+
lockWaitMs?: number;
|
|
170
|
+
/**
|
|
171
|
+
* Invoked exactly once — at the first poll where the total wait has reached
|
|
172
|
+
* `deadlineMs` while a fresh foreign lock is still held (the moment the
|
|
173
|
+
* pre-lock-aware wait would have given up). Lets the seam print a one-line
|
|
174
|
+
* notice that the pause is a live updater, not a hang. Never invoked when no
|
|
175
|
+
* lock is seen or the lock clears before the deadline.
|
|
176
|
+
*/
|
|
177
|
+
onLockExtended?: () => void;
|
|
135
178
|
}
|
|
136
179
|
export interface WaitForInstallTreeSettledResult {
|
|
137
180
|
settled: boolean;
|
|
@@ -140,20 +183,46 @@ export interface WaitForInstallTreeSettledResult {
|
|
|
140
183
|
sawLock: boolean;
|
|
141
184
|
/** An npm retired/staging sibling (`.hq-cli-<rand>`) was observed at least once. */
|
|
142
185
|
sawRetired: boolean;
|
|
186
|
+
/** A fresh foreign lock was held on the FINAL poll of an unsettled wait. */
|
|
187
|
+
lockHeldAtEnd: boolean;
|
|
188
|
+
/** Cumulative wall time observed under a fresh foreign lock held by a live pid. */
|
|
189
|
+
lockedMs: number;
|
|
190
|
+
/** The bounded `tool` of the last fresh foreign lock seen, or "" if none. */
|
|
191
|
+
lockTool: string;
|
|
143
192
|
}
|
|
144
193
|
/**
|
|
145
194
|
* Poll until the install tree has been continuously READY for `quietMs`, or the
|
|
146
|
-
*
|
|
147
|
-
* (ii) the missing target is present, and — when `packageRoot` is known —
|
|
148
|
-
* (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
* bounded
|
|
195
|
+
* wait exhausts its bounds. READY means: (i) no FRESH update lock held by another
|
|
196
|
+
* pid, (ii) the missing target is present, and — when `packageRoot` is known —
|
|
197
|
+
* (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and (iv) no
|
|
198
|
+
* `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem error makes
|
|
199
|
+
* its condition "not ready" rather than throwing.
|
|
200
|
+
*
|
|
201
|
+
* The wait is LOCK-AWARE and bounded on TWO independent axes, so a live
|
|
202
|
+
* cooperating writer (the desktop app, the box's update timer, another `hq`)
|
|
203
|
+
* cannot make a legitimately-in-progress reinstall look like a torn install:
|
|
204
|
+
* - UNLOCKED time — wall time observed with no fresh foreign lock — is bounded
|
|
205
|
+
* by `deadlineMs`. That is the budget for the TREE to settle once no writer is
|
|
206
|
+
* visible; with no lock ever seen, unlocked time equals total wait time and
|
|
207
|
+
* the behaviour is byte-for-byte the pre-lock-aware one (unsettled at exactly
|
|
208
|
+
* `deadlineMs`, and `deadlineMs === 0` evaluates readiness exactly once).
|
|
209
|
+
* - LOCKED time — wall time observed under a fresh foreign lock held by a live
|
|
210
|
+
* pid — is bounded SEPARATELY by `lockWaitMs` (default the lock contract's own
|
|
211
|
+
* 10-minute staleness window) and does NOT consume the settle budget. When
|
|
212
|
+
* that ceiling is reached with the lock still held, the result is unsettled
|
|
213
|
+
* with `lockHeldAtEnd` true so the seam can report a distinct `lock-held`
|
|
214
|
+
* outcome and remedy rather than telling the user to reinstall over a live
|
|
215
|
+
* writer.
|
|
216
|
+
* Each poll's REAL elapsed interval (now() deltas) is attributed to the locked or
|
|
217
|
+
* unlocked bucket by whether a fresh foreign lock was held when the interval
|
|
218
|
+
* began, so a starved event loop only makes the wait coarser, never unbounded. A
|
|
219
|
+
* stale lock (dead pid, older than the staleness window, unparseable) or our own
|
|
220
|
+
* pid never extends anything. The whole wait is bounded (≤ `deadlineMs +
|
|
221
|
+
* lockWaitMs`), so the caller can never hang.
|
|
153
222
|
*/
|
|
154
223
|
export declare function waitForInstallTreeSettled(args: WaitForInstallTreeSettledArgs): Promise<WaitForInstallTreeSettledResult>;
|
|
155
224
|
/** The recovery OUTCOME, which — not the dialect — decides capture. */
|
|
156
|
-
export type InstallTreeTornOutcome = "guarded" | "unsettled" | "reexec-failed";
|
|
225
|
+
export type InstallTreeTornOutcome = "guarded" | "unsettled" | "reexec-failed" | "lock-held";
|
|
157
226
|
export type InstallTreeTornDiagnostics = {
|
|
158
227
|
dialect: ModuleErrorDialect;
|
|
159
228
|
code: ModuleNotFoundCode;
|
|
@@ -165,6 +234,12 @@ export type InstallTreeTornDiagnostics = {
|
|
|
165
234
|
waitedMs: number;
|
|
166
235
|
sawLock: boolean;
|
|
167
236
|
sawRetired: boolean;
|
|
237
|
+
/** A fresh foreign lock was still held when the wait gave up (→ 'lock-held'). */
|
|
238
|
+
lockHeldAtEnd: boolean;
|
|
239
|
+
/** Cumulative wall time the wait spent under a fresh foreign lock. */
|
|
240
|
+
lockedMs: number;
|
|
241
|
+
/** The bounded `tool` of the last fresh foreign lock seen, or "". */
|
|
242
|
+
lockTool: string;
|
|
168
243
|
node: string;
|
|
169
244
|
};
|
|
170
245
|
export interface InstallTreeTornInit {
|
|
@@ -177,6 +252,12 @@ export interface InstallTreeTornInit {
|
|
|
177
252
|
waitedMs: number;
|
|
178
253
|
sawLock: boolean;
|
|
179
254
|
sawRetired: boolean;
|
|
255
|
+
/** Whether a fresh foreign lock was held when the wait gave up (default false). */
|
|
256
|
+
lockHeldAtEnd?: boolean;
|
|
257
|
+
/** Cumulative time under a fresh foreign lock (default 0). */
|
|
258
|
+
lockedMs?: number;
|
|
259
|
+
/** The last fresh foreign lock's `tool`, bounded on construction (default ""). */
|
|
260
|
+
lockTool?: string;
|
|
180
261
|
}
|
|
181
262
|
/**
|
|
182
263
|
* A torn-install failure that stays VISIBLE in Sentry with bounded, hq-derived
|
|
@@ -203,10 +284,20 @@ export declare function installTreeTornCaptureContext(err: InstallTreeTornError)
|
|
|
203
284
|
* appended by {@link installTreeTornStderrLine}.
|
|
204
285
|
*/
|
|
205
286
|
export declare const INSTALL_TREE_TORN_REMEDY: string;
|
|
287
|
+
/**
|
|
288
|
+
* The fixed, input-free remedy for the 'lock-held' outcome: a cooperating writer
|
|
289
|
+
* held the shared update lock past its own staleness ceiling. Telling the user to
|
|
290
|
+
* `npm i -g` here would collide with a live installer mid-rename — the exact race
|
|
291
|
+
* update-lock.ts exists to prevent — so this remedy tells them to let the running
|
|
292
|
+
* update finish and NEVER to reinstall over it. Interpolates NOTHING.
|
|
293
|
+
*/
|
|
294
|
+
export declare const INSTALL_TREE_LOCK_HELD_REMEDY: string;
|
|
206
295
|
/**
|
|
207
296
|
* The single actionable stderr line for a captured torn-install failure: the
|
|
208
|
-
* fixed remedy plus the bounded, hq-derived missing specifier — the only
|
|
209
|
-
* interpolated value, never argv.
|
|
297
|
+
* outcome's fixed remedy plus the bounded, hq-derived missing specifier — the only
|
|
298
|
+
* interpolated value, never argv. A 'lock-held' outcome selects the "let the
|
|
299
|
+
* update finish" remedy (never the reinstall advice); every other outcome keeps
|
|
300
|
+
* {@link INSTALL_TREE_TORN_REMEDY} byte-for-byte.
|
|
210
301
|
*/
|
|
211
302
|
export declare function installTreeTornStderrLine(err: InstallTreeTornError): string;
|
|
212
303
|
//# sourceMappingURL=install-tree-torn.d.ts.map
|
|
@@ -34,11 +34,13 @@ import * as path from "node:path";
|
|
|
34
34
|
import { fileURLToPath } from "node:url";
|
|
35
35
|
import { CLI_NAME } from "../cli-version.js";
|
|
36
36
|
import { boundedDiagnosticValue } from "./package-root-diagnostics.js";
|
|
37
|
-
import { isLockStale } from "./update-lock.js";
|
|
37
|
+
import { isLockStale, UPDATE_LOCK_STALE_MS } from "./update-lock.js";
|
|
38
38
|
/** Byte caps for the (hq-derived, path-shaped) diagnostic strings. */
|
|
39
39
|
const SPECIFIER_BYTES = 256;
|
|
40
40
|
const IMPORTER_BYTES = 256;
|
|
41
41
|
const PACKAGE_ROOT_BYTES = 256;
|
|
42
|
+
/** Byte cap for the lock holder's `tool` (the only foreign string this reads). */
|
|
43
|
+
const LOCK_TOOL_BYTES = 64;
|
|
42
44
|
const ESM_PACKAGE_RE = /^Cannot find package '([^']+)' imported from (.+)$/s;
|
|
43
45
|
const CJS_MODULE_RE = /^Cannot find module '([^']+)'/s;
|
|
44
46
|
const ESM_MODULE_IMPORTED_RE = /^Cannot find module '([^']+)' imported from (.+)$/s;
|
|
@@ -52,6 +54,13 @@ const ESM_MODULE_IMPORTED_RE = /^Cannot find module '([^']+)' imported from (.+)
|
|
|
52
54
|
* the settle wait. `load\b` also excludes the sibling `esm/loader` module.
|
|
53
55
|
*/
|
|
54
56
|
const ESM_LOADER_FRAME = /node:internal[\\/]modules[\\/]esm[\\/]load\b/;
|
|
57
|
+
/**
|
|
58
|
+
* A relative module specifier — `./x`, `../x`, `.\x`, `..\x`, or a bare `.`/`..`.
|
|
59
|
+
* Mirrors the shape src/utils/incomplete-install-error.ts uses, extended with the
|
|
60
|
+
* bare-`.`/`..` case so a token that packageNameOf would reduce to the
|
|
61
|
+
* un-probeable name `.` is caught here and re-resolved instead.
|
|
62
|
+
*/
|
|
63
|
+
const RELATIVE_CJS_SPECIFIER = /^\.\.?([\\/]|$)/;
|
|
55
64
|
/**
|
|
56
65
|
* Reduce a bare specifier to its PACKAGE name: `@scope/name/sub` → `@scope/name`,
|
|
57
66
|
* `name/sub` → `name`, `name` → `name`. This is the unit the readiness probe
|
|
@@ -64,6 +73,20 @@ export function packageNameOf(specifier) {
|
|
|
64
73
|
return parts.slice(0, 2).join("/");
|
|
65
74
|
return parts[0] ?? specifier;
|
|
66
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Build a `package` target from an already-reduced name, UNLESS that name is not
|
|
78
|
+
* a plausible bare package: empty, a relative token reduced to `.`/`..`, or a
|
|
79
|
+
* subpath-imports token (`#internal`). Each of those reduces to a name whose
|
|
80
|
+
* `node_modules/<name>/package.json` ancestor probe can NEVER be true, so it
|
|
81
|
+
* falls back to `unknown` — readiness then turns on the lock / retired-dir /
|
|
82
|
+
* quiet signals rather than a probe that stays permanently false.
|
|
83
|
+
*/
|
|
84
|
+
function barePackageTarget(name, from) {
|
|
85
|
+
if (name.length === 0 || name.startsWith(".") || name.startsWith("#")) {
|
|
86
|
+
return { kind: "unknown" };
|
|
87
|
+
}
|
|
88
|
+
return { kind: "package", name, from };
|
|
89
|
+
}
|
|
67
90
|
/**
|
|
68
91
|
* Classify a thrown value as a module-resolution failure and extract the missing
|
|
69
92
|
* target, or return `null` for anything that is not one. The decision is
|
|
@@ -128,7 +151,7 @@ export function classifyModuleNotFound(err) {
|
|
|
128
151
|
dialect: "esm-package",
|
|
129
152
|
specifier: spec,
|
|
130
153
|
importer,
|
|
131
|
-
target:
|
|
154
|
+
target: barePackageTarget(spec, path.dirname(importer)),
|
|
132
155
|
};
|
|
133
156
|
}
|
|
134
157
|
// (c) CJS `Cannot find module '<spec>'` (+ Require stack). Absolute → a path
|
|
@@ -142,12 +165,32 @@ export function classifyModuleNotFound(err) {
|
|
|
142
165
|
if (path.isAbsolute(spec)) {
|
|
143
166
|
return { code, dialect: "cjs-path", specifier: spec, importer, target: { kind: "path", path: spec } };
|
|
144
167
|
}
|
|
168
|
+
// Relative specifier (`./x`, `../x`): Node reports the RAW token, so
|
|
169
|
+
// packageNameOf would reduce it to `.`/`..` and build a package target
|
|
170
|
+
// whose ancestor probe can never be true. Re-resolve it from the importer's
|
|
171
|
+
// directory, and report the RESOLVED path as the diagnostic specifier
|
|
172
|
+
// (actionable — the missing sibling's real location), mirroring esm-path.
|
|
173
|
+
if (RELATIVE_CJS_SPECIFIER.test(spec)) {
|
|
174
|
+
if (importer && path.isAbsolute(importer)) {
|
|
175
|
+
const from = path.dirname(importer);
|
|
176
|
+
return {
|
|
177
|
+
code,
|
|
178
|
+
dialect: "cjs-relative",
|
|
179
|
+
specifier: path.resolve(from, spec),
|
|
180
|
+
importer,
|
|
181
|
+
target: { kind: "relative", specifier: spec, from },
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
// No usable (absolute) importer survived — fall back to the lock /
|
|
185
|
+
// retired-dir / quiet signals rather than a probe that cannot be true.
|
|
186
|
+
return { code, dialect: "cjs-relative", specifier: spec, importer, target: { kind: "unknown" } };
|
|
187
|
+
}
|
|
145
188
|
return {
|
|
146
189
|
code,
|
|
147
190
|
dialect: "cjs-package",
|
|
148
191
|
specifier: spec,
|
|
149
192
|
importer,
|
|
150
|
-
target:
|
|
193
|
+
target: barePackageTarget(packageNameOf(spec), path.dirname(importer)),
|
|
151
194
|
};
|
|
152
195
|
}
|
|
153
196
|
}
|
|
@@ -179,6 +222,58 @@ function defaultIsPidAlive(pid) {
|
|
|
179
222
|
return err.code === "EPERM";
|
|
180
223
|
}
|
|
181
224
|
}
|
|
225
|
+
/** CJS extensions Node's LOAD_AS_FILE / LOAD_INDEX try, in order. */
|
|
226
|
+
const CJS_FILE_EXTENSIONS = [".js", ".json", ".node"];
|
|
227
|
+
/** Whether `p` exists AND is a regular file (not a directory); any fs error → false. */
|
|
228
|
+
function isResolvableFile(p, fs) {
|
|
229
|
+
try {
|
|
230
|
+
return fs.existsSync(p) && !fs.statSync(p).isDirectory();
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** Node's LOAD_AS_FILE(X): X itself, then X + each CJS extension — each must be a file. */
|
|
237
|
+
function loadAsFile(base, fs) {
|
|
238
|
+
if (isResolvableFile(base, fs))
|
|
239
|
+
return true;
|
|
240
|
+
return CJS_FILE_EXTENSIONS.some((ext) => isResolvableFile(base + ext, fs));
|
|
241
|
+
}
|
|
242
|
+
/** Node's LOAD_INDEX(X): X/index.{js,json,node} — each must be a file. */
|
|
243
|
+
function loadIndex(dir, fs) {
|
|
244
|
+
return CJS_FILE_EXTENSIONS.some((ext) => isResolvableFile(path.join(dir, `index${ext}`), fs));
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Whether a relative target's resolved `base` is loadable exactly as Node's CJS
|
|
248
|
+
* require would load it: LOAD_AS_FILE(base), else LOAD_AS_DIRECTORY(base) — its
|
|
249
|
+
* package.json `main` resolved as a file (or that main's index), else
|
|
250
|
+
* base/index.*. A bare or partially-extracted directory — one that holds neither
|
|
251
|
+
* a resolvable `main` nor an index file — is deliberately NOT loadable, so it
|
|
252
|
+
* reads as not-present and the single re-exec cannot fire on a still-torn tree
|
|
253
|
+
* (mirrors the MODULE_NOT_FOUND Node would still throw for that state).
|
|
254
|
+
*/
|
|
255
|
+
function relativeTargetResolvable(base, fs) {
|
|
256
|
+
if (loadAsFile(base, fs))
|
|
257
|
+
return true;
|
|
258
|
+
const manifest = path.join(base, "package.json");
|
|
259
|
+
if (isResolvableFile(manifest, fs)) {
|
|
260
|
+
let main = null;
|
|
261
|
+
try {
|
|
262
|
+
const parsed = JSON.parse(fs.readFileSync(manifest, "utf-8"));
|
|
263
|
+
if (typeof parsed.main === "string" && parsed.main.trim() !== "")
|
|
264
|
+
main = parsed.main;
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
main = null; // unreadable / unparseable manifest — fall back to index.*
|
|
268
|
+
}
|
|
269
|
+
if (main !== null) {
|
|
270
|
+
const mainBase = path.resolve(base, main);
|
|
271
|
+
if (loadAsFile(mainBase, fs) || loadIndex(mainBase, fs))
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return loadIndex(base, fs);
|
|
276
|
+
}
|
|
182
277
|
/**
|
|
183
278
|
* Whether the missing target is now present on disk — the readiness signal the
|
|
184
279
|
* settle wait polls. It re-resolves the SAME step Node's resolver took:
|
|
@@ -189,6 +284,11 @@ function defaultIsPidAlive(pid) {
|
|
|
189
284
|
* exact ancestor walk `getPackageJSONURL` performs, done WITHOUT
|
|
190
285
|
* `createRequire` so an ESM-only / export-conditioned package
|
|
191
286
|
* cannot false-negative.
|
|
287
|
+
* - `relative`: re-resolve `path.resolve(from, specifier)` as Node's CJS
|
|
288
|
+
* require would — a real file (exact / `.js` / `.json` / `.node`),
|
|
289
|
+
* or a directory whose package.json `main` (or that main's index)
|
|
290
|
+
* or own `index.*` is a real file. A bare or partially-extracted
|
|
291
|
+
* directory is NOT loadable and reads as not-present.
|
|
192
292
|
* - `unknown`: true (readiness turns on the other signals).
|
|
193
293
|
* Any filesystem error reads as "not present" rather than throwing.
|
|
194
294
|
*/
|
|
@@ -209,6 +309,15 @@ export function installTargetPresent(target, fs = nodeInstallTreeFs) {
|
|
|
209
309
|
}
|
|
210
310
|
return true;
|
|
211
311
|
}
|
|
312
|
+
if (target.kind === "relative") {
|
|
313
|
+
// Re-resolve exactly as Node's CJS require would from `from`, so the probe
|
|
314
|
+
// turns true only when the missing module is ACTUALLY loadable again — a
|
|
315
|
+
// bare or partially-extracted directory must not read as present, or the
|
|
316
|
+
// single re-exec would fire on a still-torn tree. The extensionless case
|
|
317
|
+
// matters — a production sibling shape (`./Base/TreeIterator`, HQ-CLI-1N)
|
|
318
|
+
// carries no extension, so an exact-path-only probe would stay false for it.
|
|
319
|
+
return relativeTargetResolvable(path.resolve(target.from, target.specifier), fs);
|
|
320
|
+
}
|
|
212
321
|
// package: ancestor walk from `from`.
|
|
213
322
|
let dir = path.resolve(target.from);
|
|
214
323
|
for (;;) {
|
|
@@ -225,6 +334,15 @@ export function installTargetPresent(target, fs = nodeInstallTreeFs) {
|
|
|
225
334
|
return false;
|
|
226
335
|
}
|
|
227
336
|
}
|
|
337
|
+
/**
|
|
338
|
+
* Default ceiling on the CUMULATIVE time the wait may spend observing a FRESH
|
|
339
|
+
* foreign lock held by a live pid. Equal to the shared update-lock contract's own
|
|
340
|
+
* staleness window ({@link UPDATE_LOCK_STALE_MS} — "no healthy npm install -g of
|
|
341
|
+
* this package runs 10 minutes"), so the wait trusts a live cooperating writer for
|
|
342
|
+
* exactly as long as the contract legitimises holding the lock, and never longer.
|
|
343
|
+
* Overridable by the seam (env HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS).
|
|
344
|
+
*/
|
|
345
|
+
export const DEFAULT_INSTALL_SETTLE_LOCK_WAIT_MS = UPDATE_LOCK_STALE_MS;
|
|
228
346
|
/** The scope/leaf split of CLI_NAME, e.g. `@indigoai-us` / `hq-cli`. */
|
|
229
347
|
function cliNameParts() {
|
|
230
348
|
const slash = CLI_NAME.indexOf("/");
|
|
@@ -232,26 +350,32 @@ function cliNameParts() {
|
|
|
232
350
|
return { scope: null, leaf: CLI_NAME };
|
|
233
351
|
return { scope: CLI_NAME.slice(0, slash), leaf: CLI_NAME.slice(slash + 1) };
|
|
234
352
|
}
|
|
235
|
-
/**
|
|
236
|
-
|
|
353
|
+
/**
|
|
354
|
+
* The FRESH update lock held by a DIFFERENT live pid, or null. A non-null result
|
|
355
|
+
* blocks readiness AND extends the wait under the lock ceiling; the lock file is
|
|
356
|
+
* only ever READ. Freshness is decided by {@link isLockStale} exactly as before
|
|
357
|
+
* (parseable, `startedAt` younger than {@link UPDATE_LOCK_STALE_MS}, pid alive),
|
|
358
|
+
* and our own pid never counts.
|
|
359
|
+
*/
|
|
360
|
+
function freshForeignLock(lockPath, nowMs, isPidAlive, fs) {
|
|
237
361
|
let raw;
|
|
238
362
|
try {
|
|
239
363
|
raw = fs.readFileSync(lockPath, "utf-8");
|
|
240
364
|
}
|
|
241
365
|
catch {
|
|
242
|
-
return
|
|
366
|
+
return null; // no lock (ENOENT) or unreadable — not a fresh foreign holder
|
|
243
367
|
}
|
|
244
368
|
if (isLockStale(raw, nowMs, isPidAlive))
|
|
245
|
-
return
|
|
369
|
+
return null;
|
|
246
370
|
try {
|
|
247
371
|
const info = JSON.parse(raw);
|
|
248
372
|
if (info.pid === process.pid)
|
|
249
|
-
return
|
|
373
|
+
return null; // our own lock never blocks us
|
|
374
|
+
return info;
|
|
250
375
|
}
|
|
251
376
|
catch {
|
|
252
|
-
return
|
|
377
|
+
return null; // isLockStale already treats unparseable as stale, unreachable
|
|
253
378
|
}
|
|
254
|
-
return true;
|
|
255
379
|
}
|
|
256
380
|
/** Whether an npm retired/staging sibling (`.<leaf>-*`) sits beside packageRoot. */
|
|
257
381
|
function retiredSiblingPresent(packageRoot, fs) {
|
|
@@ -268,13 +392,33 @@ function packageRootHealthy(packageRoot, fs) {
|
|
|
268
392
|
}
|
|
269
393
|
/**
|
|
270
394
|
* Poll until the install tree has been continuously READY for `quietMs`, or the
|
|
271
|
-
*
|
|
272
|
-
* (ii) the missing target is present, and — when `packageRoot` is known —
|
|
273
|
-
* (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
* bounded
|
|
395
|
+
* wait exhausts its bounds. READY means: (i) no FRESH update lock held by another
|
|
396
|
+
* pid, (ii) the missing target is present, and — when `packageRoot` is known —
|
|
397
|
+
* (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and (iv) no
|
|
398
|
+
* `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem error makes
|
|
399
|
+
* its condition "not ready" rather than throwing.
|
|
400
|
+
*
|
|
401
|
+
* The wait is LOCK-AWARE and bounded on TWO independent axes, so a live
|
|
402
|
+
* cooperating writer (the desktop app, the box's update timer, another `hq`)
|
|
403
|
+
* cannot make a legitimately-in-progress reinstall look like a torn install:
|
|
404
|
+
* - UNLOCKED time — wall time observed with no fresh foreign lock — is bounded
|
|
405
|
+
* by `deadlineMs`. That is the budget for the TREE to settle once no writer is
|
|
406
|
+
* visible; with no lock ever seen, unlocked time equals total wait time and
|
|
407
|
+
* the behaviour is byte-for-byte the pre-lock-aware one (unsettled at exactly
|
|
408
|
+
* `deadlineMs`, and `deadlineMs === 0` evaluates readiness exactly once).
|
|
409
|
+
* - LOCKED time — wall time observed under a fresh foreign lock held by a live
|
|
410
|
+
* pid — is bounded SEPARATELY by `lockWaitMs` (default the lock contract's own
|
|
411
|
+
* 10-minute staleness window) and does NOT consume the settle budget. When
|
|
412
|
+
* that ceiling is reached with the lock still held, the result is unsettled
|
|
413
|
+
* with `lockHeldAtEnd` true so the seam can report a distinct `lock-held`
|
|
414
|
+
* outcome and remedy rather than telling the user to reinstall over a live
|
|
415
|
+
* writer.
|
|
416
|
+
* Each poll's REAL elapsed interval (now() deltas) is attributed to the locked or
|
|
417
|
+
* unlocked bucket by whether a fresh foreign lock was held when the interval
|
|
418
|
+
* began, so a starved event loop only makes the wait coarser, never unbounded. A
|
|
419
|
+
* stale lock (dead pid, older than the staleness window, unparseable) or our own
|
|
420
|
+
* pid never extends anything. The whole wait is bounded (≤ `deadlineMs +
|
|
421
|
+
* lockWaitMs`), so the caller can never hang.
|
|
278
422
|
*/
|
|
279
423
|
export async function waitForInstallTreeSettled(args) {
|
|
280
424
|
const now = args.now ?? Date.now;
|
|
@@ -284,16 +428,32 @@ export async function waitForInstallTreeSettled(args) {
|
|
|
284
428
|
const pollMs = args.pollMs ?? 250;
|
|
285
429
|
const quietMs = args.quietMs ?? 1500;
|
|
286
430
|
const deadlineMs = args.deadlineMs;
|
|
431
|
+
const lockWaitMs = args.lockWaitMs ?? DEFAULT_INSTALL_SETTLE_LOCK_WAIT_MS;
|
|
287
432
|
const start = now();
|
|
288
433
|
let readySince = null;
|
|
289
434
|
let sawLock = false;
|
|
290
435
|
let sawRetired = false;
|
|
436
|
+
let lockedMs = 0;
|
|
437
|
+
let unlockedMs = 0;
|
|
438
|
+
let lockTool = "";
|
|
439
|
+
let prevT = start;
|
|
440
|
+
let prevForeignLockHeld = false;
|
|
441
|
+
let lockExtendedFired = false;
|
|
291
442
|
for (;;) {
|
|
292
443
|
const t = now();
|
|
293
|
-
|
|
294
|
-
|
|
444
|
+
// Attribute the interval that just elapsed [prevT, t) to the bucket for the
|
|
445
|
+
// lock state that held when it BEGAN (real now() deltas, never nominal pollMs).
|
|
446
|
+
if (prevForeignLockHeld)
|
|
447
|
+
lockedMs += t - prevT;
|
|
448
|
+
else
|
|
449
|
+
unlockedMs += t - prevT;
|
|
450
|
+
const lock = freshForeignLock(args.lockPath, t, isPidAlive, fs);
|
|
451
|
+
const foreignLockHeld = lock !== null;
|
|
452
|
+
if (foreignLockHeld) {
|
|
295
453
|
sawLock = true;
|
|
296
|
-
|
|
454
|
+
lockTool = boundedDiagnosticValue(lock.tool, LOCK_TOOL_BYTES);
|
|
455
|
+
}
|
|
456
|
+
let ready = !foreignLockHeld && installTargetPresent(args.target, fs);
|
|
297
457
|
if (ready && args.packageRoot) {
|
|
298
458
|
try {
|
|
299
459
|
if (!packageRootHealthy(args.packageRoot, fs))
|
|
@@ -323,11 +483,29 @@ export async function waitForInstallTreeSettled(args) {
|
|
|
323
483
|
}
|
|
324
484
|
const waitedMs = t - start;
|
|
325
485
|
if (ready && (deadlineMs === 0 || t - readySince >= quietMs)) {
|
|
326
|
-
|
|
486
|
+
// Settling requires no foreign lock, so it is never held at the end here.
|
|
487
|
+
return { settled: true, waitedMs, sawLock, sawRetired, lockHeldAtEnd: false, lockedMs, lockTool };
|
|
488
|
+
}
|
|
489
|
+
// Give up when the TREE has had a full unlocked budget with no writer visible,
|
|
490
|
+
// OR a writer is STILL holding the lock and has held it past its own staleness
|
|
491
|
+
// ceiling. The lock ceiling is gated on a lock being held RIGHT NOW so that a
|
|
492
|
+
// ceiling of 0 (HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS=0, "do not extend under a
|
|
493
|
+
// lock") disables only the extension and does not collapse the ordinary
|
|
494
|
+
// deadlineMs settle when no lock exists — `lockedMs` is 0 ≥ 0 on every poll.
|
|
495
|
+
// `deadlineMs` 0 makes the unlocked test fire on the first poll (evaluate-once),
|
|
496
|
+
// matching the pre-lock-aware contract; `lockHeldAtEnd` distinguishes the causes.
|
|
497
|
+
if (unlockedMs >= deadlineMs || (foreignLockHeld && lockedMs >= lockWaitMs)) {
|
|
498
|
+
return { settled: false, waitedMs, sawLock, sawRetired, lockHeldAtEnd: foreignLockHeld, lockedMs, lockTool };
|
|
327
499
|
}
|
|
328
|
-
|
|
329
|
-
|
|
500
|
+
// The first poll at which the wait has reached `deadlineMs` while a lock is
|
|
501
|
+
// still held is exactly where the pre-lock-aware wait gave up — announce the
|
|
502
|
+
// extension once, and only when we are genuinely going to keep waiting.
|
|
503
|
+
if (!lockExtendedFired && foreignLockHeld && waitedMs >= deadlineMs) {
|
|
504
|
+
lockExtendedFired = true;
|
|
505
|
+
args.onLockExtended?.();
|
|
330
506
|
}
|
|
507
|
+
prevT = t;
|
|
508
|
+
prevForeignLockHeld = foreignLockHeld;
|
|
331
509
|
await sleep(pollMs);
|
|
332
510
|
}
|
|
333
511
|
}
|
|
@@ -357,6 +535,9 @@ export class InstallTreeTornError extends Error {
|
|
|
357
535
|
waitedMs: init.waitedMs,
|
|
358
536
|
sawLock: init.sawLock,
|
|
359
537
|
sawRetired: init.sawRetired,
|
|
538
|
+
lockHeldAtEnd: init.lockHeldAtEnd ?? false,
|
|
539
|
+
lockedMs: init.lockedMs ?? 0,
|
|
540
|
+
lockTool: boundedDiagnosticValue(init.lockTool ?? "", LOCK_TOOL_BYTES),
|
|
360
541
|
node: process.version,
|
|
361
542
|
};
|
|
362
543
|
Object.setPrototypeOf(this, new.target.prototype);
|
|
@@ -379,15 +560,26 @@ export const INSTALL_TREE_TORN_REMEDY = "the hq-cli install was being updated by
|
|
|
379
560
|
"timer, the desktop app, or another hq command) while this command started. " +
|
|
380
561
|
"Re-run your command; if it keeps failing, reinstall with " +
|
|
381
562
|
"`npm i -g @indigoai-us/hq-cli` (or `pnpm add -g @indigoai-us/hq-cli`).";
|
|
563
|
+
/**
|
|
564
|
+
* The fixed, input-free remedy for the 'lock-held' outcome: a cooperating writer
|
|
565
|
+
* held the shared update lock past its own staleness ceiling. Telling the user to
|
|
566
|
+
* `npm i -g` here would collide with a live installer mid-rename — the exact race
|
|
567
|
+
* update-lock.ts exists to prevent — so this remedy tells them to let the running
|
|
568
|
+
* update finish and NEVER to reinstall over it. Interpolates NOTHING.
|
|
569
|
+
*/
|
|
570
|
+
export const INSTALL_TREE_LOCK_HELD_REMEDY = "another hq updater (the desktop app, the box's hq-cli-update timer, or another " +
|
|
571
|
+
"hq command) was still installing hq-cli when this command stopped waiting. Let " +
|
|
572
|
+
"that update finish, then re-run your command; do not reinstall while it is running.";
|
|
382
573
|
/**
|
|
383
574
|
* The single actionable stderr line for a captured torn-install failure: the
|
|
384
|
-
* fixed remedy plus the bounded, hq-derived missing specifier — the only
|
|
385
|
-
* interpolated value, never argv.
|
|
575
|
+
* outcome's fixed remedy plus the bounded, hq-derived missing specifier — the only
|
|
576
|
+
* interpolated value, never argv. A 'lock-held' outcome selects the "let the
|
|
577
|
+
* update finish" remedy (never the reinstall advice); every other outcome keeps
|
|
578
|
+
* {@link INSTALL_TREE_TORN_REMEDY} byte-for-byte.
|
|
386
579
|
*/
|
|
387
580
|
export function installTreeTornStderrLine(err) {
|
|
581
|
+
const remedy = err.diagnostics.outcome === "lock-held" ? INSTALL_TREE_LOCK_HELD_REMEDY : INSTALL_TREE_TORN_REMEDY;
|
|
388
582
|
const specifier = err.diagnostics.specifier;
|
|
389
|
-
return specifier
|
|
390
|
-
? `${INSTALL_TREE_TORN_REMEDY} (missing: ${specifier})`
|
|
391
|
-
: INSTALL_TREE_TORN_REMEDY;
|
|
583
|
+
return specifier ? `${remedy} (missing: ${specifier})` : remedy;
|
|
392
584
|
}
|
|
393
585
|
//# sourceMappingURL=install-tree-torn.js.map
|
|
@@ -92,8 +92,8 @@ const KNOWN_ERROR_NAMES = new Set([
|
|
|
92
92
|
// (HQ-CLI-1A).
|
|
93
93
|
"QmdWorkdirMissingError",
|
|
94
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
|
|
96
|
-
// bounded `diagnostics.outcome`, so the whole family groups into ≤
|
|
95
|
+
// recover (HQ-CLI-1G/1H/1J/1K, HQ-CLI-1Y). Fingerprinted on its own branch below
|
|
96
|
+
// by the bounded `diagnostics.outcome`, so the whole family groups into ≤4 issues.
|
|
97
97
|
"InstallTreeTornError",
|
|
98
98
|
]);
|
|
99
99
|
/**
|
|
@@ -102,7 +102,7 @@ const KNOWN_ERROR_NAMES = new Set([
|
|
|
102
102
|
* the carrier's message is fixed, so this outcome IS the only discriminator.
|
|
103
103
|
*/
|
|
104
104
|
const KNOWN_INSTALL_TREE_OUTCOMES = new Set([
|
|
105
|
-
"guarded", "unsettled", "reexec-failed",
|
|
105
|
+
"guarded", "unsettled", "reexec-failed", "lock-held",
|
|
106
106
|
]);
|
|
107
107
|
/** Fixed bucket for any error name outside the closed allowlist. */
|
|
108
108
|
const FALLBACK_ERROR_NAME = "other";
|
|
@@ -214,8 +214,8 @@ export function sentryFingerprintFor(err) {
|
|
|
214
214
|
}
|
|
215
215
|
// A torn-install failure carries neither an rpcCode nor an HTTP status; its
|
|
216
216
|
// only bounded discriminator is the recovery `diagnostics.outcome`. Keyed
|
|
217
|
-
// BEFORE the rpc/status branches so the family groups into ≤
|
|
218
|
-
//
|
|
217
|
+
// BEFORE the rpc/status branches so the family groups into ≤4 predictable issues
|
|
218
|
+
// (guarded / unsettled / reexec-failed / lock-held) rather than one fungible bucket.
|
|
219
219
|
if (rawName === "InstallTreeTornError") {
|
|
220
220
|
const diagnostics = record.diagnostics;
|
|
221
221
|
const outcome = diagnostics !== null && typeof diagnostics === "object"
|