@indigoai-us/hq-cli 5.345.81 → 5.345.82

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 CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.82] — 2026-10-11
6
+ - Session-start health no longer treats an ordinary HQ Core upgrade warning
7
+ as an unrepaired doctor finding. `hq doctor` still shows the upgrade;
8
+ `--fix` still cannot apply it.
9
+ - `hq storage status` runs the local report, the offload scan and the cloud
10
+ listing at the same time, and lists cloud file history by folder in parallel
11
+ instead of one page at a time. Cloud credentials are vended right before the
12
+ first listing call and vended again before they expire, so a long scan or
13
+ dry run no longer fails with "The provided token has expired."
14
+
5
15
  ## [5.345.81] — 2026-10-11
6
16
  - HQ Storage (`hq storage status|prune|offload|fetch`) is available behind the Indigo-only `hq-storage` flag and fails closed when the flag is off. Requires @indigoai-us/hq-cloud 6.18.61, whose sync skips `.hq-offload/` and supports cloud-only `.hqcloud` placeholders.
7
17
  - `hq storage status` reports local autosave history and cloud file-version usage per company, grouped by age. `hq storage prune` deletes old noncurrent cloud file versions in companies where you are an owner or admin, and never touches the current version. A version's age counts from when it was replaced, so a recent undo copy of an old file is kept. `status --json` marks each company with `can_delete`, and still shows local usage when you are signed out.
@@ -2931,6 +2931,10 @@ export declare const COMMAND_CATALOG: readonly [{
2931
2931
  readonly flags: "--company <slug>";
2932
2932
  readonly description: "Only this company (must be one of yours)";
2933
2933
  readonly hidden: false;
2934
+ }, {
2935
+ readonly flags: "--no-cache";
2936
+ readonly description: "List cloud file history live instead of using figures from the last 15 minutes";
2937
+ readonly hidden: false;
2934
2938
  }];
2935
2939
  readonly subcommands: readonly [];
2936
2940
  }, {
@@ -3533,6 +3533,11 @@ export const COMMAND_CATALOG = [
3533
3533
  "flags": "--company <slug>",
3534
3534
  "description": "Only this company (must be one of yours)",
3535
3535
  "hidden": false
3536
+ },
3537
+ {
3538
+ "flags": "--no-cache",
3539
+ "description": "List cloud file history live instead of using figures from the last 15 minutes",
3540
+ "hidden": false
3536
3541
  }
3537
3542
  ],
3538
3543
  "subcommands": []
@@ -59,6 +59,11 @@ export interface RunDoctorOptions {
59
59
  resolveClientHealthReasonCodesForCompany?: (companySlug: string | null) => Promise<boolean>;
60
60
  /** Resolve the separate status-report flag per company in sync results. */
61
61
  resolveClientHealthCheckStatusForCompany?: (companySlug: string | null) => Promise<boolean>;
62
+ /**
63
+ * Omit ordinary hq-core upgrade WARNs from `--json` (the SessionStart health
64
+ * hook). Default `--json` and text output still include them.
65
+ */
66
+ clientHealthReport?: boolean;
62
67
  /** In text mode, also print every PASS result. Default: false. */
63
68
  verbose?: boolean;
64
69
  /** In text mode, emit ANSI colour. Default: false (no escape sequences). */
@@ -181,6 +181,7 @@ export async function runDoctor(options = {}) {
181
181
  scope,
182
182
  clientHealthReasonCodesEnabledCheckIds,
183
183
  clientHealthCheckStatusEnabledCheckIds,
184
+ clientHealthReport: options.clientHealthReport === true,
184
185
  })));
185
186
  }
186
187
  else {
@@ -437,6 +438,7 @@ export function registerDoctorCommand(program) {
437
438
  only: parseOnly(opts.only),
438
439
  resolveClientHealthReasonCodesForCompany: clientHealthReasonCodesResolver,
439
440
  resolveClientHealthCheckStatusForCompany: clientHealthCheckStatusResolver,
441
+ clientHealthReport: opts.clientHealthReport === true,
440
442
  });
441
443
  // Set the exit code rather than calling process.exit, so the CLI's
442
444
  // normal shutdown (telemetry flush) still runs. Non-zero means either an
@@ -51,6 +51,8 @@ export interface CloudCompanyStatus {
51
51
  delete_markers: number;
52
52
  tranches: CloudTranche[];
53
53
  error?: string;
54
+ /** When these figures were listed (ISO). Older than now when served from the cache. */
55
+ listed_at?: string;
54
56
  }
55
57
  export interface StorageStatus {
56
58
  local: LocalReport;
@@ -112,7 +114,24 @@ export interface StorageDeps {
112
114
  log: (line: string) => void;
113
115
  /** Offload section for `status`. Absent in deps that only prune. */
114
116
  getOffload?: () => Promise<OffloadStatus>;
117
+ /** Cache of cloud status per company (see cloudStatusCache). Absent = always list. */
118
+ cloudCache?: CloudStatusCache;
115
119
  }
120
+ /**
121
+ * Listing every version of a large vault takes minutes (about 2 for 2.4M
122
+ * versions, most of it parsing S3's XML), so `status` keeps each company's
123
+ * cloud figures for CLOUD_CACHE_TTL_MS. Only complete, successful listings are
124
+ * kept; a real cloud prune drops the company's entry; dry runs and real prunes
125
+ * always list live.
126
+ */
127
+ export interface CloudStatusCache {
128
+ read(company: CompanyRef): CloudCompanyStatus | undefined;
129
+ write(company: CompanyRef, status: CloudCompanyStatus): void;
130
+ drop(company: CompanyRef): void;
131
+ }
132
+ export declare const CLOUD_CACHE_TTL_MS: number;
133
+ /** File cache under <hq>/workspace/.storage-cache/cloud-<company uid>.json. */
134
+ export declare function cloudStatusCache(hqRoot: string, now: () => Date, ttlMs?: number): CloudStatusCache;
116
135
  export declare function localScriptPath(hqRoot: string): string;
117
136
  /**
118
137
  * Core `drop-backups --expired` right before a real local prune; undefined
@@ -148,6 +167,16 @@ export interface VersionEntry {
148
167
  * to its own LastModified.
149
168
  */
150
169
  export declare function annotateNoncurrentSince(entries: VersionEntry[]): VersionEntry[];
170
+ /** Folder levels split into parallel listings (`a/`, then `a/b/`). */
171
+ export declare const LIST_SPLIT_DEPTH = 2;
172
+ /** Listings in flight at once per bucket. */
173
+ export declare const LIST_CONCURRENCY = 32;
174
+ /**
175
+ * A printable-ASCII key strictly between `lo` and `hi` in S3's (UTF-8 byte)
176
+ * order, or undefined when none is found. Used to split one long listing into
177
+ * two that run at the same time.
178
+ */
179
+ export declare function keyBetween(lo: string, hi: string): string | undefined;
151
180
  /** Plain message when S3 refuses a version delete for one company. */
152
181
  export declare function deleteDeniedMessage(slug: string): string;
153
182
  export declare function describeCloudError(err: unknown, action: "list" | "delete", slug?: string): string;
@@ -162,6 +191,7 @@ export declare function selectPrunable(versions: VersionEntry[], cutoff: Date):
162
191
  export declare function pruneCloud(company: CompanyRef, cutoff: Date, dryRun: boolean, deps: StorageDeps): Promise<CloudPruneResult>;
163
192
  export declare function runStorageStatus(opts: {
164
193
  company?: string;
194
+ noCache?: boolean;
165
195
  }, deps: StorageDeps): Promise<StorageStatus>;
166
196
  export declare function parseCutoff(value: string, flag: string): Date;
167
197
  export declare function runStoragePrune(opts: {
@@ -195,5 +225,25 @@ export declare const NEEDS_YES = "No terminal to confirm in. Re-run with --yes t
195
225
  export declare function confirmOnTty(message: string, input?: NodeJS.ReadableStream & {
196
226
  isTTY?: boolean;
197
227
  }, output?: NodeJS.WritableStream): Promise<boolean>;
228
+ /** STS credentials from one vend, with the time they stop working. */
229
+ export interface VendedCredentials {
230
+ accessKeyId: string;
231
+ secretAccessKey: string;
232
+ sessionToken: string;
233
+ expiresAt?: string;
234
+ }
235
+ /**
236
+ * AWS credential provider that vends fresh credentials on first use and again
237
+ * whenever the previous ones are within `marginMs` of expiring. Each vend calls
238
+ * `vend` anew, which signs in again (the CLI refreshes an expiring Cognito
239
+ * session there), so a cloud listing or delete that starts after a long local
240
+ * scan, or that itself outlasts one STS session, never sends an expired token.
241
+ */
242
+ export declare function refreshingVendedCredentials(vend: () => Promise<VendedCredentials>, now?: () => number, marginMs?: number): () => Promise<{
243
+ accessKeyId: string;
244
+ secretAccessKey: string;
245
+ sessionToken: string;
246
+ expiration?: Date;
247
+ }>;
198
248
  export declare function registerStorageCommand(program: Command): Command;
199
249
  //# sourceMappingURL=storage.d.ts.map
@@ -56,6 +56,45 @@ export function canDeleteCloudHistory(company) {
56
56
  return company.role !== undefined && DELETE_ROLES.has(company.role);
57
57
  }
58
58
  export const DELETE_ROLE_REASON = "Only company owners and admins can delete cloud file history.";
59
+ export const CLOUD_CACHE_TTL_MS = 15 * 60 * 1000;
60
+ /** File cache under <hq>/workspace/.storage-cache/cloud-<company uid>.json. */
61
+ export function cloudStatusCache(hqRoot, now, ttlMs = CLOUD_CACHE_TTL_MS) {
62
+ const dir = path.join(hqRoot, "workspace", ".storage-cache");
63
+ const file = (c) => path.join(dir, `cloud-${c.uid.replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
64
+ return {
65
+ read(c) {
66
+ try {
67
+ const raw = JSON.parse(fs.readFileSync(file(c), "utf8"));
68
+ const at = Date.parse(raw.listed_at ?? "");
69
+ const age = now().getTime() - at;
70
+ if (raw.uid !== c.uid || raw.available !== true || Number.isNaN(at) || age < 0 || age >= ttlMs)
71
+ return undefined;
72
+ const { uid: _uid, ...status } = raw;
73
+ // Role can change between runs; it comes from the live membership.
74
+ return { ...status, company: c.slug, can_delete: canDeleteCloudHistory(c) };
75
+ }
76
+ catch {
77
+ return undefined;
78
+ }
79
+ },
80
+ write(c, status) {
81
+ if (!status.available)
82
+ return;
83
+ try {
84
+ fs.mkdirSync(dir, { recursive: true });
85
+ const tmp = `${file(c)}.tmp.${process.pid}`;
86
+ fs.writeFileSync(tmp, JSON.stringify({ ...status, uid: c.uid }));
87
+ fs.renameSync(tmp, file(c));
88
+ }
89
+ catch {
90
+ // A cache that cannot be written only costs the next status a listing.
91
+ }
92
+ },
93
+ drop(c) {
94
+ fs.rmSync(file(c), { force: true });
95
+ },
96
+ };
97
+ }
59
98
  // ── Local (hq-core script) ──────────────────────────────────────────────────
60
99
  export function localScriptPath(hqRoot) {
61
100
  return path.join(hqRoot, "core", "scripts", "hq-storage.sh");
@@ -149,52 +188,195 @@ export function annotateNoncurrentSince(entries) {
149
188
  function noncurrentAge(v) {
150
189
  return v.noncurrentSince ?? v.lastModified;
151
190
  }
152
- async function* listAllVersions(store) {
153
- let keyMarker;
154
- let versionIdMarker;
155
- do {
156
- const page = await store.s3.send(new ListObjectVersionsCommand({
157
- Bucket: store.bucket,
158
- KeyMarker: keyMarker,
159
- VersionIdMarker: versionIdMarker,
160
- }));
161
- for (const v of page.Versions ?? []) {
162
- if (!v.Key || !v.VersionId)
163
- continue;
164
- yield {
165
- key: v.Key,
166
- versionId: v.VersionId,
167
- isLatest: Boolean(v.IsLatest),
168
- lastModified: v.LastModified ? new Date(v.LastModified) : new Date(0),
169
- size: v.Size ?? 0,
170
- isDeleteMarker: false,
171
- };
191
+ function toEntries(page) {
192
+ const out = [];
193
+ for (const v of page.Versions ?? []) {
194
+ if (!v.Key || !v.VersionId)
195
+ continue;
196
+ out.push({
197
+ key: v.Key,
198
+ versionId: v.VersionId,
199
+ isLatest: Boolean(v.IsLatest),
200
+ lastModified: v.LastModified ? new Date(v.LastModified) : new Date(0),
201
+ size: v.Size ?? 0,
202
+ isDeleteMarker: false,
203
+ });
204
+ }
205
+ for (const m of page.DeleteMarkers ?? []) {
206
+ if (!m.Key || !m.VersionId)
207
+ continue;
208
+ out.push({
209
+ key: m.Key,
210
+ versionId: m.VersionId,
211
+ isLatest: Boolean(m.IsLatest),
212
+ lastModified: m.LastModified ? new Date(m.LastModified) : new Date(0),
213
+ size: 0,
214
+ isDeleteMarker: true,
215
+ });
216
+ }
217
+ return out;
218
+ }
219
+ /** Folder levels split into parallel listings (`a/`, then `a/b/`). */
220
+ export const LIST_SPLIT_DEPTH = 2;
221
+ /** Listings in flight at once per bucket. */
222
+ export const LIST_CONCURRENCY = 32;
223
+ function utf8Compare(a, b) {
224
+ return Buffer.compare(Buffer.from(a), Buffer.from(b));
225
+ }
226
+ /**
227
+ * A printable-ASCII key strictly between `lo` and `hi` in S3's (UTF-8 byte)
228
+ * order, or undefined when none is found. Used to split one long listing into
229
+ * two that run at the same time.
230
+ */
231
+ export function keyBetween(lo, hi) {
232
+ const a = Buffer.from(lo);
233
+ const b = Buffer.from(hi);
234
+ const FIRST = 0x20;
235
+ const LAST = 0x7e;
236
+ let k = 0;
237
+ while (k < a.length && k < b.length && a[k] === b[k])
238
+ k++;
239
+ const out = [...a.subarray(0, k)];
240
+ // Walk down from the first differing byte, keeping `lo`'s bytes while the
241
+ // gap is too small, until a byte strictly between both bounds fits.
242
+ for (let i = k; i < k + 8; i++) {
243
+ const ca = i < a.length ? a[i] : FIRST - 1;
244
+ const cb = i === k ? (i < b.length ? b[i] : LAST + 1) : LAST + 1;
245
+ const loB = Math.max(ca, FIRST - 1);
246
+ const hiB = Math.min(cb, LAST + 1);
247
+ if (hiB - loB >= 2) {
248
+ out.push((loB + hiB) >> 1);
249
+ const bytes = Buffer.from(out);
250
+ const mid = bytes.toString("utf8");
251
+ // The kept part of `lo` may end inside a multi-byte character.
252
+ if (!Buffer.from(mid).equals(bytes))
253
+ return undefined;
254
+ return utf8Compare(lo, mid) < 0 && utf8Compare(mid, hi) < 0 ? mid : undefined;
172
255
  }
173
- for (const m of page.DeleteMarkers ?? []) {
174
- if (!m.Key || !m.VersionId)
175
- continue;
176
- yield {
177
- key: m.Key,
178
- versionId: m.VersionId,
179
- isLatest: Boolean(m.IsLatest),
180
- lastModified: m.LastModified ? new Date(m.LastModified) : new Date(0),
181
- size: 0,
182
- isDeleteMarker: true,
183
- };
256
+ if (i >= a.length || a[i] < FIRST || a[i] > LAST)
257
+ return undefined;
258
+ out.push(a[i]);
259
+ }
260
+ return undefined;
261
+ }
262
+ /**
263
+ * Every version and delete marker in the bucket. One ListObjectVersions call
264
+ * returns at most 1,000 entries, so a sequential walk of a vault with millions
265
+ * of versions takes many minutes (and outlives a 15-minute STS session). The
266
+ * walk runs up to LIST_CONCURRENCY listings at once:
267
+ * - down to LIST_SPLIT_DEPTH levels a folder is listed with a "/" delimiter
268
+ * (its own entries plus its subfolders, each queued as its own listing);
269
+ * - deeper folders are listed whole, as key ranges: while listings are idle,
270
+ * a long range hands its second half (split at keyBetween) to a new one;
271
+ * - a vault policy can refuse a Prefix listing of one folder (private DM
272
+ * folders) while allowing the plain listing the sequential walk used, so a
273
+ * refused folder is listed as a key range of the plain listing instead.
274
+ * Ranges never overlap and every version of a key falls in exactly one, so the
275
+ * result is the same set as one flat listing. Any other failure fails the walk.
276
+ */
277
+ async function listAllVersions(store) {
278
+ const all = [];
279
+ const queue = [{ prefix: "", depth: 0, split: LIST_SPLIT_DEPTH > 0, usePrefix: true }];
280
+ let active = 0;
281
+ let failed;
282
+ const runOne = async (w) => {
283
+ const found = [];
284
+ const children = [];
285
+ let hi = w.hi;
286
+ let keyMarker = w.lo ?? (w.usePrefix || !w.prefix ? undefined : w.prefix.slice(0, -1));
287
+ let versionIdMarker;
288
+ let done = false;
289
+ try {
290
+ do {
291
+ const page = await store.s3.send(new ListObjectVersionsCommand({
292
+ Bucket: store.bucket,
293
+ KeyMarker: keyMarker,
294
+ VersionIdMarker: versionIdMarker,
295
+ ...(w.usePrefix && w.prefix ? { Prefix: w.prefix } : {}),
296
+ ...(w.split ? { Delimiter: "/" } : {}),
297
+ }));
298
+ for (const e of toEntries(page)) {
299
+ if (!e.key.startsWith(w.prefix)) {
300
+ // Only the plain listing returns keys outside the folder.
301
+ if (utf8Compare(e.key, w.prefix) > 0)
302
+ done = true;
303
+ continue;
304
+ }
305
+ if (hi !== undefined && utf8Compare(e.key, hi) > 0) {
306
+ done = true;
307
+ continue;
308
+ }
309
+ found.push(e);
310
+ }
311
+ if (w.split) {
312
+ for (const cp of page.CommonPrefixes ?? []) {
313
+ if (cp.Prefix && cp.Prefix !== w.prefix) {
314
+ const depth = w.depth + 1;
315
+ children.push({ prefix: cp.Prefix, depth, split: depth < LIST_SPLIT_DEPTH, usePrefix: w.usePrefix });
316
+ }
317
+ }
318
+ }
319
+ if (page.IsTruncated && !page.NextKeyMarker) {
320
+ throw new Error("File history listing was cut short (no next marker). Nothing was changed.");
321
+ }
322
+ keyMarker = page.IsTruncated && !done ? page.NextKeyMarker : undefined;
323
+ versionIdMarker = page.IsTruncated && !done ? page.NextVersionIdMarker : undefined;
324
+ // Hand the rest of a long range to an idle slot: this listing keeps
325
+ // keys up to `mid`, the new one starts after it.
326
+ if (keyMarker !== undefined && !w.split && active + queue.length < LIST_CONCURRENCY) {
327
+ const end = hi ?? `${w.prefix}\x7f`;
328
+ const mid = keyBetween(keyMarker, end);
329
+ if (mid !== undefined && mid.startsWith(w.prefix)) {
330
+ queue.push({ ...w, lo: mid, hi });
331
+ hi = mid;
332
+ pump();
333
+ }
334
+ }
335
+ } while (keyMarker !== undefined && failed === undefined);
184
336
  }
185
- if (page.IsTruncated && !page.NextKeyMarker) {
186
- throw new Error("File history listing was cut short (no next marker). Nothing was changed.");
337
+ catch (err) {
338
+ if (!w.usePrefix || !isAccessDenied(err))
339
+ throw err;
340
+ // Refused for this folder: list it again from the plain listing. Nothing
341
+ // from the refused attempt is kept, so nothing counts twice.
342
+ // Same key range as this attempt (a split-off rest is already queued).
343
+ queue.push({ ...w, split: false, usePrefix: false, hi });
344
+ return;
187
345
  }
188
- keyMarker = page.IsTruncated ? page.NextKeyMarker : undefined;
189
- versionIdMarker = page.IsTruncated ? page.NextVersionIdMarker : undefined;
190
- } while (keyMarker !== undefined);
346
+ for (const e of found)
347
+ all.push(e);
348
+ for (const c of children)
349
+ queue.push(c);
350
+ };
351
+ let finish;
352
+ const finished = new Promise((resolve) => (finish = resolve));
353
+ function pump() {
354
+ if (failed === undefined) {
355
+ while (active < LIST_CONCURRENCY && queue.length > 0) {
356
+ const next = queue.shift();
357
+ active += 1;
358
+ runOne(next)
359
+ .catch((err) => {
360
+ failed ??= err;
361
+ })
362
+ .finally(() => {
363
+ active -= 1;
364
+ pump();
365
+ });
366
+ }
367
+ }
368
+ if (active === 0 && (queue.length === 0 || failed !== undefined))
369
+ finish();
370
+ }
371
+ pump();
372
+ await finished;
373
+ if (failed !== undefined)
374
+ throw failed;
375
+ return all;
191
376
  }
192
377
  /** Every version and delete marker in the bucket, with `noncurrentSince` set. */
193
378
  async function listVersions(store) {
194
- const all = [];
195
- for await (const v of listAllVersions(store))
196
- all.push(v);
197
- return annotateNoncurrentSince(all);
379
+ return annotateNoncurrentSince(await listAllVersions(store));
198
380
  }
199
381
  function emptyTranches() {
200
382
  return TRANCHES.map((t) => ({ id: t.id, label: t.label, count: 0, bytes: 0 }));
@@ -264,7 +446,7 @@ export async function getCloudStatus(company, deps) {
264
446
  error: describeCloudError(err, "list"),
265
447
  };
266
448
  }
267
- return status;
449
+ return { ...status, listed_at: now.toISOString() };
268
450
  }
269
451
  /**
270
452
  * Versions eligible for prune: noncurrent content versions and noncurrent
@@ -345,29 +527,42 @@ export async function pruneCloud(company, cutoff, dryRun, deps) {
345
527
  // ── Orchestrators ───────────────────────────────────────────────────────────
346
528
  export async function runStorageStatus(opts, deps) {
347
529
  // Read-only: no backup drop, no gc. Pending backup bytes come from report.
348
- const local = await getLocalReport(deps);
349
- const offload = deps.getOffload ? await deps.getOffload().catch(offloadUnavailable) : undefined;
350
- let memberships;
351
- try {
352
- memberships = await deps.listCompanies();
353
- }
354
- catch (err) {
355
- // Local sections still report when cloud is unreachable (signed out, API key).
356
- return {
357
- local,
358
- cloud: [],
359
- cloud_error: err instanceof Error ? err.message : String(err),
360
- ...(offload ? { offload } : {}),
361
- generated_at: deps.now().toISOString(),
530
+ // The local report, the offload scan and the cloud listing are independent,
531
+ // so they run at the same time: status takes as long as the slowest one.
532
+ const localP = getLocalReport(deps);
533
+ const offloadP = deps.getOffload ? deps.getOffload().catch(offloadUnavailable) : Promise.resolve(undefined);
534
+ const cloudP = (async () => {
535
+ let memberships;
536
+ try {
537
+ memberships = await deps.listCompanies();
538
+ }
539
+ catch (err) {
540
+ // Local sections still report when cloud is unreachable (signed out, API key).
541
+ return { cloud: [], cloud_error: err instanceof Error ? err.message : String(err) };
542
+ }
543
+ const companies = selectCompanies(memberships, opts.company);
544
+ const cached = async (c) => {
545
+ const hit = opts.noCache ? undefined : deps.cloudCache?.read(c);
546
+ if (hit)
547
+ return hit;
548
+ const fresh = await getCloudStatus(c, deps);
549
+ deps.cloudCache?.write(c, fresh);
550
+ return fresh;
362
551
  };
363
- }
364
- const companies = selectCompanies(memberships, opts.company);
365
- const cloud = [];
366
- for (const c of companies)
367
- cloud.push(await getCloudStatus(c, deps));
552
+ return { cloud: await Promise.all(companies.map(cached)) };
553
+ })();
554
+ // Settle all three before returning, so no scan is left running behind a throw.
555
+ const [localR, offloadR, cloudR] = await Promise.allSettled([localP, offloadP, cloudP]);
556
+ for (const r of [localR, cloudR])
557
+ if (r.status === "rejected")
558
+ throw r.reason;
559
+ const local = localR.value;
560
+ const offload = offloadR.status === "fulfilled" ? offloadR.value : undefined;
561
+ const { cloud, cloud_error } = cloudR.value;
368
562
  return {
369
563
  local,
370
564
  cloud,
565
+ ...(cloud_error !== undefined ? { cloud_error } : {}),
371
566
  ...(offload ? { offload } : {}),
372
567
  generated_at: deps.now().toISOString(),
373
568
  };
@@ -422,8 +617,12 @@ export async function runStoragePrune(opts, deps) {
422
617
  }
423
618
  const cloud = [];
424
619
  if (cloudCutoff) {
425
- for (const c of companies)
620
+ for (const c of companies) {
621
+ // A real prune changes the figures: the next status lists live.
622
+ if (!opts.dryRun)
623
+ deps.cloudCache?.drop(c);
426
624
  cloud.push(await pruneCloud(c, cloudCutoff, opts.dryRun, deps));
625
+ }
427
626
  for (const c of skipped) {
428
627
  cloud.push({
429
628
  company: c.slug,
@@ -774,10 +973,43 @@ async function buildOffloadDeps() {
774
973
  },
775
974
  };
776
975
  }
976
+ /**
977
+ * AWS credential provider that vends fresh credentials on first use and again
978
+ * whenever the previous ones are within `marginMs` of expiring. Each vend calls
979
+ * `vend` anew, which signs in again (the CLI refreshes an expiring Cognito
980
+ * session there), so a cloud listing or delete that starts after a long local
981
+ * scan, or that itself outlasts one STS session, never sends an expired token.
982
+ */
983
+ export function refreshingVendedCredentials(vend, now = Date.now, marginMs = 5 * 60 * 1000) {
984
+ let cached;
985
+ let pending;
986
+ return async () => {
987
+ if (cached && cached.expiration && cached.expiration.getTime() - now() > marginMs)
988
+ return cached;
989
+ pending ??= vend()
990
+ .then((c) => {
991
+ const t = c.expiresAt ? Date.parse(c.expiresAt) : NaN;
992
+ cached = {
993
+ accessKeyId: c.accessKeyId,
994
+ secretAccessKey: c.secretAccessKey,
995
+ sessionToken: c.sessionToken,
996
+ // Unknown expiry: treat as already due so the next call vends again.
997
+ expiration: new Date(Number.isNaN(t) ? now() : t),
998
+ };
999
+ return cached;
1000
+ })
1001
+ .finally(() => {
1002
+ pending = undefined;
1003
+ });
1004
+ return pending;
1005
+ };
1006
+ }
777
1007
  async function buildDeps() {
778
- // Sign-in is lazy so `status` can still report local sections when signed out.
779
- let session;
780
- const signedIn = () => (session ??= signedInVault());
1008
+ // Sign-in is lazy so `status` can still report local sections when signed out,
1009
+ // and it is resolved again for every cloud call (never once at the start): the
1010
+ // local scan before the cloud listing can outlast a session token, and
1011
+ // resolveVaultCredential refreshes an expiring Cognito session.
1012
+ const signedIn = () => signedInVault();
781
1013
  const offloadDeps = await buildOffloadDeps();
782
1014
  return {
783
1015
  getOffload: () => getOffloadStatus(offloadDeps, {
@@ -786,6 +1018,7 @@ async function buildDeps() {
786
1018
  }),
787
1019
  hqRoot: findHqRoot(),
788
1020
  now: () => new Date(),
1021
+ cloudCache: cloudStatusCache(findHqRoot(), () => new Date()),
789
1022
  runScript: defaultRunScript,
790
1023
  confirm: (message) => confirmOnTty(message),
791
1024
  log: (line) => console.log(line),
@@ -799,10 +1032,16 @@ async function buildDeps() {
799
1032
  },
800
1033
  openCompany: async (company) => {
801
1034
  const { vault } = await signedIn();
802
- const vend = await vault.sts.vend({ companyUid: company.uid });
803
1035
  const entity = await vault.entity.get(company.uid);
804
1036
  const bucket = entity.bucketName ?? `hq-vault-cmp-${company.slug}`;
805
- const s3 = new S3Client({ region: DEFAULT_COGNITO.region, credentials: vend.credentials });
1037
+ // Credentials are vended right before the first S3 call and re-vended
1038
+ // (with a freshly refreshed sign-in) before they expire.
1039
+ const credentials = refreshingVendedCredentials(async () => {
1040
+ const { vault: fresh } = await signedIn();
1041
+ const vend = await fresh.sts.vend({ companyUid: company.uid });
1042
+ return { ...vend.credentials, expiresAt: vend.expiresAt };
1043
+ });
1044
+ const s3 = new S3Client({ region: DEFAULT_COGNITO.region, credentials });
806
1045
  return { bucket, s3 };
807
1046
  },
808
1047
  };
@@ -817,9 +1056,10 @@ export function registerStorageCommand(program) {
817
1056
  .description("Show local autosave history and cloud file history sizes")
818
1057
  .option("--json", "Print JSON")
819
1058
  .option("--company <slug>", "Only this company (must be one of yours)")
1059
+ .option("--no-cache", "List cloud file history live instead of using figures from the last 15 minutes")
820
1060
  .action(async (opts) => {
821
1061
  try {
822
- const status = await runStorageStatus({ company: opts.company }, await buildDeps());
1062
+ const status = await runStorageStatus({ company: opts.company, noCache: opts.cache === false }, await buildDeps());
823
1063
  console.log(opts.json ? JSON.stringify(status, null, 2) : formatStorageStatus(status));
824
1064
  }
825
1065
  catch (err) {
@@ -119,6 +119,16 @@ export function deriveRemediation(result) {
119
119
  command: remediation ?? `hq bot start ${target}`,
120
120
  };
121
121
  }
122
+ // Available hq-core upgrade. WARN is intentional in `hq doctor` and is
123
+ // not a broken sync; --fix has no mechanical repair for it.
124
+ if (checkId === "sync.update.core") {
125
+ return {
126
+ autoFixable: false,
127
+ fixClass: null,
128
+ action: "Ordinary hq-core upgrade, not a failed repair. hq doctor --fix will leave this WARN in place. Run /update-hq in a fresh session.",
129
+ command: "",
130
+ };
131
+ }
122
132
  // refresh-sync — a company journal that is stale or never-synced, or a
123
133
  // live personal / personal-vault journal. Leftover shards are NA at the
124
134
  // check layer, so they never reach this classifier. A SessionStart
@@ -104,7 +104,23 @@ export interface BuildDoctorJsonInput {
104
104
  clientHealthReasonCodesEnabledCheckIds?: ReadonlySet<string>;
105
105
  /** Check ids whose own company enabled per-check status reporting in hq-flags. */
106
106
  clientHealthCheckStatusEnabledCheckIds?: ReadonlySet<string>;
107
+ /**
108
+ * JSON consumed by the SessionStart health hook (`--client-health-report`).
109
+ * Ordinary hq-core upgrade WARNs stay in human `hq doctor` output and in
110
+ * default `--json`, but they are omitted here: `--fix` cannot apply a
111
+ * release, and older hooks treated the leftover WARN as an unrepaired
112
+ * failure.
113
+ */
114
+ clientHealthReport?: boolean;
107
115
  }
116
+ /**
117
+ * True when a sync finding is an ordinary, non-repairable hq-core upgrade.
118
+ * FAIL stays visible: that is a broken local core, not "a newer release exists".
119
+ */
120
+ export declare function isOrdinaryCoreUpdateWarn(result: {
121
+ checkId: string;
122
+ status: DoctorStatus;
123
+ }): boolean;
108
124
  /** Assemble the machine-readable document from a completed run. */
109
125
  export declare function buildDoctorJson(input: BuildDoctorJsonInput): DoctorJsonDocument;
110
126
  /** Serialise the document as pretty-printed JSON with a trailing newline. */
@@ -29,11 +29,21 @@ import { deriveRemediation } from "./fix/remediation.js";
29
29
  * was passed, so a consumer can see what `--fix` would do without invoking it.
30
30
  */
31
31
  export const DOCTOR_JSON_SCHEMA_VERSION = 2;
32
+ /**
33
+ * True when a sync finding is an ordinary, non-repairable hq-core upgrade.
34
+ * FAIL stays visible: that is a broken local core, not "a newer release exists".
35
+ */
36
+ export function isOrdinaryCoreUpdateWarn(result) {
37
+ return result.checkId === "sync.update.core" && result.status === "WARN";
38
+ }
32
39
  /** Assemble the machine-readable document from a completed run. */
33
40
  export function buildDoctorJson(input) {
34
41
  const results = [];
35
42
  for (const run of input.families) {
36
43
  for (const result of run.results) {
44
+ if (input.clientHealthReport === true && isOrdinaryCoreUpdateWarn(result)) {
45
+ continue;
46
+ }
37
47
  results.push(toJsonResult(run.family.id, result, input.clientHealthReasonCodesEnabledCheckIds, input.clientHealthCheckStatusEnabledCheckIds));
38
48
  }
39
49
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.81",
3
+ "version": "5.345.82",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {