@indigoai-us/hq-cli 5.123.0 → 5.123.2
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 +30 -0
- package/dist/commands/mesh.js +7 -2
- package/dist/commands/update.js +6 -0
- package/dist/lib/mesh/live/backfill-held.d.ts +43 -7
- package/dist/lib/mesh/live/backfill-held.js +323 -42
- package/dist/lib/mesh/live/daemon/config.d.ts +9 -0
- package/dist/lib/mesh/live/daemon/config.js +45 -0
- package/dist/lib/mesh/live/daemon/doctor.d.ts +4 -0
- package/dist/lib/mesh/live/daemon/doctor.js +17 -2
- package/dist/lib/mesh/live/daemon/index.d.ts +4 -2
- package/dist/lib/mesh/live/daemon/index.js +3 -2
- package/dist/lib/mesh/live/daemon/install.d.ts +27 -0
- package/dist/lib/mesh/live/daemon/install.js +54 -4
- package/dist/lib/mesh/live/daemon/run.d.ts +6 -0
- package/dist/lib/mesh/live/daemon/run.js +44 -2
- package/dist/lib/mesh/live/daemon/version-drift.d.ts +5 -0
- package/dist/lib/mesh/live/daemon/version-drift.js +47 -0
- package/dist/lib/mesh/live/paths.d.ts +2 -0
- package/dist/lib/mesh/live/paths.js +4 -0
- package/dist/lib/work-context/reconcile.js +67 -6
- package/dist/lib/work-context/state.d.ts +5 -3
- package/dist/lib/work-context/state.js +28 -4
- package/dist/utils/self-update.d.ts +3 -0
- package/dist/utils/self-update.js +11 -0
- package/dist/utils/vault-api.js +106 -52
- package/package.json +1 -1
|
@@ -112,10 +112,12 @@ export declare function writeSessionState(state: SessionStateFile, root: string)
|
|
|
112
112
|
/** Strip any non-allowlisted fields before durable write. */
|
|
113
113
|
export declare function projectSessionState(state: SessionStateFile): SessionStateFile;
|
|
114
114
|
/**
|
|
115
|
-
* Merge prior local
|
|
116
|
-
*
|
|
115
|
+
* Merge prior local fields into a new state write so PostToolUse bumps and
|
|
116
|
+
* reconcile updates do not clobber an acknowledged company binding.
|
|
117
117
|
*/
|
|
118
|
-
export declare function mergeLocalSessionFields(next: SessionStateFile, prior: SessionStateFile | null
|
|
118
|
+
export declare function mergeLocalSessionFields(next: SessionStateFile, prior: SessionStateFile | null, options?: {
|
|
119
|
+
preserveBindingEpisode?: boolean;
|
|
120
|
+
}): SessionStateFile;
|
|
119
121
|
/**
|
|
120
122
|
* Upgrade the local projection after the server acknowledges a register.
|
|
121
123
|
*
|
|
@@ -103,13 +103,30 @@ export function projectSessionState(state) {
|
|
|
103
103
|
return out;
|
|
104
104
|
}
|
|
105
105
|
/**
|
|
106
|
-
* Merge prior local
|
|
107
|
-
*
|
|
106
|
+
* Merge prior local fields into a new state write so PostToolUse bumps and
|
|
107
|
+
* reconcile updates do not clobber an acknowledged company binding.
|
|
108
108
|
*/
|
|
109
|
-
export function mergeLocalSessionFields(next, prior) {
|
|
109
|
+
export function mergeLocalSessionFields(next, prior, options = {}) {
|
|
110
110
|
if (!prior)
|
|
111
111
|
return next;
|
|
112
112
|
const out = { ...next };
|
|
113
|
+
// Hook observations commonly carry only tool-write/session metadata. Those
|
|
114
|
+
// writes must not erase a company that an acknowledged register already
|
|
115
|
+
// projected locally; losing it causes held events and repeat registrations.
|
|
116
|
+
// A caller that carries either company field is intentionally changing the
|
|
117
|
+
// binding and remains authoritative.
|
|
118
|
+
if (!out.companyUid && !out.companySlug && (prior.companyUid || prior.companySlug)) {
|
|
119
|
+
out.companyUid = prior.companyUid;
|
|
120
|
+
out.companySlug = prior.companySlug;
|
|
121
|
+
out.contextStatus = prior.contextStatus;
|
|
122
|
+
if (!out.projectId && prior.projectId)
|
|
123
|
+
out.projectId = prior.projectId;
|
|
124
|
+
if (!out.taskId && prior.taskId)
|
|
125
|
+
out.taskId = prior.taskId;
|
|
126
|
+
if (out.version === undefined && prior.version !== undefined) {
|
|
127
|
+
out.version = prior.version;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
113
130
|
if (out.toolWrites === undefined && typeof prior.toolWrites === "number") {
|
|
114
131
|
out.toolWrites = prior.toolWrites;
|
|
115
132
|
}
|
|
@@ -131,7 +148,14 @@ export function mergeLocalSessionFields(next, prior) {
|
|
|
131
148
|
if (!out.transcriptMarker && prior.transcriptMarker) {
|
|
132
149
|
out.transcriptMarker = prior.transcriptMarker;
|
|
133
150
|
}
|
|
134
|
-
|
|
151
|
+
const sameBinding = prior.companyUid === out.companyUid &&
|
|
152
|
+
prior.companySlug === out.companySlug &&
|
|
153
|
+
prior.projectId === out.projectId &&
|
|
154
|
+
prior.taskId === out.taskId;
|
|
155
|
+
if (options.preserveBindingEpisode !== false &&
|
|
156
|
+
!out.bindingEpisodeId &&
|
|
157
|
+
prior.bindingEpisodeId &&
|
|
158
|
+
sameBinding) {
|
|
135
159
|
out.bindingEpisodeId = prior.bindingEpisodeId;
|
|
136
160
|
}
|
|
137
161
|
return out;
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
* version-check), `hq rescue --no-self-update`, and the re-exec guard env.
|
|
58
58
|
*/
|
|
59
59
|
import { type RunningInstall, type UpdateResult } from "./version-gate.js";
|
|
60
|
+
import { type RestartDaemonResult } from "../lib/mesh/live/daemon/install.js";
|
|
60
61
|
/**
|
|
61
62
|
* Set on the re-exec'd child so it can never self-update (and re-exec) again.
|
|
62
63
|
* One update + one re-exec per user invocation, ever.
|
|
@@ -153,6 +154,8 @@ export interface SelfUpdateDeps {
|
|
|
153
154
|
* underneath themselves: hooks, scripts, agents, cron, CI.
|
|
154
155
|
*/
|
|
155
156
|
interactive?: boolean;
|
|
157
|
+
/** Best-effort daemon kick after the CLI install converges (tests). */
|
|
158
|
+
restartDaemon?: () => RestartDaemonResult | void;
|
|
156
159
|
}
|
|
157
160
|
/**
|
|
158
161
|
* Startup path: the running CLI is behind npm `latest`, so update in place and
|
|
@@ -63,6 +63,7 @@ import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
|
|
|
63
63
|
import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, checkUpdateConvergence, isLocalDependencyInstall, isPrefixWritable, nonWritablePrefixNote, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, runSupervisedUpdateCommand, } from "./version-gate.js";
|
|
64
64
|
import { acquireUpdateLock as acquireSharedUpdateLock } from "./update-lock.js";
|
|
65
65
|
import { markLatestIneffective } from "./version-check.js";
|
|
66
|
+
import { restartDaemonServiceIfInstalled } from "../lib/mesh/live/daemon/install.js";
|
|
66
67
|
/**
|
|
67
68
|
* Set on the re-exec'd child so it can never self-update (and re-exec) again.
|
|
68
69
|
* One update + one re-exec per user invocation, ever.
|
|
@@ -278,6 +279,16 @@ async function updateAndReexec(argv, flavor, known, deps) {
|
|
|
278
279
|
console.error(chalk.dim(` Continuing the ${flavor.noun} on ${current}.`));
|
|
279
280
|
return { action: "update-ineffective", latest };
|
|
280
281
|
}
|
|
282
|
+
// A resident mesh daemon keeps the old JS loaded after a global install.
|
|
283
|
+
// Service restart is best-effort and cannot turn a successful update red.
|
|
284
|
+
try {
|
|
285
|
+
const restart = (deps.restartDaemon ?? restartDaemonServiceIfInstalled)();
|
|
286
|
+
if (restart?.message)
|
|
287
|
+
console.error(chalk.yellow(` ${restart.message}`));
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
// Defensive only; the default helper already absorbs manager failures.
|
|
291
|
+
}
|
|
281
292
|
const childEnv = { ...env, [REEXEC_GUARD_ENV]: "1" };
|
|
282
293
|
const status = (deps.reexec ?? reexecHq)([...argv.slice(2)], childEnv);
|
|
283
294
|
if (status === null) {
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -320,16 +320,37 @@ async function resolveCompanyByUid(token, uid) {
|
|
|
320
320
|
if (!res.ok) {
|
|
321
321
|
raiseIfUnauthorized(res);
|
|
322
322
|
const body = (await res.json().catch(() => ({})));
|
|
323
|
-
throw new Error(`Failed to resolve company '${uid}': ${body
|
|
323
|
+
throw new Error(`Failed to resolve company '${uid}': ${formatSlugLookupReason(res, body)}`);
|
|
324
324
|
}
|
|
325
325
|
const data = (await res.json());
|
|
326
326
|
return data.entity.uid;
|
|
327
327
|
}
|
|
328
|
+
/**
|
|
329
|
+
* Node's fetch (undici) often leaves `statusText` empty, and some vault
|
|
330
|
+
* error bodies send `error: ""` or omit it. `??` does not treat "" as missing,
|
|
331
|
+
* so `body.error ?? res.statusText` produced
|
|
332
|
+
* `Failed to resolve company slug '…':` with nothing after the colon
|
|
333
|
+
* (Shepherd #2306). Never return an empty reason: prefer a non-blank body
|
|
334
|
+
* error, then statusText, then `HTTP <status>`.
|
|
335
|
+
*/
|
|
336
|
+
function formatSlugLookupReason(res, body) {
|
|
337
|
+
const fromBody = typeof body.error === "string" ? body.error.trim() : "";
|
|
338
|
+
if (fromBody)
|
|
339
|
+
return fromBody;
|
|
340
|
+
const fromStatus = (res.statusText ?? "").trim();
|
|
341
|
+
if (fromStatus)
|
|
342
|
+
return fromStatus;
|
|
343
|
+
return `HTTP ${res.status}`;
|
|
344
|
+
}
|
|
345
|
+
function isTransientSlugLookupStatus(status) {
|
|
346
|
+
return status === 408 || status === 425 || status === 429 || status >= 500;
|
|
347
|
+
}
|
|
328
348
|
/**
|
|
329
349
|
* Resolve a company slug WITHIN THE CALLER'S NAMESPACE via the server's
|
|
330
350
|
* caller-scoped `/entity/check-slug/me` endpoint. Returns the caller's own
|
|
331
|
-
* company uid for that slug,
|
|
332
|
-
* to no company with that slug
|
|
351
|
+
* company uid for that slug, `miss` when the caller owns / actively belongs
|
|
352
|
+
* to no company with that slug, or `transient` when the lookup itself failed
|
|
353
|
+
* (timeout, 429, 5xx) after one retry.
|
|
333
354
|
*
|
|
334
355
|
* This is the load-bearing fix for DEV-1742. Under hq-pro's per-user-namespace
|
|
335
356
|
* model two unrelated tenants may each hold "acme", so the legacy GLOBAL
|
|
@@ -344,25 +365,63 @@ async function resolveCompanyByUid(token, uid) {
|
|
|
344
365
|
* the same primitive hq-cloud's `context.ts` already resolves slugs through.
|
|
345
366
|
*/
|
|
346
367
|
async function resolveSlugInCallerNamespace(token, slug) {
|
|
347
|
-
const
|
|
368
|
+
const attempt = async () => {
|
|
369
|
+
const res = await vaultApiFetch({
|
|
370
|
+
token,
|
|
371
|
+
path: "/entity/check-slug/me",
|
|
372
|
+
query: { type: "company", slug },
|
|
373
|
+
signal: AbortSignal.timeout(identityLookupTimeoutMs()),
|
|
374
|
+
});
|
|
375
|
+
if (!res.ok) {
|
|
376
|
+
raiseIfUnauthorized(res);
|
|
377
|
+
// Namespace lookup unavailable (e.g. membership table not configured →
|
|
378
|
+
// 503, or the caller has no person entity). A 401 short-circuits above
|
|
379
|
+
// because the token is bad and the global fallback would only 401 again.
|
|
380
|
+
// Transient statuses are retried once below; other non-2xx still mean
|
|
381
|
+
// "couldn't resolve here" and fall back to the global lookup.
|
|
382
|
+
// vaultApiFetch already recorded the non-2xx as a Sentry breadcrumb.
|
|
383
|
+
if (isTransientSlugLookupStatus(res.status)) {
|
|
384
|
+
return { kind: "transient" };
|
|
385
|
+
}
|
|
386
|
+
return { kind: "miss" };
|
|
387
|
+
}
|
|
388
|
+
const data = (await res.json());
|
|
389
|
+
// `available: false` carries the caller's own company holding the slug.
|
|
390
|
+
const uid = data.conflictingCompanyUid;
|
|
391
|
+
return uid ? { kind: "hit", uid } : { kind: "miss" };
|
|
392
|
+
};
|
|
393
|
+
const run = async () => {
|
|
394
|
+
try {
|
|
395
|
+
return await attempt();
|
|
396
|
+
}
|
|
397
|
+
catch (err) {
|
|
398
|
+
if (networkTransportErrorCode(err) === null)
|
|
399
|
+
throw err;
|
|
400
|
+
return { kind: "transient" };
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
const first = await run();
|
|
404
|
+
if (first.kind !== "transient")
|
|
405
|
+
return first;
|
|
406
|
+
return run();
|
|
407
|
+
}
|
|
408
|
+
async function fetchGlobalCompanyBySlug(token, ref) {
|
|
409
|
+
const attempt = () => vaultApiFetch({
|
|
348
410
|
token,
|
|
349
|
-
path:
|
|
350
|
-
query: { type: 'company', slug },
|
|
411
|
+
path: `/entity/by-slug/company/${encodeURIComponent(ref)}`,
|
|
351
412
|
signal: AbortSignal.timeout(identityLookupTimeoutMs()),
|
|
352
413
|
});
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
// because the token is bad and the global fallback would only 401 again;
|
|
358
|
-
// other non-2xx statuses signal "couldn't resolve here" and let the caller
|
|
359
|
-
// fall back to the global lookup. vaultApiFetch already recorded the
|
|
360
|
-
// non-2xx as a Sentry breadcrumb, so this is not a silent swallow.
|
|
361
|
-
return null;
|
|
414
|
+
try {
|
|
415
|
+
const res = await attempt();
|
|
416
|
+
if (res.ok || !isTransientSlugLookupStatus(res.status))
|
|
417
|
+
return res;
|
|
362
418
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
419
|
+
catch (err) {
|
|
420
|
+
if (networkTransportErrorCode(err) === null)
|
|
421
|
+
throw err;
|
|
422
|
+
return attempt();
|
|
423
|
+
}
|
|
424
|
+
return attempt();
|
|
366
425
|
}
|
|
367
426
|
async function resolveCompanyUid(token, ref) {
|
|
368
427
|
// Silent escape hatch: a `cmp_` ref is a uid, not a slug — resolve directly.
|
|
@@ -379,39 +438,25 @@ async function resolveCompanyUid(token, ref) {
|
|
|
379
438
|
// caller's OWN company (unique within their namespace by the invariant
|
|
380
439
|
// above), making a stranger's same-slug company invisible.
|
|
381
440
|
//
|
|
382
|
-
// A non-2xx already degrades to the global fallback below (
|
|
383
|
-
// A TRANSPORT failure must degrade the SAME way, not abort
|
|
384
|
-
// `resolveSlugInCallerNamespace`'s fetch is bounded by
|
|
385
|
-
// a vault gateway that accepts the connection but
|
|
386
|
-
// DOMException named `TimeoutError`.
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
try {
|
|
395
|
-
mine = await resolveSlugInCallerNamespace(token, ref);
|
|
441
|
+
// A non-2xx already degrades to the global fallback below (`miss` /
|
|
442
|
+
// `transient`). A TRANSPORT failure must degrade the SAME way, not abort
|
|
443
|
+
// the command: `resolveSlugInCallerNamespace`'s fetch is bounded by
|
|
444
|
+
// AbortSignal.timeout, so a vault gateway that accepts the connection but
|
|
445
|
+
// never answers rejects with a DOMException named `TimeoutError`. That is
|
|
446
|
+
// classified as `transient` (and retried once) inside the helper; a genuine
|
|
447
|
+
// hq-cli defect still throws, and the 401 AuthError that
|
|
448
|
+
// `raiseIfUnauthorized` throws still short-circuits before any fallback
|
|
449
|
+
// (Sentry HQ-CLI-1P / issue 7716090679).
|
|
450
|
+
const mine = await resolveSlugInCallerNamespace(token, ref);
|
|
451
|
+
if (mine.kind === "hit") {
|
|
452
|
+
return mine.uid;
|
|
396
453
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
if (mine) {
|
|
404
|
-
return mine;
|
|
405
|
-
}
|
|
406
|
-
// The caller owns / belongs to no company with this slug. Fall back to the
|
|
407
|
-
// GLOBAL by-slug lookup for the cross-company / admin resolutions that
|
|
408
|
-
// intentionally target a company the caller isn't a member of (e.g. a
|
|
409
|
-
// group-grant target). A single global match resolves cleanly.
|
|
410
|
-
const res = await vaultApiFetch({
|
|
411
|
-
token,
|
|
412
|
-
path: `/entity/by-slug/company/${encodeURIComponent(ref)}`,
|
|
413
|
-
signal: AbortSignal.timeout(identityLookupTimeoutMs()),
|
|
414
|
-
});
|
|
454
|
+
// The caller owns / belongs to no company with this slug, or the namespace
|
|
455
|
+
// lookup itself failed. Fall back to the GLOBAL by-slug lookup for the
|
|
456
|
+
// cross-company / admin resolutions that intentionally target a company the
|
|
457
|
+
// caller isn't a member of (e.g. a group-grant target). A single global
|
|
458
|
+
// match resolves cleanly.
|
|
459
|
+
const res = await fetchGlobalCompanyBySlug(token, ref);
|
|
415
460
|
if (!res.ok) {
|
|
416
461
|
raiseIfUnauthorized(res);
|
|
417
462
|
const body = (await res.json().catch(() => ({})));
|
|
@@ -425,10 +470,19 @@ async function resolveCompanyUid(token, ref) {
|
|
|
425
470
|
`is in your namespace. Re-run with --company <uid> to pick one:\n` +
|
|
426
471
|
body.uids.map((u) => ` --company ${u}`).join('\n'));
|
|
427
472
|
}
|
|
428
|
-
|
|
473
|
+
// Only a clean namespace miss plus a global 404 is "this slug does not
|
|
474
|
+
// exist". A transient namespace failure followed by 404 is still a lookup
|
|
475
|
+
// failure — treating it as not-found sent owners to check a slug that
|
|
476
|
+
// was fine (Shepherd #2306).
|
|
477
|
+
if (res.status === 404 && mine.kind === "miss") {
|
|
429
478
|
throw Object.assign(new Error(`Company slug '${ref}' was not found. Check the slug or run \`hq companies list\`.`), { expected: true });
|
|
430
479
|
}
|
|
431
|
-
|
|
480
|
+
const reason = formatSlugLookupReason(res, body);
|
|
481
|
+
const retryable = mine.kind === "transient" || isTransientSlugLookupStatus(res.status);
|
|
482
|
+
const hint = retryable
|
|
483
|
+
? " This is a temporary lookup failure, not a missing company. Retry the command."
|
|
484
|
+
: "";
|
|
485
|
+
throw new Error(`Failed to resolve company slug '${ref}': ${reason}.${hint}`);
|
|
432
486
|
}
|
|
433
487
|
const data = (await res.json());
|
|
434
488
|
return data.entity.uid;
|