@indigoai-us/hq-cli 5.269.0 → 5.271.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/dist/command-catalog.generated.d.ts +7 -4
- package/dist/command-catalog.generated.js +8 -4
- package/dist/commands/files.js +6 -3
- package/dist/commands/lanes-message.js +6 -3
- package/dist/commands/lanes-resume.js +6 -3
- package/dist/commands/lanes-shared.d.ts +20 -4
- package/dist/commands/lanes.d.ts +1 -1
- package/dist/commands/lanes.js +215 -36
- package/dist/lib/billing/plan-limit-upgrade-url.d.ts +1 -4
- package/dist/lib/billing/plan-limit-upgrade-url.js +7 -18
- package/dist/lib/doctor/checks/sync-runtime.d.ts +1 -1
- package/dist/lib/doctor/checks/sync-runtime.js +69 -19
- package/dist/lib/plan-limit-nag.js +2 -4
- package/dist/utils/feedback-logs.d.ts +14 -9
- package/dist/utils/feedback-logs.js +43 -14
- package/dist/utils/local-files-overview.d.ts +4 -2
- package/dist/utils/local-files-overview.js +47 -6
- package/dist/utils/sync-log-sources.d.ts +15 -0
- package/dist/utils/sync-log-sources.js +36 -0
- package/dist/utils/version-gate.d.ts +1 -0
- package/dist/utils/version-gate.js +6 -1
- package/package.json +1 -1
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import * as fs from "node:fs";
|
|
10
10
|
import * as os from "node:os";
|
|
11
11
|
import * as path from "node:path";
|
|
12
|
+
import { currentSyncLogFamily, SYNC_LOG_GENERATIONS, syncLogBasePaths, syncLogGenerationPath, } from "../../../utils/sync-log-sources.js";
|
|
12
13
|
export const SYNC_RUNTIME_WINDOW_MS = 60 * 60_000;
|
|
13
14
|
const LAG_SECONDS = 15 * 60;
|
|
14
15
|
const LAG_MIN_RECEIPTS = 3;
|
|
@@ -32,6 +33,32 @@ function parseRecord(rest) {
|
|
|
32
33
|
return null;
|
|
33
34
|
}
|
|
34
35
|
}
|
|
36
|
+
function timestampFromRecord(record) {
|
|
37
|
+
if (!record)
|
|
38
|
+
return null;
|
|
39
|
+
for (const key of ["time", "timestamp", "ts"]) {
|
|
40
|
+
const value = record[key];
|
|
41
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
42
|
+
const millis = value < 100_000_000_000 ? value * 1000 : value;
|
|
43
|
+
if (Number.isFinite(millis))
|
|
44
|
+
return millis;
|
|
45
|
+
}
|
|
46
|
+
if (typeof value === "string") {
|
|
47
|
+
const parsed = Date.parse(value);
|
|
48
|
+
if (Number.isFinite(parsed))
|
|
49
|
+
return parsed;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
function lineTimestamp(line) {
|
|
55
|
+
const stamp = TIMESTAMP.exec(line);
|
|
56
|
+
if (stamp) {
|
|
57
|
+
const parsed = Date.parse(stamp[1]);
|
|
58
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
59
|
+
}
|
|
60
|
+
return timestampFromRecord(parseRecord(line));
|
|
61
|
+
}
|
|
35
62
|
/** Classify the last hour of sync log lines into privacy-safe results. */
|
|
36
63
|
export function analyzeSyncRuntimeLog(lines, options) {
|
|
37
64
|
const since = options.now - SYNC_RUNTIME_WINDOW_MS;
|
|
@@ -40,13 +67,13 @@ export function analyzeSyncRuntimeLog(lines, options) {
|
|
|
40
67
|
let starts = 0;
|
|
41
68
|
let deviceLimited = 0;
|
|
42
69
|
for (const line of lines) {
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
70
|
+
const time = lineTimestamp(line);
|
|
71
|
+
if (time === null)
|
|
45
72
|
continue;
|
|
46
|
-
const time = Date.parse(stamp[1]);
|
|
47
73
|
if (!Number.isFinite(time) || time < since || time > options.now)
|
|
48
74
|
continue;
|
|
49
|
-
const
|
|
75
|
+
const stamp = TIMESTAMP.exec(line);
|
|
76
|
+
const rest = stamp ? line.slice(stamp[0].length) : line;
|
|
50
77
|
if (DEVICE_LIMIT.test(rest))
|
|
51
78
|
deviceLimited += 1;
|
|
52
79
|
const record = parseRecord(rest);
|
|
@@ -61,7 +88,8 @@ export function analyzeSyncRuntimeLog(lines, options) {
|
|
|
61
88
|
else if (event === "receiver.start") {
|
|
62
89
|
starts += 1;
|
|
63
90
|
}
|
|
64
|
-
if ((record.type === "receiver" || record.type === "error"
|
|
91
|
+
if ((record.type === "receiver" || record.type === "error" ||
|
|
92
|
+
(typeof event === "string" && event.startsWith("receiver."))) &&
|
|
65
93
|
PERMISSION.test(JSON.stringify(record.err ?? record.message ?? ""))) {
|
|
66
94
|
denied += 1;
|
|
67
95
|
}
|
|
@@ -80,7 +108,7 @@ export function analyzeSyncRuntimeLog(lines, options) {
|
|
|
80
108
|
checkId: `sync.runtime.${check.id}`,
|
|
81
109
|
reasonCode: check.reasonCode,
|
|
82
110
|
message: `${check.count} ${check.label} in the last hour.`,
|
|
83
|
-
remediation: "Run `hq sync status` and
|
|
111
|
+
remediation: "Run `hq sync status` and inspect the selected sync log under ~/.hq/logs/, ~/.hq/daemon/logs/, or the configured HQ_DAEMON_DIR/logs/.",
|
|
84
112
|
}
|
|
85
113
|
: {
|
|
86
114
|
status: "PASS",
|
|
@@ -88,7 +116,6 @@ export function analyzeSyncRuntimeLog(lines, options) {
|
|
|
88
116
|
message: `${check.count} ${check.label} in the last hour.`,
|
|
89
117
|
});
|
|
90
118
|
}
|
|
91
|
-
const LOG_GENERATIONS = 4;
|
|
92
119
|
function readLogTail(file) {
|
|
93
120
|
const fd = fs.openSync(file, "r");
|
|
94
121
|
try {
|
|
@@ -104,9 +131,9 @@ function readLogTail(file) {
|
|
|
104
131
|
}
|
|
105
132
|
function firstTimestamp(lines) {
|
|
106
133
|
for (const line of lines) {
|
|
107
|
-
const
|
|
108
|
-
if (
|
|
109
|
-
return
|
|
134
|
+
const timestamp = lineTimestamp(line);
|
|
135
|
+
if (timestamp !== null)
|
|
136
|
+
return timestamp;
|
|
110
137
|
}
|
|
111
138
|
return null;
|
|
112
139
|
}
|
|
@@ -114,15 +141,21 @@ function firstTimestamp(lines) {
|
|
|
114
141
|
* Lines from the active log and as many rotated generations as needed to
|
|
115
142
|
* cover the window. Throws when a present log cannot be read.
|
|
116
143
|
*/
|
|
117
|
-
function readWindowLines(logFile, since) {
|
|
144
|
+
function readWindowLines(logFile, since, daemonLog) {
|
|
118
145
|
const generations = [];
|
|
119
|
-
for (let n = 0; n <
|
|
120
|
-
const file =
|
|
146
|
+
for (let n = 0; n < SYNC_LOG_GENERATIONS; n += 1) {
|
|
147
|
+
const file = syncLogGenerationPath(logFile, n);
|
|
121
148
|
if (!fs.existsSync(file))
|
|
122
|
-
|
|
149
|
+
continue;
|
|
123
150
|
const lines = readLogTail(file);
|
|
124
|
-
|
|
125
|
-
const
|
|
151
|
+
const fallbackTime = daemonLog ? fs.statSync(file).mtimeMs : null;
|
|
152
|
+
const timestampedLines = fallbackTime === null
|
|
153
|
+
? lines
|
|
154
|
+
: lines.map((line) => lineTimestamp(line) === null
|
|
155
|
+
? `${new Date(fallbackTime).toISOString()} ${line}`
|
|
156
|
+
: line);
|
|
157
|
+
generations.unshift(timestampedLines);
|
|
158
|
+
const first = firstTimestamp(timestampedLines);
|
|
126
159
|
if (first !== null && first <= since)
|
|
127
160
|
break;
|
|
128
161
|
}
|
|
@@ -139,15 +172,32 @@ function readConflictCount(file) {
|
|
|
139
172
|
}
|
|
140
173
|
}
|
|
141
174
|
/** Read the machine's sync logs and client health file. */
|
|
142
|
-
export function defaultSyncRuntimeResults(now, hqHome = path.join(os.homedir(), ".hq")) {
|
|
175
|
+
export function defaultSyncRuntimeResults(now, hqHome = path.join(os.homedir(), ".hq"), env = process.env) {
|
|
143
176
|
const options = {
|
|
144
177
|
now: now.getTime(),
|
|
145
178
|
conflictCount: readConflictCount(path.join(hqHome, "client-health.json")),
|
|
146
179
|
};
|
|
147
|
-
const
|
|
180
|
+
const paths = syncLogBasePaths(hqHome, env);
|
|
181
|
+
const familyModifiedMs = (family) => {
|
|
182
|
+
let newest = null;
|
|
183
|
+
for (let generation = 0; generation < SYNC_LOG_GENERATIONS; generation += 1) {
|
|
184
|
+
try {
|
|
185
|
+
const stat = fs.lstatSync(syncLogGenerationPath(paths[family], generation));
|
|
186
|
+
if (!stat.isFile() && !stat.isDirectory())
|
|
187
|
+
continue;
|
|
188
|
+
newest = newest === null ? stat.mtimeMs : Math.max(newest, stat.mtimeMs);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// Missing or inaccessible generations do not make a source family newer.
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return newest;
|
|
195
|
+
};
|
|
196
|
+
const family = currentSyncLogFamily(familyModifiedMs("desktop"), familyModifiedMs("daemon"));
|
|
197
|
+
const logFile = family === null ? paths.desktop : paths[family];
|
|
148
198
|
let lines;
|
|
149
199
|
try {
|
|
150
|
-
lines = readWindowLines(logFile, options.now - SYNC_RUNTIME_WINDOW_MS);
|
|
200
|
+
lines = readWindowLines(logFile, options.now - SYNC_RUNTIME_WINDOW_MS, family === "daemon");
|
|
151
201
|
}
|
|
152
202
|
catch {
|
|
153
203
|
return [
|
|
@@ -37,7 +37,7 @@ import * as fs from "node:fs";
|
|
|
37
37
|
import * as os from "node:os";
|
|
38
38
|
import * as path from "node:path";
|
|
39
39
|
import { resolveProcessFlagCompanyContext } from "./flag-registry.js";
|
|
40
|
-
import {
|
|
40
|
+
import { planLimitUpgradeFallbackUrl, selectPlanLimitUpgradeUrl, } from "./billing/plan-limit-upgrade-url.js";
|
|
41
41
|
export const PLAN_LIMIT_UPGRADE_URL = "https://hq.computer/billing/upgrade";
|
|
42
42
|
/**
|
|
43
43
|
* Cadence of the over-limit per-turn line (US-036). Was once a day; the nag
|
|
@@ -230,9 +230,7 @@ export function recordPlanLimitStatus(body, opts = {}) {
|
|
|
230
230
|
: null;
|
|
231
231
|
const topLevelUpgradeUrl = parseUpgradeUrl(bodyRecord?.upgradeUrl);
|
|
232
232
|
const nestedUpgradeUrl = parseNestedUpgradeUrl(body);
|
|
233
|
-
const companySlug =
|
|
234
|
-
? resolveProcessFlagCompanyContext().companySlug
|
|
235
|
-
: undefined;
|
|
233
|
+
const companySlug = resolveProcessFlagCompanyContext().companySlug;
|
|
236
234
|
resolvedUpgradeUrl = selectPlanLimitUpgradeUrl(companySlug, {
|
|
237
235
|
topLevelUrl: topLevelUpgradeUrl,
|
|
238
236
|
nestedUrl: nestedUpgradeUrl,
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
* (`hq-sync.log.1`, `.2`, `.3`) are eligible too — just after a rotation
|
|
14
14
|
* the active file is nearly empty and the history lives in `.1`.
|
|
15
15
|
* `logs/mcp-registry.log` is the CLI's own append-only MCP audit trail.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* The daemon runner's `daemon/logs/sync.log` and its three rotations are
|
|
17
|
+
* eligible too. When both sync families exist, only the newer family is
|
|
18
|
+
* attached.
|
|
18
19
|
*
|
|
19
20
|
* - STATE SNAPSHOTS (`state`) — small JSON files that describe where sync
|
|
20
21
|
* and client health currently stand: the in-flight sync phase and conflict
|
|
@@ -36,8 +37,9 @@
|
|
|
36
37
|
*
|
|
37
38
|
* 1. ALLOWLIST, NOT A SCAN. `~/.hq` also holds `cognito-tokens.json`,
|
|
38
39
|
* `deploy-passwords.json`, and `secrets-cache/`. Only `*.log` files
|
|
39
|
-
* directly inside `~/.hq/logs
|
|
40
|
-
*
|
|
40
|
+
* directly inside `~/.hq/logs/`, the exact daemon sync-log generations
|
|
41
|
+
* under the configured daemon root, and the two known root-level boot
|
|
42
|
+
* logs are eligible. A recursive walk over `~/.hq` is a credential leak
|
|
41
43
|
* and must never be introduced here.
|
|
42
44
|
*
|
|
43
45
|
* 2. NO SYMLINKS. Eligibility is decided with `lstat`, and a candidate must
|
|
@@ -235,12 +237,13 @@ export declare function feedbackLogsEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
|
235
237
|
* Enumerate eligible log files under `hqDir`.
|
|
236
238
|
*
|
|
237
239
|
* Eligibility is the allowlist described in the module header: `*.log` files
|
|
238
|
-
* directly inside `hqDir/logs/`,
|
|
239
|
-
* the root. Every candidate must be a
|
|
240
|
-
* and, critically, symlinks are rejected.
|
|
241
|
-
* the most relevant log is offered
|
|
240
|
+
* directly inside `hqDir/logs/`, the exact daemon sync-log generations, plus
|
|
241
|
+
* {@link FEEDBACK_ROOT_LOG_FILENAMES} at the root. Every candidate must be a
|
|
242
|
+
* REGULAR file by `lstat` — directories and, critically, symlinks are rejected.
|
|
243
|
+
* Returned newest-modified first so the most relevant log is offered budget
|
|
244
|
+
* before older ones.
|
|
242
245
|
*/
|
|
243
|
-
export declare function discoverLogFiles(hqDir: string): LogCandidate[];
|
|
246
|
+
export declare function discoverLogFiles(hqDir: string, env?: NodeJS.ProcessEnv): LogCandidate[];
|
|
244
247
|
/**
|
|
245
248
|
* Read the last `maxBytes` of a file.
|
|
246
249
|
*
|
|
@@ -314,6 +317,8 @@ export interface CollectFeedbackLogsOptions {
|
|
|
314
317
|
hqDir?: string;
|
|
315
318
|
/** Override the home directory used to derive `~/.hq` (tests). */
|
|
316
319
|
homeDir?: string;
|
|
320
|
+
/** Override process environment for configured log paths (tests). */
|
|
321
|
+
env?: NodeJS.ProcessEnv;
|
|
317
322
|
}
|
|
318
323
|
/**
|
|
319
324
|
* Collect redacted `~/.hq` evidence within `budgetBytes`.
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
* (`hq-sync.log.1`, `.2`, `.3`) are eligible too — just after a rotation
|
|
14
14
|
* the active file is nearly empty and the history lives in `.1`.
|
|
15
15
|
* `logs/mcp-registry.log` is the CLI's own append-only MCP audit trail.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* The daemon runner's `daemon/logs/sync.log` and its three rotations are
|
|
17
|
+
* eligible too. When both sync families exist, only the newer family is
|
|
18
|
+
* attached.
|
|
18
19
|
*
|
|
19
20
|
* - STATE SNAPSHOTS (`state`) — small JSON files that describe where sync
|
|
20
21
|
* and client health currently stand: the in-flight sync phase and conflict
|
|
@@ -36,8 +37,9 @@
|
|
|
36
37
|
*
|
|
37
38
|
* 1. ALLOWLIST, NOT A SCAN. `~/.hq` also holds `cognito-tokens.json`,
|
|
38
39
|
* `deploy-passwords.json`, and `secrets-cache/`. Only `*.log` files
|
|
39
|
-
* directly inside `~/.hq/logs
|
|
40
|
-
*
|
|
40
|
+
* directly inside `~/.hq/logs/`, the exact daemon sync-log generations
|
|
41
|
+
* under the configured daemon root, and the two known root-level boot
|
|
42
|
+
* logs are eligible. A recursive walk over `~/.hq` is a credential leak
|
|
41
43
|
* and must never be introduced here.
|
|
42
44
|
*
|
|
43
45
|
* 2. NO SYMLINKS. Eligibility is decided with `lstat`, and a candidate must
|
|
@@ -58,6 +60,7 @@
|
|
|
58
60
|
import * as fs from "node:fs";
|
|
59
61
|
import * as os from "node:os";
|
|
60
62
|
import * as path from "node:path";
|
|
63
|
+
import { currentSyncLogFamily, SYNC_LOG_RELATIVE_PATHS, SYNC_LOG_GENERATIONS, syncLogBasePaths, syncLogFamilyForRelativePath, syncLogGenerationPath, } from "./sync-log-sources.js";
|
|
61
64
|
/**
|
|
62
65
|
* Ceiling on the total redacted log text attached to one submission.
|
|
63
66
|
*
|
|
@@ -279,15 +282,15 @@ function lstatOrNull(absPath) {
|
|
|
279
282
|
* Enumerate eligible log files under `hqDir`.
|
|
280
283
|
*
|
|
281
284
|
* Eligibility is the allowlist described in the module header: `*.log` files
|
|
282
|
-
* directly inside `hqDir/logs/`,
|
|
283
|
-
* the root. Every candidate must be a
|
|
284
|
-
* and, critically, symlinks are rejected.
|
|
285
|
-
* the most relevant log is offered
|
|
285
|
+
* directly inside `hqDir/logs/`, the exact daemon sync-log generations, plus
|
|
286
|
+
* {@link FEEDBACK_ROOT_LOG_FILENAMES} at the root. Every candidate must be a
|
|
287
|
+
* REGULAR file by `lstat` — directories and, critically, symlinks are rejected.
|
|
288
|
+
* Returned newest-modified first so the most relevant log is offered budget
|
|
289
|
+
* before older ones.
|
|
286
290
|
*/
|
|
287
|
-
export function discoverLogFiles(hqDir) {
|
|
291
|
+
export function discoverLogFiles(hqDir, env = process.env) {
|
|
288
292
|
const candidates = [];
|
|
289
|
-
const consider = (relName) => {
|
|
290
|
-
const absPath = path.join(hqDir, relName);
|
|
293
|
+
const consider = (relName, absPath = path.join(hqDir, relName)) => {
|
|
291
294
|
const st = lstatOrNull(absPath);
|
|
292
295
|
// isFile() is false for both directories and symlinks under lstat. A
|
|
293
296
|
// symlink here would be the credential-exfiltration path (see header).
|
|
@@ -313,11 +316,37 @@ export function discoverLogFiles(hqDir) {
|
|
|
313
316
|
continue;
|
|
314
317
|
consider(`${FEEDBACK_LOGS_DIRNAME}/${entry}`);
|
|
315
318
|
}
|
|
319
|
+
const daemonLogPath = syncLogBasePaths(hqDir, env).daemon;
|
|
320
|
+
for (let generation = 0; generation < SYNC_LOG_GENERATIONS; generation += 1) {
|
|
321
|
+
const relName = syncLogGenerationPath(SYNC_LOG_RELATIVE_PATHS.daemon, generation);
|
|
322
|
+
const absPath = syncLogGenerationPath(daemonLogPath, generation);
|
|
323
|
+
consider(relName, absPath);
|
|
324
|
+
}
|
|
316
325
|
for (const filename of FEEDBACK_ROOT_LOG_FILENAMES) {
|
|
317
326
|
consider(filename);
|
|
318
327
|
}
|
|
319
|
-
|
|
320
|
-
|
|
328
|
+
let desktopModifiedMs = null;
|
|
329
|
+
let daemonModifiedMs = null;
|
|
330
|
+
for (const candidate of candidates) {
|
|
331
|
+
const family = syncLogFamilyForRelativePath(candidate.name);
|
|
332
|
+
if (family === "desktop") {
|
|
333
|
+
desktopModifiedMs = desktopModifiedMs === null
|
|
334
|
+
? candidate.modifiedMs
|
|
335
|
+
: Math.max(desktopModifiedMs, candidate.modifiedMs);
|
|
336
|
+
}
|
|
337
|
+
else if (family === "daemon") {
|
|
338
|
+
daemonModifiedMs = daemonModifiedMs === null
|
|
339
|
+
? candidate.modifiedMs
|
|
340
|
+
: Math.max(daemonModifiedMs, candidate.modifiedMs);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const selectedFamily = currentSyncLogFamily(desktopModifiedMs, daemonModifiedMs);
|
|
344
|
+
const selected = candidates.filter((candidate) => {
|
|
345
|
+
const family = syncLogFamilyForRelativePath(candidate.name);
|
|
346
|
+
return family === null || family === selectedFamily;
|
|
347
|
+
});
|
|
348
|
+
selected.sort((a, b) => b.modifiedMs - a.modifiedMs);
|
|
349
|
+
return selected;
|
|
321
350
|
}
|
|
322
351
|
/**
|
|
323
352
|
* Read the last `maxBytes` of a file.
|
|
@@ -546,7 +575,7 @@ export function collectFeedbackLogs(opts = {}) {
|
|
|
546
575
|
let logCandidates;
|
|
547
576
|
let stateCandidates;
|
|
548
577
|
try {
|
|
549
|
-
logCandidates = discoverLogFiles(hqDir);
|
|
578
|
+
logCandidates = discoverLogFiles(hqDir, opts.env);
|
|
550
579
|
// Order matters: root status documents first, then the lock/claim files
|
|
551
580
|
// (highest signal for a hang), then scheduled-job status. Under a tight
|
|
552
581
|
// budget the earlier ones win, and that is the intended priority.
|
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
* cross the wire; there is no string field on it for anything sensitive to
|
|
12
12
|
* hide inside.
|
|
13
13
|
*
|
|
14
|
-
* Sources (metadata only, via `fs.
|
|
14
|
+
* Sources (metadata only, via `fs.lstat` — full content is read only for the
|
|
15
15
|
* bounded error-line count below, and even then only a capped tail window,
|
|
16
16
|
* and even then only an integer count is ever returned):
|
|
17
|
-
* - the
|
|
17
|
+
* - the newest available desktop or daemon sync-log family and its rotations
|
|
18
|
+
* under `~/.hq/logs/` or `~/.hq/daemon/logs/`
|
|
18
19
|
* - the CLI's own per-company sync journals, enumerated via `listJournals()`
|
|
19
20
|
* (never a hand-reconstructed path — see client-health.ts's note on the
|
|
20
21
|
* single-path-reconstruction regression this mirrors).
|
|
@@ -42,6 +43,7 @@ export declare function countRecentErrorLines(filePath: string, maxBytes?: numbe
|
|
|
42
43
|
export interface CollectLocalFilesOverviewDeps {
|
|
43
44
|
now?: () => Date;
|
|
44
45
|
homeDir?: () => string;
|
|
46
|
+
env?: NodeJS.ProcessEnv;
|
|
45
47
|
journals?: () => readonly {
|
|
46
48
|
path: string;
|
|
47
49
|
}[];
|
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
* cross the wire; there is no string field on it for anything sensitive to
|
|
12
12
|
* hide inside.
|
|
13
13
|
*
|
|
14
|
-
* Sources (metadata only, via `fs.
|
|
14
|
+
* Sources (metadata only, via `fs.lstat` — full content is read only for the
|
|
15
15
|
* bounded error-line count below, and even then only a capped tail window,
|
|
16
16
|
* and even then only an integer count is ever returned):
|
|
17
|
-
* - the
|
|
17
|
+
* - the newest available desktop or daemon sync-log family and its rotations
|
|
18
|
+
* under `~/.hq/logs/` or `~/.hq/daemon/logs/`
|
|
18
19
|
* - the CLI's own per-company sync journals, enumerated via `listJournals()`
|
|
19
20
|
* (never a hand-reconstructed path — see client-health.ts's note on the
|
|
20
21
|
* single-path-reconstruction regression this mirrors).
|
|
@@ -28,6 +29,7 @@ import * as fs from "node:fs";
|
|
|
28
29
|
import * as os from "node:os";
|
|
29
30
|
import * as path from "node:path";
|
|
30
31
|
import { listJournals } from "@indigoai-us/hq-cloud";
|
|
32
|
+
import { currentSyncLogFamily, SYNC_LOG_GENERATIONS, syncLogBasePaths, syncLogGenerationPath, } from "./sync-log-sources.js";
|
|
31
33
|
import { CLIENT_HEALTH_MAX_ERROR_LINE_COUNT, CLIENT_HEALTH_MAX_FILE_AGE_SECONDS, CLIENT_HEALTH_MAX_FILE_SIZE_BYTES, } from "./client-health-contract.js";
|
|
32
34
|
/**
|
|
33
35
|
* Only the last N bytes of the sync log are read for error-line counting —
|
|
@@ -37,7 +39,8 @@ import { CLIENT_HEALTH_MAX_ERROR_LINE_COUNT, CLIENT_HEALTH_MAX_FILE_AGE_SECONDS,
|
|
|
37
39
|
export const LOCAL_FILES_OVERVIEW_TAIL_READ_BYTES = 2 * 1024 * 1024; // 2 MiB
|
|
38
40
|
/** Case-insensitive marker matched against each line (or JSON-lines `level`). */
|
|
39
41
|
const ERROR_MARKER = /error/i;
|
|
40
|
-
const JSON_LEVEL_ERROR = /"level"\s*:\s*"error"/i;
|
|
42
|
+
const JSON_LEVEL_ERROR = /"level"\s*:\s*"error"\s*(?=[,}])/i;
|
|
43
|
+
const JSON_NUMERIC_LEVEL = /"level"\s*:\s*(-?\d+(?:\.\d+)?)\s*(?=[,}])/;
|
|
41
44
|
const ABSENT = { exists: false, sizeBytes: 0, ageSeconds: 0 };
|
|
42
45
|
function clampInt(value, max) {
|
|
43
46
|
if (!Number.isFinite(value) || value < 0)
|
|
@@ -102,6 +105,12 @@ export function countRecentErrorLines(filePath, maxBytes = LOCAL_FILES_OVERVIEW_
|
|
|
102
105
|
for (const line of text.split("\n")) {
|
|
103
106
|
if (count >= cap)
|
|
104
107
|
break;
|
|
108
|
+
const numericLevel = JSON_NUMERIC_LEVEL.exec(line)?.[1];
|
|
109
|
+
if (numericLevel !== undefined) {
|
|
110
|
+
if (Number(numericLevel) >= 50)
|
|
111
|
+
count += 1;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
105
114
|
if (ERROR_MARKER.test(line) || JSON_LEVEL_ERROR.test(line))
|
|
106
115
|
count += 1;
|
|
107
116
|
}
|
|
@@ -133,9 +142,41 @@ export function collectLocalFilesOverview(deps = {}) {
|
|
|
133
142
|
const nowMs = now.getTime();
|
|
134
143
|
const homeDir = (deps.homeDir ?? os.homedir)();
|
|
135
144
|
const countErrorLines = deps.countErrorLines ?? countRecentErrorLines;
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
const
|
|
145
|
+
const hqDir = path.join(homeDir, ".hq");
|
|
146
|
+
const syncLogPaths = syncLogBasePaths(hqDir, deps.env);
|
|
147
|
+
const generations = {
|
|
148
|
+
desktop: [],
|
|
149
|
+
daemon: [],
|
|
150
|
+
};
|
|
151
|
+
const familyModifiedMs = (family) => {
|
|
152
|
+
let newest = null;
|
|
153
|
+
for (let generation = 0; generation < SYNC_LOG_GENERATIONS; generation += 1) {
|
|
154
|
+
const file = syncLogGenerationPath(syncLogPaths[family], generation);
|
|
155
|
+
try {
|
|
156
|
+
const stat = fs.lstatSync(file);
|
|
157
|
+
if (!stat.isFile())
|
|
158
|
+
continue;
|
|
159
|
+
generations[family].push({ file, modifiedMs: stat.mtimeMs });
|
|
160
|
+
newest = newest === null ? stat.mtimeMs : Math.max(newest, stat.mtimeMs);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// Missing, denied, or raced generations are absent from this overview.
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return newest;
|
|
167
|
+
};
|
|
168
|
+
const family = currentSyncLogFamily(familyModifiedMs("desktop"), familyModifiedMs("daemon"));
|
|
169
|
+
const selectedFamily = family ?? "desktop";
|
|
170
|
+
const selectedFiles = generations[selectedFamily];
|
|
171
|
+
const activePath = syncLogPaths[selectedFamily];
|
|
172
|
+
const activeFacts = statFacts(activePath, nowMs);
|
|
173
|
+
const freshestGeneration = [...selectedFiles].sort((a, b) => b.modifiedMs - a.modifiedMs)[0];
|
|
174
|
+
const syncLogPath = activeFacts.exists ? activePath : freshestGeneration?.file;
|
|
175
|
+
const syncLogFacts = syncLogPath ? statFacts(syncLogPath, nowMs) : ABSENT;
|
|
176
|
+
let recentErrorLineCount = 0;
|
|
177
|
+
for (const generation of selectedFiles) {
|
|
178
|
+
recentErrorLineCount = Math.min(CLIENT_HEALTH_MAX_ERROR_LINE_COUNT, recentErrorLineCount + countErrorLines(generation.file));
|
|
179
|
+
}
|
|
139
180
|
let journalEntries;
|
|
140
181
|
try {
|
|
141
182
|
journalEntries = (deps.journals ?? listJournals)();
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Stable relative names used for feedback attachments. */
|
|
2
|
+
export declare const SYNC_LOG_RELATIVE_PATHS: {
|
|
3
|
+
readonly desktop: "logs/hq-sync.log";
|
|
4
|
+
readonly daemon: "daemon/logs/sync.log";
|
|
5
|
+
};
|
|
6
|
+
export type SyncLogFamily = keyof typeof SYNC_LOG_RELATIVE_PATHS;
|
|
7
|
+
/** Read the active file plus the three retained rotations. */
|
|
8
|
+
export declare const SYNC_LOG_GENERATIONS = 4;
|
|
9
|
+
/** Resolve both sync-log families, honoring the daemon's configured root. */
|
|
10
|
+
export declare function syncLogBasePaths(hqDir: string, env?: NodeJS.ProcessEnv): Record<SyncLogFamily, string>;
|
|
11
|
+
export declare function syncLogGenerationPath(basePath: string, generation: number): string;
|
|
12
|
+
export declare function syncLogFamilyForRelativePath(name: string): SyncLogFamily | null;
|
|
13
|
+
/** Choose the most recently written family; desktop wins a timestamp tie. */
|
|
14
|
+
export declare function currentSyncLogFamily(desktopModifiedMs: number | null, daemonModifiedMs: number | null): SyncLogFamily | null;
|
|
15
|
+
//# sourceMappingURL=sync-log-sources.d.ts.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { daemonPaths } from "../lib/daemon/paths.js";
|
|
3
|
+
/** Stable relative names used for feedback attachments. */
|
|
4
|
+
export const SYNC_LOG_RELATIVE_PATHS = {
|
|
5
|
+
desktop: "logs/hq-sync.log",
|
|
6
|
+
daemon: "daemon/logs/sync.log",
|
|
7
|
+
};
|
|
8
|
+
/** Read the active file plus the three retained rotations. */
|
|
9
|
+
export const SYNC_LOG_GENERATIONS = 4;
|
|
10
|
+
/** Resolve both sync-log families, honoring the daemon's configured root. */
|
|
11
|
+
export function syncLogBasePaths(hqDir, env = process.env) {
|
|
12
|
+
const daemonLogDir = daemonPaths(path.dirname(hqDir), env).logsDir;
|
|
13
|
+
return {
|
|
14
|
+
desktop: path.join(hqDir, SYNC_LOG_RELATIVE_PATHS.desktop),
|
|
15
|
+
daemon: path.join(daemonLogDir, "sync.log"),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function syncLogGenerationPath(basePath, generation) {
|
|
19
|
+
return generation === 0 ? basePath : `${basePath}.${generation}`;
|
|
20
|
+
}
|
|
21
|
+
export function syncLogFamilyForRelativePath(name) {
|
|
22
|
+
if (/^logs\/hq-sync\.log(?:\.\d+)?$/.test(name))
|
|
23
|
+
return "desktop";
|
|
24
|
+
if (/^daemon\/logs\/sync\.log(?:\.\d+)?$/.test(name))
|
|
25
|
+
return "daemon";
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
/** Choose the most recently written family; desktop wins a timestamp tie. */
|
|
29
|
+
export function currentSyncLogFamily(desktopModifiedMs, daemonModifiedMs) {
|
|
30
|
+
if (desktopModifiedMs === null)
|
|
31
|
+
return daemonModifiedMs === null ? null : "daemon";
|
|
32
|
+
if (daemonModifiedMs === null)
|
|
33
|
+
return "desktop";
|
|
34
|
+
return daemonModifiedMs > desktopModifiedMs ? "daemon" : "desktop";
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=sync-log-sources.js.map
|
|
@@ -390,6 +390,7 @@ interface EnforceUpdateDeps {
|
|
|
390
390
|
performUpdateString?: (command: string) => UpdateResult;
|
|
391
391
|
resolveInstall?: () => RunningInstall;
|
|
392
392
|
runner?: UpdateRunner;
|
|
393
|
+
platform?: NodeJS.Platform;
|
|
393
394
|
cleanStale?: (prefix: string) => string[];
|
|
394
395
|
acquireLock?: () => UpdateLockHandle | null;
|
|
395
396
|
/**
|
|
@@ -1224,7 +1224,12 @@ function attemptRequiredUpdate(decision, deps, install) {
|
|
|
1224
1224
|
// Never for pnpm or Bun: elevation changes the package manager's global home,
|
|
1225
1225
|
// leaving the user's shim untouched while reporting success. A failed
|
|
1226
1226
|
// non-npm update must surface instead.
|
|
1227
|
-
|
|
1227
|
+
const platform = deps.platform ?? process.platform;
|
|
1228
|
+
if (platform !== "win32" &&
|
|
1229
|
+
!result.ok &&
|
|
1230
|
+
result.code !== "ETIMEDOUT" &&
|
|
1231
|
+
primaryCmd &&
|
|
1232
|
+
!isManagedOutsideNpm) {
|
|
1228
1233
|
console.error(chalk.dim(` Update failed unprivileged; retrying with: sudo -n ${primaryCmd} ${primaryArgs.join(" ")}`));
|
|
1229
1234
|
const sudoResult = performUpdateCommand("sudo", ["-n", primaryCmd, ...primaryArgs], runner);
|
|
1230
1235
|
if (sudoResult.ok)
|