@indigoai-us/hq-cli 5.108.5 → 5.108.6
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 +117 -0
- package/dist/commands/feedback.d.ts +38 -0
- package/dist/commands/feedback.js +80 -0
- package/dist/commands/signals.js +12 -0
- package/dist/commands/sources.js +12 -0
- package/dist/utils/feedback-diagnostics.d.ts +11 -0
- package/dist/utils/feedback-log-bundle.d.ts +149 -0
- package/dist/utils/feedback-log-bundle.js +430 -0
- package/dist/utils/feedback-logs.d.ts +329 -0
- package/dist/utils/feedback-logs.js +669 -0
- package/package.json +2 -2
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug-log and state capture for `hq feedback` submissions.
|
|
3
|
+
*
|
|
4
|
+
* A bug report is far cheaper to triage when the submitter's recent log output
|
|
5
|
+
* travels with it. This module collects two bounded, redacted classes of
|
|
6
|
+
* evidence from the user's `~/.hq` directory and hands them to the feedback
|
|
7
|
+
* command, which attaches them to the `diagnostics` blob on the submission:
|
|
8
|
+
*
|
|
9
|
+
* - LOG TAILS (`files`) — the end of each rolling log. One file covers more
|
|
10
|
+
* than its name suggests: `logs/hq-sync.log` is the shared diagnostic log
|
|
11
|
+
* for BOTH the hq-cloud sync engine and the desktop sync app, which write
|
|
12
|
+
* to it through the same path, and its ROTATED generations
|
|
13
|
+
* (`hq-sync.log.1`, `.2`, `.3`) are eligible too — just after a rotation
|
|
14
|
+
* the active file is nearly empty and the history lives in `.1`.
|
|
15
|
+
* `logs/mcp-registry.log` is the CLI's own append-only MCP audit trail.
|
|
16
|
+
* Any future `*.log`/`*.jsonl` dropped into `~/.hq/logs/` is picked up
|
|
17
|
+
* automatically.
|
|
18
|
+
*
|
|
19
|
+
* - STATE SNAPSHOTS (`state`) — small JSON files that describe where sync
|
|
20
|
+
* and client health currently stand: the in-flight sync phase and conflict
|
|
21
|
+
* count, the client-health failure streak, the per-company sync journal
|
|
22
|
+
* locators. These are tiny and answer the questions a log tail usually
|
|
23
|
+
* cannot ("was it mid-pull?", "how many conflicts?", "how long since the
|
|
24
|
+
* last successful sync?"), so they are collected FIRST and the log budget
|
|
25
|
+
* takes what remains.
|
|
26
|
+
*
|
|
27
|
+
* Contrast with `local-files-overview.ts`, which deliberately sends only
|
|
28
|
+
* metadata (existence, size, age, error-line counts) and NEVER a line of log
|
|
29
|
+
* text. That module feeds the BACKGROUND client-health heartbeat, where the
|
|
30
|
+
* user has not asked for anything and content would be surveillance. This
|
|
31
|
+
* module runs only on an EXPLICIT, user-initiated `hq feedback` invocation,
|
|
32
|
+
* where the user is asking us to look at their problem, so content is both
|
|
33
|
+
* expected and useful. The two must not be merged.
|
|
34
|
+
*
|
|
35
|
+
* Three hard safety properties, each with a regression test:
|
|
36
|
+
*
|
|
37
|
+
* 1. ALLOWLIST, NOT A SCAN. `~/.hq` also holds `cognito-tokens.json`,
|
|
38
|
+
* `deploy-passwords.json`, and `secrets-cache/`. Only `*.log` files
|
|
39
|
+
* directly inside `~/.hq/logs/` plus the two known root-level boot logs
|
|
40
|
+
* are ever eligible. A recursive walk over `~/.hq` is a credential leak
|
|
41
|
+
* and must never be introduced here.
|
|
42
|
+
*
|
|
43
|
+
* 2. NO SYMLINKS. Eligibility is decided with `lstat`, and a candidate must
|
|
44
|
+
* be a REGULAR file. Without this, a symlink named `anything.log` inside
|
|
45
|
+
* `~/.hq/logs/` pointing at `~/.hq/cognito-tokens.json` would satisfy the
|
|
46
|
+
* allowlist and exfiltrate the user's tokens. `stat` would follow the link
|
|
47
|
+
* and report a regular file — `lstat` is the whole guard.
|
|
48
|
+
*
|
|
49
|
+
* 3. REDACTION IS APPLIED BEFORE BUDGETING. Every tail passes through
|
|
50
|
+
* `redactLogText` before it is measured or returned, so no code path can
|
|
51
|
+
* emit an unredacted span. Redaction is defence in depth, not the primary
|
|
52
|
+
* control — properties 1 and 2 are what keep secrets out.
|
|
53
|
+
*
|
|
54
|
+
* Every filesystem operation is independently best-effort: a missing file, a
|
|
55
|
+
* permission error, or a file that disappears mid-read degrades that one entry
|
|
56
|
+
* and never throws. Collecting diagnostics must never break a bug report.
|
|
57
|
+
*/
|
|
58
|
+
import * as fs from "node:fs";
|
|
59
|
+
import * as os from "node:os";
|
|
60
|
+
import * as path from "node:path";
|
|
61
|
+
/**
|
|
62
|
+
* Ceiling on the total redacted log text attached to one submission.
|
|
63
|
+
*
|
|
64
|
+
* The server caps the WHOLE feedback request body at 64 KiB, so this is only
|
|
65
|
+
* an upper bound — the caller passes a smaller `budgetBytes` computed from the
|
|
66
|
+
* headroom actually left after the title, body, and the rest of diagnostics.
|
|
67
|
+
* See `submitFeedback` in `../commands/feedback.ts`.
|
|
68
|
+
*/
|
|
69
|
+
export const FEEDBACK_LOGS_MAX_TOTAL_BYTES = 40 * 1024;
|
|
70
|
+
/** Per-file ceiling, so one noisy log cannot consume the entire budget. */
|
|
71
|
+
export const FEEDBACK_LOGS_MAX_FILE_TAIL_BYTES = 16 * 1024;
|
|
72
|
+
/**
|
|
73
|
+
* Below this much remaining budget a tail is too short to be worth reading,
|
|
74
|
+
* so the file is recorded as skipped rather than truncated into noise.
|
|
75
|
+
*/
|
|
76
|
+
export const FEEDBACK_LOGS_MIN_USEFUL_BYTES = 1024;
|
|
77
|
+
/**
|
|
78
|
+
* Ceiling on how many skipped entries are recorded.
|
|
79
|
+
*
|
|
80
|
+
* `skipped` is metadata, not payload, so it was originally left outside the
|
|
81
|
+
* byte budget — which made it unbounded. An installation with hundreds of log
|
|
82
|
+
* files produced hundreds of `{name, reason}` objects and a blob several times
|
|
83
|
+
* the requested budget; `attachDebugLogs` then dropped the whole thing to stay
|
|
84
|
+
* under the request cap, so the user with the MOST log history got the LEAST
|
|
85
|
+
* evidence. The overflow count preserves the signal without the bytes.
|
|
86
|
+
*/
|
|
87
|
+
export const FEEDBACK_MAX_SKIPPED_ENTRIES = 25;
|
|
88
|
+
/** Subdirectory of `~/.hq` whose log files are eligible. Not recursive. */
|
|
89
|
+
export const FEEDBACK_LOGS_DIRNAME = "logs";
|
|
90
|
+
/**
|
|
91
|
+
* Eligible extensions inside `~/.hq/logs/`. `.jsonl` is included because the
|
|
92
|
+
* CLI's MCP registry audit trail is JSON-lines, not plain text.
|
|
93
|
+
*/
|
|
94
|
+
export const FEEDBACK_LOG_EXTENSIONS = [".log", ".jsonl"];
|
|
95
|
+
/**
|
|
96
|
+
* True for a log filename, including a ROTATED generation.
|
|
97
|
+
*
|
|
98
|
+
* The desktop/sync logger keeps 32 MiB x 3 generations named `hq-sync.log.1`,
|
|
99
|
+
* `.2`, `.3` (see hq-desktop-core `logfile.rs`). A plain "ends with .log"
|
|
100
|
+
* test misses every one of them — and they matter most in the worst case: just
|
|
101
|
+
* after a rotation the active log is nearly empty and all the history a
|
|
102
|
+
* triager needs sits in `.1`.
|
|
103
|
+
*/
|
|
104
|
+
export function isEligibleLogName(name) {
|
|
105
|
+
const lower = name.toLowerCase();
|
|
106
|
+
if (FEEDBACK_LOG_EXTENSIONS.some((ext) => lower.endsWith(ext)))
|
|
107
|
+
return true;
|
|
108
|
+
const rotated = /^(.+)\.(\d+)$/.exec(lower);
|
|
109
|
+
if (!rotated)
|
|
110
|
+
return false;
|
|
111
|
+
const base = rotated[1];
|
|
112
|
+
return FEEDBACK_LOG_EXTENSIONS.some((ext) => base.endsWith(ext));
|
|
113
|
+
}
|
|
114
|
+
/** Per-state-file ceiling. These are status documents, not streams. */
|
|
115
|
+
export const FEEDBACK_STATE_MAX_FILE_BYTES = 4 * 1024;
|
|
116
|
+
/**
|
|
117
|
+
* Ceiling on all state snapshots combined. Deliberately a small slice of the
|
|
118
|
+
* overall budget: state is dense but finite, and the rest belongs to logs.
|
|
119
|
+
*/
|
|
120
|
+
export const FEEDBACK_STATE_MAX_TOTAL_BYTES = 12 * 1024;
|
|
121
|
+
/**
|
|
122
|
+
* State files at the `~/.hq` ROOT that are eligible, by EXACT name.
|
|
123
|
+
*
|
|
124
|
+
* The root is where `cognito-tokens.json`, `deploy-passwords.json`, and
|
|
125
|
+
* `secrets-cache/` live, so it is never globbed. Adding a name here is a
|
|
126
|
+
* deliberate act: confirm the file carries no credential before listing it.
|
|
127
|
+
*/
|
|
128
|
+
export const FEEDBACK_STATE_FILENAMES = [
|
|
129
|
+
// In-flight sync: phase, file counts, conflict count, current file.
|
|
130
|
+
"sync-progress.json",
|
|
131
|
+
// Sync contract version the installation last wrote.
|
|
132
|
+
"sync-version.json",
|
|
133
|
+
// Client-health heartbeat: installation id and consecutive failure streak.
|
|
134
|
+
"cli-client-health.json",
|
|
135
|
+
// Last observed sync/heartbeat timestamps.
|
|
136
|
+
"cli-client-health.observation.json",
|
|
137
|
+
// Outpost session liveness — the answer to "is my Outpost still alive".
|
|
138
|
+
"outpost-session-heartbeat.json",
|
|
139
|
+
// Cached latest release + fetch time — the answer to "why won't it update".
|
|
140
|
+
"version-check.json",
|
|
141
|
+
// Plan-limit nag state.
|
|
142
|
+
"plan-limit-nag.json",
|
|
143
|
+
];
|
|
144
|
+
/**
|
|
145
|
+
* Per-company sync journal LOCATORS (`sync-journal.<slug>.json`) are also
|
|
146
|
+
* eligible. Matched by an exact prefix AND a `.json` suffix, which is what
|
|
147
|
+
* excludes the multi-hundred-megabyte `…json.last-good` snapshots sitting
|
|
148
|
+
* beside them. No credential file at the root carries this prefix.
|
|
149
|
+
*/
|
|
150
|
+
export const FEEDBACK_STATE_JOURNAL_PREFIX = "sync-journal.";
|
|
151
|
+
/**
|
|
152
|
+
* `~/.hq/locks/` — held operation locks and background-work claims.
|
|
153
|
+
*
|
|
154
|
+
* The highest-signal thing in the whole collection, and the one place where the
|
|
155
|
+
* FILENAME is the diagnosis: a `…stale-claim…` entry names the operation, the
|
|
156
|
+
* process that abandoned it, and the fact that the claim went stale. That is
|
|
157
|
+
* the answer to "sync/reindex is stuck", which is otherwise invisible in a log
|
|
158
|
+
* tail because a hung process writes nothing. Contents are tiny (tens to a
|
|
159
|
+
* couple hundred bytes) and included too.
|
|
160
|
+
*/
|
|
161
|
+
export const FEEDBACK_LOCKS_DIRNAME = "locks";
|
|
162
|
+
/**
|
|
163
|
+
* `~/.hq/jobs/` — scheduled-job status, reconcile results, and probe attempts.
|
|
164
|
+
* Nested one or two levels (`jobs/probes/<job>/last-attempt.json`), so this is
|
|
165
|
+
* the only source needing a directory walk. See {@link walkJsonFiles} for the
|
|
166
|
+
* bounds that walk carries.
|
|
167
|
+
*/
|
|
168
|
+
export const FEEDBACK_JOBS_DIRNAME = "jobs";
|
|
169
|
+
/** Per-entry ceiling for a lock file. Their value is the name plus a little context. */
|
|
170
|
+
export const FEEDBACK_LOCK_MAX_FILE_BYTES = 512;
|
|
171
|
+
/**
|
|
172
|
+
* Bounds on the `jobs/` walk. Depth 3 reaches
|
|
173
|
+
* `jobs/probes/<job>/last-attempt.json`; the file cap stops a pathological
|
|
174
|
+
* tree from crowding out logs.
|
|
175
|
+
*/
|
|
176
|
+
export const FEEDBACK_JOBS_MAX_DEPTH = 3;
|
|
177
|
+
export const FEEDBACK_JOBS_MAX_FILES = 20;
|
|
178
|
+
/** Ceiling on how many lock entries are collected. */
|
|
179
|
+
export const FEEDBACK_LOCKS_MAX_FILES = 20;
|
|
180
|
+
/**
|
|
181
|
+
* Log files that live at the root of `~/.hq` rather than under `logs/`.
|
|
182
|
+
* Listed explicitly — the root directory holds credentials and must never be
|
|
183
|
+
* enumerated by pattern.
|
|
184
|
+
*/
|
|
185
|
+
export const FEEDBACK_ROOT_LOG_FILENAMES = [
|
|
186
|
+
"boot-sync.log",
|
|
187
|
+
"boot-capture.log",
|
|
188
|
+
];
|
|
189
|
+
/**
|
|
190
|
+
* Redaction patterns, applied in order. Structured secrets (whole PEM blocks)
|
|
191
|
+
* come first so a later, narrower pattern cannot chop one into fragments that
|
|
192
|
+
* then escape.
|
|
193
|
+
*/
|
|
194
|
+
const REDACTION_PATTERNS = [
|
|
195
|
+
// Whole PEM private-key blocks.
|
|
196
|
+
{
|
|
197
|
+
pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
198
|
+
replace: () => "[redacted private key]",
|
|
199
|
+
},
|
|
200
|
+
// JSON Web Tokens (Cognito access/id/refresh tokens land in logs this way).
|
|
201
|
+
{
|
|
202
|
+
pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+/g,
|
|
203
|
+
replace: () => "[redacted jwt]",
|
|
204
|
+
},
|
|
205
|
+
// AWS access key identifiers.
|
|
206
|
+
{
|
|
207
|
+
pattern: /\b(?:AKIA|ASIA|AIDA|AROA|AGPA|AIPA|ANPA|ANVA)[0-9A-Z]{16}\b/g,
|
|
208
|
+
replace: () => "[redacted aws key id]",
|
|
209
|
+
},
|
|
210
|
+
// GitHub personal access / OAuth / refresh tokens.
|
|
211
|
+
{
|
|
212
|
+
pattern: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g,
|
|
213
|
+
replace: () => "[redacted github token]",
|
|
214
|
+
},
|
|
215
|
+
// Slack bot/user/app tokens.
|
|
216
|
+
{
|
|
217
|
+
pattern: /\bxox[abposr]-[A-Za-z0-9-]{10,}\b/g,
|
|
218
|
+
replace: () => "[redacted slack token]",
|
|
219
|
+
},
|
|
220
|
+
// SigV4 presigned-URL material. Keeps the URL readable, drops what signs it.
|
|
221
|
+
{
|
|
222
|
+
pattern: /([?&](?:X-Amz-Signature|X-Amz-Credential|X-Amz-Security-Token)=)[^&\s"']+/gi,
|
|
223
|
+
replace: (_m, prefix) => `${prefix}[redacted]`,
|
|
224
|
+
},
|
|
225
|
+
// `Authorization: Bearer <token>` in any casing.
|
|
226
|
+
{
|
|
227
|
+
pattern: /\b([Bb]earer\s+)[A-Za-z0-9._~+/=-]{12,}/g,
|
|
228
|
+
replace: (_m, prefix) => `${prefix}[redacted]`,
|
|
229
|
+
},
|
|
230
|
+
// Generic `key: value` / `key=value` for names that denote a secret. Runs
|
|
231
|
+
// last so the specific patterns above produce the more informative label.
|
|
232
|
+
{
|
|
233
|
+
pattern: /("?\b(?:token|secret|password|passwd|api[_-]?key|access[_-]?key|secret[_-]?key|auth[_-]?token|authorization|credential|session[_-]?token)"?\s*[:=]\s*"?)([^\s",;&}]{6,})/gi,
|
|
234
|
+
replace: (_m, prefix) => `${prefix}[redacted]`,
|
|
235
|
+
},
|
|
236
|
+
];
|
|
237
|
+
/**
|
|
238
|
+
* Replace every secret-shaped span in `text`. Returns the redacted text and a
|
|
239
|
+
* count of replacements, so triage can tell "no secrets present" from
|
|
240
|
+
* "redaction never ran".
|
|
241
|
+
*/
|
|
242
|
+
export function redactLogText(text) {
|
|
243
|
+
let out = text;
|
|
244
|
+
let redactions = 0;
|
|
245
|
+
for (const { pattern, replace } of REDACTION_PATTERNS) {
|
|
246
|
+
out = out.replace(pattern, (match, ...rest) => {
|
|
247
|
+
redactions++;
|
|
248
|
+
// String.replace passes (match, ...groups, offset, string). Keep only
|
|
249
|
+
// the capture groups — the trailing offset/string are not groups.
|
|
250
|
+
const groups = rest.slice(0, Math.max(0, rest.length - 2));
|
|
251
|
+
return replace(match, ...groups);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
return { text: out, redactions };
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* `true` unless the user disabled log capture via `HQ_FEEDBACK_LOGS`.
|
|
258
|
+
*
|
|
259
|
+
* Uses `?.trim() ||` rather than `??` on purpose: an env var that is SET BUT
|
|
260
|
+
* EMPTY must fall back to the default, and `??` would pass `""` through as a
|
|
261
|
+
* deliberate choice.
|
|
262
|
+
*/
|
|
263
|
+
export function feedbackLogsEnabled(env = process.env) {
|
|
264
|
+
const raw = env.HQ_FEEDBACK_LOGS?.trim().toLowerCase() || "";
|
|
265
|
+
if (raw === "")
|
|
266
|
+
return true;
|
|
267
|
+
return !(raw === "0" || raw === "false" || raw === "no" || raw === "off");
|
|
268
|
+
}
|
|
269
|
+
/** Best-effort `lstat`. Returns null on any error. */
|
|
270
|
+
function lstatOrNull(absPath) {
|
|
271
|
+
try {
|
|
272
|
+
return fs.lstatSync(absPath);
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Enumerate eligible log files under `hqDir`.
|
|
280
|
+
*
|
|
281
|
+
* Eligibility is the allowlist described in the module header: `*.log` files
|
|
282
|
+
* directly inside `hqDir/logs/`, plus {@link FEEDBACK_ROOT_LOG_FILENAMES} at
|
|
283
|
+
* the root. Every candidate must be a REGULAR file by `lstat` — directories
|
|
284
|
+
* and, critically, symlinks are rejected. Returned newest-modified first so
|
|
285
|
+
* the most relevant log is offered the budget before older ones.
|
|
286
|
+
*/
|
|
287
|
+
export function discoverLogFiles(hqDir) {
|
|
288
|
+
const candidates = [];
|
|
289
|
+
const consider = (relName) => {
|
|
290
|
+
const absPath = path.join(hqDir, relName);
|
|
291
|
+
const st = lstatOrNull(absPath);
|
|
292
|
+
// isFile() is false for both directories and symlinks under lstat. A
|
|
293
|
+
// symlink here would be the credential-exfiltration path (see header).
|
|
294
|
+
if (!st || !st.isFile())
|
|
295
|
+
return;
|
|
296
|
+
candidates.push({
|
|
297
|
+
name: relName,
|
|
298
|
+
absPath,
|
|
299
|
+
sizeBytes: st.size,
|
|
300
|
+
modifiedMs: st.mtimeMs,
|
|
301
|
+
});
|
|
302
|
+
};
|
|
303
|
+
let entries;
|
|
304
|
+
try {
|
|
305
|
+
entries = fs.readdirSync(path.join(hqDir, FEEDBACK_LOGS_DIRNAME));
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
// No logs directory on this installation — root logs may still exist.
|
|
309
|
+
entries = [];
|
|
310
|
+
}
|
|
311
|
+
for (const entry of entries) {
|
|
312
|
+
if (!isEligibleLogName(entry))
|
|
313
|
+
continue;
|
|
314
|
+
consider(`${FEEDBACK_LOGS_DIRNAME}/${entry}`);
|
|
315
|
+
}
|
|
316
|
+
for (const filename of FEEDBACK_ROOT_LOG_FILENAMES) {
|
|
317
|
+
consider(filename);
|
|
318
|
+
}
|
|
319
|
+
candidates.sort((a, b) => b.modifiedMs - a.modifiedMs);
|
|
320
|
+
return candidates;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Read the last `maxBytes` of a file.
|
|
324
|
+
*
|
|
325
|
+
* When the file is larger than the window, the first (partial) line is dropped
|
|
326
|
+
* so the tail always starts at a line boundary. Returns null if the file
|
|
327
|
+
* cannot be read.
|
|
328
|
+
*/
|
|
329
|
+
export function readLogTail(absPath, maxBytes) {
|
|
330
|
+
let fd = null;
|
|
331
|
+
try {
|
|
332
|
+
fd = fs.openSync(absPath, "r");
|
|
333
|
+
const size = fs.fstatSync(fd).size;
|
|
334
|
+
const readBytes = Math.min(size, maxBytes);
|
|
335
|
+
if (readBytes <= 0)
|
|
336
|
+
return { text: "", truncated: false };
|
|
337
|
+
const buf = Buffer.allocUnsafe(readBytes);
|
|
338
|
+
const got = fs.readSync(fd, buf, 0, readBytes, size - readBytes);
|
|
339
|
+
let text = buf.subarray(0, got).toString("utf8");
|
|
340
|
+
const truncated = size > readBytes;
|
|
341
|
+
if (truncated) {
|
|
342
|
+
const nl = text.indexOf("\n");
|
|
343
|
+
// Drop the leading partial line. If the window holds no newline at all
|
|
344
|
+
// the whole tail is one giant line; keep it rather than returning "".
|
|
345
|
+
if (nl >= 0 && nl + 1 < text.length)
|
|
346
|
+
text = text.slice(nl + 1);
|
|
347
|
+
}
|
|
348
|
+
return { text, truncated };
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
finally {
|
|
354
|
+
if (fd !== null) {
|
|
355
|
+
try {
|
|
356
|
+
fs.closeSync(fd);
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
// Best effort — a failed close must not fail the submission.
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Enumerate eligible state files at the `~/.hq` root.
|
|
366
|
+
*
|
|
367
|
+
* Two ways in, both narrow: an exact name in {@link FEEDBACK_STATE_FILENAMES},
|
|
368
|
+
* or the `sync-journal.<slug>.json` shape. As with logs, every candidate must
|
|
369
|
+
* be a REGULAR file by `lstat`, so a symlink cannot be used to reach a
|
|
370
|
+
* credential file through an eligible-looking name.
|
|
371
|
+
*/
|
|
372
|
+
export function discoverStateFiles(hqDir) {
|
|
373
|
+
let entries;
|
|
374
|
+
try {
|
|
375
|
+
entries = fs.readdirSync(hqDir);
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return [];
|
|
379
|
+
}
|
|
380
|
+
const candidates = [];
|
|
381
|
+
for (const entry of entries) {
|
|
382
|
+
const eligible = FEEDBACK_STATE_FILENAMES.includes(entry) ||
|
|
383
|
+
(entry.startsWith(FEEDBACK_STATE_JOURNAL_PREFIX) && entry.endsWith(".json"));
|
|
384
|
+
if (!eligible)
|
|
385
|
+
continue;
|
|
386
|
+
const absPath = path.join(hqDir, entry);
|
|
387
|
+
const st = lstatOrNull(absPath);
|
|
388
|
+
if (!st || !st.isFile())
|
|
389
|
+
continue;
|
|
390
|
+
candidates.push({
|
|
391
|
+
name: entry,
|
|
392
|
+
absPath,
|
|
393
|
+
sizeBytes: st.size,
|
|
394
|
+
modifiedMs: st.mtimeMs,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
candidates.sort((a, b) => a.name.localeCompare(b.name));
|
|
398
|
+
return candidates;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Enumerate flat, non-recursive entries of a `~/.hq` subdirectory.
|
|
402
|
+
*
|
|
403
|
+
* Used for `locks/`, where every entry is eligible regardless of extension —
|
|
404
|
+
* the filename itself carries the diagnosis. As everywhere else in this module,
|
|
405
|
+
* `lstat` decides eligibility, so a symlink dropped into the directory is
|
|
406
|
+
* skipped rather than followed.
|
|
407
|
+
*
|
|
408
|
+
* Results are sorted NEWEST-FIRST and only then truncated to `maxFiles`, which
|
|
409
|
+
* is load-bearing rather than cosmetic. Sorting by name and cutting at the cap
|
|
410
|
+
* silently drops the most diagnostic entry: ordinary `operation-*.lock` files
|
|
411
|
+
* sort ahead of a `qmd-reindex-bg.claim…stale-claim…`, so an installation with
|
|
412
|
+
* more than `maxFiles` locks would have excluded exactly the entry this source
|
|
413
|
+
* exists to capture. Recency is the right proxy for relevance here, and it
|
|
414
|
+
* matches how log files are prioritised.
|
|
415
|
+
*/
|
|
416
|
+
export function discoverFlatDirFiles(hqDir, dirName, maxFiles) {
|
|
417
|
+
let entries;
|
|
418
|
+
try {
|
|
419
|
+
entries = fs.readdirSync(path.join(hqDir, dirName));
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
return [];
|
|
423
|
+
}
|
|
424
|
+
const candidates = [];
|
|
425
|
+
for (const entry of entries) {
|
|
426
|
+
const absPath = path.join(hqDir, dirName, entry);
|
|
427
|
+
const st = lstatOrNull(absPath);
|
|
428
|
+
if (!st || !st.isFile())
|
|
429
|
+
continue;
|
|
430
|
+
candidates.push({
|
|
431
|
+
name: `${dirName}/${entry}`,
|
|
432
|
+
absPath,
|
|
433
|
+
sizeBytes: st.size,
|
|
434
|
+
modifiedMs: st.mtimeMs,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
// Sort BEFORE truncating, so the cap drops the least recent rather than
|
|
438
|
+
// whatever happened to sort last by name.
|
|
439
|
+
candidates.sort((a, b) => b.modifiedMs - a.modifiedMs || a.name.localeCompare(b.name));
|
|
440
|
+
return candidates.slice(0, maxFiles);
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Walk a `~/.hq` subdirectory for `.json` files, bounded in BOTH depth and
|
|
444
|
+
* count.
|
|
445
|
+
*
|
|
446
|
+
* This is the module's only recursive traversal, so it is deliberately the
|
|
447
|
+
* most constrained. Three bounds, each load-bearing:
|
|
448
|
+
*
|
|
449
|
+
* - `lstat` per entry, and recursion only into a REAL directory. A symlinked
|
|
450
|
+
* directory is never followed, so a link at `jobs/x -> /home/user` cannot
|
|
451
|
+
* turn a two-level walk into a scan of the home directory — where, as it
|
|
452
|
+
* happens, `~/.codex/auth.json` and `~/.hq-agent/machine-creds.json` live.
|
|
453
|
+
* This is the same guard the flat enumerations use, applied to directories
|
|
454
|
+
* rather than files, and it is what keeps "walk a subtree" from being a
|
|
455
|
+
* categorically riskier operation than the allowlists around it.
|
|
456
|
+
* - `maxDepth`, so a deep tree cannot cost unbounded stat calls.
|
|
457
|
+
* - `maxFiles`, so a wide tree cannot crowd the log budget.
|
|
458
|
+
*
|
|
459
|
+
* Entries are returned sorted by name for a stable, reviewable order.
|
|
460
|
+
*/
|
|
461
|
+
export function walkJsonFiles(hqDir, dirName, maxDepth, maxFiles) {
|
|
462
|
+
const candidates = [];
|
|
463
|
+
const walk = (relDir, depth) => {
|
|
464
|
+
if (depth > maxDepth || candidates.length >= maxFiles)
|
|
465
|
+
return;
|
|
466
|
+
let entries;
|
|
467
|
+
try {
|
|
468
|
+
entries = fs.readdirSync(path.join(hqDir, relDir));
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
for (const entry of entries.sort()) {
|
|
474
|
+
if (candidates.length >= maxFiles)
|
|
475
|
+
return;
|
|
476
|
+
const rel = `${relDir}/${entry}`;
|
|
477
|
+
const absPath = path.join(hqDir, rel);
|
|
478
|
+
const st = lstatOrNull(absPath);
|
|
479
|
+
if (!st)
|
|
480
|
+
continue;
|
|
481
|
+
// Recurse ONLY into a real directory: isDirectory() is false under lstat
|
|
482
|
+
// for a symlink, so links are never traversed.
|
|
483
|
+
if (st.isDirectory()) {
|
|
484
|
+
walk(rel, depth + 1);
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
if (!st.isFile())
|
|
488
|
+
continue;
|
|
489
|
+
if (!entry.toLowerCase().endsWith(".json"))
|
|
490
|
+
continue;
|
|
491
|
+
candidates.push({
|
|
492
|
+
name: rel,
|
|
493
|
+
absPath,
|
|
494
|
+
sizeBytes: st.size,
|
|
495
|
+
modifiedMs: st.mtimeMs,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
walk(dirName, 1);
|
|
500
|
+
return candidates;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Read up to `maxBytes` from the START of a file. State documents are JSON,
|
|
504
|
+
* whose meaningful keys sit at the top, so a head is more useful than a tail.
|
|
505
|
+
* Returns null if the file cannot be read.
|
|
506
|
+
*/
|
|
507
|
+
export function readFileHead(absPath, maxBytes) {
|
|
508
|
+
let fd = null;
|
|
509
|
+
try {
|
|
510
|
+
fd = fs.openSync(absPath, "r");
|
|
511
|
+
const size = fs.fstatSync(fd).size;
|
|
512
|
+
const readBytes = Math.min(size, maxBytes);
|
|
513
|
+
if (readBytes <= 0)
|
|
514
|
+
return { text: "", truncated: false };
|
|
515
|
+
const buf = Buffer.allocUnsafe(readBytes);
|
|
516
|
+
const got = fs.readSync(fd, buf, 0, readBytes, 0);
|
|
517
|
+
return { text: buf.subarray(0, got).toString("utf8"), truncated: size > readBytes };
|
|
518
|
+
}
|
|
519
|
+
catch {
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
finally {
|
|
523
|
+
if (fd !== null) {
|
|
524
|
+
try {
|
|
525
|
+
fs.closeSync(fd);
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
// Best effort — a failed close must not fail the submission.
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Collect redacted `~/.hq` evidence within `budgetBytes`.
|
|
535
|
+
*
|
|
536
|
+
* State snapshots are taken first, capped by
|
|
537
|
+
* {@link FEEDBACK_STATE_MAX_TOTAL_BYTES}, because they are small and answer
|
|
538
|
+
* questions a log tail cannot. Log tails then take whatever budget remains.
|
|
539
|
+
*
|
|
540
|
+
* Returns `undefined` when nothing eligible exists at all, so the caller can
|
|
541
|
+
* omit the field entirely rather than attach an empty object. Never throws.
|
|
542
|
+
*/
|
|
543
|
+
export function collectFeedbackLogs(opts = {}) {
|
|
544
|
+
const budgetBytes = Math.max(0, Math.min(opts.budgetBytes ?? FEEDBACK_LOGS_MAX_TOTAL_BYTES, FEEDBACK_LOGS_MAX_TOTAL_BYTES));
|
|
545
|
+
const hqDir = opts.hqDir ?? path.join(opts.homeDir ?? os.homedir(), ".hq");
|
|
546
|
+
let logCandidates;
|
|
547
|
+
let stateCandidates;
|
|
548
|
+
try {
|
|
549
|
+
logCandidates = discoverLogFiles(hqDir);
|
|
550
|
+
// Order matters: root status documents first, then the lock/claim files
|
|
551
|
+
// (highest signal for a hang), then scheduled-job status. Under a tight
|
|
552
|
+
// budget the earlier ones win, and that is the intended priority.
|
|
553
|
+
stateCandidates = [
|
|
554
|
+
...discoverStateFiles(hqDir),
|
|
555
|
+
...discoverFlatDirFiles(hqDir, FEEDBACK_LOCKS_DIRNAME, FEEDBACK_LOCKS_MAX_FILES),
|
|
556
|
+
...walkJsonFiles(hqDir, FEEDBACK_JOBS_DIRNAME, FEEDBACK_JOBS_MAX_DEPTH, FEEDBACK_JOBS_MAX_FILES),
|
|
557
|
+
];
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
return undefined;
|
|
561
|
+
}
|
|
562
|
+
if (logCandidates.length === 0 && stateCandidates.length === 0)
|
|
563
|
+
return undefined;
|
|
564
|
+
const files = [];
|
|
565
|
+
const state = [];
|
|
566
|
+
const skipped = [];
|
|
567
|
+
let skippedOverflow = 0;
|
|
568
|
+
const recordSkip = (name, reason) => {
|
|
569
|
+
if (skipped.length < FEEDBACK_MAX_SKIPPED_ENTRIES) {
|
|
570
|
+
skipped.push({ name, reason });
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
skippedOverflow++;
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
let remaining = budgetBytes;
|
|
577
|
+
// State first: dense, bounded, and the part a triager reads before the logs.
|
|
578
|
+
let stateRemaining = Math.min(remaining, FEEDBACK_STATE_MAX_TOTAL_BYTES);
|
|
579
|
+
for (const candidate of stateCandidates) {
|
|
580
|
+
// A lock file is the one case where an EMPTY file is still evidence: the
|
|
581
|
+
// filename carries the operation, the owning pid, and whether the claim
|
|
582
|
+
// went stale, and many lock implementations write no body at all. Skipping
|
|
583
|
+
// it for emptiness would discard the single highest-signal artifact for a
|
|
584
|
+
// hang, so locks are kept with empty content and only their metadata.
|
|
585
|
+
const isLock = candidate.name.startsWith(`${FEEDBACK_LOCKS_DIRNAME}/`);
|
|
586
|
+
if (candidate.sizeBytes === 0 && !isLock) {
|
|
587
|
+
recordSkip(candidate.name, "empty");
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
if (stateRemaining <= 0) {
|
|
591
|
+
recordSkip(candidate.name, "budget");
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
const perFileMax = candidate.name.startsWith(`${FEEDBACK_LOCKS_DIRNAME}/`)
|
|
595
|
+
? FEEDBACK_LOCK_MAX_FILE_BYTES
|
|
596
|
+
: FEEDBACK_STATE_MAX_FILE_BYTES;
|
|
597
|
+
const window = Math.min(stateRemaining, perFileMax);
|
|
598
|
+
const raw = readFileHead(candidate.absPath, window);
|
|
599
|
+
if (raw === null) {
|
|
600
|
+
recordSkip(candidate.name, "unreadable");
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
const { text, redactions } = redactLogText(raw.text);
|
|
604
|
+
if (text.trim().length === 0 && !isLock) {
|
|
605
|
+
recordSkip(candidate.name, "empty");
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
const includedBytes = Buffer.byteLength(text, "utf8");
|
|
609
|
+
state.push({
|
|
610
|
+
name: candidate.name,
|
|
611
|
+
sizeBytes: candidate.sizeBytes,
|
|
612
|
+
modifiedIso: Number.isFinite(candidate.modifiedMs)
|
|
613
|
+
? new Date(candidate.modifiedMs).toISOString()
|
|
614
|
+
: null,
|
|
615
|
+
includedBytes,
|
|
616
|
+
truncated: raw.truncated,
|
|
617
|
+
redactions,
|
|
618
|
+
content: text,
|
|
619
|
+
});
|
|
620
|
+
stateRemaining -= includedBytes;
|
|
621
|
+
remaining -= includedBytes;
|
|
622
|
+
}
|
|
623
|
+
for (const candidate of logCandidates) {
|
|
624
|
+
if (candidate.sizeBytes === 0) {
|
|
625
|
+
recordSkip(candidate.name, "empty");
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
if (remaining < FEEDBACK_LOGS_MIN_USEFUL_BYTES) {
|
|
629
|
+
recordSkip(candidate.name, "budget");
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
const window = Math.min(remaining, FEEDBACK_LOGS_MAX_FILE_TAIL_BYTES);
|
|
633
|
+
const raw = readLogTail(candidate.absPath, window);
|
|
634
|
+
if (raw === null) {
|
|
635
|
+
recordSkip(candidate.name, "unreadable");
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
// Redact BEFORE measuring, so the budget is spent on what actually ships
|
|
639
|
+
// and no unredacted span can reach the return value.
|
|
640
|
+
const { text, redactions } = redactLogText(raw.text);
|
|
641
|
+
if (text.trim().length === 0) {
|
|
642
|
+
recordSkip(candidate.name, "empty");
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
const includedBytes = Buffer.byteLength(text, "utf8");
|
|
646
|
+
files.push({
|
|
647
|
+
name: candidate.name,
|
|
648
|
+
sizeBytes: candidate.sizeBytes,
|
|
649
|
+
modifiedIso: Number.isFinite(candidate.modifiedMs)
|
|
650
|
+
? new Date(candidate.modifiedMs).toISOString()
|
|
651
|
+
: null,
|
|
652
|
+
includedBytes,
|
|
653
|
+
// Truncated if we only read a window, OR if redaction happened to be a
|
|
654
|
+
// no-op but the read itself was partial.
|
|
655
|
+
truncated: raw.truncated,
|
|
656
|
+
redactions,
|
|
657
|
+
tail: text,
|
|
658
|
+
});
|
|
659
|
+
remaining -= includedBytes;
|
|
660
|
+
}
|
|
661
|
+
return {
|
|
662
|
+
budgetBytes,
|
|
663
|
+
files,
|
|
664
|
+
state,
|
|
665
|
+
skipped,
|
|
666
|
+
...(skippedOverflow > 0 ? { skippedOverflow } : {}),
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
//# sourceMappingURL=feedback-logs.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.108.
|
|
3
|
+
"version": "5.108.6",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@aws-sdk/client-iot-data-plane": "^3.1096.0",
|
|
33
33
|
"@aws-sdk/client-s3": "^3.1049.0",
|
|
34
|
-
"@indigoai-us/hq-cloud": "~6.16.
|
|
34
|
+
"@indigoai-us/hq-cloud": "~6.16.11",
|
|
35
35
|
"@indigoai-us/hq-flags-client": "^0.1.2",
|
|
36
36
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
37
37
|
"@sentry/node": "^10.49.0",
|