@indigoai-us/hq-cli 5.345.5 → 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 +11 -0
- package/dist/commands/login.js +2 -2
- package/dist/commands/mesh.js +17 -17
- 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/lib/work-context/config.d.ts +13 -12
- package/dist/lib/work-context/config.js +18 -18
- package/dist/lib/work-context/person-identity.d.ts +11 -7
- package/dist/lib/work-context/person-identity.js +24 -11
- 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,6 +2,17 @@
|
|
|
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
|
+
|
|
12
|
+
## [5.345.6] — 2026-10-05
|
|
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.
|
|
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.
|
|
15
|
+
|
|
5
16
|
## [5.345.5] — 2026-10-05
|
|
6
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.
|
|
7
18
|
|
package/dist/commands/login.js
CHANGED
|
@@ -9,7 +9,7 @@ import { cognitoConfigForLoginProvider } from '../utils/login-provider.js';
|
|
|
9
9
|
import * as readline from "node:readline/promises";
|
|
10
10
|
import { listActiveMembershipCompanies } from "../lib/mesh/api.js";
|
|
11
11
|
import { retainUsageAttributionForPerson, setDefaultCompany } from "../lib/work-context/config.js";
|
|
12
|
-
import {
|
|
12
|
+
import { cognitoSubFromTokens } from "../lib/work-context/person-identity.js";
|
|
13
13
|
import { ensureDefaultCompany } from "../lib/work-context/default-company.js";
|
|
14
14
|
import { workContextRoot } from "../lib/work-context/paths.js";
|
|
15
15
|
function peekIdToken(idToken) {
|
|
@@ -66,7 +66,7 @@ export function registerLoginCommand(program) {
|
|
|
66
66
|
try {
|
|
67
67
|
// The usage-attribution opt-in belongs to the person who gave it. A
|
|
68
68
|
// different (or unknown) account signing in on this OS user drops it.
|
|
69
|
-
retainUsageAttributionForPerson(
|
|
69
|
+
retainUsageAttributionForPerson(cognitoSubFromTokens(loadCachedTokens()), {
|
|
70
70
|
root: workContextRoot(),
|
|
71
71
|
});
|
|
72
72
|
}
|
package/dist/commands/mesh.js
CHANGED
|
@@ -20,7 +20,7 @@ import { buildPreflightFixtures } from "../lib/work-context/preflight-contract.j
|
|
|
20
20
|
import { createCandidatesFetcher, createMigratePoster, createOrganizePoster, createWorkSessionDeliverer, fetchCompanyLive, formatCompanyLiveTable, openMeshTransport, probeMigrationCapabilityForMemberships, requireToken, } from "../lib/mesh/client.js";
|
|
21
21
|
import { clearDefaultCompany, effectiveUsageAttribution, isDefaultCompanyDisabled, readDeviceConfig, recordMigrationCapabilitySnapshot, setRepairHeldWithDefault, setDefaultCompany, setUsageAttribution, } from "../lib/work-context/config.js";
|
|
22
22
|
import { ensureDefaultCompany } from "../lib/work-context/default-company.js";
|
|
23
|
-
import {
|
|
23
|
+
import { cognitoSubFromTokens } from "../lib/work-context/person-identity.js";
|
|
24
24
|
import { DefaultCompanyUnavailableError, WorkContextError, } from "../lib/work-context/errors.js";
|
|
25
25
|
import { isValidSessionId } from "../lib/mesh/live/session-identity.js";
|
|
26
26
|
import { flushSessionEvents } from "../lib/mesh/live/flush.js";
|
|
@@ -1212,17 +1212,17 @@ export async function askYesNo(question, io = {}) {
|
|
|
1212
1212
|
rl.close();
|
|
1213
1213
|
}
|
|
1214
1214
|
}
|
|
1215
|
-
/** The signed-in person's
|
|
1216
|
-
function
|
|
1215
|
+
/** The signed-in person's Cognito sub from the cached session, or null. */
|
|
1216
|
+
function currentCognitoSub() {
|
|
1217
1217
|
try {
|
|
1218
|
-
return
|
|
1218
|
+
return cognitoSubFromTokens(loadCachedTokens());
|
|
1219
1219
|
}
|
|
1220
1220
|
catch {
|
|
1221
1221
|
return null;
|
|
1222
1222
|
}
|
|
1223
1223
|
}
|
|
1224
|
-
function usageAttributionJson(cfg,
|
|
1225
|
-
const effective = effectiveUsageAttribution(cfg,
|
|
1224
|
+
function usageAttributionJson(cfg, cognitoSub) {
|
|
1225
|
+
const effective = effectiveUsageAttribution(cfg, cognitoSub);
|
|
1226
1226
|
return { countUsage: effective !== null, usageAttribution: effective };
|
|
1227
1227
|
}
|
|
1228
1228
|
async function runContextDefaultGet(opts) {
|
|
@@ -1244,7 +1244,7 @@ async function runContextDefaultGet(opts) {
|
|
|
1244
1244
|
needsChoice: !current && !disabled && memberships.length > 1,
|
|
1245
1245
|
memberships,
|
|
1246
1246
|
repairHeldWithDefault: cfg.repairHeldWithDefault === true,
|
|
1247
|
-
...usageAttributionJson(cfg,
|
|
1247
|
+
...usageAttributionJson(cfg, currentCognitoSub()),
|
|
1248
1248
|
}, null, 2));
|
|
1249
1249
|
return;
|
|
1250
1250
|
}
|
|
@@ -1257,7 +1257,7 @@ async function runContextDefaultGet(opts) {
|
|
|
1257
1257
|
return;
|
|
1258
1258
|
}
|
|
1259
1259
|
console.log(`Default company: ${current.slug}${current.uid ? ` (${current.uid})` : ""} enabled=${current.enabled} source=${source}`);
|
|
1260
|
-
console.log(effectiveUsageAttribution(cfg,
|
|
1260
|
+
console.log(effectiveUsageAttribution(cfg, currentCognitoSub())
|
|
1261
1261
|
? usageCountedLine(current.slug)
|
|
1262
1262
|
: usageNotCountedLine(current.slug));
|
|
1263
1263
|
}
|
|
@@ -1269,8 +1269,8 @@ async function runContextDefaultSet(slug, opts, promptDeps = {}) {
|
|
|
1269
1269
|
: opts.countUsage === false
|
|
1270
1270
|
? "opt-out"
|
|
1271
1271
|
: "keep-if-same";
|
|
1272
|
-
const
|
|
1273
|
-
const priorOptIn = effectiveUsageAttribution(readDeviceConfig({ root }),
|
|
1272
|
+
const cognitoSub = currentCognitoSub();
|
|
1273
|
+
const priorOptIn = effectiveUsageAttribution(readDeviceConfig({ root }), cognitoSub);
|
|
1274
1274
|
let token;
|
|
1275
1275
|
try {
|
|
1276
1276
|
token = await requireToken();
|
|
@@ -1311,7 +1311,7 @@ async function runContextDefaultSet(slug, opts, promptDeps = {}) {
|
|
|
1311
1311
|
root,
|
|
1312
1312
|
migrationCapability,
|
|
1313
1313
|
usageAttribution: usageChoice,
|
|
1314
|
-
|
|
1314
|
+
cognitoSub,
|
|
1315
1315
|
validateMembership: async (candidate) => {
|
|
1316
1316
|
if (!token) {
|
|
1317
1317
|
throw new DefaultCompanyUnavailableError(`Cannot verify membership for company "${candidate}" (no Cognito session; offline or not logged in)`);
|
|
@@ -1340,7 +1340,7 @@ async function runContextDefaultSet(slug, opts, promptDeps = {}) {
|
|
|
1340
1340
|
ok: true,
|
|
1341
1341
|
config: configured,
|
|
1342
1342
|
migrationWarning,
|
|
1343
|
-
...usageAttributionJson(configured,
|
|
1343
|
+
...usageAttributionJson(configured, cognitoSub),
|
|
1344
1344
|
}, null, 2));
|
|
1345
1345
|
return;
|
|
1346
1346
|
}
|
|
@@ -1352,8 +1352,8 @@ async function runContextDefaultSet(slug, opts, promptDeps = {}) {
|
|
|
1352
1352
|
(() => isInteractiveTerminal()))();
|
|
1353
1353
|
if (usageChoice === "keep-if-same" &&
|
|
1354
1354
|
interactive &&
|
|
1355
|
-
|
|
1356
|
-
!effectiveUsageAttribution(configured,
|
|
1355
|
+
cognitoSub &&
|
|
1356
|
+
!effectiveUsageAttribution(configured, cognitoSub) &&
|
|
1357
1357
|
stored?.uid?.startsWith("cmp_")) {
|
|
1358
1358
|
console.error(usageOptInExplainer(company));
|
|
1359
1359
|
let yes = false;
|
|
@@ -1364,9 +1364,9 @@ async function runContextDefaultSet(slug, opts, promptDeps = {}) {
|
|
|
1364
1364
|
yes = false;
|
|
1365
1365
|
}
|
|
1366
1366
|
if (yes)
|
|
1367
|
-
configured = setUsageAttribution({ companyUid: stored.uid,
|
|
1367
|
+
configured = setUsageAttribution({ companyUid: stored.uid, cognitoSub }, { root });
|
|
1368
1368
|
}
|
|
1369
|
-
console.log(effectiveUsageAttribution(configured,
|
|
1369
|
+
console.log(effectiveUsageAttribution(configured, cognitoSub)
|
|
1370
1370
|
? usageCountedLine(company)
|
|
1371
1371
|
: usageNotCountedLine(company));
|
|
1372
1372
|
if (migrationWarning)
|
|
@@ -1503,7 +1503,7 @@ async function runContextDefaultClear(opts) {
|
|
|
1503
1503
|
const hadOptIn = readDeviceConfig({ root }).usageAttribution !== undefined;
|
|
1504
1504
|
const cfg = clearDefaultCompany({ root });
|
|
1505
1505
|
if (opts.json) {
|
|
1506
|
-
console.log(JSON.stringify({ ok: true, config: cfg, ...usageAttributionJson(cfg,
|
|
1506
|
+
console.log(JSON.stringify({ ok: true, config: cfg, ...usageAttributionJson(cfg, currentCognitoSub()) }, null, 2));
|
|
1507
1507
|
return;
|
|
1508
1508
|
}
|
|
1509
1509
|
console.log("Default company cleared.");
|
|
@@ -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)
|
|
@@ -56,15 +56,16 @@ export interface UsageAttributionOptIn {
|
|
|
56
56
|
mode: "default-company";
|
|
57
57
|
companyUid: string;
|
|
58
58
|
/**
|
|
59
|
-
* The signed-in person who consented: the `
|
|
60
|
-
*
|
|
61
|
-
*
|
|
59
|
+
* The signed-in person who consented: the Cognito `sub` claim of the cached
|
|
60
|
+
* person session. Consent never carries to another account on the same OS
|
|
61
|
+
* user. Older records that carry only `personUid` are treated as not opted
|
|
62
|
+
* in (fail closed).
|
|
62
63
|
*/
|
|
63
|
-
|
|
64
|
+
cognitoSub: string;
|
|
64
65
|
optedInAt: string;
|
|
65
66
|
}
|
|
66
|
-
/** A
|
|
67
|
-
export declare function
|
|
67
|
+
/** A Cognito `sub` claim: any non-empty string (after trimming). */
|
|
68
|
+
export declare function isCognitoSub(value: unknown): value is string;
|
|
68
69
|
/** How `setDefaultCompany` treats the usage-attribution opt-in. */
|
|
69
70
|
export type UsageAttributionChoice =
|
|
70
71
|
/** Write an opt-in for the newly set company (requires its uid). */
|
|
@@ -113,10 +114,10 @@ export interface DeviceConfigDeps {
|
|
|
113
114
|
*/
|
|
114
115
|
usageAttribution?: UsageAttributionChoice;
|
|
115
116
|
/**
|
|
116
|
-
* The signed-in person (
|
|
117
|
+
* The signed-in person (Cognito `sub`). Required to write an opt-in; an existing
|
|
117
118
|
* opt-in is kept only when it was given by this person.
|
|
118
119
|
*/
|
|
119
|
-
|
|
120
|
+
cognitoSub?: string | null;
|
|
120
121
|
}
|
|
121
122
|
/** Acquire an interprocess config lock; exported for direct lock regression tests. */
|
|
122
123
|
export declare function acquireDeviceConfigLock(root: string, options?: {
|
|
@@ -130,7 +131,7 @@ export declare function sanitizeUsageAttribution(raw: unknown): UsageAttribution
|
|
|
130
131
|
* default company, and given by the signed-in person. Mirrors the hq-cloud
|
|
131
132
|
* reader. No signed-in person means no opt-in.
|
|
132
133
|
*/
|
|
133
|
-
export declare function effectiveUsageAttribution(cfg: Pick<WorkContextDeviceConfig, "defaultCompany" | "usageAttribution">,
|
|
134
|
+
export declare function effectiveUsageAttribution(cfg: Pick<WorkContextDeviceConfig, "defaultCompany" | "usageAttribution">, cognitoSub: string | null | undefined): UsageAttributionOptIn | null;
|
|
134
135
|
/** Injection points for assertSafeConfigPath (tests only). */
|
|
135
136
|
export interface SafeConfigPathIo {
|
|
136
137
|
platform?: NodeJS.Platform;
|
|
@@ -162,14 +163,14 @@ export declare function setDefaultCompany(slug: string, deps: DeviceConfigDeps):
|
|
|
162
163
|
*/
|
|
163
164
|
export declare function setUsageAttribution(optIn: {
|
|
164
165
|
companyUid: string;
|
|
165
|
-
|
|
166
|
+
cognitoSub: string;
|
|
166
167
|
} | null, deps: Pick<DeviceConfigDeps, "root" | "now">): WorkContextDeviceConfig;
|
|
167
168
|
/**
|
|
168
|
-
* Drop the usage-attribution opt-in unless it was given by `
|
|
169
|
+
* Drop the usage-attribution opt-in unless it was given by `cognitoSub`. Called
|
|
169
170
|
* on login (identity may have changed) and logout (null: always drop). A
|
|
170
171
|
* config with no opt-in, or one already matching, is left untouched.
|
|
171
172
|
*/
|
|
172
|
-
export declare function retainUsageAttributionForPerson(
|
|
173
|
+
export declare function retainUsageAttributionForPerson(cognitoSub: string | null | undefined, deps: Pick<DeviceConfigDeps, "root" | "now">): boolean;
|
|
173
174
|
export declare function clearDefaultCompany(deps: DeviceConfigDeps): WorkContextDeviceConfig;
|
|
174
175
|
/**
|
|
175
176
|
* Read the persisted company mapping for a repo identity key (gap 4).
|
|
@@ -9,9 +9,9 @@ import { DefaultCompanyUnavailableError, UnsafeConfigPathError, WorkContextError
|
|
|
9
9
|
import { workContextConfigPath } from "./paths.js";
|
|
10
10
|
import { assertWindowsOwnerOnlyAcl } from "./windows-acl.js";
|
|
11
11
|
const DEVICE_CONFIG_SCHEMA_VERSION = 1;
|
|
12
|
-
/** A
|
|
13
|
-
export function
|
|
14
|
-
return typeof value === "string" &&
|
|
12
|
+
/** A Cognito `sub` claim: any non-empty string (after trimming). */
|
|
13
|
+
export function isCognitoSub(value) {
|
|
14
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
15
15
|
}
|
|
16
16
|
const CONFIG_LOCK_TIMEOUT_MS = 5_000;
|
|
17
17
|
const CONFIG_LOCK_RETRY_MS = 10;
|
|
@@ -158,24 +158,24 @@ export function sanitizeUsageAttribution(raw) {
|
|
|
158
158
|
if (v.mode !== "default-company")
|
|
159
159
|
return undefined;
|
|
160
160
|
const companyUid = typeof v.companyUid === "string" ? v.companyUid.trim() : "";
|
|
161
|
-
const
|
|
161
|
+
const cognitoSub = typeof v.cognitoSub === "string" ? v.cognitoSub.trim() : "";
|
|
162
162
|
const optedInAt = typeof v.optedInAt === "string" ? v.optedInAt.trim() : "";
|
|
163
163
|
if (!companyUid.startsWith("cmp_") ||
|
|
164
|
-
!
|
|
164
|
+
!isCognitoSub(cognitoSub) ||
|
|
165
165
|
!optedInAt ||
|
|
166
166
|
!Number.isFinite(Date.parse(optedInAt))) {
|
|
167
167
|
return undefined;
|
|
168
168
|
}
|
|
169
|
-
return { mode: "default-company", companyUid,
|
|
169
|
+
return { mode: "default-company", companyUid, cognitoSub, optedInAt };
|
|
170
170
|
}
|
|
171
171
|
/**
|
|
172
172
|
* The opt-in that is in effect: present, for the uid of the current enabled
|
|
173
173
|
* default company, and given by the signed-in person. Mirrors the hq-cloud
|
|
174
174
|
* reader. No signed-in person means no opt-in.
|
|
175
175
|
*/
|
|
176
|
-
export function effectiveUsageAttribution(cfg,
|
|
176
|
+
export function effectiveUsageAttribution(cfg, cognitoSub) {
|
|
177
177
|
const opt = cfg.usageAttribution;
|
|
178
|
-
if (!opt || !
|
|
178
|
+
if (!opt || !isCognitoSub(cognitoSub) || opt.cognitoSub !== cognitoSub)
|
|
179
179
|
return null;
|
|
180
180
|
const def = cfg.defaultCompany;
|
|
181
181
|
if (!opt || !def || def.enabled !== true || def.source === "disabled")
|
|
@@ -332,27 +332,27 @@ export async function setDefaultCompany(slug, deps) {
|
|
|
332
332
|
defaultCompany.uid = resolvedUid;
|
|
333
333
|
const choice = deps.usageAttribution ?? "keep-if-same";
|
|
334
334
|
let usageAttribution;
|
|
335
|
-
const
|
|
335
|
+
const cognitoSub = isCognitoSub(deps.cognitoSub) ? deps.cognitoSub : undefined;
|
|
336
336
|
if (choice === "opt-in") {
|
|
337
337
|
if (!resolvedUid?.startsWith("cmp_")) {
|
|
338
338
|
throw new WorkContextError("UsageAttributionUnavailable", `Cannot count usage toward "${trimmed}": its company uid is unknown`);
|
|
339
339
|
}
|
|
340
|
-
if (!
|
|
340
|
+
if (!cognitoSub) {
|
|
341
341
|
throw new WorkContextError("UsageAttributionUnavailable", `Cannot count usage toward "${trimmed}": the signed-in person could not be determined`);
|
|
342
342
|
}
|
|
343
343
|
usageAttribution = {
|
|
344
344
|
mode: "default-company",
|
|
345
345
|
companyUid: resolvedUid,
|
|
346
|
-
|
|
346
|
+
cognitoSub,
|
|
347
347
|
optedInAt: now().toISOString(),
|
|
348
348
|
};
|
|
349
349
|
}
|
|
350
350
|
else if (choice === "keep-if-same") {
|
|
351
351
|
usageAttribution =
|
|
352
352
|
resolvedUid &&
|
|
353
|
-
|
|
353
|
+
cognitoSub &&
|
|
354
354
|
prior.usageAttribution?.companyUid === resolvedUid &&
|
|
355
|
-
prior.usageAttribution.
|
|
355
|
+
prior.usageAttribution.cognitoSub === cognitoSub
|
|
356
356
|
? prior.usageAttribution
|
|
357
357
|
: undefined;
|
|
358
358
|
}
|
|
@@ -388,13 +388,13 @@ export function setUsageAttribution(optIn, deps) {
|
|
|
388
388
|
def.source !== "chosen" ||
|
|
389
389
|
!def.uid?.startsWith("cmp_") ||
|
|
390
390
|
def.uid !== optIn.companyUid ||
|
|
391
|
-
!
|
|
391
|
+
!isCognitoSub(optIn.cognitoSub)) {
|
|
392
392
|
throw new WorkContextError("UsageAttributionUnavailable", "Usage can only be counted toward a default company you chose on this device");
|
|
393
393
|
}
|
|
394
394
|
usageAttribution = {
|
|
395
395
|
mode: "default-company",
|
|
396
396
|
companyUid: def.uid,
|
|
397
|
-
|
|
397
|
+
cognitoSub: optIn.cognitoSub,
|
|
398
398
|
optedInAt: now().toISOString(),
|
|
399
399
|
};
|
|
400
400
|
}
|
|
@@ -413,11 +413,11 @@ export function setUsageAttribution(optIn, deps) {
|
|
|
413
413
|
});
|
|
414
414
|
}
|
|
415
415
|
/**
|
|
416
|
-
* Drop the usage-attribution opt-in unless it was given by `
|
|
416
|
+
* Drop the usage-attribution opt-in unless it was given by `cognitoSub`. Called
|
|
417
417
|
* on login (identity may have changed) and logout (null: always drop). A
|
|
418
418
|
* config with no opt-in, or one already matching, is left untouched.
|
|
419
419
|
*/
|
|
420
|
-
export function retainUsageAttributionForPerson(
|
|
420
|
+
export function retainUsageAttributionForPerson(cognitoSub, deps) {
|
|
421
421
|
if (!fs.existsSync(workContextConfigPath(deps.root)))
|
|
422
422
|
return false;
|
|
423
423
|
return withDeviceConfigLock(deps.root, () => {
|
|
@@ -425,7 +425,7 @@ export function retainUsageAttributionForPerson(personUid, deps) {
|
|
|
425
425
|
const opt = prior.usageAttribution;
|
|
426
426
|
if (!opt)
|
|
427
427
|
return false;
|
|
428
|
-
if (
|
|
428
|
+
if (isCognitoSub(cognitoSub) && opt.cognitoSub === cognitoSub)
|
|
429
429
|
return false;
|
|
430
430
|
const now = deps.now ?? (() => new Date());
|
|
431
431
|
const { usageAttribution: _dropped, ...rest } = prior;
|
|
@@ -2,14 +2,18 @@
|
|
|
2
2
|
* Signed-in person identity for the usage-attribution opt-in.
|
|
3
3
|
*
|
|
4
4
|
* Source: the cached Cognito person session (`~/.hq/cognito-tokens.json`,
|
|
5
|
-
* read through `loadCachedTokens`). The
|
|
6
|
-
* claim of the ID token (falling back to the access token)
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* read through `loadCachedTokens`). The identity key is the Cognito `sub`
|
|
6
|
+
* claim of the ID token (falling back to the access token); it must be a
|
|
7
|
+
* non-empty string. Human sessions carry no `custom:entityUid` claim (hq-pro
|
|
8
|
+
* sets it only on agent and outpost machine identities), so it is not used.
|
|
9
|
+
* An agent or outpost session has its own Cognito user and therefore a
|
|
10
|
+
* different `sub`, so it could never match a person's opt-in; a token that
|
|
11
|
+
* names a machine identity (`custom:entityType` agent/outpost, or a
|
|
12
|
+
* non-`prs_` `custom:entityUid`) is refused outright. Claims are read
|
|
13
|
+
* without verification; this decides only whether local consent belongs to
|
|
14
|
+
* the account that is signed in, never authorization.
|
|
11
15
|
*/
|
|
12
|
-
export declare function
|
|
16
|
+
export declare function cognitoSubFromTokens(tokens: {
|
|
13
17
|
idToken?: string;
|
|
14
18
|
accessToken?: string;
|
|
15
19
|
} | null | undefined): string | null;
|
|
@@ -2,14 +2,18 @@
|
|
|
2
2
|
* Signed-in person identity for the usage-attribution opt-in.
|
|
3
3
|
*
|
|
4
4
|
* Source: the cached Cognito person session (`~/.hq/cognito-tokens.json`,
|
|
5
|
-
* read through `loadCachedTokens`). The
|
|
6
|
-
* claim of the ID token (falling back to the access token)
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* read through `loadCachedTokens`). The identity key is the Cognito `sub`
|
|
6
|
+
* claim of the ID token (falling back to the access token); it must be a
|
|
7
|
+
* non-empty string. Human sessions carry no `custom:entityUid` claim (hq-pro
|
|
8
|
+
* sets it only on agent and outpost machine identities), so it is not used.
|
|
9
|
+
* An agent or outpost session has its own Cognito user and therefore a
|
|
10
|
+
* different `sub`, so it could never match a person's opt-in; a token that
|
|
11
|
+
* names a machine identity (`custom:entityType` agent/outpost, or a
|
|
12
|
+
* non-`prs_` `custom:entityUid`) is refused outright. Claims are read
|
|
13
|
+
* without verification; this decides only whether local consent belongs to
|
|
14
|
+
* the account that is signed in, never authorization.
|
|
11
15
|
*/
|
|
12
|
-
import {
|
|
16
|
+
import { isCognitoSub } from "./config.js";
|
|
13
17
|
function claimsOf(token) {
|
|
14
18
|
if (typeof token !== "string")
|
|
15
19
|
return null;
|
|
@@ -26,13 +30,22 @@ function claimsOf(token) {
|
|
|
26
30
|
return null;
|
|
27
31
|
}
|
|
28
32
|
}
|
|
29
|
-
export function
|
|
33
|
+
export function cognitoSubFromTokens(tokens) {
|
|
30
34
|
if (!tokens)
|
|
31
35
|
return null;
|
|
32
36
|
for (const token of [tokens.idToken, tokens.accessToken]) {
|
|
33
|
-
const
|
|
34
|
-
if (
|
|
35
|
-
|
|
37
|
+
const claims = claimsOf(token);
|
|
38
|
+
if (!claims)
|
|
39
|
+
continue;
|
|
40
|
+
const entityType = claims["custom:entityType"];
|
|
41
|
+
const entityUid = claims["custom:entityUid"];
|
|
42
|
+
if (entityType === "agent" || entityType === "outpost")
|
|
43
|
+
return null;
|
|
44
|
+
if (typeof entityUid === "string" && !entityUid.startsWith("prs_"))
|
|
45
|
+
return null;
|
|
46
|
+
const sub = claims.sub;
|
|
47
|
+
if (isCognitoSub(sub))
|
|
48
|
+
return sub.trim();
|
|
36
49
|
}
|
|
37
50
|
return null;
|
|
38
51
|
}
|
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);
|