@indigoai-us/hq-cli 5.108.2 → 5.108.4
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 +22 -0
- package/dist/commands/index-cmd.js +13 -0
- package/dist/commands/mesh.js +11 -1
- package/dist/lib/mesh/live/daemon/credentials.d.ts +68 -5
- package/dist/lib/mesh/live/daemon/credentials.js +168 -7
- package/dist/lib/mesh/live/daemon/doctor.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/doctor.js +8 -0
- package/dist/lib/mesh/live/daemon/index.d.ts +4 -4
- package/dist/lib/mesh/live/daemon/index.js +1 -1
- package/dist/lib/mesh/live/daemon/presence.d.ts +27 -3
- package/dist/lib/mesh/live/daemon/presence.js +105 -14
- package/dist/lib/mesh/live/daemon/run.js +10 -0
- package/dist/lib/mesh/live/daemon/state.d.ts +7 -0
- package/dist/lib/search-index/index.d.ts +53 -0
- package/dist/lib/search-index/index.js +98 -2
- package/dist/main.js +56 -4
- package/dist/utils/qmd-workdir-missing-error.d.ts +18 -0
- package/dist/utils/qmd-workdir-missing-error.js +78 -0
- package/dist/utils/sentry-fingerprint.js +5 -0
- package/package.json +1 -1
|
@@ -6,15 +6,20 @@
|
|
|
6
6
|
* - Publishes retained online ONLY to own presence topics
|
|
7
7
|
* - Offline is server-only (IoT lifecycle → PresenceIngestFunction)
|
|
8
8
|
* - Never subscribes to thread topics
|
|
9
|
-
* - Reconnects with
|
|
9
|
+
* - Reconnects with monotonic full-jitter backoff 1s–60s on close / network
|
|
10
|
+
* - Server credential refusals use a long retry (default 10m ±20%)
|
|
10
11
|
*/
|
|
11
12
|
import mqtt from "mqtt";
|
|
12
|
-
import { CredentialRenewalManager, MQTT_KEEPALIVE_SECONDS, realTimerHost, } from "./credentials.js";
|
|
13
|
+
import { CredentialVendError, CredentialRenewalManager, MQTT_KEEPALIVE_SECONDS, clampRetryDelayMs, defaultRefusedRetryMs, realTimerHost, refusedRetryDelayMs, scheduleBoundedTimeout, } from "./credentials.js";
|
|
13
14
|
import { presignIotWssUrl } from "./presign.js";
|
|
14
|
-
/**
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Full-jitter capped exponential backoff (1s base → 60s cap by default),
|
|
17
|
+
* floored at `previousMs` so consecutive failures never shrink the delay.
|
|
18
|
+
*/
|
|
19
|
+
export function backoffDelayMs(attempt, baseMs, maxMs, random, previousMs = 0) {
|
|
16
20
|
const cap = Math.min(maxMs, baseMs * 2 ** attempt);
|
|
17
|
-
|
|
21
|
+
const raw = Math.max(0, random() * cap);
|
|
22
|
+
return Math.min(maxMs, Math.max(previousMs, raw));
|
|
18
23
|
}
|
|
19
24
|
export function buildPresencePayload(input) {
|
|
20
25
|
return {
|
|
@@ -43,15 +48,20 @@ export class PresenceClient {
|
|
|
43
48
|
bundle = null;
|
|
44
49
|
state = "idle";
|
|
45
50
|
attempt = 0;
|
|
51
|
+
lastBackoffMs = 0;
|
|
46
52
|
reconnectHandle = null;
|
|
47
53
|
stopped = false;
|
|
48
54
|
generation = 0;
|
|
49
55
|
publishedTopics = [];
|
|
56
|
+
/** Last refusal code we logged via onError (episode dedupe). */
|
|
57
|
+
loggedRefusalKey = null;
|
|
58
|
+
refusal = null;
|
|
50
59
|
mqttConnect;
|
|
51
60
|
timers;
|
|
52
61
|
random;
|
|
53
62
|
baseBackoffMs;
|
|
54
63
|
maxBackoffMs;
|
|
64
|
+
refusedRetryMs;
|
|
55
65
|
renewal;
|
|
56
66
|
now;
|
|
57
67
|
constructor(options) {
|
|
@@ -63,8 +73,10 @@ export class PresenceClient {
|
|
|
63
73
|
this.random = options.random ?? Math.random;
|
|
64
74
|
this.baseBackoffMs = options.baseBackoffMs ?? 1_000;
|
|
65
75
|
this.maxBackoffMs = options.maxBackoffMs ?? 60_000;
|
|
76
|
+
this.refusedRetryMs =
|
|
77
|
+
options.refusedRetryMs ?? defaultRefusedRetryMs(options.env ?? process.env);
|
|
66
78
|
this.now = options.now ?? (() => new Date());
|
|
67
|
-
this.renewal = new CredentialRenewalManager(options.fetchCredentials, (bundle) => void this.onCredentialsRenewed(bundle), (err) =>
|
|
79
|
+
this.renewal = new CredentialRenewalManager(options.fetchCredentials, (bundle) => void this.onCredentialsRenewed(bundle), (err, info) => this.handleRenewalError(err, info), this.timers, 30_000, this.refusedRetryMs, this.random);
|
|
68
80
|
}
|
|
69
81
|
getConnectionState() {
|
|
70
82
|
return this.state;
|
|
@@ -72,6 +84,9 @@ export class PresenceClient {
|
|
|
72
84
|
getBundle() {
|
|
73
85
|
return this.bundle;
|
|
74
86
|
}
|
|
87
|
+
getRefusal() {
|
|
88
|
+
return this.refusal;
|
|
89
|
+
}
|
|
75
90
|
/** Topics this client has published to (for doctor / tests). */
|
|
76
91
|
getPublishedTopics() {
|
|
77
92
|
return this.publishedTopics;
|
|
@@ -99,13 +114,21 @@ export class PresenceClient {
|
|
|
99
114
|
this.renewal.stop();
|
|
100
115
|
this.clearReconnect();
|
|
101
116
|
this.teardownClient(false);
|
|
117
|
+
this.clearRefusal(/* logClear */ false);
|
|
102
118
|
this.setState("closed");
|
|
103
119
|
}
|
|
104
120
|
/** Force a reconnect cycle (network change / sleep-wake). */
|
|
105
121
|
reconnectNow() {
|
|
106
122
|
if (this.stopped)
|
|
107
123
|
return;
|
|
108
|
-
|
|
124
|
+
// Do not bypass an active refusal / Retry-After wait (network nudges).
|
|
125
|
+
if (this.refusal) {
|
|
126
|
+
const nextMs = Date.parse(this.refusal.nextRetryAt);
|
|
127
|
+
if (!Number.isNaN(nextMs) && nextMs > this.timers.now()) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// Backoff counters reset only on MQTT connect — not here.
|
|
109
132
|
this.clearReconnect();
|
|
110
133
|
void this.connectOnce();
|
|
111
134
|
}
|
|
@@ -128,12 +151,51 @@ export class PresenceClient {
|
|
|
128
151
|
}
|
|
129
152
|
async onCredentialsRenewed(bundle) {
|
|
130
153
|
this.bundle = bundle;
|
|
154
|
+
// Successful vend ends a refusal episode (renewal skips connectOnce's vend path).
|
|
155
|
+
this.clearRefusal(true);
|
|
131
156
|
if (this.stopped)
|
|
132
157
|
return;
|
|
133
|
-
// Reconnect with the new presigned URL
|
|
134
|
-
this.attempt = 0;
|
|
158
|
+
// Reconnect with the new presigned URL; backoff resets only on MQTT connect.
|
|
135
159
|
await this.connectOnce();
|
|
136
160
|
}
|
|
161
|
+
handleRenewalError(err, info) {
|
|
162
|
+
if (err instanceof CredentialVendError && err.kind === "refused") {
|
|
163
|
+
this.noteRefusal(err, info?.nextRetryAt);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
this.options.onError?.(err);
|
|
167
|
+
}
|
|
168
|
+
refusalKey(err) {
|
|
169
|
+
return err.code ?? `HTTP_${err.status}`;
|
|
170
|
+
}
|
|
171
|
+
noteRefusal(err, nextRetryAt) {
|
|
172
|
+
const code = this.refusalKey(err);
|
|
173
|
+
const keyChanged = this.loggedRefusalKey !== code;
|
|
174
|
+
if (keyChanged) {
|
|
175
|
+
this.loggedRefusalKey = code;
|
|
176
|
+
this.options.onError?.(err);
|
|
177
|
+
}
|
|
178
|
+
const refusal = {
|
|
179
|
+
code,
|
|
180
|
+
status: err.status,
|
|
181
|
+
nextRetryAt: nextRetryAt ??
|
|
182
|
+
this.refusal?.nextRetryAt ??
|
|
183
|
+
new Date(this.timers.now()).toISOString(),
|
|
184
|
+
};
|
|
185
|
+
this.refusal = refusal;
|
|
186
|
+
this.options.onRefusal?.(refusal);
|
|
187
|
+
}
|
|
188
|
+
clearRefusal(logClear) {
|
|
189
|
+
if (this.loggedRefusalKey === null && this.refusal === null)
|
|
190
|
+
return;
|
|
191
|
+
const previous = this.loggedRefusalKey;
|
|
192
|
+
this.loggedRefusalKey = null;
|
|
193
|
+
this.refusal = null;
|
|
194
|
+
if (logClear && previous) {
|
|
195
|
+
this.options.onError?.(new Error(`presence refusal cleared (was ${previous})`));
|
|
196
|
+
}
|
|
197
|
+
this.options.onRefusal?.(null);
|
|
198
|
+
}
|
|
137
199
|
async connectOnce() {
|
|
138
200
|
if (this.stopped)
|
|
139
201
|
return;
|
|
@@ -143,6 +205,8 @@ export class PresenceClient {
|
|
|
143
205
|
try {
|
|
144
206
|
if (!this.bundle) {
|
|
145
207
|
this.bundle = await this.options.fetchCredentials();
|
|
208
|
+
// Successful vend ends a refusal episode (even before MQTT connects).
|
|
209
|
+
this.clearRefusal(true);
|
|
146
210
|
this.renewal.schedule(this.bundle);
|
|
147
211
|
}
|
|
148
212
|
const bundle = this.bundle;
|
|
@@ -170,6 +234,7 @@ export class PresenceClient {
|
|
|
170
234
|
if (this.stopped || generation !== this.generation)
|
|
171
235
|
return;
|
|
172
236
|
this.attempt = 0;
|
|
237
|
+
this.lastBackoffMs = 0;
|
|
173
238
|
this.setState("connected");
|
|
174
239
|
void this.publishAll("online").then(() => {
|
|
175
240
|
this.options.onOnline?.(bundle.companies.map((c) => c.companyUid));
|
|
@@ -187,6 +252,10 @@ export class PresenceClient {
|
|
|
187
252
|
});
|
|
188
253
|
}
|
|
189
254
|
catch (err) {
|
|
255
|
+
if (err instanceof CredentialVendError && err.kind === "refused") {
|
|
256
|
+
this.scheduleReconnect({ refused: err });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
190
259
|
this.options.onError?.(err);
|
|
191
260
|
this.scheduleReconnect();
|
|
192
261
|
}
|
|
@@ -224,21 +293,43 @@ export class PresenceClient {
|
|
|
224
293
|
});
|
|
225
294
|
}
|
|
226
295
|
}
|
|
227
|
-
scheduleReconnect() {
|
|
296
|
+
scheduleReconnect(opts) {
|
|
228
297
|
if (this.stopped)
|
|
229
298
|
return;
|
|
230
299
|
this.setState("reconnecting");
|
|
231
300
|
this.clearReconnect();
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
301
|
+
let delay;
|
|
302
|
+
if (opts?.refused) {
|
|
303
|
+
const refused = opts.refused;
|
|
304
|
+
if (refused.retryAfterMs !== undefined) {
|
|
305
|
+
delay = Math.max(0, refused.retryAfterMs);
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
delay = refusedRetryDelayMs(this.refusedRetryMs, this.random);
|
|
309
|
+
}
|
|
310
|
+
delay = clampRetryDelayMs(delay);
|
|
311
|
+
const nextRetryAt = new Date(this.timers.now() + delay).toISOString();
|
|
312
|
+
this.noteRefusal(refused, nextRetryAt);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
delay = backoffDelayMs(this.attempt, this.baseBackoffMs, this.maxBackoffMs, this.random, this.lastBackoffMs);
|
|
316
|
+
this.lastBackoffMs = delay;
|
|
317
|
+
this.attempt += 1;
|
|
318
|
+
// Do not clear refusal here — MQTT close must retain doctor/status refusal
|
|
319
|
+
// until a subsequent credential vend succeeds.
|
|
320
|
+
}
|
|
321
|
+
this.reconnectHandle = scheduleBoundedTimeout(this.timers, () => {
|
|
235
322
|
this.reconnectHandle = null;
|
|
323
|
+
// Drop stale creds so the next attempt re-vends after a refusal/failure.
|
|
324
|
+
if (opts?.refused) {
|
|
325
|
+
this.bundle = null;
|
|
326
|
+
}
|
|
236
327
|
void this.connectOnce();
|
|
237
328
|
}, delay);
|
|
238
329
|
}
|
|
239
330
|
clearReconnect() {
|
|
240
331
|
if (this.reconnectHandle !== null) {
|
|
241
|
-
this.
|
|
332
|
+
this.reconnectHandle.clear();
|
|
242
333
|
this.reconnectHandle = null;
|
|
243
334
|
}
|
|
244
335
|
}
|
|
@@ -126,6 +126,7 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
126
126
|
timers,
|
|
127
127
|
random: deps.random,
|
|
128
128
|
now,
|
|
129
|
+
env,
|
|
129
130
|
onState: (state) => {
|
|
130
131
|
patchDaemonState(dir, { mqttState: state }, now);
|
|
131
132
|
},
|
|
@@ -134,9 +135,18 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
134
135
|
mqttState: "connected",
|
|
135
136
|
companiesOnline: companies,
|
|
136
137
|
actorUid: presence?.getBundle()?.actorUid,
|
|
138
|
+
presenceRefusal: null,
|
|
137
139
|
}, now);
|
|
138
140
|
log(dir, `presence online companies=${companies.length}`);
|
|
139
141
|
},
|
|
142
|
+
onRefusal: (refusal) => {
|
|
143
|
+
patchDaemonState(dir, {
|
|
144
|
+
presenceRefusal: refusal,
|
|
145
|
+
lastErrorCode: refusal
|
|
146
|
+
? `refused:${refusal.code}`.slice(0, 120)
|
|
147
|
+
: undefined,
|
|
148
|
+
}, now);
|
|
149
|
+
},
|
|
140
150
|
onError: (err) => {
|
|
141
151
|
const msg = err instanceof Error ? err.message : String(err);
|
|
142
152
|
patchDaemonState(dir, { lastErrorCode: msg.slice(0, 120) }, now);
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import type { MqttConnectionState } from "./presence.js";
|
|
5
5
|
import type { FlushSummary } from "../flush.js";
|
|
6
|
+
export interface PresenceRefusalState {
|
|
7
|
+
code: string;
|
|
8
|
+
status: number;
|
|
9
|
+
nextRetryAt: string;
|
|
10
|
+
}
|
|
6
11
|
export interface DaemonStateFile {
|
|
7
12
|
v: 1;
|
|
8
13
|
pid: number;
|
|
@@ -15,6 +20,8 @@ export interface DaemonStateFile {
|
|
|
15
20
|
ok: boolean;
|
|
16
21
|
};
|
|
17
22
|
lastErrorCode?: string;
|
|
23
|
+
/** Set while credential vend is refused (FEATURE_DISABLED / unsupported / 403). */
|
|
24
|
+
presenceRefusal?: PresenceRefusalState | null;
|
|
18
25
|
updatedAt: string;
|
|
19
26
|
}
|
|
20
27
|
export declare function defaultDaemonState(pid: number, now?: () => Date): DaemonStateFile;
|
|
@@ -38,6 +38,25 @@ export type QmdInvocation = {
|
|
|
38
38
|
prefixArgs: string[];
|
|
39
39
|
execDir?: string;
|
|
40
40
|
};
|
|
41
|
+
/**
|
|
42
|
+
* Bounded, hq-DERIVED evidence stamped onto a CAPTURED qmd spawn-level failure
|
|
43
|
+
* (the interpreter-missing and residual QmdBinaryMissingError cases). The
|
|
44
|
+
* reporting host (HQ-CLI-1A) shipped no platform log, so spawnSync's ENOENT
|
|
45
|
+
* could not be pinned to a missing command vs. a missing working directory. The
|
|
46
|
+
* boundary forwards this into a bounded Sentry context so the NEXT occurrence
|
|
47
|
+
* carries the evidence this one lacked. Every field is hq-derived — the errno
|
|
48
|
+
* code, the resolved `cwd`, `process.execPath`, the bundled launcher, and their
|
|
49
|
+
* existence at failure time — never caller argv or query text.
|
|
50
|
+
*/
|
|
51
|
+
export type QmdSpawnDiagnostics = {
|
|
52
|
+
errnoCode: string | null;
|
|
53
|
+
cwd: string | null;
|
|
54
|
+
cwdExists: boolean | null;
|
|
55
|
+
command: string;
|
|
56
|
+
commandExists: boolean;
|
|
57
|
+
launcher: string;
|
|
58
|
+
launcherExists: boolean;
|
|
59
|
+
};
|
|
41
60
|
/** Low-level spawn seam (defaults to `spawnSync`); injected in tests. */
|
|
42
61
|
export type QmdSpawn = (cmd: string, args: string[], options: {
|
|
43
62
|
cwd?: string;
|
|
@@ -59,6 +78,34 @@ export type QmdSpawn = (cmd: string, args: string[], options: {
|
|
|
59
78
|
export declare function withNodeDirOnPath(env: NodeJS.ProcessEnv | undefined, execDir: string | undefined, platform?: NodeJS.Platform): NodeJS.ProcessEnv | undefined;
|
|
60
79
|
export declare class QmdBinaryMissingError extends Error {
|
|
61
80
|
name: string;
|
|
81
|
+
/**
|
|
82
|
+
* Present only when the failure came through the real spawn path (attribution
|
|
83
|
+
* available): the errno code and per-component existence booleans captured at
|
|
84
|
+
* failure time, forwarded to a bounded Sentry context by the boundary so the
|
|
85
|
+
* next occurrence carries the evidence the reported one lacked (HQ-CLI-1A).
|
|
86
|
+
* Absent on the legacy injected-runner path and the resolver's opaque throws,
|
|
87
|
+
* so their existing shapes are unchanged.
|
|
88
|
+
*/
|
|
89
|
+
readonly spawnDiagnostics?: QmdSpawnDiagnostics;
|
|
90
|
+
constructor(message: string, spawnDiagnostics?: QmdSpawnDiagnostics);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* qmd could not be SPAWNED because its working directory does not exist:
|
|
94
|
+
* spawnSync returned ENOENT while chdir-ing into `cwd` (the resolved hq root at
|
|
95
|
+
* every index call site) before it could exec the command. On Linux that ENOENT
|
|
96
|
+
* is byte-identical to a missing-command ENOENT — Node sets `error.path` to the
|
|
97
|
+
* COMMAND either way — so finishRunQmd disambiguates by existence and raises
|
|
98
|
+
* THIS typed carrier only when the working directory itself is absent. Carries
|
|
99
|
+
* the hq-DERIVED `workdir`, the `command` that was spawned, and the bounded
|
|
100
|
+
* `errnoCode`; a missing working directory is the caller's filesystem, so the
|
|
101
|
+
* boundary prints qmdWorkdirMissingMessage and SKIPS capture (HQ-CLI-1A).
|
|
102
|
+
*/
|
|
103
|
+
export declare class QmdWorkdirMissingError extends Error {
|
|
104
|
+
readonly workdir: string;
|
|
105
|
+
readonly command: string;
|
|
106
|
+
readonly errnoCode: string;
|
|
107
|
+
name: string;
|
|
108
|
+
constructor(message: string, workdir: string, command: string, errnoCode: string);
|
|
62
109
|
}
|
|
63
110
|
export declare class QmdExitError extends Error {
|
|
64
111
|
readonly args: string[];
|
|
@@ -469,6 +516,12 @@ export type RunQmdOptions = {
|
|
|
469
516
|
execPath?: string;
|
|
470
517
|
/** Invocation resolver seam (default {@link resolveQmdInvocation}). */
|
|
471
518
|
resolveInvocation?: (options: ResolveQmdInvocationOptions) => QmdInvocation;
|
|
519
|
+
/**
|
|
520
|
+
* Existence-check seam threaded into finishRunQmd's spawn-failure
|
|
521
|
+
* disambiguation (default `fs.existsSync`); injected in tests so the missing
|
|
522
|
+
* cwd / interpreter branches are provable without touching the real disk.
|
|
523
|
+
*/
|
|
524
|
+
exists?: (candidate: string) => boolean;
|
|
472
525
|
};
|
|
473
526
|
/** Run qmd with captured output and typed failures. */
|
|
474
527
|
export declare function runQmd(args: string[], options?: RunQmdOptions): QmdProcessResult;
|
|
@@ -30,6 +30,43 @@ export function withNodeDirOnPath(env, execDir, platform = process.platform) {
|
|
|
30
30
|
}
|
|
31
31
|
export class QmdBinaryMissingError extends Error {
|
|
32
32
|
name = 'QmdBinaryMissingError';
|
|
33
|
+
/**
|
|
34
|
+
* Present only when the failure came through the real spawn path (attribution
|
|
35
|
+
* available): the errno code and per-component existence booleans captured at
|
|
36
|
+
* failure time, forwarded to a bounded Sentry context by the boundary so the
|
|
37
|
+
* next occurrence carries the evidence the reported one lacked (HQ-CLI-1A).
|
|
38
|
+
* Absent on the legacy injected-runner path and the resolver's opaque throws,
|
|
39
|
+
* so their existing shapes are unchanged.
|
|
40
|
+
*/
|
|
41
|
+
spawnDiagnostics;
|
|
42
|
+
constructor(message, spawnDiagnostics) {
|
|
43
|
+
super(message);
|
|
44
|
+
if (spawnDiagnostics)
|
|
45
|
+
this.spawnDiagnostics = spawnDiagnostics;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* qmd could not be SPAWNED because its working directory does not exist:
|
|
50
|
+
* spawnSync returned ENOENT while chdir-ing into `cwd` (the resolved hq root at
|
|
51
|
+
* every index call site) before it could exec the command. On Linux that ENOENT
|
|
52
|
+
* is byte-identical to a missing-command ENOENT — Node sets `error.path` to the
|
|
53
|
+
* COMMAND either way — so finishRunQmd disambiguates by existence and raises
|
|
54
|
+
* THIS typed carrier only when the working directory itself is absent. Carries
|
|
55
|
+
* the hq-DERIVED `workdir`, the `command` that was spawned, and the bounded
|
|
56
|
+
* `errnoCode`; a missing working directory is the caller's filesystem, so the
|
|
57
|
+
* boundary prints qmdWorkdirMissingMessage and SKIPS capture (HQ-CLI-1A).
|
|
58
|
+
*/
|
|
59
|
+
export class QmdWorkdirMissingError extends Error {
|
|
60
|
+
workdir;
|
|
61
|
+
command;
|
|
62
|
+
errnoCode;
|
|
63
|
+
name = 'QmdWorkdirMissingError';
|
|
64
|
+
constructor(message, workdir, command, errnoCode) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.workdir = workdir;
|
|
67
|
+
this.command = command;
|
|
68
|
+
this.errnoCode = errnoCode;
|
|
69
|
+
}
|
|
33
70
|
}
|
|
34
71
|
export class QmdExitError extends Error {
|
|
35
72
|
args;
|
|
@@ -966,7 +1003,7 @@ function describeQmdTermination(stdout, stderr) {
|
|
|
966
1003
|
return '';
|
|
967
1004
|
}
|
|
968
1005
|
/** Normalise a spawn result and raise the typed qmd failures. */
|
|
969
|
-
function finishRunQmd(result, bin, args) {
|
|
1006
|
+
function finishRunQmd(result, bin, args, attribution) {
|
|
970
1007
|
const normalized = {
|
|
971
1008
|
status: result.status,
|
|
972
1009
|
stdout: result.stdout ?? '',
|
|
@@ -975,6 +1012,54 @@ function finishRunQmd(result, bin, args) {
|
|
|
975
1012
|
signal: result.signal,
|
|
976
1013
|
};
|
|
977
1014
|
if (normalized.error) {
|
|
1015
|
+
// A spawn-LEVEL failure: the child could not even be exec'd. When the real
|
|
1016
|
+
// spawn path supplied attribution we can attribute it to the right
|
|
1017
|
+
// component; the legacy injected-runner path passes none and keeps today's
|
|
1018
|
+
// exact class and message.
|
|
1019
|
+
if (attribution) {
|
|
1020
|
+
const spawnError = normalized.error;
|
|
1021
|
+
const errnoCode = typeof spawnError.code === 'string' ? spawnError.code : null;
|
|
1022
|
+
const exists = attribution.exists ?? ((candidate) => fs.existsSync(candidate));
|
|
1023
|
+
const cwd = attribution.cwd ?? null;
|
|
1024
|
+
const cwdExists = cwd !== null ? exists(cwd) : null;
|
|
1025
|
+
const commandExists = exists(attribution.command);
|
|
1026
|
+
const launcherExists = exists(attribution.launcher);
|
|
1027
|
+
// (a) MISSING WORKING DIRECTORY. On Linux spawnSync reports ENOENT with
|
|
1028
|
+
// `error.path` set to the COMMAND both when the command is absent AND when
|
|
1029
|
+
// chdir into `cwd` fails, so this cwd check MUST precede the command check
|
|
1030
|
+
// below — a path-based test alone would keep blaming the interpreter for a
|
|
1031
|
+
// directory that is simply gone (HQ-CLI-1A). The directory is the caller's
|
|
1032
|
+
// filesystem, so it is raised as its own carrier and the boundary skips
|
|
1033
|
+
// capture.
|
|
1034
|
+
if (errnoCode === 'ENOENT' && cwd !== null && cwdExists === false) {
|
|
1035
|
+
throw new QmdWorkdirMissingError(`qmd could not run: its working directory (${cwd}) does not exist`, cwd, attribution.command, errnoCode);
|
|
1036
|
+
}
|
|
1037
|
+
const diagnostics = {
|
|
1038
|
+
errnoCode,
|
|
1039
|
+
cwd,
|
|
1040
|
+
cwdExists,
|
|
1041
|
+
command: attribution.command,
|
|
1042
|
+
commandExists,
|
|
1043
|
+
launcher: attribution.launcher,
|
|
1044
|
+
launcherExists,
|
|
1045
|
+
};
|
|
1046
|
+
// (b) MISSING INTERPRETER. The bundled qmd is launched as `<node> <qmd
|
|
1047
|
+
// launcher>`; when the interpreter (`command`, distinct from the launcher)
|
|
1048
|
+
// is what could not be executed, name IT rather than the launcher the
|
|
1049
|
+
// reported message wrongly blamed. Still captured — an absent interpreter
|
|
1050
|
+
// is a genuine defect — but now correct and self-describing.
|
|
1051
|
+
if (errnoCode === 'ENOENT' &&
|
|
1052
|
+
attribution.command !== attribution.launcher &&
|
|
1053
|
+
commandExists === false) {
|
|
1054
|
+
throw new QmdBinaryMissingError(`Unable to execute qmd: its Node interpreter (${attribution.command}) could not be executed: ${normalized.error.message}`, diagnostics);
|
|
1055
|
+
}
|
|
1056
|
+
// (c) RESIDUAL: an ENOENT the fix cannot attribute (a genuinely missing
|
|
1057
|
+
// qmd, or a race where the directory/interpreter reappeared) or any other
|
|
1058
|
+
// spawn errno (EACCES/EINVAL/…). Keep today's class and message byte-for-
|
|
1059
|
+
// byte, and attach the bounded diagnostics so the next occurrence carries
|
|
1060
|
+
// the per-component existence evidence.
|
|
1061
|
+
throw new QmdBinaryMissingError(`Unable to execute qmd at ${bin}: ${normalized.error.message}`, diagnostics);
|
|
1062
|
+
}
|
|
978
1063
|
throw new QmdBinaryMissingError(`Unable to execute qmd at ${bin}: ${normalized.error.message}`);
|
|
979
1064
|
}
|
|
980
1065
|
if (normalized.status === 0)
|
|
@@ -1157,13 +1242,24 @@ export function runQmd(args, options = {}) {
|
|
|
1157
1242
|
// idempotent; only the real spawn path needs it (the injected-runner path
|
|
1158
1243
|
// above never opens a store).
|
|
1159
1244
|
ensureQmdStoreDir(options.env ?? process.env, { cwd: options.cwd });
|
|
1245
|
+
const launcher = invocationBin(invocation);
|
|
1160
1246
|
const result = spawnQmd(invocation, args, {
|
|
1161
1247
|
cwd: options.cwd,
|
|
1162
1248
|
env: options.env ?? process.env,
|
|
1163
1249
|
platform: options.platform,
|
|
1164
1250
|
spawn: options.spawn,
|
|
1165
1251
|
});
|
|
1166
|
-
|
|
1252
|
+
// Thread attribution so finishRunQmd can attribute a spawn-level ENOENT to the
|
|
1253
|
+
// right component — the working directory, the Node interpreter, or a
|
|
1254
|
+
// genuinely missing qmd — instead of blaming the launcher for all three
|
|
1255
|
+
// (HQ-CLI-1A). `command` is the process actually spawned; `launcher` is qmd's
|
|
1256
|
+
// file identity.
|
|
1257
|
+
return finishRunQmd(result, launcher, args, {
|
|
1258
|
+
command: invocation.command,
|
|
1259
|
+
launcher,
|
|
1260
|
+
cwd: options.cwd,
|
|
1261
|
+
exists: options.exists,
|
|
1262
|
+
});
|
|
1167
1263
|
}
|
|
1168
1264
|
function containsIndexedMarkdown(directory) {
|
|
1169
1265
|
if (!fs.existsSync(directory))
|
package/dist/main.js
CHANGED
|
@@ -75,6 +75,7 @@ import { qmdStoreMissingMessage } from "./utils/qmd-store-missing-error.js";
|
|
|
75
75
|
import { qmdStoreUnopenableMessage } from "./utils/qmd-store-unopenable-error.js";
|
|
76
76
|
import { qmdQueryDocumentMessage } from "./utils/qmd-query-document-error.js";
|
|
77
77
|
import { qmdModelDownloadMessage } from "./utils/qmd-model-download-error.js";
|
|
78
|
+
import { qmdWorkdirMissingMessage } from "./utils/qmd-workdir-missing-error.js";
|
|
78
79
|
import { hqStateWriteErrorMessage } from "./utils/hq-state-write-error.js";
|
|
79
80
|
import { isExpectedUserError } from "./utils/expected-cli-error.js";
|
|
80
81
|
import { isVarlockEnvError } from "./run/env-graph-guard.js";
|
|
@@ -379,6 +380,26 @@ const defaultTopLevelErrorDependencies = {
|
|
|
379
380
|
process.exitCode = code;
|
|
380
381
|
},
|
|
381
382
|
};
|
|
383
|
+
/**
|
|
384
|
+
* Bounded Sentry context for a CAPTURED qmd spawn-level failure (the
|
|
385
|
+
* interpreter-missing and residual QmdBinaryMissingError cases). finishRunQmd
|
|
386
|
+
* stamps the errno code and per-component existence booleans onto the error at
|
|
387
|
+
* failure time; because the reporting host (HQ-CLI-1A) shipped no platform log,
|
|
388
|
+
* this is the evidence the next occurrence carries. Read STRUCTURALLY off the
|
|
389
|
+
* error's own `spawnDiagnostics` field (the boundary reconstructs typed carriers
|
|
390
|
+
* structurally in tests). Only hq-DERIVED paths (the resolved cwd,
|
|
391
|
+
* process.execPath, the bundled launcher) and a bounded errno code appear —
|
|
392
|
+
* never caller argv or query text. Returns undefined when the error carries no
|
|
393
|
+
* such diagnostics, so the generic capture stays a bare captureException(err).
|
|
394
|
+
*/
|
|
395
|
+
function qmdSpawnFailureCaptureContext(err) {
|
|
396
|
+
if (err === null || typeof err !== "object")
|
|
397
|
+
return undefined;
|
|
398
|
+
const diagnostics = err.spawnDiagnostics;
|
|
399
|
+
if (diagnostics === null || typeof diagnostics !== "object")
|
|
400
|
+
return undefined;
|
|
401
|
+
return { contexts: { qmd_spawn_failure: diagnostics } };
|
|
402
|
+
}
|
|
382
403
|
/** Classify a top-level failure without making the CLI process boundary opaque to tests. */
|
|
383
404
|
export async function handleTopLevelError(err, deps = defaultTopLevelErrorDependencies) {
|
|
384
405
|
// A broken pipe (EPIPE) means the reader of `hq`'s output closed it early
|
|
@@ -628,6 +649,23 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
628
649
|
const modelDownloadMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg
|
|
629
650
|
? null
|
|
630
651
|
: qmdModelDownloadMessage(err);
|
|
652
|
+
// A qmd spawn-level failure whose ENOENT was a MISSING WORKING DIRECTORY
|
|
653
|
+
// (the resolved hq root the qmd child was handed as `cwd` does not exist).
|
|
654
|
+
// finishRunQmd types it QmdWorkdirMissingError, carrying the hq-derived
|
|
655
|
+
// directory; the boundary prints a self-describing remedy naming that
|
|
656
|
+
// directory and skips capture — a working directory that does not exist is
|
|
657
|
+
// the caller's filesystem, the same disposition as the store-missing /
|
|
658
|
+
// store-unopenable branches. Before this branch it was MISATTRIBUTED to a
|
|
659
|
+
// missing qmd (QmdBinaryMissingError, remedy `Install @tobilu/qmd`) and,
|
|
660
|
+
// with no boundary branch for it, captured as a crash (HQ-CLI-1A, Sentry
|
|
661
|
+
// 7705642434). Evaluated immediately AFTER the store-unopenable /
|
|
662
|
+
// query-document / model-download checks and BEFORE the environmental /
|
|
663
|
+
// transport / generic branches; the carrier's name is disjoint from every
|
|
664
|
+
// neighbour and it carries no `.code` environmentalFsErrorMessage could
|
|
665
|
+
// read, so ordering changes no existing branch.
|
|
666
|
+
const workdirMissingMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg
|
|
667
|
+
? null
|
|
668
|
+
: qmdWorkdirMissingMessage(err);
|
|
631
669
|
// An hq STATE-TREE write that failed for an environmental errno
|
|
632
670
|
// (permission/quota/read-only/parent-gone) is typed at the write site as an
|
|
633
671
|
// HqStateWriteError carrying the exact hq-DERIVED path it tried to write.
|
|
@@ -637,10 +675,10 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
637
675
|
// remedy and skip capture. Placed with the environmental-fs family — BEFORE
|
|
638
676
|
// the generic envMsg computation — so this typed carrier wins even though
|
|
639
677
|
// it also carries an errno `code` environmentalFsErrorMessage could read.
|
|
640
|
-
const stateWriteMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg
|
|
678
|
+
const stateWriteMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg || workdirMissingMsg
|
|
641
679
|
? null
|
|
642
680
|
: hqStateWriteErrorMessage(err);
|
|
643
|
-
const envMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg || stateWriteMsg
|
|
681
|
+
const envMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg || workdirMissingMsg || stateWriteMsg
|
|
644
682
|
? null
|
|
645
683
|
: environmentalFsErrorMessage(err);
|
|
646
684
|
// A LOCAL sync-state lock failure (@indigoai-us/hq-cloud's
|
|
@@ -657,7 +695,7 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
657
695
|
// environmental-fs check, before network-transport — is pinned by tests.
|
|
658
696
|
// The `in-process-async-holder` reason is deliberately NOT suppressed here
|
|
659
697
|
// (see sync-state-lock-error.ts); it stays captured.
|
|
660
|
-
const lockMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg || stateWriteMsg || envMsg
|
|
698
|
+
const lockMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg || workdirMissingMsg || stateWriteMsg || envMsg
|
|
661
699
|
? null
|
|
662
700
|
: syncStateLockMessage(err);
|
|
663
701
|
// A raw network transport failure (undici's `TypeError: fetch failed`
|
|
@@ -679,6 +717,7 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
679
717
|
storeUnopenableMsg ||
|
|
680
718
|
queryDocumentMsg ||
|
|
681
719
|
modelDownloadMsg ||
|
|
720
|
+
workdirMissingMsg ||
|
|
682
721
|
stateWriteMsg ||
|
|
683
722
|
envMsg ||
|
|
684
723
|
lockMsg
|
|
@@ -711,6 +750,9 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
711
750
|
else if (modelDownloadMsg) {
|
|
712
751
|
deps.stderr.write(`hq: ${modelDownloadMsg}\n`);
|
|
713
752
|
}
|
|
753
|
+
else if (workdirMissingMsg) {
|
|
754
|
+
deps.stderr.write(`hq: ${workdirMissingMsg}\n`);
|
|
755
|
+
}
|
|
714
756
|
else if (stateWriteMsg) {
|
|
715
757
|
deps.stderr.write(`hq: ${stateWriteMsg}\n`);
|
|
716
758
|
}
|
|
@@ -724,7 +766,17 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
724
766
|
deps.stderr.write(`hq: ${transportMsg}\n`);
|
|
725
767
|
}
|
|
726
768
|
else {
|
|
727
|
-
|
|
769
|
+
// A genuinely unclassified fault is still captured exactly once. When it
|
|
770
|
+
// is a qmd spawn-level failure the fix could not attribute, attach the
|
|
771
|
+
// bounded existence context so the next occurrence carries the evidence
|
|
772
|
+
// the reported HQ-CLI-1A event lacked; every other error captures bare.
|
|
773
|
+
const spawnContext = qmdSpawnFailureCaptureContext(err);
|
|
774
|
+
if (spawnContext) {
|
|
775
|
+
deps.sentry.captureException(err, spawnContext);
|
|
776
|
+
}
|
|
777
|
+
else {
|
|
778
|
+
deps.sentry.captureException(err);
|
|
779
|
+
}
|
|
728
780
|
// Always emit something. Printing only when unexpectedCliErrorMessage()
|
|
729
781
|
// returned a value meant every error class except IntegrationsCliError
|
|
730
782
|
// exited 1 with zero bytes on stdout AND stderr — a silent failure the
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True when `err` is a qmd working-directory-missing failure: it carries the
|
|
3
|
+
* QmdWorkdirMissingError name and an hq-populated `workdir`. Accepts either the
|
|
4
|
+
* thrown error or a bare `{ name, workdir }` probe object. A true result means
|
|
5
|
+
* the caller should print the classified remedy, exit non-zero, and SKIP Sentry
|
|
6
|
+
* capture.
|
|
7
|
+
*/
|
|
8
|
+
export declare function isQmdWorkdirMissingError(err: unknown): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* If `err` is a qmd working-directory-missing failure, return the actionable
|
|
11
|
+
* remedy; otherwise return `null`. Mirrors qmdStoreMissingMessage /
|
|
12
|
+
* qmdStoreUnopenableMessage 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". The working directory is read STRUCTURALLY from the error's own
|
|
15
|
+
* hq-populated `workdir` field, NEVER from qmd's output.
|
|
16
|
+
*/
|
|
17
|
+
export declare function qmdWorkdirMissingMessage(err: unknown): string | null;
|
|
18
|
+
//# sourceMappingURL=qmd-workdir-missing-error.d.ts.map
|