@indigoai-us/hq-cli 5.343.0 → 5.344.0
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 +14 -0
- package/dist/command-catalog.generated.d.ts +15 -4
- package/dist/command-catalog.generated.js +19 -4
- package/dist/commands/cloud.js +2 -2
- package/dist/commands/daemon.js +2 -11
- package/dist/commands/dm.js +10 -1
- package/dist/commands/lanes-shared.d.ts +1 -0
- package/dist/commands/lanes.d.ts +1 -0
- package/dist/commands/lanes.js +11 -0
- package/dist/commands/login.js +12 -1
- package/dist/commands/logout.js +10 -0
- package/dist/commands/mesh.d.ts +27 -1
- package/dist/commands/mesh.js +123 -12
- package/dist/lib/agent-kit/mcp/tools.js +1 -1
- package/dist/lib/bot/api.d.ts +1 -0
- package/dist/lib/bot/api.js +2 -0
- package/dist/lib/bot/progress.d.ts +12 -14
- package/dist/lib/bot/progress.js +29 -40
- package/dist/lib/bot/run.js +24 -24
- package/dist/lib/core-utils/sentry-report.d.ts +1 -1
- package/dist/lib/core-utils/sentry-report.js +18 -1
- package/dist/lib/daemon/command-call.d.ts +9 -0
- package/dist/lib/daemon/command-call.js +18 -0
- package/dist/lib/lanes/outpost-dispatch.d.ts +1 -0
- package/dist/lib/lanes/outpost-dispatch.js +1 -0
- package/dist/lib/work-context/config.d.ts +66 -0
- package/dist/lib/work-context/config.js +142 -0
- package/dist/lib/work-context/person-identity.d.ts +16 -0
- package/dist/lib/work-context/person-identity.js +39 -0
- package/dist/utils/cli-telemetry.js +5 -1
- package/dist/utils/client-health.d.ts +7 -3
- package/dist/utils/client-health.js +30 -6
- package/dist/utils/install-tree-torn.d.ts +16 -13
- package/dist/utils/install-tree-torn.js +58 -26
- package/dist/utils/resident-package-preload.d.ts +1 -1
- package/dist/utils/resident-package-preload.js +18 -0
- package/package.json +1 -1
|
@@ -9,6 +9,10 @@ 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 person principal uid, as carried in the `custom:entityUid` token claim. */
|
|
13
|
+
export function isPersonUid(value) {
|
|
14
|
+
return typeof value === "string" && /^prs_[A-Za-z0-9]+$/.test(value);
|
|
15
|
+
}
|
|
12
16
|
const CONFIG_LOCK_TIMEOUT_MS = 5_000;
|
|
13
17
|
const CONFIG_LOCK_RETRY_MS = 10;
|
|
14
18
|
function waitForConfigLock(ms) {
|
|
@@ -146,6 +150,44 @@ function sanitizeRepoCompanyMap(raw) {
|
|
|
146
150
|
}
|
|
147
151
|
return Object.keys(out).length > 0 ? out : null;
|
|
148
152
|
}
|
|
153
|
+
/** Parse a raw usageAttribution value; anything malformed is dropped (fail closed). */
|
|
154
|
+
export function sanitizeUsageAttribution(raw) {
|
|
155
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
156
|
+
return undefined;
|
|
157
|
+
const v = raw;
|
|
158
|
+
if (v.mode !== "default-company")
|
|
159
|
+
return undefined;
|
|
160
|
+
const companyUid = typeof v.companyUid === "string" ? v.companyUid.trim() : "";
|
|
161
|
+
const personUid = typeof v.personUid === "string" ? v.personUid.trim() : "";
|
|
162
|
+
const optedInAt = typeof v.optedInAt === "string" ? v.optedInAt.trim() : "";
|
|
163
|
+
if (!companyUid.startsWith("cmp_") ||
|
|
164
|
+
!isPersonUid(personUid) ||
|
|
165
|
+
!optedInAt ||
|
|
166
|
+
!Number.isFinite(Date.parse(optedInAt))) {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
return { mode: "default-company", companyUid, personUid, optedInAt };
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The opt-in that is in effect: present, for the uid of the current enabled
|
|
173
|
+
* default company, and given by the signed-in person. Mirrors the hq-cloud
|
|
174
|
+
* reader. No signed-in person means no opt-in.
|
|
175
|
+
*/
|
|
176
|
+
export function effectiveUsageAttribution(cfg, personUid) {
|
|
177
|
+
const opt = cfg.usageAttribution;
|
|
178
|
+
if (!opt || !isPersonUid(personUid) || opt.personUid !== personUid)
|
|
179
|
+
return null;
|
|
180
|
+
const def = cfg.defaultCompany;
|
|
181
|
+
if (!opt || !def || def.enabled !== true || def.source === "disabled")
|
|
182
|
+
return null;
|
|
183
|
+
if (!def.uid || def.uid !== opt.companyUid)
|
|
184
|
+
return null;
|
|
185
|
+
return opt;
|
|
186
|
+
}
|
|
187
|
+
/** Keep `usageAttribution` next to `defaultCompany`, omitting it when absent. */
|
|
188
|
+
function usageAttributionField(value) {
|
|
189
|
+
return value ? { usageAttribution: value } : {};
|
|
190
|
+
}
|
|
149
191
|
/**
|
|
150
192
|
* Refuse a config path that is a symlink (every platform) or writable by
|
|
151
193
|
* anyone other than the owner.
|
|
@@ -216,6 +258,7 @@ export function readDeviceConfig(deps) {
|
|
|
216
258
|
defaultCompany: legacyNullDefault
|
|
217
259
|
? { slug: "", enabled: false, source: "disabled" }
|
|
218
260
|
: raw.defaultCompany ?? null,
|
|
261
|
+
...usageAttributionField(sanitizeUsageAttribution(raw.usageAttribution)),
|
|
219
262
|
migrationCapability: raw.migrationCapability ?? null,
|
|
220
263
|
repoCompanyMap: sanitizeRepoCompanyMap(raw.repoCompanyMap),
|
|
221
264
|
defaultCompanyMemberships: sanitizeMembershipSnapshot(raw.defaultCompanyMemberships),
|
|
@@ -287,9 +330,36 @@ export async function setDefaultCompany(slug, deps) {
|
|
|
287
330
|
};
|
|
288
331
|
if (resolvedUid)
|
|
289
332
|
defaultCompany.uid = resolvedUid;
|
|
333
|
+
const choice = deps.usageAttribution ?? "keep-if-same";
|
|
334
|
+
let usageAttribution;
|
|
335
|
+
const personUid = isPersonUid(deps.personUid) ? deps.personUid : undefined;
|
|
336
|
+
if (choice === "opt-in") {
|
|
337
|
+
if (!resolvedUid?.startsWith("cmp_")) {
|
|
338
|
+
throw new WorkContextError("UsageAttributionUnavailable", `Cannot count usage toward "${trimmed}": its company uid is unknown`);
|
|
339
|
+
}
|
|
340
|
+
if (!personUid) {
|
|
341
|
+
throw new WorkContextError("UsageAttributionUnavailable", `Cannot count usage toward "${trimmed}": the signed-in person could not be determined`);
|
|
342
|
+
}
|
|
343
|
+
usageAttribution = {
|
|
344
|
+
mode: "default-company",
|
|
345
|
+
companyUid: resolvedUid,
|
|
346
|
+
personUid,
|
|
347
|
+
optedInAt: now().toISOString(),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
else if (choice === "keep-if-same") {
|
|
351
|
+
usageAttribution =
|
|
352
|
+
resolvedUid &&
|
|
353
|
+
personUid &&
|
|
354
|
+
prior.usageAttribution?.companyUid === resolvedUid &&
|
|
355
|
+
prior.usageAttribution.personUid === personUid
|
|
356
|
+
? prior.usageAttribution
|
|
357
|
+
: undefined;
|
|
358
|
+
}
|
|
290
359
|
const next = {
|
|
291
360
|
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
292
361
|
defaultCompany,
|
|
362
|
+
...usageAttributionField(usageAttribution),
|
|
293
363
|
migrationCapability: prior.migrationCapability ?? null,
|
|
294
364
|
repoCompanyMap: prior.repoCompanyMap ?? null,
|
|
295
365
|
defaultCompanyMemberships: prior.defaultCompanyMemberships ?? null,
|
|
@@ -300,6 +370,69 @@ export async function setDefaultCompany(slug, deps) {
|
|
|
300
370
|
return next;
|
|
301
371
|
});
|
|
302
372
|
}
|
|
373
|
+
/**
|
|
374
|
+
* Record or remove the usage-attribution opt-in for the current default
|
|
375
|
+
* company. Opting in requires an enabled, explicitly chosen default with a
|
|
376
|
+
* cmp_ uid that equals `companyUid` (the company the person was asked about);
|
|
377
|
+
* an automatic single-company default is never opted in.
|
|
378
|
+
*/
|
|
379
|
+
export function setUsageAttribution(optIn, deps) {
|
|
380
|
+
const now = deps.now ?? (() => new Date());
|
|
381
|
+
return withDeviceConfigLock(deps.root, () => {
|
|
382
|
+
const prior = readDeviceConfig(deps);
|
|
383
|
+
let usageAttribution;
|
|
384
|
+
if (optIn) {
|
|
385
|
+
const def = prior.defaultCompany;
|
|
386
|
+
if (!def ||
|
|
387
|
+
def.enabled !== true ||
|
|
388
|
+
def.source !== "chosen" ||
|
|
389
|
+
!def.uid?.startsWith("cmp_") ||
|
|
390
|
+
def.uid !== optIn.companyUid ||
|
|
391
|
+
!isPersonUid(optIn.personUid)) {
|
|
392
|
+
throw new WorkContextError("UsageAttributionUnavailable", "Usage can only be counted toward a default company you chose on this device");
|
|
393
|
+
}
|
|
394
|
+
usageAttribution = {
|
|
395
|
+
mode: "default-company",
|
|
396
|
+
companyUid: def.uid,
|
|
397
|
+
personUid: optIn.personUid,
|
|
398
|
+
optedInAt: now().toISOString(),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
const next = {
|
|
402
|
+
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
403
|
+
defaultCompany: prior.defaultCompany ?? null,
|
|
404
|
+
...usageAttributionField(usageAttribution),
|
|
405
|
+
migrationCapability: prior.migrationCapability ?? null,
|
|
406
|
+
repoCompanyMap: prior.repoCompanyMap ?? null,
|
|
407
|
+
defaultCompanyMemberships: prior.defaultCompanyMemberships ?? null,
|
|
408
|
+
repairHeldWithDefault: prior.repairHeldWithDefault === true,
|
|
409
|
+
updatedAt: now().toISOString(),
|
|
410
|
+
};
|
|
411
|
+
writeDeviceConfig({ root: deps.root }, next);
|
|
412
|
+
return next;
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Drop the usage-attribution opt-in unless it was given by `personUid`. Called
|
|
417
|
+
* on login (identity may have changed) and logout (null: always drop). A
|
|
418
|
+
* config with no opt-in, or one already matching, is left untouched.
|
|
419
|
+
*/
|
|
420
|
+
export function retainUsageAttributionForPerson(personUid, deps) {
|
|
421
|
+
if (!fs.existsSync(workContextConfigPath(deps.root)))
|
|
422
|
+
return false;
|
|
423
|
+
return withDeviceConfigLock(deps.root, () => {
|
|
424
|
+
const prior = readDeviceConfig(deps);
|
|
425
|
+
const opt = prior.usageAttribution;
|
|
426
|
+
if (!opt)
|
|
427
|
+
return false;
|
|
428
|
+
if (isPersonUid(personUid) && opt.personUid === personUid)
|
|
429
|
+
return false;
|
|
430
|
+
const now = deps.now ?? (() => new Date());
|
|
431
|
+
const { usageAttribution: _dropped, ...rest } = prior;
|
|
432
|
+
writeDeviceConfig({ root: deps.root }, { ...rest, updatedAt: now().toISOString() });
|
|
433
|
+
return true;
|
|
434
|
+
});
|
|
435
|
+
}
|
|
303
436
|
export function clearDefaultCompany(deps) {
|
|
304
437
|
const now = deps.now ?? (() => new Date());
|
|
305
438
|
return withDeviceConfigLock(deps.root, () => {
|
|
@@ -307,6 +440,8 @@ export function clearDefaultCompany(deps) {
|
|
|
307
440
|
const next = {
|
|
308
441
|
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
309
442
|
defaultCompany: { slug: "", enabled: false, source: "disabled" },
|
|
443
|
+
// usageAttribution is intentionally omitted: clearing the default also
|
|
444
|
+
// withdraws the opt-in to count usage toward it.
|
|
310
445
|
migrationCapability: prior.migrationCapability ?? null,
|
|
311
446
|
repoCompanyMap: prior.repoCompanyMap ?? null,
|
|
312
447
|
defaultCompanyMemberships: prior.defaultCompanyMemberships ?? null,
|
|
@@ -355,6 +490,7 @@ export function setRepoCompany(key, mapping, deps) {
|
|
|
355
490
|
const next = {
|
|
356
491
|
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
357
492
|
defaultCompany: prior.defaultCompany ?? null,
|
|
493
|
+
...usageAttributionField(prior.usageAttribution),
|
|
358
494
|
migrationCapability: prior.migrationCapability ?? null,
|
|
359
495
|
repoCompanyMap: nextMap,
|
|
360
496
|
defaultCompanyMemberships: prior.defaultCompanyMemberships ?? null,
|
|
@@ -373,6 +509,7 @@ export function recordMigrationCapabilitySnapshot(snapshot, deps) {
|
|
|
373
509
|
const next = {
|
|
374
510
|
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
375
511
|
defaultCompany: prior.defaultCompany ?? null,
|
|
512
|
+
...usageAttributionField(prior.usageAttribution),
|
|
376
513
|
migrationCapability: {
|
|
377
514
|
unlocked: snapshot.unlocked,
|
|
378
515
|
offline: snapshot.offline,
|
|
@@ -414,6 +551,7 @@ export function recordDefaultCompanyMemberships(snapshot, deps) {
|
|
|
414
551
|
const next = {
|
|
415
552
|
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
416
553
|
defaultCompany: prior.defaultCompany ?? null,
|
|
554
|
+
...usageAttributionField(prior.usageAttribution),
|
|
417
555
|
migrationCapability: prior.migrationCapability ?? null,
|
|
418
556
|
repoCompanyMap: prior.repoCompanyMap ?? null,
|
|
419
557
|
defaultCompanyMemberships: {
|
|
@@ -435,6 +573,7 @@ export function invalidateDefaultCompanyMemberships(deps) {
|
|
|
435
573
|
const next = {
|
|
436
574
|
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
437
575
|
defaultCompany: prior.defaultCompany ?? null,
|
|
576
|
+
...usageAttributionField(prior.usageAttribution),
|
|
438
577
|
migrationCapability: prior.migrationCapability ?? null,
|
|
439
578
|
repoCompanyMap: prior.repoCompanyMap ?? null,
|
|
440
579
|
defaultCompanyMemberships: null,
|
|
@@ -468,6 +607,8 @@ export function setAutoDefaultCompany(membership, deps) {
|
|
|
468
607
|
enabled: true,
|
|
469
608
|
source: "auto_single",
|
|
470
609
|
},
|
|
610
|
+
// usageAttribution is intentionally omitted: an automatic default is
|
|
611
|
+
// never opted in to usage attribution.
|
|
471
612
|
migrationCapability: prior.migrationCapability ?? null,
|
|
472
613
|
repoCompanyMap: prior.repoCompanyMap ?? null,
|
|
473
614
|
defaultCompanyMemberships: prior.defaultCompanyMemberships ?? null,
|
|
@@ -485,6 +626,7 @@ export function setRepairHeldWithDefault(enabled, deps) {
|
|
|
485
626
|
const next = {
|
|
486
627
|
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
487
628
|
defaultCompany: prior.defaultCompany ?? null,
|
|
629
|
+
...usageAttributionField(prior.usageAttribution),
|
|
488
630
|
migrationCapability: prior.migrationCapability ?? null,
|
|
489
631
|
repoCompanyMap: prior.repoCompanyMap ?? null,
|
|
490
632
|
defaultCompanyMemberships: prior.defaultCompanyMemberships ?? null,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signed-in person identity for the usage-attribution opt-in.
|
|
3
|
+
*
|
|
4
|
+
* Source: the cached Cognito person session (`~/.hq/cognito-tokens.json`,
|
|
5
|
+
* read through `loadCachedTokens`). The person uid is the `custom:entityUid`
|
|
6
|
+
* claim of the ID token (falling back to the access token), and only a
|
|
7
|
+
* `prs_` value counts. Agent (`agt_`) or unknown identities return null, so
|
|
8
|
+
* no opt-in is written or kept for them. Claims are read without
|
|
9
|
+
* verification; this decides only whether local consent belongs to the
|
|
10
|
+
* account that is signed in, never authorization.
|
|
11
|
+
*/
|
|
12
|
+
export declare function personUidFromTokens(tokens: {
|
|
13
|
+
idToken?: string;
|
|
14
|
+
accessToken?: string;
|
|
15
|
+
} | null | undefined): string | null;
|
|
16
|
+
//# sourceMappingURL=person-identity.d.ts.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signed-in person identity for the usage-attribution opt-in.
|
|
3
|
+
*
|
|
4
|
+
* Source: the cached Cognito person session (`~/.hq/cognito-tokens.json`,
|
|
5
|
+
* read through `loadCachedTokens`). The person uid is the `custom:entityUid`
|
|
6
|
+
* claim of the ID token (falling back to the access token), and only a
|
|
7
|
+
* `prs_` value counts. Agent (`agt_`) or unknown identities return null, so
|
|
8
|
+
* no opt-in is written or kept for them. Claims are read without
|
|
9
|
+
* verification; this decides only whether local consent belongs to the
|
|
10
|
+
* account that is signed in, never authorization.
|
|
11
|
+
*/
|
|
12
|
+
import { isPersonUid } from "./config.js";
|
|
13
|
+
function claimsOf(token) {
|
|
14
|
+
if (typeof token !== "string")
|
|
15
|
+
return null;
|
|
16
|
+
const part = token.split(".")[1];
|
|
17
|
+
if (!part)
|
|
18
|
+
return null;
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
|
|
21
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
22
|
+
? parsed
|
|
23
|
+
: null;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function personUidFromTokens(tokens) {
|
|
30
|
+
if (!tokens)
|
|
31
|
+
return null;
|
|
32
|
+
for (const token of [tokens.idToken, tokens.accessToken]) {
|
|
33
|
+
const uid = claimsOf(token)?.["custom:entityUid"];
|
|
34
|
+
if (isPersonUid(uid))
|
|
35
|
+
return uid;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=person-identity.js.map
|
|
@@ -186,7 +186,11 @@ export async function emitCliFeatureUsed(usage, timeoutMs = TELEMETRY_TIMEOUT_MS
|
|
|
186
186
|
consentBasis,
|
|
187
187
|
schemaVersion: 1,
|
|
188
188
|
...(companyUid ? { companyUid } : {}),
|
|
189
|
-
properties: {
|
|
189
|
+
properties: {
|
|
190
|
+
kind: usage.kind,
|
|
191
|
+
outcome: usage.outcome,
|
|
192
|
+
version: CLI_VERSION.slice(0, 40),
|
|
193
|
+
},
|
|
190
194
|
},
|
|
191
195
|
],
|
|
192
196
|
},
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
*/
|
|
33
33
|
import { isExpiring, loadCachedTokens } from "./cognito-session.js";
|
|
34
34
|
import { type VersionInfo } from "./feedback-versions.js";
|
|
35
|
-
import { type ClientHealthArch, type ClientHealthHeartbeat, type ClientHealthLocalFilesOverview, type ClientHealthPlatform } from "./client-health-contract.js";
|
|
35
|
+
import { type ClientHealthArch, type ClientHealthFailureReason, type ClientHealthHeartbeat, type ClientHealthLocalFilesOverview, type ClientHealthPlatform } from "./client-health-contract.js";
|
|
36
36
|
export declare const CLIENT_HEALTH_STATE_FILE = "cli-client-health.json";
|
|
37
37
|
/**
|
|
38
38
|
* Single-flight marker for the journal observation below. Held only for the
|
|
@@ -141,6 +141,8 @@ export interface BuildCliHeartbeatInput {
|
|
|
141
141
|
now: Date;
|
|
142
142
|
platform?: ClientHealthPlatform | null;
|
|
143
143
|
arch?: ClientHealthArch | null;
|
|
144
|
+
/** Closed, typed reason from the terminal sync runner; never free-form text. */
|
|
145
|
+
failureReason?: ClientHealthFailureReason;
|
|
144
146
|
/**
|
|
145
147
|
* Bounded local log/journal file facts (US-016). Only ever attached for
|
|
146
148
|
* `sync_success` / `sync_failure` kinds — support asked for this on the
|
|
@@ -148,6 +150,8 @@ export interface BuildCliHeartbeatInput {
|
|
|
148
150
|
*/
|
|
149
151
|
localFilesOverview?: ClientHealthLocalFilesOverview | null;
|
|
150
152
|
}
|
|
153
|
+
/** Classify sync failures using typed error identity/code fields only. */
|
|
154
|
+
export declare function classifySyncFailure(err: unknown): ClientHealthFailureReason;
|
|
151
155
|
/**
|
|
152
156
|
* Build one contract-valid CLI heartbeat, or null when this environment
|
|
153
157
|
* cannot be represented in the closed contract enums (unknown platform/arch)
|
|
@@ -184,7 +188,7 @@ export interface ClientHealthDeps {
|
|
|
184
188
|
* ignores its abort signal) must never delay a command past the bound. On
|
|
185
189
|
* timeout the pending work is abandoned silently.
|
|
186
190
|
*/
|
|
187
|
-
export declare function contributeClientHealth(kind: ClientHealthEventKind, deps?: ClientHealthDeps): Promise<void>;
|
|
191
|
+
export declare function contributeClientHealth(kind: ClientHealthEventKind, deps?: ClientHealthDeps, failureReason?: ClientHealthFailureReason): Promise<void>;
|
|
188
192
|
/**
|
|
189
193
|
* Fire-once-per-process invocation heartbeat (commander preAction seam, next
|
|
190
194
|
* to `emitCliSessionStarted`). Reports the real CLI + Core versions, platform,
|
|
@@ -194,7 +198,7 @@ export declare function reportCliClientHealthInvocation(deps?: ClientHealthDeps)
|
|
|
194
198
|
export interface SyncHealthReport {
|
|
195
199
|
/** Report the terminal outcome. Bounded, swallowed, safe to await. */
|
|
196
200
|
succeeded(): Promise<void>;
|
|
197
|
-
failed(): Promise<void>;
|
|
201
|
+
failed(reason?: ClientHealthFailureReason): Promise<void>;
|
|
198
202
|
}
|
|
199
203
|
/**
|
|
200
204
|
* Report a CLI sync command run: an attempt heartbeat now (fire-and-forget)
|
|
@@ -37,6 +37,7 @@ import { getStateDir, listJournals } from "@indigoai-us/hq-cloud";
|
|
|
37
37
|
import { isExpiring, isMachineIdentity, loadCachedTokens, } from "./cognito-session.js";
|
|
38
38
|
import { vaultApiFetch } from "./vault-api.js";
|
|
39
39
|
import { collectVersions } from "./feedback-versions.js";
|
|
40
|
+
import { isAuthError } from "./auth-error.js";
|
|
40
41
|
import { CLIENT_HEALTH_CONTRACT_VERSION, parseClientHealthHeartbeat, } from "./client-health-contract.js";
|
|
41
42
|
import { collectLocalFilesOverview } from "./local-files-overview.js";
|
|
42
43
|
/** Same bound as CLI telemetry: a heartbeat may never stall a command. */
|
|
@@ -314,6 +315,25 @@ function acquireJournalObservationLock(stateDir, nowMs, staleMs) {
|
|
|
314
315
|
}
|
|
315
316
|
};
|
|
316
317
|
}
|
|
318
|
+
/** Classify sync failures using typed error identity/code fields only. */
|
|
319
|
+
export function classifySyncFailure(err) {
|
|
320
|
+
if (isAuthError(err))
|
|
321
|
+
return "AUTH_EXPIRED";
|
|
322
|
+
let current = err;
|
|
323
|
+
for (let depth = 0; depth < 3; depth += 1) {
|
|
324
|
+
if (current === null || typeof current !== "object")
|
|
325
|
+
break;
|
|
326
|
+
const value = current;
|
|
327
|
+
if (value.code === "ENOSPC" || value.code === "EDQUOT") {
|
|
328
|
+
return "DISK_FULL";
|
|
329
|
+
}
|
|
330
|
+
if (value.code === "EACCES" || value.code === "EPERM") {
|
|
331
|
+
return "PERMISSION_DENIED";
|
|
332
|
+
}
|
|
333
|
+
current = value.cause;
|
|
334
|
+
}
|
|
335
|
+
return "RUNNER_FAILED";
|
|
336
|
+
}
|
|
317
337
|
/**
|
|
318
338
|
* Build one contract-valid CLI heartbeat, or null when this environment
|
|
319
339
|
* cannot be represented in the closed contract enums (unknown platform/arch)
|
|
@@ -388,6 +408,9 @@ export function buildCliHeartbeat(input) {
|
|
|
388
408
|
if (input.state.lastSyncAttemptAt !== undefined) {
|
|
389
409
|
heartbeat.lastSyncAttemptAt = input.state.lastSyncAttemptAt;
|
|
390
410
|
}
|
|
411
|
+
if (input.kind === "sync_failure" && input.failureReason !== undefined) {
|
|
412
|
+
heartbeat.failureReason = input.failureReason;
|
|
413
|
+
}
|
|
391
414
|
if (lastSyncSuccessAt !== null) {
|
|
392
415
|
heartbeat.lastSyncSuccessAt = lastSyncSuccessAt;
|
|
393
416
|
}
|
|
@@ -435,10 +458,10 @@ function settleWithin(work, ms) {
|
|
|
435
458
|
* ignores its abort signal) must never delay a command past the bound. On
|
|
436
459
|
* timeout the pending work is abandoned silently.
|
|
437
460
|
*/
|
|
438
|
-
export async function contributeClientHealth(kind, deps = {}) {
|
|
439
|
-
await settleWithin(contributeClientHealthUnbounded(kind, deps), deps.timeoutMs ?? CLIENT_HEALTH_TIMEOUT_MS);
|
|
461
|
+
export async function contributeClientHealth(kind, deps = {}, failureReason) {
|
|
462
|
+
await settleWithin(contributeClientHealthUnbounded(kind, deps, failureReason), deps.timeoutMs ?? CLIENT_HEALTH_TIMEOUT_MS);
|
|
440
463
|
}
|
|
441
|
-
async function contributeClientHealthUnbounded(kind, deps) {
|
|
464
|
+
async function contributeClientHealthUnbounded(kind, deps, failureReason) {
|
|
442
465
|
try {
|
|
443
466
|
const now = (deps.now ?? (() => new Date()))();
|
|
444
467
|
const stateDir = (deps.stateDir ?? getStateDir)();
|
|
@@ -546,6 +569,7 @@ async function contributeClientHealthUnbounded(kind, deps) {
|
|
|
546
569
|
journalLastSyncAt,
|
|
547
570
|
now,
|
|
548
571
|
localFilesOverview,
|
|
572
|
+
failureReason,
|
|
549
573
|
});
|
|
550
574
|
if (!heartbeat)
|
|
551
575
|
return;
|
|
@@ -587,12 +611,12 @@ export function reportCliClientHealthInvocation(deps) {
|
|
|
587
611
|
*/
|
|
588
612
|
export function beginSyncHealthReport(deps = {}) {
|
|
589
613
|
const attempt = contributeClientHealth("sync_attempt", deps);
|
|
590
|
-
const finish = (kind) => attempt
|
|
591
|
-
.then(() => contributeClientHealth(kind, deps))
|
|
614
|
+
const finish = (kind, failureReason) => attempt
|
|
615
|
+
.then(() => contributeClientHealth(kind, deps, failureReason))
|
|
592
616
|
.catch(() => undefined);
|
|
593
617
|
return {
|
|
594
618
|
succeeded: () => finish("sync_success"),
|
|
595
|
-
failed: () => finish("sync_failure"),
|
|
619
|
+
failed: (reason) => finish("sync_failure", reason),
|
|
596
620
|
};
|
|
597
621
|
}
|
|
598
622
|
//# sourceMappingURL=client-health.js.map
|
|
@@ -278,7 +278,8 @@ type VersionGatePhase = "preflight" | "lock_acquired" | "installer_running" | "v
|
|
|
278
278
|
/**
|
|
279
279
|
* Poll until the install tree has been continuously READY for `quietMs`, or the
|
|
280
280
|
* wait exhausts its bounds. READY means: (i) no FRESH update lock held by another
|
|
281
|
-
* pid
|
|
281
|
+
* pid and no live recorded updater while the tree is in flight, (ii) the missing
|
|
282
|
+
* target is present, and — when `packageRoot` is known —
|
|
282
283
|
* (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and (iv) no
|
|
283
284
|
* `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem error makes
|
|
284
285
|
* its condition "not ready" rather than throwing.
|
|
@@ -295,20 +296,22 @@ type VersionGatePhase = "preflight" | "lock_acquired" | "installer_running" | "v
|
|
|
295
296
|
* any installer before attempting a repair. Any observed signature change
|
|
296
297
|
* keeps the ordinary settle deadline in force. `deadlineMs === 0` evaluates
|
|
297
298
|
* readiness once.
|
|
298
|
-
* -
|
|
299
|
-
* pid
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
* Each poll's REAL elapsed interval (now() deltas) is attributed to the
|
|
306
|
-
* unlocked bucket by whether a fresh
|
|
307
|
-
* began, so a starved event loop only makes the wait coarser,
|
|
299
|
+
* - WRITER time — wall time observed under a fresh foreign lock, or while a
|
|
300
|
+
* recorded foreign updater pid is alive and the tree has an in-flight signal
|
|
301
|
+
* — is bounded SEPARATELY by `lockWaitMs` (default the lock contract's own
|
|
302
|
+
* 10-minute staleness window) and does NOT consume the settle budget. This
|
|
303
|
+
* also covers a still-running updater whose lock became stale by age. When
|
|
304
|
+
* that ceiling is reached, the result is unsettled; `lockHeldAtEnd` remains
|
|
305
|
+
* reserved for a fresh lock held at the end.
|
|
306
|
+
* Each poll's REAL elapsed interval (now() deltas) is attributed to the writer or
|
|
307
|
+
* unlocked bucket by whether a fresh lock or qualifying live updater was observed
|
|
308
|
+
* when the interval began, so a starved event loop only makes the wait coarser,
|
|
309
|
+
* never unbounded. A
|
|
308
310
|
* stale lock (dead pid, older than the staleness window, unparseable) or our own
|
|
309
|
-
* pid never extends anything. The whole wait is bounded (≤ `deadlineMs +
|
|
311
|
+
* pid never extends anything by itself. The whole wait is bounded (≤ `deadlineMs +
|
|
310
312
|
* lockWaitMs`), and the unchanged-tree path normally returns after the much
|
|
311
|
-
* shorter `staleProbeMs` observation window
|
|
313
|
+
* shorter `staleProbeMs` observation window unless a live updater pid is still
|
|
314
|
+
* present with an in-flight tree signal.
|
|
312
315
|
*/
|
|
313
316
|
export declare function waitForInstallTreeSettled(args: WaitForInstallTreeSettledArgs): Promise<WaitForInstallTreeSettledResult>;
|
|
314
317
|
export interface InstallRewriteSignalArgs {
|
|
@@ -714,6 +714,28 @@ function updaterPidAlive(lockPath, fs, isPidAlive) {
|
|
|
714
714
|
return "unknown";
|
|
715
715
|
}
|
|
716
716
|
}
|
|
717
|
+
/** Whether the lock names a live process other than this CLI invocation. */
|
|
718
|
+
function liveForeignUpdaterPid(lockPath, fs, isPidAlive) {
|
|
719
|
+
try {
|
|
720
|
+
const lock = JSON.parse(fs.readFileSync(lockPath, "utf-8"));
|
|
721
|
+
if (typeof lock.startedAt !== "string" ||
|
|
722
|
+
Number.isNaN(Date.parse(lock.startedAt)) ||
|
|
723
|
+
typeof lock.tool !== "string" ||
|
|
724
|
+
lock.tool.length === 0 ||
|
|
725
|
+
typeof lock.version !== "string" ||
|
|
726
|
+
lock.version.length === 0) {
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
729
|
+
return typeof lock.pid === "number" &&
|
|
730
|
+
Number.isSafeInteger(lock.pid) &&
|
|
731
|
+
lock.pid > 0 &&
|
|
732
|
+
lock.pid !== process.pid &&
|
|
733
|
+
isPidAlive(lock.pid);
|
|
734
|
+
}
|
|
735
|
+
catch {
|
|
736
|
+
return false;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
717
739
|
/** Snapshot retired sibling trees so changes below their root stay observable. */
|
|
718
740
|
function retiredSiblingActivitySignature(packageRoot, fs) {
|
|
719
741
|
const { leaf } = cliNameParts();
|
|
@@ -917,7 +939,8 @@ const DEFAULT_INSTALL_TREE_STALE_PROBE_MS = 1_500;
|
|
|
917
939
|
/**
|
|
918
940
|
* Poll until the install tree has been continuously READY for `quietMs`, or the
|
|
919
941
|
* wait exhausts its bounds. READY means: (i) no FRESH update lock held by another
|
|
920
|
-
* pid
|
|
942
|
+
* pid and no live recorded updater while the tree is in flight, (ii) the missing
|
|
943
|
+
* target is present, and — when `packageRoot` is known —
|
|
921
944
|
* (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and (iv) no
|
|
922
945
|
* `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem error makes
|
|
923
946
|
* its condition "not ready" rather than throwing.
|
|
@@ -934,20 +957,22 @@ const DEFAULT_INSTALL_TREE_STALE_PROBE_MS = 1_500;
|
|
|
934
957
|
* any installer before attempting a repair. Any observed signature change
|
|
935
958
|
* keeps the ordinary settle deadline in force. `deadlineMs === 0` evaluates
|
|
936
959
|
* readiness once.
|
|
937
|
-
* -
|
|
938
|
-
* pid
|
|
939
|
-
*
|
|
940
|
-
*
|
|
941
|
-
*
|
|
942
|
-
*
|
|
943
|
-
*
|
|
944
|
-
* Each poll's REAL elapsed interval (now() deltas) is attributed to the
|
|
945
|
-
* unlocked bucket by whether a fresh
|
|
946
|
-
* began, so a starved event loop only makes the wait coarser,
|
|
960
|
+
* - WRITER time — wall time observed under a fresh foreign lock, or while a
|
|
961
|
+
* recorded foreign updater pid is alive and the tree has an in-flight signal
|
|
962
|
+
* — is bounded SEPARATELY by `lockWaitMs` (default the lock contract's own
|
|
963
|
+
* 10-minute staleness window) and does NOT consume the settle budget. This
|
|
964
|
+
* also covers a still-running updater whose lock became stale by age. When
|
|
965
|
+
* that ceiling is reached, the result is unsettled; `lockHeldAtEnd` remains
|
|
966
|
+
* reserved for a fresh lock held at the end.
|
|
967
|
+
* Each poll's REAL elapsed interval (now() deltas) is attributed to the writer or
|
|
968
|
+
* unlocked bucket by whether a fresh lock or qualifying live updater was observed
|
|
969
|
+
* when the interval began, so a starved event loop only makes the wait coarser,
|
|
970
|
+
* never unbounded. A
|
|
947
971
|
* stale lock (dead pid, older than the staleness window, unparseable) or our own
|
|
948
|
-
* pid never extends anything. The whole wait is bounded (≤ `deadlineMs +
|
|
972
|
+
* pid never extends anything by itself. The whole wait is bounded (≤ `deadlineMs +
|
|
949
973
|
* lockWaitMs`), and the unchanged-tree path normally returns after the much
|
|
950
|
-
* shorter `staleProbeMs` observation window
|
|
974
|
+
* shorter `staleProbeMs` observation window unless a live updater pid is still
|
|
975
|
+
* present with an in-flight tree signal.
|
|
951
976
|
*/
|
|
952
977
|
export async function waitForInstallTreeSettled(args) {
|
|
953
978
|
const now = args.now ?? Date.now;
|
|
@@ -967,17 +992,21 @@ export async function waitForInstallTreeSettled(args) {
|
|
|
967
992
|
let sawLock = false;
|
|
968
993
|
let sawRetired = false;
|
|
969
994
|
let lockedMs = 0;
|
|
995
|
+
let writerMs = 0;
|
|
970
996
|
let unlockedMs = 0;
|
|
971
997
|
let lockTool = "";
|
|
972
998
|
let prevT = start;
|
|
973
999
|
let prevForeignLockHeld = false;
|
|
1000
|
+
let prevWriterHeld = false;
|
|
974
1001
|
let lockExtendedFired = false;
|
|
975
1002
|
for (;;) {
|
|
976
1003
|
const t = now();
|
|
977
|
-
// Attribute the interval that just elapsed [prevT, t)
|
|
978
|
-
//
|
|
1004
|
+
// Attribute the interval that just elapsed [prevT, t) using the state that
|
|
1005
|
+
// held when it BEGAN (real now() deltas, never nominal pollMs).
|
|
979
1006
|
if (prevForeignLockHeld)
|
|
980
1007
|
lockedMs += t - prevT;
|
|
1008
|
+
if (prevWriterHeld)
|
|
1009
|
+
writerMs += t - prevT;
|
|
981
1010
|
else
|
|
982
1011
|
unlockedMs += t - prevT;
|
|
983
1012
|
const lock = freshForeignLock(args.lockPath, t, isPidAlive, fs);
|
|
@@ -1015,7 +1044,10 @@ export async function waitForInstallTreeSettled(args) {
|
|
|
1015
1044
|
retiredCheckFailed = true;
|
|
1016
1045
|
}
|
|
1017
1046
|
}
|
|
1018
|
-
|
|
1047
|
+
const inFlightTree = sawLock || sawRetired || sawTreeChange;
|
|
1048
|
+
const updaterPidHeld = inFlightTree && liveForeignUpdaterPid(args.lockPath, fs, isPidAlive);
|
|
1049
|
+
const writerHeld = foreignLockHeld || updaterPidHeld;
|
|
1050
|
+
let ready = !writerHeld && installTargetPresent(args.target, fs);
|
|
1019
1051
|
if (ready && args.packageRoot) {
|
|
1020
1052
|
try {
|
|
1021
1053
|
if (!packageRootHealthy(args.packageRoot, fs))
|
|
@@ -1055,6 +1087,7 @@ export async function waitForInstallTreeSettled(args) {
|
|
|
1055
1087
|
}
|
|
1056
1088
|
if (!ready &&
|
|
1057
1089
|
!foreignLockHeld &&
|
|
1090
|
+
!updaterPidHeld &&
|
|
1058
1091
|
!retiredCheckFailed &&
|
|
1059
1092
|
!sawLock &&
|
|
1060
1093
|
!sawTreeChange &&
|
|
@@ -1078,14 +1111,13 @@ export async function waitForInstallTreeSettled(args) {
|
|
|
1078
1111
|
};
|
|
1079
1112
|
}
|
|
1080
1113
|
// Give up when the TREE has had a full unlocked budget with no writer visible,
|
|
1081
|
-
// OR a writer
|
|
1082
|
-
// ceiling
|
|
1083
|
-
// ceiling of 0
|
|
1084
|
-
//
|
|
1085
|
-
// deadlineMs settle when no lock exists — `lockedMs` is 0 ≥ 0 on every poll.
|
|
1114
|
+
// OR a qualifying writer has exceeded the shared writer ceiling. The writer
|
|
1115
|
+
// ceiling only extends while a fresh lock or in-flight live PID is present, so
|
|
1116
|
+
// a ceiling of 0 disables only that extension and does not collapse the normal
|
|
1117
|
+
// deadlineMs settle when no writer exists.
|
|
1086
1118
|
// `deadlineMs` 0 makes the unlocked test fire on the first poll (evaluate-once),
|
|
1087
1119
|
// matching the pre-lock-aware contract; `lockHeldAtEnd` distinguishes the causes.
|
|
1088
|
-
if (unlockedMs >= deadlineMs || (
|
|
1120
|
+
if (unlockedMs >= deadlineMs || (writerHeld && writerMs >= lockWaitMs)) {
|
|
1089
1121
|
return {
|
|
1090
1122
|
settled: false,
|
|
1091
1123
|
waitedMs,
|
|
@@ -1102,15 +1134,15 @@ export async function waitForInstallTreeSettled(args) {
|
|
|
1102
1134
|
updaterPidAlive: updaterPidAlive(args.lockPath, fs, isPidAlive),
|
|
1103
1135
|
};
|
|
1104
1136
|
}
|
|
1105
|
-
//
|
|
1106
|
-
//
|
|
1107
|
-
|
|
1108
|
-
if (!lockExtendedFired && foreignLockHeld && waitedMs >= deadlineMs) {
|
|
1137
|
+
// Announce once when the deadline has passed and a qualifying writer is why
|
|
1138
|
+
// the bounded wait continues.
|
|
1139
|
+
if (!lockExtendedFired && writerHeld && waitedMs >= deadlineMs) {
|
|
1109
1140
|
lockExtendedFired = true;
|
|
1110
1141
|
args.onLockExtended?.();
|
|
1111
1142
|
}
|
|
1112
1143
|
prevT = t;
|
|
1113
1144
|
prevForeignLockHeld = foreignLockHeld;
|
|
1145
|
+
prevWriterHeld = writerHeld;
|
|
1114
1146
|
await sleep(pollMs);
|
|
1115
1147
|
}
|
|
1116
1148
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type ResidentPackagePath = "agent-kit" | "agent-mcp" | "auth-login" | "agents-terminal" | "bot" | "bot-logs-follow" | "childWrapper" | "coreJobRun" | "login" | "mcp" | "daemon" | "daemon-logs-follow" | "mesh-daemon" | "lanes-watch" | "monitor-wait" | "monitor-run" | "agents-message-wait" | "integrations-oauth-wait" | "secrets-sandbox" | "pkg-update" | "pack-install";
|
|
1
|
+
export type ResidentPackagePath = "agent-kit" | "agent-mcp" | "auth-login" | "agents-terminal" | "bot" | "bot-logs-follow" | "childWrapper" | "coreJobRun" | "daemon-call" | "login" | "mcp" | "daemon" | "daemon-logs-follow" | "mesh-daemon" | "lanes-watch" | "monitor-wait" | "monitor-run" | "agents-message-wait" | "integrations-oauth-wait" | "outpost-wait" | "secrets-sandbox" | "pkg-update" | "pack-install";
|
|
2
2
|
/** Runtime modules that resident commands may need after a wait starts. */
|
|
3
3
|
export declare const RESIDENT_PACKAGE_MODULES: Record<ResidentPackagePath, readonly string[]>;
|
|
4
4
|
export declare class ResidentPackagePreloadError extends Error {
|