@indigoai-us/hq-cli 5.103.31 → 5.103.33
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 +4 -0
- package/dist/commands/auth.js +3 -13
- package/dist/commands/index-cmd.js +17 -3
- package/dist/commands/skill.js +25 -3
- package/dist/lib/doctor/checks/integrations.js +15 -0
- package/dist/lib/integrations/health.d.ts +11 -1
- package/dist/lib/integrations/health.js +93 -13
- package/dist/lib/search-index/index.d.ts +65 -0
- package/dist/lib/search-index/index.js +135 -0
- package/dist/main.js +68 -2
- package/dist/utils/callback-port-busy.d.ts +29 -0
- package/dist/utils/callback-port-busy.js +87 -0
- package/dist/utils/qmd-store-missing-error.d.ts +20 -0
- package/dist/utils/qmd-store-missing-error.js +96 -0
- package/dist/utils/sentry-fingerprint.js +1 -0
- package/dist/utils/sync-state-lock-error.d.ts +26 -0
- package/dist/utils/sync-state-lock-error.js +78 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
package/dist/commands/auth.js
CHANGED
|
@@ -17,6 +17,7 @@ import chalk from "chalk";
|
|
|
17
17
|
import { browserLogin, clearCachedTokens, loadCachedTokens, isExpiring, isMachineIdentity, loadMachineCreds, CognitoAuthError, } from "@indigoai-us/hq-cloud";
|
|
18
18
|
import { DEFAULT_COGNITO, refreshCachedSession, } from "../utils/cognito-session.js";
|
|
19
19
|
import { cognitoConfigForLoginProvider } from "../utils/login-provider.js";
|
|
20
|
+
import { callbackPortBusyGuidance, DEFAULT_CALLBACK_PORT, isCallbackPortBusy, } from "../utils/callback-port-busy.js";
|
|
20
21
|
/**
|
|
21
22
|
* Decode the (unverified) ID token payload for display purposes only.
|
|
22
23
|
* The token was just returned by Cognito's token endpoint, so its contents
|
|
@@ -46,17 +47,6 @@ function machineIdentityLabel() {
|
|
|
46
47
|
const creds = loadMachineCreds();
|
|
47
48
|
return creds ? `machine identity ${creds.username}` : "machine identity";
|
|
48
49
|
}
|
|
49
|
-
function isCallbackPortCollision(error) {
|
|
50
|
-
if (!error || typeof error !== "object")
|
|
51
|
-
return false;
|
|
52
|
-
const { code, message } = error;
|
|
53
|
-
return code === "EADDRINUSE" || message?.includes("EADDRINUSE") === true;
|
|
54
|
-
}
|
|
55
|
-
function callbackPortCollisionGuidance(port) {
|
|
56
|
-
return (` The browser-login callback port (127.0.0.1:${port}) is already in use. ` +
|
|
57
|
-
"Another `hq auth login` may still be waiting for browser sign-in. Finish that login, " +
|
|
58
|
-
"or stop its terminal/process, then retry.");
|
|
59
|
-
}
|
|
60
50
|
export function registerAuthCommands(program) {
|
|
61
51
|
const authCmd = program
|
|
62
52
|
.command("auth")
|
|
@@ -83,7 +73,7 @@ export function registerAuthCommands(program) {
|
|
|
83
73
|
console.log(chalk.dim(` Token cached at ~/.hq/cognito-tokens.json (expires ${tokens.expiresAt})`));
|
|
84
74
|
}
|
|
85
75
|
catch (err) {
|
|
86
|
-
const callbackPortCollision =
|
|
76
|
+
const callbackPortCollision = isCallbackPortBusy(err);
|
|
87
77
|
const msg = callbackPortCollision
|
|
88
78
|
? "Browser-login callback port is already in use."
|
|
89
79
|
: err instanceof CognitoAuthError
|
|
@@ -93,7 +83,7 @@ export function registerAuthCommands(program) {
|
|
|
93
83
|
: String(err);
|
|
94
84
|
console.error(chalk.red(`Login failed: ${msg}`));
|
|
95
85
|
if (callbackPortCollision) {
|
|
96
|
-
console.error(chalk.dim(
|
|
86
|
+
console.error(chalk.dim(` ${callbackPortBusyGuidance(DEFAULT_COGNITO.port ?? DEFAULT_CALLBACK_PORT)}`));
|
|
97
87
|
}
|
|
98
88
|
else {
|
|
99
89
|
console.error(chalk.dim(" If you do not have an account, sign up at https://onboarding.hq.computer"));
|
|
@@ -3,6 +3,7 @@ import { deriveCollections, listRegisteredCollections, packageLocalBin, reconcil
|
|
|
3
3
|
import { backgroundStatus, defaultBackgroundDependencies, runBackgroundLauncher, runBackgroundWorker, } from '../lib/search-index/background.js';
|
|
4
4
|
import { findHqRoot } from '../utils/manifest.js';
|
|
5
5
|
import { QMD_NATIVE_BINDING_REMEDY, isQmdNativeBindingError, } from '../utils/qmd-native-binding-error.js';
|
|
6
|
+
import { isQmdStoreMissingError, qmdStoreMissingMessage, } from '../utils/qmd-store-missing-error.js';
|
|
6
7
|
const defaults = {
|
|
7
8
|
reconcileCollections,
|
|
8
9
|
deriveCollections,
|
|
@@ -119,10 +120,23 @@ export function registerIndexCommand(program, dependencies = defaults) {
|
|
|
119
120
|
qmdStatus = dependencies.runQmd(['status'], { cwd: hqRoot });
|
|
120
121
|
}
|
|
121
122
|
catch (error) {
|
|
122
|
-
if (
|
|
123
|
+
if (isQmdNativeBindingError(error)) {
|
|
124
|
+
process.stderr.write(`qmd: unusable — native bindings unbuilt. ${QMD_NATIVE_BINDING_REMEDY}\n`);
|
|
125
|
+
process.exitCode = 1;
|
|
126
|
+
}
|
|
127
|
+
else if (isQmdStoreMissingError(error)) {
|
|
128
|
+
// The local qmd store directory does not exist (HQ-CLI-16). Like the
|
|
129
|
+
// native-binding case, this diagnostic command should DESCRIBE the
|
|
130
|
+
// broken local store, not crash on it: print the classified reason +
|
|
131
|
+
// remedy and exit 1 without rethrowing, so the boundary never files it
|
|
132
|
+
// as a Sentry crash. Every OTHER qmd failure keeps propagating.
|
|
133
|
+
const remedy = qmdStoreMissingMessage(error) ?? 'its local search store directory does not exist';
|
|
134
|
+
process.stderr.write(`qmd: unusable — ${remedy}\n`);
|
|
135
|
+
process.exitCode = 1;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
123
138
|
throw error;
|
|
124
|
-
|
|
125
|
-
process.exitCode = 1;
|
|
139
|
+
}
|
|
126
140
|
}
|
|
127
141
|
if (registered) {
|
|
128
142
|
console.log(collectionSummary(expected, registered));
|
package/dist/commands/skill.js
CHANGED
|
@@ -68,7 +68,13 @@ export function readActiveCompanySlug(hqRoot) {
|
|
|
68
68
|
export function resolveCompanySlug(flag, hqRoot = DEFAULT_HQ_ROOT) {
|
|
69
69
|
const slug = flag ?? readActiveCompanySlug(hqRoot);
|
|
70
70
|
if (!slug) {
|
|
71
|
-
|
|
71
|
+
// HQ-CLI-13 (Sentry 7695157352): neither `--company` nor an active company
|
|
72
|
+
// in `.hq/config.json` — ordinary caller state with a self-describing
|
|
73
|
+
// remedy, not an hq-cli defect. Mark it `expected` (via localSkillError) so
|
|
74
|
+
// the top-level boundary prints this line and skips Sentry, exactly as the
|
|
75
|
+
// resolveSkillUid local conditions do (HQ-CLI-10/11). The text is unchanged
|
|
76
|
+
// and already tells the caller what to do.
|
|
77
|
+
throw localSkillError("No company specified. Pass --company <slug> (or set an active company in .hq/config.json).");
|
|
72
78
|
}
|
|
73
79
|
return slug;
|
|
74
80
|
}
|
|
@@ -354,10 +360,26 @@ export function registerSkillCommand(program, deps = {}) {
|
|
|
354
360
|
});
|
|
355
361
|
}
|
|
356
362
|
catch (err) {
|
|
357
|
-
|
|
363
|
+
// HQ-CLI-14 (Sentry 7695715459): registration and the local stamp
|
|
364
|
+
// already SUCCEEDED; only the upload failed. Do NOT flatten the
|
|
365
|
+
// inner error into a new generic Error — that discarded its class,
|
|
366
|
+
// `name`, `cause` and any `expected` marker, and spliced the
|
|
367
|
+
// caller's absolute SKILL.md path into a message Sentry then
|
|
368
|
+
// fingerprinted per home directory. Keep the partial-success context
|
|
369
|
+
// on stderr, then rethrow the ORIGINAL error so every classifier the
|
|
370
|
+
// boundary already has (expected, AuthError, plan-gate, EPIPE,
|
|
371
|
+
// environmental-fs, sync-state-lock, network-transport) applies to a
|
|
372
|
+
// sync failure exactly as it does everywhere else, and a genuine
|
|
373
|
+
// fault is captured under its own type and stack.
|
|
374
|
+
console.warn(chalk.yellow(`⚠ Skill ${registered.skillUid} is stamped locally at '${filePath}', but sync failed.`));
|
|
375
|
+
throw err;
|
|
358
376
|
}
|
|
359
377
|
if (syncResult.aborted) {
|
|
360
|
-
|
|
378
|
+
// A remote-file conflict under hq-cli's own `onConflict: "abort"`
|
|
379
|
+
// choice is unambiguous caller state with a user-side remedy, not an
|
|
380
|
+
// hq-cli defect — mark it `expected` so it is printed and skipped for
|
|
381
|
+
// Sentry rather than filing a crash on its first occurrence.
|
|
382
|
+
throw localSkillError(`Skill ${registered.skillUid} is stamped locally at '${filePath}', but sync aborted because the remote file conflicts.`);
|
|
361
383
|
}
|
|
362
384
|
}
|
|
363
385
|
console.log(chalk.green(`Skill ready: ${registered.skillUid}`));
|
|
@@ -128,6 +128,21 @@ function resultForGroup(entries, company) {
|
|
|
128
128
|
remediation: serverRemediation ?? `hq integrations connect ${first.provider} --token-stdin${company ? ` --company ${company}` : ""}`,
|
|
129
129
|
};
|
|
130
130
|
}
|
|
131
|
+
if (first.kind === "re-add-with-auth") {
|
|
132
|
+
// Deliberately NOT the `re-add` branch. That one's default remediation is
|
|
133
|
+
// the API-key flow (`--token-stdin`), and this fault is the opposite case:
|
|
134
|
+
// an install that was created with no credential at all and needs a
|
|
135
|
+
// browser sign-in. Falling through to the reconnect default would be worse
|
|
136
|
+
// still, since there is no stored credential to refresh.
|
|
137
|
+
return {
|
|
138
|
+
status: "FAIL",
|
|
139
|
+
checkId: `${INTEGRATIONS_PREFIX}.re-add-with-auth.${first.provider}`,
|
|
140
|
+
target: namedConnections,
|
|
141
|
+
message: `${first.provider}: ${count} ${plural} ${first.message}.`,
|
|
142
|
+
remediation: serverRemediation
|
|
143
|
+
?? `Remove the integration and add it again, signing in this time: hq integrations connect ${first.provider}${company ? ` --company ${company}` : ""}`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
131
146
|
if (first.kind === "contact-admin") {
|
|
132
147
|
return {
|
|
133
148
|
status: "FAIL",
|
|
@@ -39,8 +39,18 @@ export interface IntegrationConnection extends AdminConnection {
|
|
|
39
39
|
*
|
|
40
40
|
* A server that DID say `fix_kind: "reconnect"` still classifies as
|
|
41
41
|
* `reconnect`: that is a diagnosis, not an absence of one.
|
|
42
|
+
*
|
|
43
|
+
* `re-add-with-auth` is NOT a synonym for `re-add`. `re-add` means an API key
|
|
44
|
+
* has to be typed in again; this one means the install was created with no
|
|
45
|
+
* credential at all and needs a browser sign-in. Collapsing the two would print
|
|
46
|
+
* the API-key command against a connection that never had a key.
|
|
47
|
+
*
|
|
48
|
+
* This union is kept identical to the console's twin (`_connection-health.ts`
|
|
49
|
+
* `FixKind`) on purpose: a person who reads one surface and then the other must
|
|
50
|
+
* not find the same failure sorted into two different buckets. Any member added
|
|
51
|
+
* here has to be added there in the same change.
|
|
42
52
|
*/
|
|
43
|
-
export type FindingKind = "reconnect" | "re-add" | "contact-admin" | "wait" | "provider-blocked" | "retryable" | "hq-configuration" | "undiagnosed";
|
|
53
|
+
export type FindingKind = "reconnect" | "re-add" | "re-add-with-auth" | "contact-admin" | "wait" | "provider-blocked" | "retryable" | "hq-configuration" | "undiagnosed";
|
|
44
54
|
export interface Finding {
|
|
45
55
|
provider: string;
|
|
46
56
|
connectionId: string;
|
|
@@ -19,6 +19,12 @@ import { bareProvider } from "./provider-slug.js";
|
|
|
19
19
|
const RECONNECT_REASON_CODES = new Set([
|
|
20
20
|
"oauth_refresh_invalid_grant",
|
|
21
21
|
"oauth_refresh_unavailable",
|
|
22
|
+
// The stored grant carries no refresh token, so nothing can be renewed
|
|
23
|
+
// without the person signing in again. The legacy text heuristic below
|
|
24
|
+
// happens to match this string through its `refresh[\s_-]*token` branch, but
|
|
25
|
+
// that is coincidence rather than contract: it is listed here so the
|
|
26
|
+
// classification survives a rename of the heuristic.
|
|
27
|
+
"oauth_refresh_missing_refresh_token",
|
|
22
28
|
"token_refresh_failed",
|
|
23
29
|
"credentials_rejected",
|
|
24
30
|
]);
|
|
@@ -26,7 +32,27 @@ const RETRYABLE_REASON_CODES = new Set([
|
|
|
26
32
|
"oauth_refresh_transient",
|
|
27
33
|
"oauth_refresh_write_conflict",
|
|
28
34
|
]);
|
|
29
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Faults that sit on HQ's side of the boundary.
|
|
37
|
+
*
|
|
38
|
+
* Neither is repairable by anyone at the customer: `oauth_client_secret_unavailable`
|
|
39
|
+
* means HQ could not read its own client secret, and `oauth_refresh_missing_client`
|
|
40
|
+
* means HQ has no OAuth client configured for the provider at all. Sending a
|
|
41
|
+
* person round a reconnect for either costs them a working credential and
|
|
42
|
+
* cannot succeed, which is why hq-pro answers both with `contact-support`
|
|
43
|
+
* ahead of its own role gate — a member sees the same instruction an owner does.
|
|
44
|
+
*/
|
|
45
|
+
const HQ_CONFIGURATION_REASON_CODES = new Set([
|
|
46
|
+
"oauth_client_secret_unavailable",
|
|
47
|
+
"oauth_refresh_missing_client",
|
|
48
|
+
]);
|
|
49
|
+
/**
|
|
50
|
+
* The install exists but was created without credentials, and the provider
|
|
51
|
+
* refused it. The repair is to add the integration again and sign in — NOT the
|
|
52
|
+
* `re-add` API-key flow, and not a reconnect of a credential that was never
|
|
53
|
+
* stored.
|
|
54
|
+
*/
|
|
55
|
+
const RE_ADD_WITH_AUTH_REASON_CODE = "unauthenticated_install_rejected";
|
|
30
56
|
const UNSPECIFIED_REASON_CODE = "unspecified";
|
|
31
57
|
/** Classify without echoing untrusted provider text, which may contain secrets. */
|
|
32
58
|
export function classifyConnection(connection) {
|
|
@@ -45,10 +71,11 @@ export function classifyConnection(connection) {
|
|
|
45
71
|
if (knownReasonCode && knownReasonCode !== UNSPECIFIED_REASON_CODE) {
|
|
46
72
|
const finding = findingForKnownReasonCode(connection, provider, knownReasonCode);
|
|
47
73
|
// Retryable and HQ-configuration reason codes diagnose conditions that a
|
|
48
|
-
// caller-specific remediation cannot change.
|
|
49
|
-
//
|
|
50
|
-
// install in progress is never interrupted by a reconnect
|
|
51
|
-
|
|
74
|
+
// caller-specific remediation cannot change. Role-dependent codes defer to
|
|
75
|
+
// the server's credential/role-aware remediation classification, so an
|
|
76
|
+
// install in progress is never interrupted by a reconnect and a member is
|
|
77
|
+
// never told to perform a repair only an owner or admin can carry out.
|
|
78
|
+
return [withServerRemediation(connection, defersToServerFixKind(knownReasonCode)
|
|
52
79
|
? findingForServerFixKind(finding, connection.fix_kind)
|
|
53
80
|
: finding)];
|
|
54
81
|
}
|
|
@@ -161,6 +188,32 @@ function findingForServerFixKind(fallback, fixKind) {
|
|
|
161
188
|
kind: "wait",
|
|
162
189
|
message: "is waiting for the Factory installation to complete",
|
|
163
190
|
};
|
|
191
|
+
case "re-add-with-auth":
|
|
192
|
+
return {
|
|
193
|
+
...fallback,
|
|
194
|
+
kind: "re-add-with-auth",
|
|
195
|
+
message: "was added without a sign-in, and the provider refused it",
|
|
196
|
+
};
|
|
197
|
+
case "contact-support":
|
|
198
|
+
// hq-pro answers `contact-support` only for a gap in HQ's own OAuth
|
|
199
|
+
// configuration, which is the same class `hq-configuration` already
|
|
200
|
+
// names. It gets that kind rather than a member of its own so both paths
|
|
201
|
+
// into the class carry the same VERDICT — an HQ administrator has to fix
|
|
202
|
+
// it and reconnecting will not — instead of one cause reaching a person
|
|
203
|
+
// under two different remediations.
|
|
204
|
+
//
|
|
205
|
+
// The message stays distinct, and `hq doctor` groups on the message, so a
|
|
206
|
+
// provider with both faults prints two lines under the one class. That is
|
|
207
|
+
// deliberate and matches every other class here (two `reconnect` rows with
|
|
208
|
+
// different causes already print separately). Collapsing them would mean
|
|
209
|
+
// stamping a row that arrived with NO reason code with the specific
|
|
210
|
+
// wording of one that did — a confident wrong answer, which is the exact
|
|
211
|
+
// failure this vocabulary exists to stop.
|
|
212
|
+
return {
|
|
213
|
+
...fallback,
|
|
214
|
+
kind: "hq-configuration",
|
|
215
|
+
message: "is blocked by an HQ-side configuration gap",
|
|
216
|
+
};
|
|
164
217
|
case "reconnect":
|
|
165
218
|
default:
|
|
166
219
|
return fallback;
|
|
@@ -181,13 +234,30 @@ function withServerRemediation(connection, finding) {
|
|
|
181
234
|
* the legacy text heuristics, which remain below for old and unknown rows.
|
|
182
235
|
*/
|
|
183
236
|
function knownReasonCodeFor(connection) {
|
|
184
|
-
const values = [connection.errorReason, connection.needsReauthReason, connection.degradedReason]
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
237
|
+
const values = [connection.errorReason, connection.needsReauthReason, connection.degradedReason]
|
|
238
|
+
.filter((value) => typeof value === "string");
|
|
239
|
+
// An HQ-side configuration fault outranks anything else recorded on the row:
|
|
240
|
+
// no customer-side repair can clear it, so a co-recorded reconnect code must
|
|
241
|
+
// not send someone to re-enter a credential that was never the problem.
|
|
242
|
+
const hqFault = values.find((value) => HQ_CONFIGURATION_REASON_CODES.has(value));
|
|
243
|
+
if (hqFault)
|
|
244
|
+
return hqFault;
|
|
245
|
+
return values.find((value) => (RECONNECT_REASON_CODES.has(value) ||
|
|
246
|
+
RETRYABLE_REASON_CODES.has(value) ||
|
|
247
|
+
value === RE_ADD_WITH_AUTH_REASON_CODE ||
|
|
248
|
+
value === UNSPECIFIED_REASON_CODE));
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Whether the server's role- and credential-aware `fix_kind` should override
|
|
252
|
+
* the reason code's own classification.
|
|
253
|
+
*
|
|
254
|
+
* True for faults the customer CAN repair, because who repairs them depends on
|
|
255
|
+
* the caller's role — hq-pro answers a member with `contact-admin` where it
|
|
256
|
+
* answers an owner with the repair itself. False for HQ-side and retryable
|
|
257
|
+
* faults, whose remediation is the same for everyone.
|
|
258
|
+
*/
|
|
259
|
+
function defersToServerFixKind(code) {
|
|
260
|
+
return RECONNECT_REASON_CODES.has(code) || code === RE_ADD_WITH_AUTH_REASON_CODE;
|
|
191
261
|
}
|
|
192
262
|
function findingForKnownReasonCode(connection, provider, code) {
|
|
193
263
|
if (RECONNECT_REASON_CODES.has(code)) {
|
|
@@ -208,11 +278,21 @@ function findingForKnownReasonCode(connection, provider, code) {
|
|
|
208
278
|
: "token refresh is temporarily unavailable; the stored credential remains intact",
|
|
209
279
|
};
|
|
210
280
|
}
|
|
281
|
+
if (code === RE_ADD_WITH_AUTH_REASON_CODE) {
|
|
282
|
+
return {
|
|
283
|
+
provider,
|
|
284
|
+
connectionId: connection.id,
|
|
285
|
+
kind: "re-add-with-auth",
|
|
286
|
+
message: "was added without a sign-in, and the provider refused it",
|
|
287
|
+
};
|
|
288
|
+
}
|
|
211
289
|
return {
|
|
212
290
|
provider,
|
|
213
291
|
connectionId: connection.id,
|
|
214
292
|
kind: "hq-configuration",
|
|
215
|
-
message:
|
|
293
|
+
message: code === "oauth_refresh_missing_client"
|
|
294
|
+
? "cannot be refreshed because HQ has no OAuth client configured for this provider"
|
|
295
|
+
: "the HQ OAuth client secret is unavailable",
|
|
216
296
|
};
|
|
217
297
|
}
|
|
218
298
|
function recordedReason(connection) {
|
|
@@ -152,6 +152,29 @@ export declare class QmdLlmDisabledError extends QmdExitError {
|
|
|
152
152
|
export declare class QmdModuleMissingError extends QmdExitError {
|
|
153
153
|
name: string;
|
|
154
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* qmd could not open its SQLite store because the store's DIRECTORY does not
|
|
157
|
+
* exist. @tobilu/qmd resolves the store as $INDEX_PATH (returned verbatim,
|
|
158
|
+
* before any mkdir) else ${XDG_CACHE_HOME||~/.cache}/qmd (behind a mkdir whose
|
|
159
|
+
* failure it swallows), then `new Database(path)` throws better-sqlite3's
|
|
160
|
+
* `TypeError: Cannot open database because the directory does not exist`. That
|
|
161
|
+
* missing directory is the caller's ENVIRONMENT — a pruned cache, a fresh
|
|
162
|
+
* machine, an INDEX_PATH into a deleted tree, or a directory hq could not create
|
|
163
|
+
* (permissions / a read-only mount) — never a bug HQ can fix in code.
|
|
164
|
+
*
|
|
165
|
+
* `storeDir` carries the directory hq resolved qmd would open its store in, and
|
|
166
|
+
* `ensureReason` the bounded errno phrase for why hq's own best-effort
|
|
167
|
+
* pre-create of that directory failed (undefined when the ensure succeeded or
|
|
168
|
+
* did not run). Both are hq-DERIVED — the resolved path plus a finite errno
|
|
169
|
+
* reason — so the boundary can name them in an input-free remedy without ever
|
|
170
|
+
* echoing caller argv or upstream text. HQ-CLI-16 (Sentry 7698235964).
|
|
171
|
+
*/
|
|
172
|
+
export declare class QmdStoreMissingError extends QmdExitError {
|
|
173
|
+
readonly storeDir?: string | undefined;
|
|
174
|
+
readonly ensureReason?: string | undefined;
|
|
175
|
+
name: string;
|
|
176
|
+
constructor(message: string, args: string[], status: number | null, stdout: string, stderr: string, storeDir?: string | undefined, ensureReason?: string | undefined);
|
|
177
|
+
}
|
|
155
178
|
export type ResolveQmdBinOptions = {
|
|
156
179
|
env?: Record<string, string | undefined>;
|
|
157
180
|
isExecutable?: (candidate: string) => boolean;
|
|
@@ -203,6 +226,48 @@ export declare function packageLocalNodeEntry(): string | undefined;
|
|
|
203
226
|
* disappeared from `hq index status`. Same root cause as packageLocalBin.
|
|
204
227
|
*/
|
|
205
228
|
export declare function resolveQmdVersion(): string | undefined;
|
|
229
|
+
/**
|
|
230
|
+
* The outcome of the best-effort store-directory ensure, keyed by resolved
|
|
231
|
+
* directory so the mkdir happens at most once per distinct directory per process
|
|
232
|
+
* (idempotent, bounded — a process opens a tiny, fixed set of store dirs). Read
|
|
233
|
+
* by finishRunQmd to stamp the directory and the errno reason onto a
|
|
234
|
+
* QmdStoreMissingError, turning the condition that carried no cause in the
|
|
235
|
+
* reported event into a self-describing one.
|
|
236
|
+
*/
|
|
237
|
+
type QmdStoreEnsureOutcome = {
|
|
238
|
+
dir: string;
|
|
239
|
+
created: boolean;
|
|
240
|
+
reason?: string;
|
|
241
|
+
};
|
|
242
|
+
/** Test-only: read the most recent store-directory ensure outcome. */
|
|
243
|
+
export declare function lastQmdStoreEnsureOutcome(): QmdStoreEnsureOutcome | undefined;
|
|
244
|
+
/**
|
|
245
|
+
* Resolve the DIRECTORY @tobilu/qmd will open its SQLite store in, mirroring the
|
|
246
|
+
* pinned @tobilu/qmd@2.5.3 rule (dist/store.js getDefaultDbPath + dist/paths.js
|
|
247
|
+
* qmdHomedir) against the SAME env the qmd child receives:
|
|
248
|
+
* - $INDEX_PATH set -> dirname($INDEX_PATH) (qmd returns INDEX_PATH verbatim,
|
|
249
|
+
* so the store file's directory is the one that must exist);
|
|
250
|
+
* - else -> ${XDG_CACHE_HOME || <home>/.cache}/qmd, where <home> is
|
|
251
|
+
* $HOME || $USERPROFILE || os.homedir() || '/tmp' (qmdHomedir's exact order).
|
|
252
|
+
* An upstream-contract test pins this rule to the INSTALLED qmd so a bump that
|
|
253
|
+
* changes it turns CI red instead of silently restoring the noise.
|
|
254
|
+
*/
|
|
255
|
+
export declare function resolveQmdStoreDir(env?: NodeJS.ProcessEnv): string;
|
|
256
|
+
export type EnsureQmdStoreDirOptions = {
|
|
257
|
+
/** mkdir seam (default `fs.mkdirSync(dir, { recursive: true })`); injected in tests. */
|
|
258
|
+
mkdir?: (dir: string) => void;
|
|
259
|
+
};
|
|
260
|
+
/**
|
|
261
|
+
* Best-effort, once-per-directory self-provisioning of qmd's store directory
|
|
262
|
+
* BEFORE qmd is spawned, so the benign majority case — a pruned cache or a fresh
|
|
263
|
+
* machine — simply works instead of erroring. It creates ONLY that one directory
|
|
264
|
+
* (recursive), records the outcome (including a bounded errno reason on
|
|
265
|
+
* failure), and NEVER throws, retries, waits, or spawns. When the mkdir fails
|
|
266
|
+
* (permissions / read-only), qmd then still can't open its store, and the
|
|
267
|
+
* recorded reason is what makes the classified remedy self-describing. Modelled
|
|
268
|
+
* on the repairQmdNativeBindings best-effort contract in this file.
|
|
269
|
+
*/
|
|
270
|
+
export declare function ensureQmdStoreDir(env?: NodeJS.ProcessEnv, options?: EnsureQmdStoreDirOptions): void;
|
|
206
271
|
/** Reset per-process probe/repair memoisation. Test-only. */
|
|
207
272
|
export declare function __resetQmdProbeStateForTests(): void;
|
|
208
273
|
/** Probe a resolved invocation, memoised per invocation; records the failure
|
|
@@ -6,6 +6,7 @@ import * as path from 'node:path';
|
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { isQmdModuleMissingError } from '../../utils/qmd-module-missing-error.js';
|
|
8
8
|
import { isQmdNativeBindingError } from '../../utils/qmd-native-binding-error.js';
|
|
9
|
+
import { isQmdStoreMissingError } from '../../utils/qmd-store-missing-error.js';
|
|
9
10
|
import { redactErrorText } from '../../utils/redact-error-text.js';
|
|
10
11
|
import { planCommandSpawn } from '../../utils/windows-spawn.js';
|
|
11
12
|
const require = createRequire(import.meta.url);
|
|
@@ -131,6 +132,33 @@ export class QmdLlmDisabledError extends QmdExitError {
|
|
|
131
132
|
export class QmdModuleMissingError extends QmdExitError {
|
|
132
133
|
name = 'QmdModuleMissingError';
|
|
133
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* qmd could not open its SQLite store because the store's DIRECTORY does not
|
|
137
|
+
* exist. @tobilu/qmd resolves the store as $INDEX_PATH (returned verbatim,
|
|
138
|
+
* before any mkdir) else ${XDG_CACHE_HOME||~/.cache}/qmd (behind a mkdir whose
|
|
139
|
+
* failure it swallows), then `new Database(path)` throws better-sqlite3's
|
|
140
|
+
* `TypeError: Cannot open database because the directory does not exist`. That
|
|
141
|
+
* missing directory is the caller's ENVIRONMENT — a pruned cache, a fresh
|
|
142
|
+
* machine, an INDEX_PATH into a deleted tree, or a directory hq could not create
|
|
143
|
+
* (permissions / a read-only mount) — never a bug HQ can fix in code.
|
|
144
|
+
*
|
|
145
|
+
* `storeDir` carries the directory hq resolved qmd would open its store in, and
|
|
146
|
+
* `ensureReason` the bounded errno phrase for why hq's own best-effort
|
|
147
|
+
* pre-create of that directory failed (undefined when the ensure succeeded or
|
|
148
|
+
* did not run). Both are hq-DERIVED — the resolved path plus a finite errno
|
|
149
|
+
* reason — so the boundary can name them in an input-free remedy without ever
|
|
150
|
+
* echoing caller argv or upstream text. HQ-CLI-16 (Sentry 7698235964).
|
|
151
|
+
*/
|
|
152
|
+
export class QmdStoreMissingError extends QmdExitError {
|
|
153
|
+
storeDir;
|
|
154
|
+
ensureReason;
|
|
155
|
+
name = 'QmdStoreMissingError';
|
|
156
|
+
constructor(message, args, status, stdout, stderr, storeDir, ensureReason) {
|
|
157
|
+
super(message, args, status, stdout, stderr);
|
|
158
|
+
this.storeDir = storeDir;
|
|
159
|
+
this.ensureReason = ensureReason;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
134
162
|
function isExecutable(candidate) {
|
|
135
163
|
try {
|
|
136
164
|
fs.accessSync(candidate, fs.constants.X_OK);
|
|
@@ -265,6 +293,84 @@ const usableQmdCache = new Map();
|
|
|
265
293
|
const lastProbeFailure = new Map();
|
|
266
294
|
/** At most one native-binding repair attempt per process (bounded, no retries). */
|
|
267
295
|
let nativeBindingRepairAttempted = false;
|
|
296
|
+
const storeEnsureByDir = new Map();
|
|
297
|
+
/** The most recent ensure outcome (the directory the next qmd spawn will use). */
|
|
298
|
+
let lastQmdStoreEnsure;
|
|
299
|
+
/** Test-only: read the most recent store-directory ensure outcome. */
|
|
300
|
+
export function lastQmdStoreEnsureOutcome() {
|
|
301
|
+
return lastQmdStoreEnsure;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Resolve the DIRECTORY @tobilu/qmd will open its SQLite store in, mirroring the
|
|
305
|
+
* pinned @tobilu/qmd@2.5.3 rule (dist/store.js getDefaultDbPath + dist/paths.js
|
|
306
|
+
* qmdHomedir) against the SAME env the qmd child receives:
|
|
307
|
+
* - $INDEX_PATH set -> dirname($INDEX_PATH) (qmd returns INDEX_PATH verbatim,
|
|
308
|
+
* so the store file's directory is the one that must exist);
|
|
309
|
+
* - else -> ${XDG_CACHE_HOME || <home>/.cache}/qmd, where <home> is
|
|
310
|
+
* $HOME || $USERPROFILE || os.homedir() || '/tmp' (qmdHomedir's exact order).
|
|
311
|
+
* An upstream-contract test pins this rule to the INSTALLED qmd so a bump that
|
|
312
|
+
* changes it turns CI red instead of silently restoring the noise.
|
|
313
|
+
*/
|
|
314
|
+
export function resolveQmdStoreDir(env = process.env) {
|
|
315
|
+
const indexPath = env.INDEX_PATH;
|
|
316
|
+
if (indexPath)
|
|
317
|
+
return path.dirname(indexPath);
|
|
318
|
+
const home = env.HOME || env.USERPROFILE || os.homedir() || '/tmp';
|
|
319
|
+
const cacheDir = env.XDG_CACHE_HOME || path.join(home, '.cache');
|
|
320
|
+
return path.join(cacheDir, 'qmd');
|
|
321
|
+
}
|
|
322
|
+
/** Map a mkdir ErrnoException to a bounded, human errno reason (never free text). */
|
|
323
|
+
function ensureFailureReason(error) {
|
|
324
|
+
const code = error?.code;
|
|
325
|
+
switch (code) {
|
|
326
|
+
case 'EACCES':
|
|
327
|
+
case 'EPERM':
|
|
328
|
+
return 'permission denied';
|
|
329
|
+
case 'EROFS':
|
|
330
|
+
return 'the filesystem is read-only';
|
|
331
|
+
case 'ENOTDIR':
|
|
332
|
+
return 'a path component is not a directory';
|
|
333
|
+
case 'ENOENT':
|
|
334
|
+
return 'a parent path does not exist';
|
|
335
|
+
case 'ENOSPC':
|
|
336
|
+
return 'no space left on device';
|
|
337
|
+
default:
|
|
338
|
+
return typeof code === 'string' && code.length > 0 ? code : 'an unknown error';
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Best-effort, once-per-directory self-provisioning of qmd's store directory
|
|
343
|
+
* BEFORE qmd is spawned, so the benign majority case — a pruned cache or a fresh
|
|
344
|
+
* machine — simply works instead of erroring. It creates ONLY that one directory
|
|
345
|
+
* (recursive), records the outcome (including a bounded errno reason on
|
|
346
|
+
* failure), and NEVER throws, retries, waits, or spawns. When the mkdir fails
|
|
347
|
+
* (permissions / read-only), qmd then still can't open its store, and the
|
|
348
|
+
* recorded reason is what makes the classified remedy self-describing. Modelled
|
|
349
|
+
* on the repairQmdNativeBindings best-effort contract in this file.
|
|
350
|
+
*/
|
|
351
|
+
export function ensureQmdStoreDir(env = process.env, options = {}) {
|
|
352
|
+
let dir;
|
|
353
|
+
try {
|
|
354
|
+
dir = resolveQmdStoreDir(env);
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
// Resolution itself must never break a qmd run.
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
let outcome = storeEnsureByDir.get(dir);
|
|
361
|
+
if (!outcome) {
|
|
362
|
+
const mkdir = options.mkdir ?? ((target) => { fs.mkdirSync(target, { recursive: true }); });
|
|
363
|
+
try {
|
|
364
|
+
mkdir(dir);
|
|
365
|
+
outcome = { dir, created: true };
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
outcome = { dir, created: false, reason: ensureFailureReason(error) };
|
|
369
|
+
}
|
|
370
|
+
storeEnsureByDir.set(dir, outcome);
|
|
371
|
+
}
|
|
372
|
+
lastQmdStoreEnsure = outcome;
|
|
373
|
+
}
|
|
268
374
|
/** How long a single usability probe or repair sub-step may run. */
|
|
269
375
|
const PROBE_TIMEOUT_MS = 10_000;
|
|
270
376
|
const REPAIR_STEP_TIMEOUT_MS = 180_000;
|
|
@@ -275,6 +381,8 @@ export function __resetQmdProbeStateForTests() {
|
|
|
275
381
|
usableQmdCache.clear();
|
|
276
382
|
lastProbeFailure.clear();
|
|
277
383
|
nativeBindingRepairAttempted = false;
|
|
384
|
+
storeEnsureByDir.clear();
|
|
385
|
+
lastQmdStoreEnsure = undefined;
|
|
278
386
|
}
|
|
279
387
|
/** The qmd file identity of an invocation: the launcher for a node entry, else
|
|
280
388
|
* the command. Used to key probe failures and confine native-binding repair. */
|
|
@@ -823,6 +931,26 @@ function finishRunQmd(result, bin, args) {
|
|
|
823
931
|
if (isQmdModuleMissingError({ stderr: normalized.stderr, stdout: normalized.stdout })) {
|
|
824
932
|
throw new QmdModuleMissingError(`qmd ${subcommand} could not run: a module it needs was not found (the hq install tree looks incomplete)`, args, normalized.status, normalized.stdout, normalized.stderr);
|
|
825
933
|
}
|
|
934
|
+
// qmd could not open its SQLite store because the store DIRECTORY does not
|
|
935
|
+
// exist (better-sqlite3: `TypeError: Cannot open database because the
|
|
936
|
+
// directory does not exist`). That missing directory is the caller's
|
|
937
|
+
// environment — a pruned cache, a fresh machine, an INDEX_PATH into a deleted
|
|
938
|
+
// tree, or a directory hq could not create (permissions / a read-only mount) —
|
|
939
|
+
// not an hq-cli defect. Type it so the boundary prints a self-describing
|
|
940
|
+
// remedy (naming the resolved store dir and the recorded errno reason) and
|
|
941
|
+
// skips capture, and so index-cmd's diagnostic command degrades rather than
|
|
942
|
+
// crashing. Read qmd's OWN captured streams only, never the synthesized
|
|
943
|
+
// message, so a user query containing that phrase can never trip it. Placed
|
|
944
|
+
// AFTER the LLM-disabled and module-missing checks (narrower typed signatures)
|
|
945
|
+
// and BEFORE the collection-missing regex: the wordings are disjoint (that
|
|
946
|
+
// regex needs `collection`/`qmd://` adjacent to a not-found token, which this
|
|
947
|
+
// stderr lacks), so no existing branch changes behaviour (HQ-CLI-16, Sentry
|
|
948
|
+
// 7698235964). The stamped storeDir/ensureReason come from the pre-spawn
|
|
949
|
+
// ensure that ran for this exact store directory.
|
|
950
|
+
if (isQmdStoreMissingError({ stderr: normalized.stderr, stdout: normalized.stdout })) {
|
|
951
|
+
const ensure = lastQmdStoreEnsure;
|
|
952
|
+
throw new QmdStoreMissingError(`qmd ${subcommand} could not run: its local search store could not be opened (its directory does not exist)`, args, normalized.status, normalized.stdout, normalized.stderr, ensure?.dir, ensure && !ensure.created ? ensure.reason : undefined);
|
|
953
|
+
}
|
|
826
954
|
if (/(?:collection|qmd:\/\/).*(?:not found|does not exist|unknown)|(?:not found|does not exist).*collection/i.test(classifyText)) {
|
|
827
955
|
throw new QmdCollectionMissingError(message, args, normalized.status, normalized.stdout, normalized.stderr);
|
|
828
956
|
}
|
|
@@ -861,6 +989,13 @@ export function runQmd(args, options = {}) {
|
|
|
861
989
|
execPath: options.execPath,
|
|
862
990
|
spawn: options.spawn,
|
|
863
991
|
});
|
|
992
|
+
// Self-provision qmd's store directory before the spawn so the benign
|
|
993
|
+
// pruned-cache / fresh-machine case simply works, and record the outcome so a
|
|
994
|
+
// residual store-open failure (a directory hq could not create) can be
|
|
995
|
+
// classified with its cause instead of captured (HQ-CLI-16). Best-effort and
|
|
996
|
+
// idempotent; only the real spawn path needs it (the injected-runner path
|
|
997
|
+
// above never opens a store).
|
|
998
|
+
ensureQmdStoreDir(options.env ?? process.env);
|
|
864
999
|
const result = spawnQmd(invocation, args, {
|
|
865
1000
|
cwd: options.cwd,
|
|
866
1001
|
env: options.env ?? process.env,
|
package/dist/main.js
CHANGED
|
@@ -64,17 +64,21 @@ import { registerDoctorCommand } from "./commands/doctor.js";
|
|
|
64
64
|
import { registerMeshCommand } from "./commands/mesh.js";
|
|
65
65
|
import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
|
|
66
66
|
import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
|
|
67
|
+
import { syncStateLockMessage } from "./utils/sync-state-lock-error.js";
|
|
67
68
|
import { networkTransportErrorMessage } from "./utils/network-transport-error.js";
|
|
68
69
|
import { qmdNativeBindingErrorMessage } from "./utils/qmd-native-binding-error.js";
|
|
69
70
|
import { qmdMissingCollectionMessage } from "./utils/qmd-collection-missing-error.js";
|
|
70
71
|
import { qmdTerminatedMessage } from "./utils/qmd-terminated-error.js";
|
|
71
72
|
import { qmdLlmDisabledMessage } from "./utils/qmd-llm-disabled-error.js";
|
|
72
73
|
import { qmdModuleMissingMessage } from "./utils/qmd-module-missing-error.js";
|
|
74
|
+
import { qmdStoreMissingMessage } from "./utils/qmd-store-missing-error.js";
|
|
73
75
|
import { isExpectedUserError } from "./utils/expected-cli-error.js";
|
|
74
76
|
import { isEpipe } from "./utils/epipe.js";
|
|
75
77
|
import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
|
|
76
78
|
import { isAuthError } from "./utils/auth-error.js";
|
|
77
79
|
import { browserLoginAbandonedMessage } from "./utils/browser-login-abandoned.js";
|
|
80
|
+
import { callbackPortBusyMessage, DEFAULT_CALLBACK_PORT } from "./utils/callback-port-busy.js";
|
|
81
|
+
import { DEFAULT_COGNITO } from "./utils/cognito-session.js";
|
|
78
82
|
import { isCompanySelectionError } from "./utils/company-selection-error.js";
|
|
79
83
|
import { canOfferTeamUpgrade, formatPlanGateError, isPlanGateError, offerTeamUpgrade, } from "./utils/plan-gate-error.js";
|
|
80
84
|
import { upgradeToTeam } from "./utils/team-upgrade.js";
|
|
@@ -412,6 +416,23 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
412
416
|
deps.stderr.write(`hq: ${browserLoginAbandonedMessage(err)}\n`);
|
|
413
417
|
deps.setExitCode(1);
|
|
414
418
|
}
|
|
419
|
+
else if (callbackPortBusyMessage(err, DEFAULT_COGNITO.port ?? DEFAULT_CALLBACK_PORT)) {
|
|
420
|
+
// HQ-CLI-15: the caller's command fell back to the IMPLICIT browser
|
|
421
|
+
// sign-in, but the loopback OAuth callback port (127.0.0.1:<port>) was
|
|
422
|
+
// already held by another process, so @indigoai-us/hq-cloud's browserLogin
|
|
423
|
+
// rejected with a raw `listen EADDRINUSE` on that fixed port. That is the
|
|
424
|
+
// caller's local machine state — a second `hq login` still waiting for its
|
|
425
|
+
// browser sign-in, or a stray process on the port — the user fixes by
|
|
426
|
+
// finishing/stopping the other login and retrying, not an hq-cli defect.
|
|
427
|
+
// Print the fixed actionable remedy and skip Sentry so one busy port
|
|
428
|
+
// doesn't file a permanent, unfixable "crash". Placed with the
|
|
429
|
+
// abandoned-login carve-out (both mean "your HQ session couldn't be
|
|
430
|
+
// established interactively"). The match is STRUCTURAL (errno code + the
|
|
431
|
+
// configured callback port), so an EADDRINUSE on any OTHER port stays a
|
|
432
|
+
// genuine fault and still captures below.
|
|
433
|
+
deps.stderr.write(`hq: ${callbackPortBusyMessage(err, DEFAULT_COGNITO.port ?? DEFAULT_CALLBACK_PORT)}\n`);
|
|
434
|
+
deps.setExitCode(1);
|
|
435
|
+
}
|
|
415
436
|
else if (isPlanGateError(err)) {
|
|
416
437
|
// hq-pro's plan denials are expected product limits, never a CLI crash.
|
|
417
438
|
// The shared vault client has already decoded and typed the small safe
|
|
@@ -520,9 +541,41 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
520
541
|
const moduleMissingMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg
|
|
521
542
|
? null
|
|
522
543
|
: qmdModuleMissingMessage(err);
|
|
523
|
-
|
|
544
|
+
// A qmd store-directory-missing failure (better-sqlite3 could not open its
|
|
545
|
+
// SQLite store because the directory does not exist) is the caller's local
|
|
546
|
+
// filesystem/environment — a pruned cache, a fresh machine, an INDEX_PATH
|
|
547
|
+
// into a deleted tree, or a directory hq could not create — not an hq-cli
|
|
548
|
+
// defect. finishRunQmd types it QmdStoreMissingError, carrying the resolved
|
|
549
|
+
// store directory and the errno reason hq's pre-spawn ensure recorded;
|
|
550
|
+
// print that self-describing remedy and skip capture (HQ-CLI-16, Sentry
|
|
551
|
+
// 7698235964). Evaluated AFTER the native-binding / collection-missing /
|
|
552
|
+
// terminated / llm-disabled / module-missing checks (all narrower or
|
|
553
|
+
// sibling typed signatures) and BEFORE the environmental / transport /
|
|
554
|
+
// generic branches; the signatures are disjoint, so ordering changes no
|
|
555
|
+
// existing branch.
|
|
556
|
+
const storeMissingMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg
|
|
557
|
+
? null
|
|
558
|
+
: qmdStoreMissingMessage(err);
|
|
559
|
+
const envMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg
|
|
524
560
|
? null
|
|
525
561
|
: environmentalFsErrorMessage(err);
|
|
562
|
+
// A LOCAL sync-state lock failure (@indigoai-us/hq-cloud's
|
|
563
|
+
// `StateStoreLockError`: another HQ process on this machine holds the
|
|
564
|
+
// sync-state journal lock) is the caller's concurrency state, not an
|
|
565
|
+
// hq-cli defect — hq-cloud's own watcher/sync-runner treat lock contention
|
|
566
|
+
// as transient. Before HQ-CLI-14 the `hq skill create` sync step flattened
|
|
567
|
+
// it into a generic, path-bearing Error that reached the capture below and
|
|
568
|
+
// minted a new fingerprint per home directory. Print the input-free remedy,
|
|
569
|
+
// exit 1, and skip Sentry. The signatures do not overlap the neighbouring
|
|
570
|
+
// branches: a StateStoreLockError carries no `.code` (so
|
|
571
|
+
// environmentalFsErrorMessage returned null) and is not a fetch TypeError
|
|
572
|
+
// (so it is not a transport failure), and this ordering — after the
|
|
573
|
+
// environmental-fs check, before network-transport — is pinned by tests.
|
|
574
|
+
// The `in-process-async-holder` reason is deliberately NOT suppressed here
|
|
575
|
+
// (see sync-state-lock-error.ts); it stays captured.
|
|
576
|
+
const lockMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || envMsg
|
|
577
|
+
? null
|
|
578
|
+
: syncStateLockMessage(err);
|
|
526
579
|
// A raw network transport failure (undici's `TypeError: fetch failed`
|
|
527
580
|
// with a ConnectTimeoutError / ECONNREFUSED / ENOTFOUND cause) is the
|
|
528
581
|
// caller's connectivity, not an hq-cli defect. Before this branch it fell
|
|
@@ -533,7 +586,14 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
533
586
|
// message that names the unreachable host, exit 1, and skip Sentry.
|
|
534
587
|
// Ordered after the environmental check so a full disk keeps its exact
|
|
535
588
|
// existing message.
|
|
536
|
-
const transportMsg = qmdMsg ||
|
|
589
|
+
const transportMsg = qmdMsg ||
|
|
590
|
+
collectionMsg ||
|
|
591
|
+
terminatedMsg ||
|
|
592
|
+
llmDisabledMsg ||
|
|
593
|
+
moduleMissingMsg ||
|
|
594
|
+
storeMissingMsg ||
|
|
595
|
+
envMsg ||
|
|
596
|
+
lockMsg
|
|
537
597
|
? null
|
|
538
598
|
: networkTransportErrorMessage(err);
|
|
539
599
|
if (qmdMsg) {
|
|
@@ -551,9 +611,15 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
551
611
|
else if (moduleMissingMsg) {
|
|
552
612
|
deps.stderr.write(`hq: ${moduleMissingMsg}\n`);
|
|
553
613
|
}
|
|
614
|
+
else if (storeMissingMsg) {
|
|
615
|
+
deps.stderr.write(`hq: ${storeMissingMsg}\n`);
|
|
616
|
+
}
|
|
554
617
|
else if (envMsg) {
|
|
555
618
|
deps.stderr.write(`hq: ${envMsg}\n`);
|
|
556
619
|
}
|
|
620
|
+
else if (lockMsg) {
|
|
621
|
+
deps.stderr.write(`hq: ${lockMsg}\n`);
|
|
622
|
+
}
|
|
557
623
|
else if (transportMsg) {
|
|
558
624
|
deps.stderr.write(`hq: ${transportMsg}\n`);
|
|
559
625
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Fixed callback port for the loopback OAuth server when unset (mirrors DEFAULT_COGNITO). */
|
|
2
|
+
export declare const DEFAULT_CALLBACK_PORT = 8765;
|
|
3
|
+
/**
|
|
4
|
+
* The fixed, actionable remedy. Interpolates ONLY the numeric callback port (a
|
|
5
|
+
* bounded value drawn from config, never caller input or upstream error text),
|
|
6
|
+
* so this carve-out can never widen disclosure and needs no redaction pass.
|
|
7
|
+
*/
|
|
8
|
+
export declare function callbackPortBusyGuidance(port: number): string;
|
|
9
|
+
/**
|
|
10
|
+
* The LOOSE predicate for a caller that just invoked browserLogin and is
|
|
11
|
+
* catching its own error (`hq auth login`/`hq login`): an EADDRINUSE by errno
|
|
12
|
+
* code OR message is the callback-port collision, because the callback server is
|
|
13
|
+
* the only listener that call could have opened. NOT for the shared boundary —
|
|
14
|
+
* use {@link callbackPortBusyMessage} there.
|
|
15
|
+
*/
|
|
16
|
+
export declare function isCallbackPortBusy(err: unknown): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* The STRUCTURAL classifier for the shared top-level boundary. Returns the fixed
|
|
19
|
+
* remedy ONLY when `err` is an EADDRINUSE whose `port` equals the configured
|
|
20
|
+
* callback `port`; otherwise returns `null`.
|
|
21
|
+
*
|
|
22
|
+
* A non-null result means the caller should print the message and SKIP Sentry
|
|
23
|
+
* capture. A null result means "handle as usual (capture to Sentry)" — including
|
|
24
|
+
* an EADDRINUSE on ANY other port, which is a different listener and a genuine
|
|
25
|
+
* fault that must stay reportable. Matched on the errno SHAPE only (code + port),
|
|
26
|
+
* never on free text, so no upstream/caller string can reach the printed line.
|
|
27
|
+
*/
|
|
28
|
+
export declare function callbackPortBusyMessage(err: unknown, port: number): string | null;
|
|
29
|
+
//# sourceMappingURL=callback-port-busy.d.ts.map
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// src/utils/callback-port-busy.ts
|
|
2
|
+
//
|
|
3
|
+
// Classify a browser-login CALLBACK-PORT COLLISION — the loopback OAuth server
|
|
4
|
+
// @indigoai-us/hq-cloud's browserLogin binds (the fixed DEFAULT_COGNITO.port,
|
|
5
|
+
// 8765 unless HQ_COGNITO_CALLBACK_PORT overrides it) could not be bound because
|
|
6
|
+
// another process already holds it, so Node rejects with a raw ErrnoException
|
|
7
|
+
// (`listen EADDRINUSE …`, code EADDRINUSE, port 8765, syscall listen). That is
|
|
8
|
+
// the caller's LOCAL machine state — a second `hq login` still waiting for its
|
|
9
|
+
// browser sign-in, or a stray process on the port — NOT an hq-cli defect: the
|
|
10
|
+
// user fixes it by finishing/stopping the other login and retrying. So the
|
|
11
|
+
// top-level handler prints an actionable line and exits non-zero but SKIPS
|
|
12
|
+
// Sentry capture, mirroring the abandoned-browser-login (HQ-CLI-V), auth
|
|
13
|
+
// (HQ-CLI-8), company-selection (HQ-CLI-7), expected-user-error (HQ-CLI-6), and
|
|
14
|
+
// environmental-FS (HQ-CLI-2) carve-outs.
|
|
15
|
+
//
|
|
16
|
+
// HQ-CLI-15 (Sentry indigo-d0/hq-cli 7698014705): `hq integrations list
|
|
17
|
+
// --company <slug> --json` refreshed an expiring session, the refresh failed,
|
|
18
|
+
// and it fell back to the IMPLICIT browser sign-in on one of the ~100
|
|
19
|
+
// ensureCognitoToken/ensureCognitoIdToken call sites. The callback port was
|
|
20
|
+
// already held, so browserLogin rejected with a bare `listen EADDRINUSE:
|
|
21
|
+
// address already in use 127.0.0.1:8765`. That Node error carries no `expected`
|
|
22
|
+
// flag and is not an AuthError/CognitoAuthError/…, so every predicate in
|
|
23
|
+
// handleTopLevelError returned false and the collision was captured as a
|
|
24
|
+
// high-priority, unfixable "crash". `hq auth login` already classified the
|
|
25
|
+
// identical condition in a file-private helper; this module lifts that
|
|
26
|
+
// classification to the shared boundary so every implicit call site is covered
|
|
27
|
+
// from one source of truth.
|
|
28
|
+
//
|
|
29
|
+
// Two predicates, differing ONLY by how much context the caller already has:
|
|
30
|
+
// - isCallbackPortBusy(err): the LOOSE form for a call site that KNOWS it just
|
|
31
|
+
// invoked browserLogin (`hq auth login`/`hq login`). There the only listener
|
|
32
|
+
// in play is the callback server, so an EADDRINUSE — by code or message — is
|
|
33
|
+
// unambiguously the callback port.
|
|
34
|
+
// - callbackPortBusyMessage(err, port): the STRUCTURAL form for the shared
|
|
35
|
+
// boundary, which sees arbitrary errors from every call site. It matches
|
|
36
|
+
// ONLY when err.code === 'EADDRINUSE' AND err.port equals the configured
|
|
37
|
+
// callback port, so an EADDRINUSE raised by any OTHER listener stays
|
|
38
|
+
// reportable (the boundary must never swallow a genuine listener defect).
|
|
39
|
+
/** Fixed callback port for the loopback OAuth server when unset (mirrors DEFAULT_COGNITO). */
|
|
40
|
+
export const DEFAULT_CALLBACK_PORT = 8765;
|
|
41
|
+
/** Node's errno code for a `listen()` against an address already in use. */
|
|
42
|
+
const EADDRINUSE = "EADDRINUSE";
|
|
43
|
+
/**
|
|
44
|
+
* The fixed, actionable remedy. Interpolates ONLY the numeric callback port (a
|
|
45
|
+
* bounded value drawn from config, never caller input or upstream error text),
|
|
46
|
+
* so this carve-out can never widen disclosure and needs no redaction pass.
|
|
47
|
+
*/
|
|
48
|
+
export function callbackPortBusyGuidance(port) {
|
|
49
|
+
return (`The browser-login callback port (127.0.0.1:${port}) is already in use. ` +
|
|
50
|
+
"Another `hq auth login` may still be waiting for browser sign-in. Finish that login, " +
|
|
51
|
+
"or stop its terminal/process, then retry.");
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The LOOSE predicate for a caller that just invoked browserLogin and is
|
|
55
|
+
* catching its own error (`hq auth login`/`hq login`): an EADDRINUSE by errno
|
|
56
|
+
* code OR message is the callback-port collision, because the callback server is
|
|
57
|
+
* the only listener that call could have opened. NOT for the shared boundary —
|
|
58
|
+
* use {@link callbackPortBusyMessage} there.
|
|
59
|
+
*/
|
|
60
|
+
export function isCallbackPortBusy(err) {
|
|
61
|
+
if (!err || typeof err !== "object")
|
|
62
|
+
return false;
|
|
63
|
+
const { code, message } = err;
|
|
64
|
+
return code === EADDRINUSE || message?.includes(EADDRINUSE) === true;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The STRUCTURAL classifier for the shared top-level boundary. Returns the fixed
|
|
68
|
+
* remedy ONLY when `err` is an EADDRINUSE whose `port` equals the configured
|
|
69
|
+
* callback `port`; otherwise returns `null`.
|
|
70
|
+
*
|
|
71
|
+
* A non-null result means the caller should print the message and SKIP Sentry
|
|
72
|
+
* capture. A null result means "handle as usual (capture to Sentry)" — including
|
|
73
|
+
* an EADDRINUSE on ANY other port, which is a different listener and a genuine
|
|
74
|
+
* fault that must stay reportable. Matched on the errno SHAPE only (code + port),
|
|
75
|
+
* never on free text, so no upstream/caller string can reach the printed line.
|
|
76
|
+
*/
|
|
77
|
+
export function callbackPortBusyMessage(err, port) {
|
|
78
|
+
if (!err || typeof err !== "object")
|
|
79
|
+
return null;
|
|
80
|
+
const errno = err;
|
|
81
|
+
if (errno.code !== EADDRINUSE)
|
|
82
|
+
return null;
|
|
83
|
+
if (typeof errno.port !== "number" || errno.port !== port)
|
|
84
|
+
return null;
|
|
85
|
+
return callbackPortBusyGuidance(port);
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=callback-port-busy.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True when `err` is a qmd store-directory-missing failure: better-sqlite3's
|
|
3
|
+
* fixed sentence appears in qmd's captured stderr/stdout. Accepts either the
|
|
4
|
+
* thrown error (reads its `stderr`/`stdout`) or a bare `{ stderr, stdout }`
|
|
5
|
+
* probe object. A true result means the caller should print the classified
|
|
6
|
+
* remedy, exit non-zero, and SKIP Sentry capture.
|
|
7
|
+
*/
|
|
8
|
+
export declare function isQmdStoreMissingError(err: unknown): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* If `err` is a qmd store-directory-missing failure, return the actionable
|
|
11
|
+
* remedy; otherwise return `null`. Mirrors qmdNativeBindingErrorMessage /
|
|
12
|
+
* qmdMissingCollectionMessage so the top-level handler can branch on it the same
|
|
13
|
+
* way: a non-null result means print-and-skip-Sentry, null means "handle as
|
|
14
|
+
* usual (capture to Sentry)". The store directory and errno reason are read
|
|
15
|
+
* STRUCTURALLY from the error's own hq-populated `storeDir`/`ensureReason`
|
|
16
|
+
* fields (absent on a bare probe object — then the message names neither), never
|
|
17
|
+
* from qmd's output.
|
|
18
|
+
*/
|
|
19
|
+
export declare function qmdStoreMissingMessage(err: unknown): string | null;
|
|
20
|
+
//# sourceMappingURL=qmd-store-missing-error.d.ts.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// src/utils/qmd-store-missing-error.ts
|
|
2
|
+
//
|
|
3
|
+
// Classify a qmd failure caused by a MISSING STORE DIRECTORY — the directory
|
|
4
|
+
// qmd opens its SQLite index in does not exist — rather than an hq-cli code
|
|
5
|
+
// defect. This is the caller's LOCAL filesystem/environment (a pruned cache dir,
|
|
6
|
+
// a fresh machine, an INDEX_PATH pointing into a deleted tree, or a directory hq
|
|
7
|
+
// could not create because of permissions / a read-only mount), not a bug HQ can
|
|
8
|
+
// fix in code, so the CLI surfaces an actionable remedy and SKIPS Sentry
|
|
9
|
+
// capture. Sibling of qmd-native-binding-error.ts (HQ-CLI-J, unbuilt bindings),
|
|
10
|
+
// environmental-error.ts (HQ-CLI-2, full disk), and network-transport-error.ts
|
|
11
|
+
// (HQ-CLI-G, connectivity): a failure that is NOT an hq-cli defect is printed
|
|
12
|
+
// with an actionable message and never filed as a crash.
|
|
13
|
+
//
|
|
14
|
+
// HQ-CLI-16 (Sentry indigo-d0/hq-cli 7698235964): `hq index status` ran `qmd
|
|
15
|
+
// collection list`, and @tobilu/qmd's better-sqlite3 threw `TypeError: Cannot
|
|
16
|
+
// open database because the directory does not exist` the moment it opened its
|
|
17
|
+
// store — qmd's getDefaultDbPath returns $INDEX_PATH verbatim before any mkdir,
|
|
18
|
+
// else resolves ${XDG_CACHE_HOME||~/.cache}/qmd behind a mkdir whose failure it
|
|
19
|
+
// swallows. runQmd wrapped the exit-1 in a plain QmdExitError, index-cmd's catch
|
|
20
|
+
// re-threw it (narrowed to the sibling native-binding case only), and it reached
|
|
21
|
+
// the boundary's final else, which captured it — an unfixable, per-user "crash"
|
|
22
|
+
// whose title carries the caller's home path.
|
|
23
|
+
//
|
|
24
|
+
// Matching is deliberately narrow so it can neither be tripped by user input nor
|
|
25
|
+
// silence a real bug: better-sqlite3's FIXED sentence is required in qmd's OWN
|
|
26
|
+
// captured streams (stderr/stdout), NEVER the synthesized `message` — which
|
|
27
|
+
// echoes the caller's argv — so a search query that merely contains the phrase
|
|
28
|
+
// can never classify. Every other qmd store failure (a locked db, a corrupt
|
|
29
|
+
// store, an unknown exit) matches none of this and stays a reportable
|
|
30
|
+
// QmdExitError.
|
|
31
|
+
/** better-sqlite3's exact "directory does not exist" sentence — sufficient on its own. */
|
|
32
|
+
const STORE_DIR_MISSING = /Cannot open database because the directory does not exist/i;
|
|
33
|
+
/**
|
|
34
|
+
* qmd's OWN captured streams (stderr then stdout), joined. The synthesized
|
|
35
|
+
* `message` is deliberately NOT consulted: it embeds the caller's qmd arguments,
|
|
36
|
+
* so reading it would let a user query for this phrase trip the classifier.
|
|
37
|
+
*/
|
|
38
|
+
function capturedStreams(err) {
|
|
39
|
+
if (err === null || typeof err !== "object")
|
|
40
|
+
return "";
|
|
41
|
+
const record = err;
|
|
42
|
+
const stderr = typeof record.stderr === "string" ? record.stderr : "";
|
|
43
|
+
const stdout = typeof record.stdout === "string" ? record.stdout : "";
|
|
44
|
+
return `${stderr}\n${stdout}`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* True when `err` is a qmd store-directory-missing failure: better-sqlite3's
|
|
48
|
+
* fixed sentence appears in qmd's captured stderr/stdout. Accepts either the
|
|
49
|
+
* thrown error (reads its `stderr`/`stdout`) or a bare `{ stderr, stdout }`
|
|
50
|
+
* probe object. A true result means the caller should print the classified
|
|
51
|
+
* remedy, exit non-zero, and SKIP Sentry capture.
|
|
52
|
+
*/
|
|
53
|
+
export function isQmdStoreMissingError(err) {
|
|
54
|
+
return STORE_DIR_MISSING.test(capturedStreams(err));
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The actionable remedy, naming the store directory that could not be opened and
|
|
58
|
+
* (when the store-directory ensure step recorded one) the errno reason it could
|
|
59
|
+
* not be created. Both are hq-DERIVED values — the resolved store path and a
|
|
60
|
+
* bounded errno phrase — never caller argv/query or upstream free text, so the
|
|
61
|
+
* line stays input-free. Deliberately does NOT point the user at `hq index sync`:
|
|
62
|
+
* reconciliation begins with the same `qmd collection list` and would hit the
|
|
63
|
+
* identical failure, a dead-end loop.
|
|
64
|
+
*/
|
|
65
|
+
function remedyMessage(storeDir, ensureReason) {
|
|
66
|
+
const where = storeDir
|
|
67
|
+
? `hq's local search store directory (${storeDir}) could not be opened`
|
|
68
|
+
: "hq's local search store directory could not be opened";
|
|
69
|
+
const because = ensureReason
|
|
70
|
+
? ` — hq could not create it: ${ensureReason}.`
|
|
71
|
+
: " — it does not exist.";
|
|
72
|
+
return (`${where}${because} This is your machine's filesystem, not an hq bug: ` +
|
|
73
|
+
"check that the directory (and its parent) exist and are writable, or point " +
|
|
74
|
+
"INDEX_PATH at a writable location, then run your command again.");
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* If `err` is a qmd store-directory-missing failure, return the actionable
|
|
78
|
+
* remedy; otherwise return `null`. Mirrors qmdNativeBindingErrorMessage /
|
|
79
|
+
* qmdMissingCollectionMessage so the top-level handler can branch on it the same
|
|
80
|
+
* way: a non-null result means print-and-skip-Sentry, null means "handle as
|
|
81
|
+
* usual (capture to Sentry)". The store directory and errno reason are read
|
|
82
|
+
* STRUCTURALLY from the error's own hq-populated `storeDir`/`ensureReason`
|
|
83
|
+
* fields (absent on a bare probe object — then the message names neither), never
|
|
84
|
+
* from qmd's output.
|
|
85
|
+
*/
|
|
86
|
+
export function qmdStoreMissingMessage(err) {
|
|
87
|
+
if (!isQmdStoreMissingError(err))
|
|
88
|
+
return null;
|
|
89
|
+
const record = err;
|
|
90
|
+
const storeDir = typeof record.storeDir === "string" && record.storeDir.length > 0 ? record.storeDir : null;
|
|
91
|
+
const ensureReason = typeof record.ensureReason === "string" && record.ensureReason.length > 0
|
|
92
|
+
? record.ensureReason
|
|
93
|
+
: null;
|
|
94
|
+
return remedyMessage(storeDir, ensureReason);
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=qmd-store-missing-error.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `name` @indigoai-us/hq-cloud stamps on a state-store lock failure, and the
|
|
3
|
+
* prefix it puts on every such message (`super(\`state-store lock: ${message}\`)`).
|
|
4
|
+
* EITHER signal identifies the class. A unit test pins BOTH against the installed
|
|
5
|
+
* dependency so a future rename fails loudly in CI here rather than silently
|
|
6
|
+
* resuming Sentry noise.
|
|
7
|
+
*/
|
|
8
|
+
export declare const SYNC_STATE_LOCK_ERROR_NAME = "StateStoreLockError";
|
|
9
|
+
export declare const SYNC_STATE_LOCK_MESSAGE_PREFIX = "state-store lock: ";
|
|
10
|
+
/**
|
|
11
|
+
* If `err` is a LOCAL sync-state lock failure, return a short, input-free,
|
|
12
|
+
* actionable user-facing message; otherwise return `null`.
|
|
13
|
+
*
|
|
14
|
+
* A non-null result means the caller should PRINT the message, exit non-zero,
|
|
15
|
+
* and SKIP Sentry capture — the condition is another HQ process holding this
|
|
16
|
+
* machine's sync-state lock, not a bug HQ can fix. A null result means "handle
|
|
17
|
+
* this as usual (capture to Sentry)". The `in-process-async-holder` reason is
|
|
18
|
+
* deliberately excluded and still captures.
|
|
19
|
+
*
|
|
20
|
+
* The returned message never echoes the lock path or any other caller input, so
|
|
21
|
+
* the whole class groups as ONE Sentry-free condition instead of minting a new
|
|
22
|
+
* fingerprint per home directory. It is a fixed, bounded string, so no redaction
|
|
23
|
+
* or length cap is required.
|
|
24
|
+
*/
|
|
25
|
+
export declare function syncStateLockMessage(err: unknown): string | null;
|
|
26
|
+
//# sourceMappingURL=sync-state-lock-error.d.ts.map
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// src/utils/sync-state-lock-error.ts
|
|
2
|
+
//
|
|
3
|
+
// Classify a LOCAL sync-state lock failure — another HQ process on THIS machine
|
|
4
|
+
// already holds the sync-state journal lock — as the caller's machine/
|
|
5
|
+
// concurrency state rather than an hq-cli code defect. Sibling of
|
|
6
|
+
// `environmental-error.ts` (HQ-CLI-2, full disk / read-only fs) and
|
|
7
|
+
// `network-transport-error.ts` (HQ-CLI-G, connectivity): a failure that is not
|
|
8
|
+
// an hq-cli defect is surfaced to the user with an actionable message and
|
|
9
|
+
// skipped for Sentry capture.
|
|
10
|
+
//
|
|
11
|
+
// HQ-CLI-14 (Sentry 7695715459): `hq skill create` registered and stamped the
|
|
12
|
+
// skill, then its sync step called @indigoai-us/hq-cloud's `share()`, which
|
|
13
|
+
// surfaced a `StateStoreLockError: state-store lock: cannot acquire
|
|
14
|
+
// <HOME>/.hq/sync-state-v3/<digest>/append.lock`. hq-cloud's OWN consumers treat
|
|
15
|
+
// lock contention as transient — its watcher reschedules on it and its
|
|
16
|
+
// sync-runner defers only the `in-process-async-holder` reason — so a local
|
|
17
|
+
// lock failure is concurrency state the user resolves by waiting for (or
|
|
18
|
+
// quitting) the other HQ sync, not a reportable hq-cli crash. Before this the
|
|
19
|
+
// caller flattened every sync failure into a generic, path-bearing Error, so the
|
|
20
|
+
// lock class fell through the top-level boundary's closed allowlist and filed a
|
|
21
|
+
// crash whose message minted a new Sentry fingerprint per home directory.
|
|
22
|
+
//
|
|
23
|
+
// Matching is STRUCTURAL, never `instanceof`: `StateStoreLockError` is not
|
|
24
|
+
// re-exported from `@indigoai-us/hq-cloud`'s package entry (only `.`,
|
|
25
|
+
// `./outposts*` and `./package.json` are exported), so a consumer cannot name
|
|
26
|
+
// the class. Both signals below are computed from the value itself, mirroring
|
|
27
|
+
// the style `environmental-error.ts` and `network-transport-error.ts` already
|
|
28
|
+
// use.
|
|
29
|
+
/**
|
|
30
|
+
* The `name` @indigoai-us/hq-cloud stamps on a state-store lock failure, and the
|
|
31
|
+
* prefix it puts on every such message (`super(\`state-store lock: ${message}\`)`).
|
|
32
|
+
* EITHER signal identifies the class. A unit test pins BOTH against the installed
|
|
33
|
+
* dependency so a future rename fails loudly in CI here rather than silently
|
|
34
|
+
* resuming Sentry noise.
|
|
35
|
+
*/
|
|
36
|
+
export const SYNC_STATE_LOCK_ERROR_NAME = "StateStoreLockError";
|
|
37
|
+
export const SYNC_STATE_LOCK_MESSAGE_PREFIX = "state-store lock: ";
|
|
38
|
+
/**
|
|
39
|
+
* The one `reason` hq-cloud treats as a DISTINCT internal condition rather than
|
|
40
|
+
* ordinary foreign contention: this process already holds the async scope lock,
|
|
41
|
+
* so a synchronous contender cannot spin without deadlocking the holder
|
|
42
|
+
* (state-store.ts's `in-process async holder for …`). That is same-process
|
|
43
|
+
* reentrancy — a condition HQ could actually fix — so it is NOT suppressed and
|
|
44
|
+
* stays on the captured path, exactly as hq-cloud's own sync-runner special-cases
|
|
45
|
+
* it.
|
|
46
|
+
*/
|
|
47
|
+
const IN_PROCESS_ASYNC_HOLDER_REASON = "in-process-async-holder";
|
|
48
|
+
function isStateStoreLockError(err) {
|
|
49
|
+
if (!(err instanceof Error))
|
|
50
|
+
return false;
|
|
51
|
+
return (err.name === SYNC_STATE_LOCK_ERROR_NAME ||
|
|
52
|
+
err.message.startsWith(SYNC_STATE_LOCK_MESSAGE_PREFIX));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* If `err` is a LOCAL sync-state lock failure, return a short, input-free,
|
|
56
|
+
* actionable user-facing message; otherwise return `null`.
|
|
57
|
+
*
|
|
58
|
+
* A non-null result means the caller should PRINT the message, exit non-zero,
|
|
59
|
+
* and SKIP Sentry capture — the condition is another HQ process holding this
|
|
60
|
+
* machine's sync-state lock, not a bug HQ can fix. A null result means "handle
|
|
61
|
+
* this as usual (capture to Sentry)". The `in-process-async-holder` reason is
|
|
62
|
+
* deliberately excluded and still captures.
|
|
63
|
+
*
|
|
64
|
+
* The returned message never echoes the lock path or any other caller input, so
|
|
65
|
+
* the whole class groups as ONE Sentry-free condition instead of minting a new
|
|
66
|
+
* fingerprint per home directory. It is a fixed, bounded string, so no redaction
|
|
67
|
+
* or length cap is required.
|
|
68
|
+
*/
|
|
69
|
+
export function syncStateLockMessage(err) {
|
|
70
|
+
if (!isStateStoreLockError(err))
|
|
71
|
+
return null;
|
|
72
|
+
if (err.reason === IN_PROCESS_ASYNC_HOLDER_REASON)
|
|
73
|
+
return null;
|
|
74
|
+
return ("Another HQ process on this machine is holding the local sync state, so HQ " +
|
|
75
|
+
"could not update it. Wait for the other HQ sync to finish (or quit it), " +
|
|
76
|
+
"then run `hq sync` to finish syncing.");
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=sync-state-lock-error.js.map
|