@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 +10 -0
- package/dist/command-catalog.generated.d.ts +4 -0
- package/dist/command-catalog.generated.js +5 -0
- package/dist/commands/doctor.d.ts +5 -0
- package/dist/commands/doctor.js +2 -0
- package/dist/commands/storage.d.ts +50 -0
- package/dist/commands/storage.js +307 -67
- package/dist/lib/doctor/fix/remediation.js +10 -0
- package/dist/lib/doctor/json-output.d.ts +16 -0
- package/dist/lib/doctor/json-output.js +10 -0
- package/package.json +1 -1
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). */
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
package/dist/commands/storage.js
CHANGED
|
@@ -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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
cloud
|
|
359
|
-
cloud_error: err instanceof Error ? err.message : String(err)
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
780
|
-
|
|
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
|
-
|
|
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
|
}
|