@indigoai-us/hq-cli 5.345.6 → 5.345.7
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 +10 -0
- package/dist/lib/agent-kit/run/inbox.js +29 -3
- package/dist/lib/daemon/supervisor.d.ts +1 -0
- package/dist/lib/daemon/supervisor.js +95 -11
- package/dist/lib/lanes/spawn.js +2 -0
- package/dist/lib/local-tree-diff.d.ts +3 -2
- package/dist/lib/search-index/background.d.ts +0 -2
- package/dist/lib/search-index/background.js +1 -1
- package/dist/lib/search-index/index.d.ts +0 -19
- package/dist/lib/search-index/index.js +2 -2
- package/dist/main.d.ts +3 -0
- package/dist/main.js +126 -12
- package/dist/types.d.ts +3 -3
- package/dist/utils/client-health-contract.d.ts +14 -17
- package/dist/utils/client-health-contract.js +11 -11
- package/dist/utils/self-update.d.ts +2 -0
- package/dist/utils/self-update.js +37 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,10 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.345.7] — 2026-10-05
|
|
6
|
+
- Retry a just-published hq-cli version when npm briefly reports `notarget`.
|
|
7
|
+
- Wait for an active sync pass to finish before restarting sync after a CLI update.
|
|
8
|
+
- Add bounded context to captured missing-module errors targeting hq-cli's own dist output.
|
|
9
|
+
- Recover interrupted agent inbox polls without duplicating mirrored journal entries.
|
|
10
|
+
- Record the actual launch time, trigger, and caller for resumed lane workers even when legacy launch history is missing.
|
|
11
|
+
|
|
5
12
|
## [5.345.6] — 2026-10-05
|
|
6
13
|
- `hq mesh context default set <slug> --count-usage` now works for people signed in with a normal HQ account. It previously refused with "the signed-in person could not be determined". An opt-in saved by 5.344.0 is ignored; run the command again to opt in.
|
|
7
14
|
- `hq daemon sync status` now identifies the executing runner and derives its hq-cloud version from the owner status or runner package when needed.
|
|
8
15
|
|
|
16
|
+
## [5.345.5] — 2026-10-05
|
|
17
|
+
- `hq daemon sync status` now identifies the executing runner and derives its hq-cloud version from the owner status or runner package when needed.
|
|
18
|
+
|
|
9
19
|
## [5.345.4] — 2026-10-05
|
|
10
20
|
- Run a pinned or newer managed hq-cloud sync runner ahead of the staged sync bundle, so daemon sync no longer stays on the hq-cloud version bundled with the CLI after an update or pin (hq-cli#1547).
|
|
11
21
|
- Avoid dispatching the global hook when the working directory is inside an HQ root.
|
|
@@ -54,10 +54,34 @@ export function writeSeenIds(paths, seen) {
|
|
|
54
54
|
const list = [...seen].slice(-MAX_SEEN_IDS);
|
|
55
55
|
writePrivateFile(path.join(paths.inboxDir, SEEN_IDS_NAME), JSON.stringify(list), 0o600);
|
|
56
56
|
}
|
|
57
|
+
function inboxJournalContains(paths, id) {
|
|
58
|
+
const journal = path.join(paths.inboxDir, INBOX_JSONL_NAME);
|
|
59
|
+
if (!fs.existsSync(journal))
|
|
60
|
+
return false;
|
|
61
|
+
for (const line of fs.readFileSync(journal, "utf8").split("\n")) {
|
|
62
|
+
if (!line)
|
|
63
|
+
continue;
|
|
64
|
+
let entry;
|
|
65
|
+
try {
|
|
66
|
+
entry = JSON.parse(line);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// An interrupted append can leave one malformed final record; it does
|
|
70
|
+
// not prove that this item made it into the durable journal.
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (entry && typeof entry === "object" && entry.id === id)
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
57
78
|
export function mirrorInboxItem(paths, item) {
|
|
58
79
|
const dest = path.join(paths.inboxDir, `${item.id}.json`);
|
|
80
|
+
const recoveringInterruptedMirror = fs.existsSync(dest);
|
|
59
81
|
writePrivateFile(dest, `${JSON.stringify(item.raw, null, 2)}\n`, 0o600);
|
|
60
|
-
|
|
82
|
+
if (!recoveringInterruptedMirror || !inboxJournalContains(paths, item.id)) {
|
|
83
|
+
fs.appendFileSync(path.join(paths.inboxDir, INBOX_JSONL_NAME), `${JSON.stringify({ id: item.id, mirroredAt: new Date().toISOString(), ...item.raw })}\n`, { mode: 0o600 });
|
|
84
|
+
}
|
|
61
85
|
return dest;
|
|
62
86
|
}
|
|
63
87
|
export const INBOX_ERROR_BODY_MAX = 300;
|
|
@@ -148,7 +172,6 @@ export async function pollInboxOnce(deps) {
|
|
|
148
172
|
const fresh = extractInboxItems(res.body).filter((i) => !seen.has(i.id));
|
|
149
173
|
for (const item of fresh) {
|
|
150
174
|
mirrorInboxItem(deps.paths, item);
|
|
151
|
-
seen.add(item.id);
|
|
152
175
|
if (deps.ack) {
|
|
153
176
|
try {
|
|
154
177
|
const status = await ackItem(token, item.id);
|
|
@@ -159,9 +182,12 @@ export async function pollInboxOnce(deps) {
|
|
|
159
182
|
deps.log("warn", `ack ${item.id} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
160
183
|
}
|
|
161
184
|
}
|
|
185
|
+
// Persist each completed item before starting another one. If shutdown
|
|
186
|
+
// interrupts a later ack or mirror, earlier items will not be replayed.
|
|
187
|
+
seen.add(item.id);
|
|
188
|
+
writeSeenIds(deps.paths, seen);
|
|
162
189
|
}
|
|
163
190
|
if (fresh.length > 0) {
|
|
164
|
-
writeSeenIds(deps.paths, seen);
|
|
165
191
|
deps.log("info", `inbox mirrored ${fresh.length} item(s)`);
|
|
166
192
|
try {
|
|
167
193
|
await warmCache(token, deps.agentUid);
|
|
@@ -27,6 +27,7 @@ import type { ControlRequest } from "./requests.js";
|
|
|
27
27
|
import { syncPassResult } from "./sync-runner.js";
|
|
28
28
|
export declare const STOP_ESCALATION_MS = 10000;
|
|
29
29
|
export declare const CLI_UPDATE_SYNC_IDLE_MAX_MS: number;
|
|
30
|
+
export declare const CLI_UPDATE_SYNC_IDLE_KILL_GRACE_MS = 60000;
|
|
30
31
|
/** After a handoff, how long a replaced unit must stay up before the next one is replaced. */
|
|
31
32
|
export declare const HANDOFF_STAGGER_MS = 15000;
|
|
32
33
|
/** How long a draining unit may take to finish its work before it gets SIGTERM. */
|
|
@@ -28,6 +28,7 @@ const BACKOFF_MAX_MS = 60_000;
|
|
|
28
28
|
const HEALTHY_RUN_MS = 60_000;
|
|
29
29
|
export const STOP_ESCALATION_MS = 10_000;
|
|
30
30
|
export const CLI_UPDATE_SYNC_IDLE_MAX_MS = 10 * 60_000;
|
|
31
|
+
export const CLI_UPDATE_SYNC_IDLE_KILL_GRACE_MS = 60_000;
|
|
31
32
|
const MESH_OWNER_RECHECK_MS = 15_000;
|
|
32
33
|
const MESH_OWNER_LOG_INTERVAL_MS = 10 * 60_000;
|
|
33
34
|
/** After a handoff, how long a replaced unit must stay up before the next one is replaced. */
|
|
@@ -92,6 +93,7 @@ export function createSupervisor(deps) {
|
|
|
92
93
|
let deferredCliRestart = false;
|
|
93
94
|
let deferredCliRestartTimer;
|
|
94
95
|
let cliRestartInProgress = false;
|
|
96
|
+
let pendingSyncCommandRestart;
|
|
95
97
|
const snapshot = () => ({
|
|
96
98
|
version: 1,
|
|
97
99
|
pid: deps.pid,
|
|
@@ -125,15 +127,26 @@ export function createSupervisor(deps) {
|
|
|
125
127
|
clearT(deferredCliRestartTimer);
|
|
126
128
|
deferredCliRestartTimer = undefined;
|
|
127
129
|
};
|
|
128
|
-
const
|
|
130
|
+
const clearPendingSyncCommandRestart = (slot) => {
|
|
131
|
+
if (!pendingSyncCommandRestart || (slot && pendingSyncCommandRestart.slot !== slot))
|
|
132
|
+
return;
|
|
133
|
+
if (pendingSyncCommandRestart.timer !== undefined)
|
|
134
|
+
clearT(pendingSyncCommandRestart.timer);
|
|
135
|
+
pendingSyncCommandRestart = undefined;
|
|
136
|
+
};
|
|
137
|
+
const runDeferredCliRestart = (idleTimeout = false) => {
|
|
129
138
|
if (!deferredCliRestart)
|
|
130
139
|
return;
|
|
140
|
+
clearPendingSyncCommandRestart();
|
|
131
141
|
clearDeferredCliRestart();
|
|
132
142
|
cliRestartInProgress = true;
|
|
133
143
|
const sync = [...slots.values()].find((slot) => slot.plan.service === "sync" && slot.child);
|
|
134
|
-
if (sync)
|
|
135
|
-
terminate(sync);
|
|
136
|
-
|
|
144
|
+
if (sync) {
|
|
145
|
+
terminate(sync, undefined, false, idleTimeout ? CLI_UPDATE_SYNC_IDLE_KILL_GRACE_MS : STOP_ESCALATION_MS);
|
|
146
|
+
}
|
|
147
|
+
deps.log(idleTimeout ? "warn" : "info", idleTimeout
|
|
148
|
+
? "sync pass remained active for 10 minutes; restarting daemon onto the updated CLI"
|
|
149
|
+
: "sync runner is between passes; restarting daemon onto the updated CLI");
|
|
137
150
|
const releaseHold = () => {
|
|
138
151
|
cliRestartInProgress = false;
|
|
139
152
|
const currentSync = [...slots.values()].find((slot) => slot.plan.service === "sync");
|
|
@@ -163,6 +176,17 @@ export function createSupervisor(deps) {
|
|
|
163
176
|
releaseHold();
|
|
164
177
|
}
|
|
165
178
|
};
|
|
179
|
+
const finishPendingSyncCommandRestart = (idleTimeout = false) => {
|
|
180
|
+
const pending = pendingSyncCommandRestart;
|
|
181
|
+
if (!pending)
|
|
182
|
+
return;
|
|
183
|
+
clearPendingSyncCommandRestart(pending.slot);
|
|
184
|
+
if (deferredCliRestart) {
|
|
185
|
+
runDeferredCliRestart(idleTimeout);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
pending.onFinish(idleTimeout);
|
|
189
|
+
};
|
|
166
190
|
const requestCliUpdateRestart = () => {
|
|
167
191
|
if (!deps.restartDaemon)
|
|
168
192
|
return;
|
|
@@ -173,7 +197,10 @@ export function createSupervisor(deps) {
|
|
|
173
197
|
}
|
|
174
198
|
if (sync.syncPassActive === false) {
|
|
175
199
|
deferredCliRestart = true;
|
|
176
|
-
|
|
200
|
+
if (pendingSyncCommandRestart?.slot === sync)
|
|
201
|
+
finishPendingSyncCommandRestart();
|
|
202
|
+
else
|
|
203
|
+
runDeferredCliRestart();
|
|
177
204
|
return;
|
|
178
205
|
}
|
|
179
206
|
if (deferredCliRestart) {
|
|
@@ -182,7 +209,25 @@ export function createSupervisor(deps) {
|
|
|
182
209
|
}
|
|
183
210
|
deferredCliRestart = true;
|
|
184
211
|
deps.log("info", "deferring CLI-update restart until sync is idle (maximum 10 minutes)");
|
|
185
|
-
|
|
212
|
+
if (pendingSyncCommandRestart?.slot === sync)
|
|
213
|
+
return;
|
|
214
|
+
deferredCliRestartTimer = setT(() => runDeferredCliRestart(true), CLI_UPDATE_SYNC_IDLE_MAX_MS);
|
|
215
|
+
};
|
|
216
|
+
const deferSyncCommandRestart = (slot, onFinish) => {
|
|
217
|
+
if (slot.syncPassActive === false) {
|
|
218
|
+
onFinish(false);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (pendingSyncCommandRestart?.slot === slot)
|
|
222
|
+
return;
|
|
223
|
+
clearPendingSyncCommandRestart();
|
|
224
|
+
const pending = { slot, onFinish };
|
|
225
|
+
pendingSyncCommandRestart = pending;
|
|
226
|
+
deps.log("info", `deferring restart of ${slot.plan.id} until its sync pass is idle (maximum 10 minutes)`);
|
|
227
|
+
// A pending CLI restart already owns the same 10-minute idle window.
|
|
228
|
+
if (!deferredCliRestart) {
|
|
229
|
+
pending.timer = setT(() => finishPendingSyncCommandRestart(true), CLI_UPDATE_SYNC_IDLE_MAX_MS);
|
|
230
|
+
}
|
|
186
231
|
};
|
|
187
232
|
const setBlocked = (slot) => {
|
|
188
233
|
const block = slot.plan.blocked;
|
|
@@ -204,7 +249,8 @@ export function createSupervisor(deps) {
|
|
|
204
249
|
* `drain`, a unit that has a drain signal gets it first and SIGTERM only
|
|
205
250
|
* after HANDOFF_DRAIN_MAX_MS; SIGKILL follows SIGTERM after STOP_ESCALATION_MS.
|
|
206
251
|
*/
|
|
207
|
-
const terminate = (slot, then, drain = false) => {
|
|
252
|
+
const terminate = (slot, then, drain = false, killGraceMs = STOP_ESCALATION_MS) => {
|
|
253
|
+
clearPendingSyncCommandRestart(slot);
|
|
208
254
|
clearTimer(slot);
|
|
209
255
|
clearHeartbeatTimer(slot);
|
|
210
256
|
const child = slot.child;
|
|
@@ -227,9 +273,9 @@ export function createSupervisor(deps) {
|
|
|
227
273
|
slot.killTimer = undefined;
|
|
228
274
|
if (slot.child !== child)
|
|
229
275
|
return;
|
|
230
|
-
deps.log("warn", `${slot.plan.id} did not exit within ${
|
|
276
|
+
deps.log("warn", `${slot.plan.id} did not exit within ${killGraceMs / 1_000} s of SIGTERM; sending SIGKILL${lastProtocolHint(slot)}`);
|
|
231
277
|
signalChild(slot, child, "SIGKILL");
|
|
232
|
-
},
|
|
278
|
+
}, killGraceMs);
|
|
233
279
|
};
|
|
234
280
|
const drainSignal = drain ? slot.plan.drainSignal : undefined;
|
|
235
281
|
if (drainSignal) {
|
|
@@ -309,8 +355,12 @@ export function createSupervisor(deps) {
|
|
|
309
355
|
slot.syncPassActive = true;
|
|
310
356
|
}
|
|
311
357
|
}
|
|
312
|
-
if (
|
|
313
|
-
|
|
358
|
+
if (slot.syncPassActive === false) {
|
|
359
|
+
if (pendingSyncCommandRestart?.slot === slot)
|
|
360
|
+
finishPendingSyncCommandRestart();
|
|
361
|
+
else if (deferredCliRestart)
|
|
362
|
+
runDeferredCliRestart();
|
|
363
|
+
}
|
|
314
364
|
}
|
|
315
365
|
const at = now();
|
|
316
366
|
slot.state.lastHeartbeatAt = new Date(at).toISOString();
|
|
@@ -357,6 +407,11 @@ export function createSupervisor(deps) {
|
|
|
357
407
|
if (slot.child !== child)
|
|
358
408
|
return;
|
|
359
409
|
slot.child = undefined;
|
|
410
|
+
if (pendingSyncCommandRestart?.slot === slot) {
|
|
411
|
+
clearPendingSyncCommandRestart(slot);
|
|
412
|
+
if (deferredCliRestart)
|
|
413
|
+
runDeferredCliRestart();
|
|
414
|
+
}
|
|
360
415
|
clearHeartbeatTimer(slot);
|
|
361
416
|
if (slot.killTimer !== undefined) {
|
|
362
417
|
clearT(slot.killTimer);
|
|
@@ -652,6 +707,34 @@ export function createSupervisor(deps) {
|
|
|
652
707
|
else
|
|
653
708
|
activate(existing);
|
|
654
709
|
}
|
|
710
|
+
else if (changed && cliRestartInProgress && plan.service === "sync" && existing.child) {
|
|
711
|
+
// The deferred CLI restart owns this stop; do not queue a second
|
|
712
|
+
// replacement through the adopted-unit handoff path.
|
|
713
|
+
terminate(existing);
|
|
714
|
+
}
|
|
715
|
+
else if (changed && plan.service === "sync" && existing.child) {
|
|
716
|
+
existing.attempts = 0;
|
|
717
|
+
existing.state.restarts = 0;
|
|
718
|
+
const replaceWhenIdle = () => {
|
|
719
|
+
if (adopted) {
|
|
720
|
+
deps.log("info", `will replace ${plan.id}: it runs an older CLI or command`);
|
|
721
|
+
handoffQueue.push(existing);
|
|
722
|
+
pumpHandoff();
|
|
723
|
+
}
|
|
724
|
+
else {
|
|
725
|
+
deps.log("info", `restarting ${plan.id}: its command changed`);
|
|
726
|
+
terminate(existing, () => activate(existing));
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
deferSyncCommandRestart(existing, (idleTimeout) => {
|
|
730
|
+
if (!idleTimeout) {
|
|
731
|
+
replaceWhenIdle();
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
deps.log("warn", `${plan.id} sync pass did not become idle within 10 minutes; stopping it to apply the changed command`);
|
|
735
|
+
terminate(existing, () => activate(existing), false, CLI_UPDATE_SYNC_IDLE_KILL_GRACE_MS);
|
|
736
|
+
});
|
|
737
|
+
}
|
|
655
738
|
else if (changed && adopted && existing.child) {
|
|
656
739
|
deps.log("info", `will replace ${plan.id}: it runs an older CLI or command`);
|
|
657
740
|
handoffQueue.push(existing);
|
|
@@ -878,6 +961,7 @@ export function createSupervisor(deps) {
|
|
|
878
961
|
if (handoffTimer !== undefined)
|
|
879
962
|
clearT(handoffTimer);
|
|
880
963
|
clearDeferredCliRestart();
|
|
964
|
+
clearPendingSyncCommandRestart();
|
|
881
965
|
const childOwners = new Map();
|
|
882
966
|
for (const slot of slots.values()) {
|
|
883
967
|
if (slot.child)
|
package/dist/lib/lanes/spawn.js
CHANGED
|
@@ -2421,6 +2421,8 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
|
|
|
2421
2421
|
...(resumeHistoryCaller ? { caller: resumeHistoryCaller } : {}),
|
|
2422
2422
|
}),
|
|
2423
2423
|
}));
|
|
2424
|
+
}
|
|
2425
|
+
if (priorStartRecorded || resume !== undefined) {
|
|
2424
2426
|
appendLaneWorkerResumeEvent({
|
|
2425
2427
|
hqRoot,
|
|
2426
2428
|
laneId: lane.lane_id,
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
* for the destructive side effects (delete, tombstone, PUT sync-config).
|
|
25
25
|
*/
|
|
26
26
|
import { type SyncJournal } from "@indigoai-us/hq-cloud";
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
type DirtyReason = "modified-after-sync" | "hash-mismatch" | "not-in-journal" | "stat-error";
|
|
28
|
+
interface NarrowFile {
|
|
29
29
|
/**
|
|
30
30
|
* COMPANY-RELATIVE path (e.g. `meetings/a.md`) — the canonical namespace
|
|
31
31
|
* shared by the per-company journal keys, the vault S3 keys, and the
|
|
@@ -117,4 +117,5 @@ export declare function toPosixKey(p: string): string;
|
|
|
117
117
|
export declare function formatNarrowPlanSummary(plan: NarrowPlan): string;
|
|
118
118
|
/** Human-readable byte counts with a fixed unit ladder. */
|
|
119
119
|
export declare function formatBytes(n: number): string;
|
|
120
|
+
export {};
|
|
120
121
|
//# sourceMappingURL=local-tree-diff.d.ts.map
|
|
@@ -93,8 +93,6 @@ export declare function defaultSpawnWorker({ logPath, fallbackLogPath }: {
|
|
|
93
93
|
}, io?: SpawnWorkerIo): number;
|
|
94
94
|
/** Defaults used by the CLI; tests supply every nondeterministic dependency. */
|
|
95
95
|
export declare function defaultBackgroundDependencies(hqRoot: string): BackgroundDependencies;
|
|
96
|
-
/** Match the shell forwarder's hosted-agent markers before looking up qmd. */
|
|
97
|
-
export declare function isHostedAgent(env?: NodeJS.ProcessEnv): boolean;
|
|
98
96
|
export declare function installWorkerCleanup(cleanup: () => void, processEvents?: Pick<NodeJS.Process, 'once'>, exit?: (code: number) => void): void;
|
|
99
97
|
/** Start a detached worker; this public entry never owns the qmd pipeline. */
|
|
100
98
|
export declare function runBackgroundLauncher(dependencies: BackgroundDependencies): BackgroundResult;
|
|
@@ -129,7 +129,7 @@ export function defaultBackgroundDependencies(hqRoot) {
|
|
|
129
129
|
};
|
|
130
130
|
}
|
|
131
131
|
/** Match the shell forwarder's hosted-agent markers before looking up qmd. */
|
|
132
|
-
|
|
132
|
+
function isHostedAgent(env = process.env) {
|
|
133
133
|
if (env.HQ_QMD_REINDEX_MODE === 'skip-agent' || env.HQ_QMD_REINDEX_MODE === 'skip')
|
|
134
134
|
return true;
|
|
135
135
|
if (env.HQ_AGENT_BOX && env.HQ_AGENT_BOX !== '0')
|
|
@@ -327,19 +327,6 @@ export type ResolveQmdBinOptions = {
|
|
|
327
327
|
* independent of the dependency's own exports map.
|
|
328
328
|
*/
|
|
329
329
|
export declare function packageLocalBin(): string | undefined;
|
|
330
|
-
/**
|
|
331
|
-
* Locate the bundled qmd's Node LAUNCHER — `@tobilu/qmd`'s `bin.qmd` entry
|
|
332
|
-
* (`bin/qmd`, a `#!/usr/bin/env node` script) — by the same upward walk
|
|
333
|
-
* {@link resolveQmdVersion} uses to find the package. Returns the absolute
|
|
334
|
-
* launcher path only when both the manifest and the launcher file exist.
|
|
335
|
-
*
|
|
336
|
-
* This is the shell-free preferred resolution: invoking `node <launcher>`
|
|
337
|
-
* bypasses the npm-generated `.cmd` shim entirely, so the Windows CVE-2024-27980
|
|
338
|
-
* EINVAL never arises and no user-supplied search text reaches cmd.exe. A
|
|
339
|
-
* missing/partial layout returns undefined so the caller falls back to the
|
|
340
|
-
* `.bin` shim (which the shared spawn plan now makes spawnable on Windows too).
|
|
341
|
-
*/
|
|
342
|
-
export declare function packageLocalNodeEntry(): string | undefined;
|
|
343
330
|
/**
|
|
344
331
|
* Return the pinned package version when qmd is supplied by this CLI.
|
|
345
332
|
*
|
|
@@ -412,12 +399,6 @@ export type EnsureQmdStoreDirOptions = {
|
|
|
412
399
|
export declare function ensureQmdStoreDir(env?: NodeJS.ProcessEnv, options?: EnsureQmdStoreDirOptions): void;
|
|
413
400
|
/** Reset per-process probe/repair memoisation. Test-only. */
|
|
414
401
|
export declare function __resetQmdProbeStateForTests(): void;
|
|
415
|
-
/** Probe a resolved invocation, memoised per invocation; records the failure
|
|
416
|
-
* detail under the qmd bin identity so repair can read it. */
|
|
417
|
-
export declare function isUsableInvocation(invocation: QmdInvocation, options?: {
|
|
418
|
-
platform?: NodeJS.Platform;
|
|
419
|
-
spawn?: QmdSpawn;
|
|
420
|
-
}): boolean;
|
|
421
402
|
/** Backwards-compatible string form: probe a bare bin path shell-free (or via
|
|
422
403
|
* the shell plan when it is a Windows `.cmd`/`.bat`). */
|
|
423
404
|
export declare function isUsableQmd(bin: string): boolean;
|
|
@@ -332,7 +332,7 @@ export function packageLocalBin() {
|
|
|
332
332
|
* missing/partial layout returns undefined so the caller falls back to the
|
|
333
333
|
* `.bin` shim (which the shared spawn plan now makes spawnable on Windows too).
|
|
334
334
|
*/
|
|
335
|
-
|
|
335
|
+
function packageLocalNodeEntry() {
|
|
336
336
|
let directory = path.dirname(fileURLToPath(import.meta.url));
|
|
337
337
|
for (let depth = 0; depth < 10; depth++) {
|
|
338
338
|
const manifest = path.join(directory, 'node_modules', '@tobilu', 'qmd', 'package.json');
|
|
@@ -644,7 +644,7 @@ function probeQmd(invocation, options = {}) {
|
|
|
644
644
|
}
|
|
645
645
|
/** Probe a resolved invocation, memoised per invocation; records the failure
|
|
646
646
|
* detail under the qmd bin identity so repair can read it. */
|
|
647
|
-
|
|
647
|
+
function isUsableInvocation(invocation, options = {}) {
|
|
648
648
|
const key = invocationKey(invocation);
|
|
649
649
|
const cached = usableQmdCache.get(key);
|
|
650
650
|
if (cached !== undefined)
|
package/dist/main.d.ts
CHANGED
|
@@ -44,6 +44,9 @@ export type TopLevelErrorDependencies = {
|
|
|
44
44
|
openUrl?: (url: string) => Promise<void>;
|
|
45
45
|
upgradeToTeam?: typeof upgradeToTeam;
|
|
46
46
|
};
|
|
47
|
+
export declare const MAX_OWN_DIST_RELATIVE_PATH_BYTES = 256;
|
|
48
|
+
/** Return a bounded path only when targetPath is beneath packageRoot/dist. */
|
|
49
|
+
export declare function relativeOwnDistModulePath(packageRootPath: string, targetPath: string): string | undefined;
|
|
47
50
|
/** Classify a top-level failure without making the CLI process boundary opaque to tests. */
|
|
48
51
|
export declare function handleTopLevelError(err: unknown, deps?: TopLevelErrorDependencies): Promise<void>;
|
|
49
52
|
//# sourceMappingURL=main.d.ts.map
|
package/dist/main.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// Node 20+ API (e.g. util.styleText) or a newer native ABI is evaluated.
|
|
7
7
|
import "./node-preflight.js";
|
|
8
8
|
import "./node-network-compat.js";
|
|
9
|
+
import fs from "node:fs";
|
|
9
10
|
import path from "node:path";
|
|
10
11
|
import { fileURLToPath } from "node:url";
|
|
11
12
|
import { Command } from "commander";
|
|
@@ -52,10 +53,11 @@ import { settleWithin } from "./utils/settle-with-timeout.js";
|
|
|
52
53
|
import { isVersionRequest } from "./version-request.js";
|
|
53
54
|
import { emitPlanLimitNag } from "./lib/plan-limit-nag.js";
|
|
54
55
|
import { kickFlagRegistryReadiness, waitForFlagRegistryRefresh, } from "./lib/flag-registry.js";
|
|
55
|
-
import { isPackageRootResolutionError, packageRootCaptureContext, } from "./utils/package-root-diagnostics.js";
|
|
56
|
+
import { boundedDiagnosticValue, isPackageRootResolutionError, packageRootCaptureContext, } from "./utils/package-root-diagnostics.js";
|
|
56
57
|
import { registerCommandsWithRecovery } from "./startup-registration.js";
|
|
57
58
|
import { releasePackageUseLeaseAfterCommandLoad } from "./utils/package-use-lease.js";
|
|
58
|
-
import {
|
|
59
|
+
import { packageRoot } from "./utils/hq-roots.js";
|
|
60
|
+
import { classifyModuleNotFound, installTreeTornCaptureContext, installTreeTornStderrLine, isInstallTreeTornError, } from "./utils/install-tree-torn.js";
|
|
59
61
|
import { isVaultAccessDeniedError, vaultAccessDeniedMessage, } from "./utils/vault-access-denied-error.js";
|
|
60
62
|
import { fallbackOperatorMessage, unexpectedCliErrorMessage } from "./utils/unexpected-cli-error.js";
|
|
61
63
|
import { runGlobalNetworkLifecycle } from "./utils/cli-network-lifecycle.js";
|
|
@@ -410,6 +412,110 @@ function integrationsGatewayCaptureContext(err) {
|
|
|
410
412
|
},
|
|
411
413
|
};
|
|
412
414
|
}
|
|
415
|
+
export const MAX_OWN_DIST_RELATIVE_PATH_BYTES = 256;
|
|
416
|
+
function resolveThroughExistingAncestors(pathApi, inputPath) {
|
|
417
|
+
let cursor = pathApi.resolve(inputPath);
|
|
418
|
+
const missingSegments = [];
|
|
419
|
+
while (true) {
|
|
420
|
+
try {
|
|
421
|
+
const realPath = fs.realpathSync.native(cursor);
|
|
422
|
+
if (missingSegments.length > 0 && !fs.statSync(realPath).isDirectory()) {
|
|
423
|
+
return undefined;
|
|
424
|
+
}
|
|
425
|
+
return pathApi.join(realPath, ...missingSegments);
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
const code = error !== null && typeof error === "object" && "code" in error
|
|
429
|
+
? error.code
|
|
430
|
+
: undefined;
|
|
431
|
+
// A missing final module is expected here. Walk to its nearest existing
|
|
432
|
+
// parent so symlinked package roots and junctions still compare by their
|
|
433
|
+
// actual location. Other filesystem errors leave enrichment unavailable;
|
|
434
|
+
// the original error remains on the generic capture path.
|
|
435
|
+
if (code !== "ENOENT" && code !== "ENOTDIR")
|
|
436
|
+
return undefined;
|
|
437
|
+
const parent = pathApi.dirname(cursor);
|
|
438
|
+
if (parent === cursor)
|
|
439
|
+
return undefined;
|
|
440
|
+
missingSegments.unshift(pathApi.basename(cursor));
|
|
441
|
+
cursor = parent;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/** Return a bounded path only when targetPath is beneath packageRoot/dist. */
|
|
446
|
+
export function relativeOwnDistModulePath(packageRootPath, targetPath) {
|
|
447
|
+
const hasWindowsAbsolutePath = (value) => /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value);
|
|
448
|
+
const pathApi = hasWindowsAbsolutePath(packageRootPath) || hasWindowsAbsolutePath(targetPath)
|
|
449
|
+
? path.win32
|
|
450
|
+
: path;
|
|
451
|
+
let resolvedRoot;
|
|
452
|
+
let resolvedTarget;
|
|
453
|
+
if (pathApi === path.win32) {
|
|
454
|
+
// Use Windows lexical semantics for drive paths and UNC paths even when
|
|
455
|
+
// this helper is exercised on a non-Windows host.
|
|
456
|
+
resolvedRoot = pathApi.resolve(packageRootPath);
|
|
457
|
+
resolvedTarget = pathApi.resolve(targetPath);
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
const canonicalRoot = resolveThroughExistingAncestors(pathApi, packageRootPath);
|
|
461
|
+
const canonicalTarget = resolveThroughExistingAncestors(pathApi, targetPath);
|
|
462
|
+
if (canonicalRoot === undefined || canonicalTarget === undefined)
|
|
463
|
+
return undefined;
|
|
464
|
+
resolvedRoot = canonicalRoot;
|
|
465
|
+
resolvedTarget = canonicalTarget;
|
|
466
|
+
}
|
|
467
|
+
const distRoot = pathApi.resolve(resolvedRoot, "dist");
|
|
468
|
+
const relativePath = pathApi.relative(distRoot, resolvedTarget);
|
|
469
|
+
if (relativePath.length === 0 ||
|
|
470
|
+
relativePath === ".." ||
|
|
471
|
+
relativePath.startsWith(`..${pathApi.sep}`) ||
|
|
472
|
+
pathApi.isAbsolute(relativePath)) {
|
|
473
|
+
return undefined;
|
|
474
|
+
}
|
|
475
|
+
return boundedDiagnosticValue(relativePath, MAX_OWN_DIST_RELATIVE_PATH_BYTES);
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Add bounded evidence when an ESM resolver failure names a file in hq-cli's
|
|
479
|
+
* own dist tree. This only enriches an event that is already captured; it does
|
|
480
|
+
* not infer that the package tree was torn or change the recovery path.
|
|
481
|
+
*/
|
|
482
|
+
function ownDistModuleMissingCaptureContext(err) {
|
|
483
|
+
if (err === null || typeof err !== "object")
|
|
484
|
+
return undefined;
|
|
485
|
+
const record = err;
|
|
486
|
+
if (record.code !== "ERR_MODULE_NOT_FOUND")
|
|
487
|
+
return undefined;
|
|
488
|
+
const classified = classifyModuleNotFound(err);
|
|
489
|
+
if (!classified ||
|
|
490
|
+
classified.code !== "ERR_MODULE_NOT_FOUND" ||
|
|
491
|
+
classified.dialect !== "esm-path" ||
|
|
492
|
+
classified.target.kind !== "path") {
|
|
493
|
+
return undefined;
|
|
494
|
+
}
|
|
495
|
+
let root;
|
|
496
|
+
try {
|
|
497
|
+
root = packageRoot();
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
// Enrichment is optional. If root discovery itself is unavailable, retain
|
|
501
|
+
// the original generic capture without changing its recovery or output.
|
|
502
|
+
return undefined;
|
|
503
|
+
}
|
|
504
|
+
const relativePath = relativeOwnDistModulePath(root, classified.target.path);
|
|
505
|
+
if (relativePath === undefined)
|
|
506
|
+
return undefined;
|
|
507
|
+
return {
|
|
508
|
+
tags: {
|
|
509
|
+
module_missing_scope: "own_dist",
|
|
510
|
+
cli_version: CLI_VERSION,
|
|
511
|
+
},
|
|
512
|
+
contexts: {
|
|
513
|
+
module_missing_target: {
|
|
514
|
+
relative_path: relativePath,
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
};
|
|
518
|
+
}
|
|
413
519
|
/** Classify a top-level failure without making the CLI process boundary opaque to tests. */
|
|
414
520
|
export async function handleTopLevelError(err, deps = defaultTopLevelErrorDependencies) {
|
|
415
521
|
// A typed plan gate, or an hq-cloud vault error carrying a plan-limit
|
|
@@ -920,22 +1026,30 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
|
|
|
920
1026
|
else {
|
|
921
1027
|
// A genuinely unclassified fault is still captured exactly once. Two
|
|
922
1028
|
// shapes attach bounded, hq-derived context so the next occurrence
|
|
923
|
-
// carries
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
//
|
|
1029
|
+
// carries evidence otherwise missing: qmd spawn failures (HQ-CLI-1A),
|
|
1030
|
+
// loader/resolver ENOENTs with no usable path (HQ-CLI-1M/1S/20), and an
|
|
1031
|
+
// ESM module miss under hq-cli's own dist tree. The latter remains a
|
|
1032
|
+
// captured fault without guessing whether the artifact or install tore.
|
|
927
1033
|
// Every other error captures bare, exactly as before.
|
|
928
1034
|
const spawnContext = qmdSpawnFailureCaptureContext(err);
|
|
929
1035
|
const installContext = incompleteInstallCaptureContext(err);
|
|
930
1036
|
const qmdContext = qmdExitCaptureContext(err);
|
|
931
1037
|
const gatewayContext = integrationsGatewayCaptureContext(err);
|
|
932
|
-
|
|
1038
|
+
const moduleContext = ownDistModuleMissingCaptureContext(err);
|
|
1039
|
+
const tags = {
|
|
1040
|
+
...qmdContext?.tags,
|
|
1041
|
+
...gatewayContext?.tags,
|
|
1042
|
+
...moduleContext?.tags,
|
|
1043
|
+
};
|
|
1044
|
+
const contexts = {
|
|
1045
|
+
...spawnContext?.contexts,
|
|
1046
|
+
...installContext,
|
|
1047
|
+
...moduleContext?.contexts,
|
|
1048
|
+
};
|
|
1049
|
+
if (Object.keys(tags).length > 0 || Object.keys(contexts).length > 0) {
|
|
933
1050
|
deps.sentry.captureException(err, {
|
|
934
|
-
...
|
|
935
|
-
...(
|
|
936
|
-
...((spawnContext || installContext)
|
|
937
|
-
? { contexts: { ...spawnContext?.contexts, ...installContext } }
|
|
938
|
-
: {}),
|
|
1051
|
+
...(Object.keys(tags).length > 0 ? { tags } : {}),
|
|
1052
|
+
...(Object.keys(contexts).length > 0 ? { contexts } : {}),
|
|
939
1053
|
});
|
|
940
1054
|
}
|
|
941
1055
|
else {
|
package/dist/types.d.ts
CHANGED
|
@@ -9,9 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { ContributionKey } from './utils/contribution-table.js';
|
|
11
11
|
export type LegacyStrategy = 'link' | 'merge' | 'copy';
|
|
12
|
-
export type SyncStrategy = LegacyStrategy | 'package';
|
|
13
12
|
export type AccessLevel = 'public' | 'team' | `role:${string}`;
|
|
14
|
-
|
|
13
|
+
interface PathMapping {
|
|
15
14
|
src: string;
|
|
16
15
|
dest: string;
|
|
17
16
|
}
|
|
@@ -83,7 +82,7 @@ export interface PackAuthor {
|
|
|
83
82
|
* tracks installed packs by FILESYSTEM PRESENCE — not `modules.yaml`). `version`
|
|
84
83
|
* is an optional semver RANGE the installed dependency must satisfy.
|
|
85
84
|
*/
|
|
86
|
-
|
|
85
|
+
interface PackDependency {
|
|
87
86
|
name: string;
|
|
88
87
|
version?: string;
|
|
89
88
|
}
|
|
@@ -129,4 +128,5 @@ export interface PackManifest {
|
|
|
129
128
|
prompt?: string;
|
|
130
129
|
};
|
|
131
130
|
}
|
|
131
|
+
export {};
|
|
132
132
|
//# sourceMappingURL=types.d.ts.map
|
|
@@ -32,19 +32,19 @@
|
|
|
32
32
|
* ({@link shouldApplyHeartbeat}).
|
|
33
33
|
*/
|
|
34
34
|
export declare const CLIENT_HEALTH_CONTRACT_VERSION = 1;
|
|
35
|
-
|
|
35
|
+
declare const CLIENT_HEALTH_PLATFORMS: readonly ["macos", "windows", "linux"];
|
|
36
36
|
export type ClientHealthPlatform = (typeof CLIENT_HEALTH_PLATFORMS)[number];
|
|
37
|
-
|
|
37
|
+
declare const CLIENT_HEALTH_ARCHS: readonly ["x64", "arm64"];
|
|
38
38
|
export type ClientHealthArch = (typeof CLIENT_HEALTH_ARCHS)[number];
|
|
39
39
|
/** Which client produced the heartbeat. Desktop is always-on; CLI contributes per invocation. */
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
declare const CLIENT_HEALTH_SOURCES: readonly ["desktop", "cli"];
|
|
41
|
+
type ClientHealthSource = (typeof CLIENT_HEALTH_SOURCES)[number];
|
|
42
42
|
/**
|
|
43
43
|
* Current sync state of the installation. `paused` and `conflict_blocked` are
|
|
44
44
|
* first-class states (not failures folded into `error`) because support treats
|
|
45
45
|
* them differently: pause may be intentional and conflicts are user-owned.
|
|
46
46
|
*/
|
|
47
|
-
|
|
47
|
+
declare const CLIENT_HEALTH_SYNC_STATES: readonly ["idle", "syncing", "paused", "conflict_blocked", "error", "never_synced"];
|
|
48
48
|
export type ClientHealthSyncState = (typeof CLIENT_HEALTH_SYNC_STATES)[number];
|
|
49
49
|
/**
|
|
50
50
|
* Updater state. ABSENCE of the field means the client is too old to report
|
|
@@ -62,19 +62,15 @@ export type ClientHealthFailureReason = (typeof CLIENT_HEALTH_FAILURE_REASONS)[n
|
|
|
62
62
|
* fails closed.
|
|
63
63
|
*/
|
|
64
64
|
export declare const CLIENT_HEALTH_REPAIR_KINDS: readonly ["CHECK_NOW", "RETRY_SYNC", "RESUME_SYNC", "REPAIR_CLI", "UPDATE_CORE", "APPLY_DESKTOP_UPDATE", "RESTART_APP"];
|
|
65
|
-
|
|
65
|
+
type ClientHealthRepairKind = (typeof CLIENT_HEALTH_REPAIR_KINDS)[number];
|
|
66
66
|
/** Command/receipt lifecycle states (queued → acknowledged → running → terminal). */
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
declare const CLIENT_HEALTH_COMMAND_STATES: readonly ["queued", "acknowledged", "running", "succeeded", "failed", "expired"];
|
|
68
|
+
type ClientHealthCommandState = (typeof CLIENT_HEALTH_COMMAND_STATES)[number];
|
|
69
69
|
/** Closed diagnostic probe identifiers (US-007). */
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
export declare const CLIENT_HEALTH_MAX_STRING_LENGTH = 64;
|
|
75
|
-
export declare const CLIENT_HEALTH_MAX_CONSECUTIVE_FAILURES = 100000;
|
|
76
|
-
export declare const CLIENT_HEALTH_MAX_CONFLICT_COUNT = 100000;
|
|
77
|
-
export declare const CLIENT_HEALTH_MAX_CHECKS = 16;
|
|
70
|
+
declare const CLIENT_HEALTH_DIAGNOSTIC_CHECKS: readonly ["auth", "runner", "cli", "core", "updater", "sync", "conflicts", "storage", "permissions"];
|
|
71
|
+
type ClientHealthDiagnosticCheck = (typeof CLIENT_HEALTH_DIAGNOSTIC_CHECKS)[number];
|
|
72
|
+
declare const CLIENT_HEALTH_CHECK_STATUSES: readonly ["pass", "fail", "skip"];
|
|
73
|
+
type ClientHealthCheckStatus = (typeof CLIENT_HEALTH_CHECK_STATUSES)[number];
|
|
78
74
|
/**
|
|
79
75
|
* Local-files-overview bounds (client-sync-health-control-plane hq-cli
|
|
80
76
|
* addition, US-016). These cap the closed set of bounded facts support sees
|
|
@@ -146,7 +142,7 @@ export interface ClientHealthHeartbeat {
|
|
|
146
142
|
/** Absent on older clients / non-sync-outcome events. See {@link ClientHealthLocalFilesOverview}. */
|
|
147
143
|
localFilesOverview?: ClientHealthLocalFilesOverview;
|
|
148
144
|
}
|
|
149
|
-
|
|
145
|
+
interface ClientHealthCheckResult {
|
|
150
146
|
check: ClientHealthDiagnosticCheck;
|
|
151
147
|
status: ClientHealthCheckStatus;
|
|
152
148
|
/** Present only on `fail` — a closed reason code, never prose. */
|
|
@@ -187,4 +183,5 @@ export declare function parseClientHealthCommandReceipt(input: unknown): ClientH
|
|
|
187
183
|
* answers idempotently and the current snapshot remains unchanged).
|
|
188
184
|
*/
|
|
189
185
|
export declare function shouldApplyHeartbeat(storedSequence: number | undefined, incomingSequence: number): boolean;
|
|
186
|
+
export {};
|
|
190
187
|
//# sourceMappingURL=client-health-contract.d.ts.map
|
|
@@ -34,16 +34,16 @@
|
|
|
34
34
|
// ─── Contract version ────────────────────────────────────────────────────────
|
|
35
35
|
export const CLIENT_HEALTH_CONTRACT_VERSION = 1;
|
|
36
36
|
// ─── Closed enums ────────────────────────────────────────────────────────────
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
const CLIENT_HEALTH_PLATFORMS = ["macos", "windows", "linux"];
|
|
38
|
+
const CLIENT_HEALTH_ARCHS = ["x64", "arm64"];
|
|
39
39
|
/** Which client produced the heartbeat. Desktop is always-on; CLI contributes per invocation. */
|
|
40
|
-
|
|
40
|
+
const CLIENT_HEALTH_SOURCES = ["desktop", "cli"];
|
|
41
41
|
/**
|
|
42
42
|
* Current sync state of the installation. `paused` and `conflict_blocked` are
|
|
43
43
|
* first-class states (not failures folded into `error`) because support treats
|
|
44
44
|
* them differently: pause may be intentional and conflicts are user-owned.
|
|
45
45
|
*/
|
|
46
|
-
|
|
46
|
+
const CLIENT_HEALTH_SYNC_STATES = [
|
|
47
47
|
"idle",
|
|
48
48
|
"syncing",
|
|
49
49
|
"paused",
|
|
@@ -94,7 +94,7 @@ export const CLIENT_HEALTH_REPAIR_KINDS = [
|
|
|
94
94
|
"RESTART_APP",
|
|
95
95
|
];
|
|
96
96
|
/** Command/receipt lifecycle states (queued → acknowledged → running → terminal). */
|
|
97
|
-
|
|
97
|
+
const CLIENT_HEALTH_COMMAND_STATES = [
|
|
98
98
|
"queued",
|
|
99
99
|
"acknowledged",
|
|
100
100
|
"running",
|
|
@@ -103,7 +103,7 @@ export const CLIENT_HEALTH_COMMAND_STATES = [
|
|
|
103
103
|
"expired",
|
|
104
104
|
];
|
|
105
105
|
/** Closed diagnostic probe identifiers (US-007). */
|
|
106
|
-
|
|
106
|
+
const CLIENT_HEALTH_DIAGNOSTIC_CHECKS = [
|
|
107
107
|
"auth",
|
|
108
108
|
"runner",
|
|
109
109
|
"cli",
|
|
@@ -114,12 +114,12 @@ export const CLIENT_HEALTH_DIAGNOSTIC_CHECKS = [
|
|
|
114
114
|
"storage",
|
|
115
115
|
"permissions",
|
|
116
116
|
];
|
|
117
|
-
|
|
117
|
+
const CLIENT_HEALTH_CHECK_STATUSES = ["pass", "fail", "skip"];
|
|
118
118
|
// ─── Bounds ──────────────────────────────────────────────────────────────────
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
119
|
+
const CLIENT_HEALTH_MAX_STRING_LENGTH = 64;
|
|
120
|
+
const CLIENT_HEALTH_MAX_CONSECUTIVE_FAILURES = 100_000;
|
|
121
|
+
const CLIENT_HEALTH_MAX_CONFLICT_COUNT = 100_000;
|
|
122
|
+
const CLIENT_HEALTH_MAX_CHECKS = 16;
|
|
123
123
|
/**
|
|
124
124
|
* Local-files-overview bounds (client-sync-health-control-plane hq-cli
|
|
125
125
|
* addition, US-016). These cap the closed set of bounded facts support sees
|
|
@@ -145,6 +145,8 @@ export interface SelfUpdateDeps {
|
|
|
145
145
|
/** Same-filesystem install rename, injectable for rollback tests. */
|
|
146
146
|
renamePath?: typeof fs.renameSync;
|
|
147
147
|
runner?: (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => UpdateResult;
|
|
148
|
+
/** Delay between bounded npm retries when the just-published target is not visible yet. */
|
|
149
|
+
sleep?: (ms: number) => Promise<void>;
|
|
148
150
|
reexec?: (argv: string[], env: NodeJS.ProcessEnv) => number | null;
|
|
149
151
|
/** Async process-spawn seam for lease handoff tests. */
|
|
150
152
|
spawnReexec?: typeof spawn;
|
|
@@ -693,6 +693,18 @@ function reexecErrorClass(error) {
|
|
|
693
693
|
}
|
|
694
694
|
return "unknown";
|
|
695
695
|
}
|
|
696
|
+
const NPM_NOTARGET_RETRY_DELAYS_MS = [30_000, 60_000, 90_000];
|
|
697
|
+
const NPM_NOTARGET_MAX_ATTEMPTS = NPM_NOTARGET_RETRY_DELAYS_MS.length + 1;
|
|
698
|
+
function isNpmNotargetFailure(result) {
|
|
699
|
+
const code = (result.diagnosticCode ?? result.code)?.toUpperCase();
|
|
700
|
+
if (code === "ETARGET" || code === "NOTARGET")
|
|
701
|
+
return true;
|
|
702
|
+
return result.failureKind === "package_manager_exit" &&
|
|
703
|
+
/\bETARGET\b|\bnpm\s+(?:ERR!|error)\s+notarget\b/i.test(result.detail ?? "");
|
|
704
|
+
}
|
|
705
|
+
function sleepForRetry(ms) {
|
|
706
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
707
|
+
}
|
|
696
708
|
async function updateAndReexec(argv, flavor, known, deps) {
|
|
697
709
|
const env = deps.env ?? process.env;
|
|
698
710
|
if (env[REEXEC_GUARD_ENV] === "1" || env.HQ_NO_UPDATE_CHECK === "1") {
|
|
@@ -795,14 +807,32 @@ async function updateAndReexec(argv, flavor, known, deps) {
|
|
|
795
807
|
// Forward env only when PNPM_HOME had to be injected, so the common path
|
|
796
808
|
// spawns with the inherited environment and keeps the two-argument runner
|
|
797
809
|
// call it has always made.
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
810
|
+
const runInstall = () => {
|
|
811
|
+
if (staged) {
|
|
812
|
+
return stagedNpmInstall(install, latestValid, { run: runner, env, rename: renamePath });
|
|
813
|
+
}
|
|
814
|
+
return updateEnv === undefined
|
|
815
|
+
? runner(plan.cmd, plan.args)
|
|
816
|
+
: runner(plan.cmd, plan.args, updateEnv);
|
|
817
|
+
};
|
|
818
|
+
let attempt = 1;
|
|
819
|
+
result = runInstall();
|
|
820
|
+
while (install.manager === "npm" &&
|
|
821
|
+
!result.ok &&
|
|
822
|
+
isNpmNotargetFailure(result) &&
|
|
823
|
+
attempt < NPM_NOTARGET_MAX_ATTEMPTS) {
|
|
824
|
+
const delay = NPM_NOTARGET_RETRY_DELAYS_MS[attempt - 1];
|
|
825
|
+
const nextAttempt = attempt + 1;
|
|
826
|
+
console.error(chalk.dim(` npm has not published hq-cli ${latestValid} yet; retrying install (attempt ${nextAttempt}/${NPM_NOTARGET_MAX_ATTEMPTS}) after ${delay} ms.`));
|
|
827
|
+
await (deps.sleep ?? sleepForRetry)(delay);
|
|
828
|
+
attempt = nextAttempt;
|
|
829
|
+
result = runInstall();
|
|
803
830
|
}
|
|
804
|
-
|
|
805
|
-
result =
|
|
831
|
+
if (!result.ok && attempt > 1) {
|
|
832
|
+
result = {
|
|
833
|
+
...result,
|
|
834
|
+
detail: `${result.detail ?? "npm install failed"} (attempts: ${attempt})`,
|
|
835
|
+
};
|
|
806
836
|
}
|
|
807
837
|
if (result.ok && !staged && install.manager === "npm" && install.prefix && install.packageRoot) {
|
|
808
838
|
const bins = verifyOrRepairNpmBins(install.prefix, install.packageRoot);
|