@indigoai-us/hq-cli 5.103.30 → 5.103.32
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/skill.js +42 -5
- 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/main.js +22 -1
- 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/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
|
}
|
|
@@ -97,6 +103,21 @@ export function readCompanyPrefix(hqRoot, companySlug) {
|
|
|
97
103
|
return undefined;
|
|
98
104
|
}
|
|
99
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* A caller-input condition inside `resolveSkillUid` — the target has no
|
|
108
|
+
* SKILL.md, or the resolved SKILL.md carries no registered skill_uid — is the
|
|
109
|
+
* user's request/state, not an hq-cli defect. Mark it `expected` so the
|
|
110
|
+
* top-level boundary prints the actionable message and skips Sentry, exactly as
|
|
111
|
+
* `skillApiError` does for the remote 4xx siblings (HQ-CLI-Z). Redact at the
|
|
112
|
+
* throw site: the boundary's expected branch prints `err.message` verbatim, so
|
|
113
|
+
* these caller-supplied paths must be scrubbed here, not only on the fallback
|
|
114
|
+
* path they use today.
|
|
115
|
+
*/
|
|
116
|
+
function localSkillError(message) {
|
|
117
|
+
return Object.assign(new Error(redactErrorText(message) || message), {
|
|
118
|
+
expected: true,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
100
121
|
/** Resolve a UID directly, or read the immutable UID from a local SKILL.md. */
|
|
101
122
|
export function resolveSkillUid(target, cwd) {
|
|
102
123
|
if (SKILL_UID_PATTERN.test(target))
|
|
@@ -106,11 +127,11 @@ export function resolveSkillUid(target, cwd) {
|
|
|
106
127
|
filePath = path.join(filePath, "SKILL.md");
|
|
107
128
|
}
|
|
108
129
|
if (!fs.existsSync(filePath)) {
|
|
109
|
-
throw
|
|
130
|
+
throw localSkillError(`No SKILL.md found at '${target}'. Pass a SKILL.md path, its directory, or a skl_… uid.`);
|
|
110
131
|
}
|
|
111
132
|
const uid = parseSkillUid(fs.readFileSync(filePath, "utf8"));
|
|
112
133
|
if (!uid) {
|
|
113
|
-
throw
|
|
134
|
+
throw localSkillError(`The SKILL.md at '${filePath}' has no registered skill_uid. Register and stamp it with 'hq skill create <slug> --company <company>', then retry.`);
|
|
114
135
|
}
|
|
115
136
|
return uid;
|
|
116
137
|
}
|
|
@@ -339,10 +360,26 @@ export function registerSkillCommand(program, deps = {}) {
|
|
|
339
360
|
});
|
|
340
361
|
}
|
|
341
362
|
catch (err) {
|
|
342
|
-
|
|
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;
|
|
343
376
|
}
|
|
344
377
|
if (syncResult.aborted) {
|
|
345
|
-
|
|
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.`);
|
|
346
383
|
}
|
|
347
384
|
}
|
|
348
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) {
|
package/dist/main.js
CHANGED
|
@@ -64,6 +64,7 @@ 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";
|
|
@@ -523,6 +524,23 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
523
524
|
const envMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg
|
|
524
525
|
? null
|
|
525
526
|
: environmentalFsErrorMessage(err);
|
|
527
|
+
// A LOCAL sync-state lock failure (@indigoai-us/hq-cloud's
|
|
528
|
+
// `StateStoreLockError`: another HQ process on this machine holds the
|
|
529
|
+
// sync-state journal lock) is the caller's concurrency state, not an
|
|
530
|
+
// hq-cli defect — hq-cloud's own watcher/sync-runner treat lock contention
|
|
531
|
+
// as transient. Before HQ-CLI-14 the `hq skill create` sync step flattened
|
|
532
|
+
// it into a generic, path-bearing Error that reached the capture below and
|
|
533
|
+
// minted a new fingerprint per home directory. Print the input-free remedy,
|
|
534
|
+
// exit 1, and skip Sentry. The signatures do not overlap the neighbouring
|
|
535
|
+
// branches: a StateStoreLockError carries no `.code` (so
|
|
536
|
+
// environmentalFsErrorMessage returned null) and is not a fetch TypeError
|
|
537
|
+
// (so it is not a transport failure), and this ordering — after the
|
|
538
|
+
// environmental-fs check, before network-transport — is pinned by tests.
|
|
539
|
+
// The `in-process-async-holder` reason is deliberately NOT suppressed here
|
|
540
|
+
// (see sync-state-lock-error.ts); it stays captured.
|
|
541
|
+
const lockMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || envMsg
|
|
542
|
+
? null
|
|
543
|
+
: syncStateLockMessage(err);
|
|
526
544
|
// A raw network transport failure (undici's `TypeError: fetch failed`
|
|
527
545
|
// with a ConnectTimeoutError / ECONNREFUSED / ENOTFOUND cause) is the
|
|
528
546
|
// caller's connectivity, not an hq-cli defect. Before this branch it fell
|
|
@@ -533,7 +551,7 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
533
551
|
// message that names the unreachable host, exit 1, and skip Sentry.
|
|
534
552
|
// Ordered after the environmental check so a full disk keeps its exact
|
|
535
553
|
// existing message.
|
|
536
|
-
const transportMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || envMsg
|
|
554
|
+
const transportMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || envMsg || lockMsg
|
|
537
555
|
? null
|
|
538
556
|
: networkTransportErrorMessage(err);
|
|
539
557
|
if (qmdMsg) {
|
|
@@ -554,6 +572,9 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
554
572
|
else if (envMsg) {
|
|
555
573
|
deps.stderr.write(`hq: ${envMsg}\n`);
|
|
556
574
|
}
|
|
575
|
+
else if (lockMsg) {
|
|
576
|
+
deps.stderr.write(`hq: ${lockMsg}\n`);
|
|
577
|
+
}
|
|
557
578
|
else if (transportMsg) {
|
|
558
579
|
deps.stderr.write(`hq: ${transportMsg}\n`);
|
|
559
580
|
}
|
|
@@ -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
|