@indigoai-us/hq-cli 5.268.0 → 5.270.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 +36 -0
- package/dist/command-catalog.generated.d.ts +77 -4
- package/dist/command-catalog.generated.js +98 -4
- package/dist/commands/files.js +6 -3
- package/dist/commands/lanes-hold.d.ts +2 -0
- package/dist/commands/lanes-hold.js +122 -310
- package/dist/commands/lanes-message.js +6 -4
- package/dist/commands/lanes-review.d.ts +10 -0
- package/dist/commands/lanes-review.js +56 -0
- package/dist/commands/lanes-shared.d.ts +1 -0
- package/dist/commands/lanes.d.ts +2 -0
- package/dist/commands/lanes.js +29 -4
- 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/lanes/blocker-freshness.js +2 -1
- package/dist/lib/lanes/keep-alive.js +49 -20
- package/dist/lib/lanes/kr-freshness.js +3 -2
- package/dist/lib/lanes/lane-company.d.ts +3 -2
- package/dist/lib/lanes/lane-company.js +21 -4
- package/dist/lib/lanes/manager-status-gate.js +5 -3
- package/dist/lib/lanes/merge-command.d.ts +14 -0
- package/dist/lib/lanes/merge-command.js +363 -0
- package/dist/lib/lanes/reviews.d.ts +38 -0
- package/dist/lib/lanes/reviews.js +395 -0
- package/dist/lib/lanes/watcher.js +4 -2
- 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
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { assertNoSecretText } from "./text-scan.js";
|
|
5
|
+
import { LanesError } from "./errors.js";
|
|
6
|
+
export const LANE_REVIEWS_REL = path.join("workspace", "lanes", "reviews");
|
|
7
|
+
export const LANE_REVIEW_SCHEMA_VERSION = "hq-lanes-review.v1";
|
|
8
|
+
export const LANE_REVIEW_HISTORY_CAP = 50;
|
|
9
|
+
const REVIEW_LOCK_WAIT_MS = 5_000;
|
|
10
|
+
const REVIEW_LOCK_STALE_MS = 5 * 60_000;
|
|
11
|
+
function fail(error, message, details = {}) {
|
|
12
|
+
throw new LanesError(error, message, details);
|
|
13
|
+
}
|
|
14
|
+
export function canonicalLaneReviewRepo(raw) {
|
|
15
|
+
const value = raw.trim();
|
|
16
|
+
const parts = value.split("/");
|
|
17
|
+
if (parts.length !== 2) {
|
|
18
|
+
return fail("review_repo_invalid", "Repository must be a safe owner/repo pair", { field: "repo" });
|
|
19
|
+
}
|
|
20
|
+
const [owner, repository] = parts;
|
|
21
|
+
if (!owner || !repository ||
|
|
22
|
+
!/^[A-Za-z0-9][A-Za-z0-9-]{0,38}$/.test(owner) ||
|
|
23
|
+
!/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(repository) ||
|
|
24
|
+
owner === "." || owner === ".." || repository === "." || repository === "..") {
|
|
25
|
+
return fail("review_repo_invalid", "Repository must be a safe owner/repo pair", { field: "repo" });
|
|
26
|
+
}
|
|
27
|
+
return `${owner.toLowerCase()}/${repository.toLowerCase()}`;
|
|
28
|
+
}
|
|
29
|
+
export function parseLaneReviewPr(raw) {
|
|
30
|
+
const text = String(raw).trim();
|
|
31
|
+
if (!/^\d+$/.test(text)) {
|
|
32
|
+
return fail("review_pr_invalid", "Pull request number must be a positive integer", { field: "pr" });
|
|
33
|
+
}
|
|
34
|
+
const pr = Number(text);
|
|
35
|
+
if (!Number.isSafeInteger(pr) || pr <= 0) {
|
|
36
|
+
return fail("review_pr_invalid", "Pull request number must be a positive integer", { field: "pr" });
|
|
37
|
+
}
|
|
38
|
+
return pr;
|
|
39
|
+
}
|
|
40
|
+
function parseHeadSha(value, field = "head") {
|
|
41
|
+
const head = value.trim();
|
|
42
|
+
if (!/^[a-fA-F0-9]{40}$/.test(head)) {
|
|
43
|
+
return fail("review_head_invalid", "Head must be a 40-character hexadecimal commit SHA", { field });
|
|
44
|
+
}
|
|
45
|
+
return head.toLowerCase();
|
|
46
|
+
}
|
|
47
|
+
function reviewPath(hqRoot, repo, pr) {
|
|
48
|
+
const [owner, name] = canonicalLaneReviewRepo(repo).split("/");
|
|
49
|
+
return path.join(hqRoot, LANE_REVIEWS_REL, `${owner}__${name}`, `${parseLaneReviewPr(pr)}.json`);
|
|
50
|
+
}
|
|
51
|
+
export function laneReviewPath(hqRoot, repo, pr) {
|
|
52
|
+
return reviewPath(hqRoot, repo, pr);
|
|
53
|
+
}
|
|
54
|
+
function isObject(value) {
|
|
55
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
56
|
+
}
|
|
57
|
+
function isTimestamp(value) {
|
|
58
|
+
if (typeof value !== "string")
|
|
59
|
+
return false;
|
|
60
|
+
const date = Date.parse(value);
|
|
61
|
+
return Number.isFinite(date) && new Date(date).toISOString() === value;
|
|
62
|
+
}
|
|
63
|
+
function isNullableText(value) {
|
|
64
|
+
return value === null || (typeof value === "string" && value.length > 0);
|
|
65
|
+
}
|
|
66
|
+
function storedTextIsSafe(field, value, file) {
|
|
67
|
+
try {
|
|
68
|
+
assertNoSecretText(field, value);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
fail("review_record_invalid", "Review record contains unsafe text", { path: file, field });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function parseHistoryEntry(value, file) {
|
|
75
|
+
if (!isObject(value) || typeof value.repo !== "string" ||
|
|
76
|
+
!Number.isSafeInteger(value.pr) || value.pr <= 0 ||
|
|
77
|
+
typeof value.head_sha !== "string" || !/^[a-f0-9]{40}$/.test(value.head_sha) ||
|
|
78
|
+
!isNullableText(value.reviewer_lane) || !isNullableText(value.reviewer_session) ||
|
|
79
|
+
!isNullableText(value.worker_lane) || !isNullableText(value.note) ||
|
|
80
|
+
!isTimestamp(value.recorded_at)) {
|
|
81
|
+
return fail("review_record_invalid", "Review history entry has invalid fields", { path: file });
|
|
82
|
+
}
|
|
83
|
+
let repo;
|
|
84
|
+
try {
|
|
85
|
+
repo = canonicalLaneReviewRepo(value.repo);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return fail("review_record_invalid", "Review history entry has an invalid repository", { path: file });
|
|
89
|
+
}
|
|
90
|
+
if (repo !== value.repo) {
|
|
91
|
+
return fail("review_record_invalid", "Review history repository is not canonical", { path: file });
|
|
92
|
+
}
|
|
93
|
+
for (const field of ["reviewer_lane", "reviewer_session", "worker_lane", "note"]) {
|
|
94
|
+
const text = value[field];
|
|
95
|
+
if (typeof text === "string")
|
|
96
|
+
storedTextIsSafe(`review.${field}`, text, file);
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
function parseReview(value, file) {
|
|
101
|
+
if (!isObject(value) || value.schema_version !== LANE_REVIEW_SCHEMA_VERSION || !Array.isArray(value.history)) {
|
|
102
|
+
return fail("review_record_invalid", "Review record has invalid fields", { path: file });
|
|
103
|
+
}
|
|
104
|
+
const current = parseHistoryEntry(value, file);
|
|
105
|
+
if (value.history.length > LANE_REVIEW_HISTORY_CAP) {
|
|
106
|
+
return fail("review_record_invalid", "Review record history exceeds its cap", { path: file });
|
|
107
|
+
}
|
|
108
|
+
const history = value.history.map((entry) => parseHistoryEntry(entry, file));
|
|
109
|
+
return { ...current, schema_version: LANE_REVIEW_SCHEMA_VERSION, history };
|
|
110
|
+
}
|
|
111
|
+
export function readLaneReview(hqRoot, repo, pr) {
|
|
112
|
+
const canonicalRepo = canonicalLaneReviewRepo(repo);
|
|
113
|
+
const canonicalPr = parseLaneReviewPr(pr);
|
|
114
|
+
const file = reviewPath(hqRoot, canonicalRepo, canonicalPr);
|
|
115
|
+
let contents;
|
|
116
|
+
try {
|
|
117
|
+
contents = fs.readFileSync(file, "utf8");
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (error.code === "ENOENT")
|
|
121
|
+
return null;
|
|
122
|
+
return fail("review_record_unreadable", "Review record could not be read", { path: file });
|
|
123
|
+
}
|
|
124
|
+
let value;
|
|
125
|
+
try {
|
|
126
|
+
value = JSON.parse(contents);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return fail("review_record_invalid", "Review record is not valid JSON", { path: file });
|
|
130
|
+
}
|
|
131
|
+
const review = parseReview(value, file);
|
|
132
|
+
if (review.repo !== canonicalRepo || review.pr !== canonicalPr || reviewPath(hqRoot, review.repo, review.pr) !== file) {
|
|
133
|
+
return fail("review_record_invalid", "Review record identity does not match its path", { path: file });
|
|
134
|
+
}
|
|
135
|
+
return review;
|
|
136
|
+
}
|
|
137
|
+
export function listLaneReviews(hqRoot, reviewerLane) {
|
|
138
|
+
const directory = path.join(hqRoot, LANE_REVIEWS_REL);
|
|
139
|
+
let repoDirectories;
|
|
140
|
+
try {
|
|
141
|
+
repoDirectories = fs.readdirSync(directory).sort();
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (error.code === "ENOENT")
|
|
145
|
+
return { reviews: [], skipped: 0 };
|
|
146
|
+
return { reviews: [], skipped: 1 };
|
|
147
|
+
}
|
|
148
|
+
const reviews = [];
|
|
149
|
+
let skipped = 0;
|
|
150
|
+
for (const repoDirectory of repoDirectories) {
|
|
151
|
+
const match = /^([a-z0-9-]+)__([a-z0-9._-]+)$/.exec(repoDirectory);
|
|
152
|
+
if (!match || repoDirectory.startsWith(".")) {
|
|
153
|
+
skipped += 1;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const repoPath = path.join(directory, repoDirectory);
|
|
157
|
+
try {
|
|
158
|
+
const stat = fs.lstatSync(repoPath);
|
|
159
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
160
|
+
skipped += 1;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
skipped += 1;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const repo = `${match[1]}/${match[2]}`;
|
|
169
|
+
let names;
|
|
170
|
+
try {
|
|
171
|
+
names = fs.readdirSync(repoPath).sort();
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
skipped += 1;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
for (const name of names) {
|
|
178
|
+
if (/^\d+\.json\.lock$/.test(name) || /^\d+\.json\.\d+\.[a-f0-9]{12}\.tmp$/.test(name))
|
|
179
|
+
continue;
|
|
180
|
+
if (name.startsWith(".") || !/^\d+\.json$/.test(name)) {
|
|
181
|
+
skipped += 1;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const file = path.join(repoPath, name);
|
|
185
|
+
try {
|
|
186
|
+
const stat = fs.lstatSync(file);
|
|
187
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
188
|
+
skipped += 1;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
skipped += 1;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
let pr;
|
|
197
|
+
try {
|
|
198
|
+
pr = parseLaneReviewPr(name.slice(0, -5));
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
skipped += 1;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
let review;
|
|
205
|
+
try {
|
|
206
|
+
review = readLaneReview(hqRoot, repo, pr);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
skipped += 1;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (!review) {
|
|
213
|
+
skipped += 1;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (reviewerLane === undefined || review.reviewer_lane === reviewerLane)
|
|
217
|
+
reviews.push(review);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
reviews: reviews.sort((left, right) => right.recorded_at.localeCompare(left.recorded_at) ||
|
|
222
|
+
left.repo.localeCompare(right.repo) || left.pr - right.pr),
|
|
223
|
+
skipped,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
export async function recordLaneReview(hqRoot, input) {
|
|
227
|
+
const repo = canonicalLaneReviewRepo(input.repo);
|
|
228
|
+
const pr = parseLaneReviewPr(input.pr);
|
|
229
|
+
const head_sha = parseHeadSha(input.headSha);
|
|
230
|
+
const reviewer_lane = input.reviewerLane?.trim() || null;
|
|
231
|
+
const reviewer_session = input.reviewerSession?.trim() || null;
|
|
232
|
+
const worker_lane = input.workerLane?.trim() || null;
|
|
233
|
+
const note = input.note?.trim() || null;
|
|
234
|
+
if (worker_lane && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,200}$/.test(worker_lane)) {
|
|
235
|
+
return fail("review_worker_invalid", "Worker lane id has an invalid format", { field: "worker" });
|
|
236
|
+
}
|
|
237
|
+
if (reviewer_lane)
|
|
238
|
+
assertNoSecretText("reviewer_lane", reviewer_lane);
|
|
239
|
+
if (reviewer_session)
|
|
240
|
+
assertNoSecretText("reviewer_session", reviewer_session);
|
|
241
|
+
if (note)
|
|
242
|
+
assertNoSecretText("note", note);
|
|
243
|
+
if (worker_lane)
|
|
244
|
+
assertNoSecretText("worker", worker_lane);
|
|
245
|
+
const file = reviewPath(hqRoot, repo, pr);
|
|
246
|
+
return withReviewRecordLock(file, async () => {
|
|
247
|
+
const previous = readLaneReview(hqRoot, repo, pr);
|
|
248
|
+
const entry = {
|
|
249
|
+
repo,
|
|
250
|
+
pr,
|
|
251
|
+
head_sha,
|
|
252
|
+
reviewer_lane,
|
|
253
|
+
reviewer_session,
|
|
254
|
+
worker_lane,
|
|
255
|
+
note,
|
|
256
|
+
recorded_at: input.recordedAt.toISOString(),
|
|
257
|
+
};
|
|
258
|
+
const review = {
|
|
259
|
+
...entry,
|
|
260
|
+
schema_version: LANE_REVIEW_SCHEMA_VERSION,
|
|
261
|
+
history: [...(previous?.history ?? []), ...(previous ? [currentHistoryEntry(previous)] : [])]
|
|
262
|
+
.slice(-LANE_REVIEW_HISTORY_CAP),
|
|
263
|
+
};
|
|
264
|
+
writeAtomic(file, review);
|
|
265
|
+
return review;
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
function reviewLockOwnerAlive(pid) {
|
|
269
|
+
try {
|
|
270
|
+
process.kill(pid, 0);
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
return error.code !== "ESRCH";
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function removeStaleReviewLock(lockPath) {
|
|
278
|
+
let first;
|
|
279
|
+
try {
|
|
280
|
+
first = fs.lstatSync(lockPath);
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
if (error.code === "ENOENT")
|
|
284
|
+
return true;
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
if (Date.now() - first.mtimeMs < REVIEW_LOCK_STALE_MS)
|
|
288
|
+
return false;
|
|
289
|
+
let pid;
|
|
290
|
+
try {
|
|
291
|
+
pid = Number(fs.readFileSync(lockPath, "utf8").split("\n", 1)[0]);
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
if (error.code === "ENOENT")
|
|
295
|
+
return true;
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || reviewLockOwnerAlive(pid))
|
|
299
|
+
return false;
|
|
300
|
+
let second;
|
|
301
|
+
try {
|
|
302
|
+
second = fs.lstatSync(lockPath);
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
if (error.code === "ENOENT")
|
|
306
|
+
return true;
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
if (second.dev !== first.dev || second.ino !== first.ino || second.mtimeMs !== first.mtimeMs)
|
|
310
|
+
return false;
|
|
311
|
+
try {
|
|
312
|
+
fs.unlinkSync(lockPath);
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
if (error.code === "ENOENT")
|
|
317
|
+
return true;
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
async function withReviewRecordLock(file, action) {
|
|
322
|
+
const lockPath = `${file}.lock`;
|
|
323
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
324
|
+
const started = Date.now();
|
|
325
|
+
let fd;
|
|
326
|
+
while (fd === undefined) {
|
|
327
|
+
try {
|
|
328
|
+
fd = fs.openSync(lockPath, "wx", 0o600);
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
if (error.code !== "EEXIST")
|
|
332
|
+
throw error;
|
|
333
|
+
if (Date.now() - started >= REVIEW_LOCK_WAIT_MS) {
|
|
334
|
+
return fail("review_record_write_busy", "This review record is being updated; retry the command", { path: file });
|
|
335
|
+
}
|
|
336
|
+
if (!removeStaleReviewLock(lockPath))
|
|
337
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
let ownedStat;
|
|
341
|
+
try {
|
|
342
|
+
ownedStat = fs.fstatSync(fd);
|
|
343
|
+
fs.writeSync(fd, `${process.pid}\n${Date.now()}\n`);
|
|
344
|
+
return await action();
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
try {
|
|
348
|
+
fs.closeSync(fd);
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
process.stderr.write(`hq lanes review: lock close failed for ${lockPath}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
352
|
+
}
|
|
353
|
+
if (ownedStat) {
|
|
354
|
+
try {
|
|
355
|
+
const current = fs.lstatSync(lockPath);
|
|
356
|
+
if (current.dev === ownedStat.dev && current.ino === ownedStat.ino)
|
|
357
|
+
fs.unlinkSync(lockPath);
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
if (error.code !== "ENOENT") {
|
|
361
|
+
process.stderr.write(`hq lanes review: lock cleanup failed for ${lockPath}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
function currentHistoryEntry(review) {
|
|
368
|
+
const { repo, pr, head_sha, reviewer_lane, reviewer_session, worker_lane, note, recorded_at } = review;
|
|
369
|
+
return { repo, pr, head_sha, reviewer_lane, reviewer_session, worker_lane, note, recorded_at };
|
|
370
|
+
}
|
|
371
|
+
function writeAtomic(file, review) {
|
|
372
|
+
const temporary = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
373
|
+
try {
|
|
374
|
+
fs.writeFileSync(temporary, `${JSON.stringify(review, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
375
|
+
fs.renameSync(temporary, file);
|
|
376
|
+
fs.chmodSync(file, 0o600);
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
try {
|
|
380
|
+
fs.rmSync(temporary, { force: true });
|
|
381
|
+
}
|
|
382
|
+
catch (cleanupError) {
|
|
383
|
+
throw new LanesError("review_record_write_failed", "Review record could not be written and temporary cleanup failed", {
|
|
384
|
+
path: file,
|
|
385
|
+
error: error instanceof Error ? error.name : "UnknownError",
|
|
386
|
+
cleanup_error: cleanupError instanceof Error ? cleanupError.name : "UnknownError",
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
throw new LanesError("review_record_write_failed", "Review record could not be written", {
|
|
390
|
+
path: file,
|
|
391
|
+
error: error instanceof Error ? error.name : "UnknownError",
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
//# sourceMappingURL=reviews.js.map
|
|
@@ -48,6 +48,7 @@ import { resolveAdmissionHost as resolveProcessAdmissionHost } from "./admission
|
|
|
48
48
|
import { createLaneMesh, emitLaneDecisionEvents, emitLaneProgressEvent, emitLaneTerminalStateEvent, laneStoryStatusSuppressionKey, progressEventDue, } from "./mesh-events.js";
|
|
49
49
|
import { envelopeQuestionId } from "./questions.js";
|
|
50
50
|
import { readLanesRegistryFlag } from "./registry-flag.js";
|
|
51
|
+
import { laneCompanyUidSource, resolveLaneCompany } from "./lane-company.js";
|
|
51
52
|
import { blockerFreshnessFingerprint, resolveBlockerFreshnessForLane, } from "./blocker-freshness.js";
|
|
52
53
|
import { krFreshnessFingerprint, resolveKrFreshnessForLane, } from "./kr-freshness.js";
|
|
53
54
|
import { MANAGER_STATUS_GATE_FLAG, managerStatusWasWritten, readStatusSnapshot, resolveManagerStatusTarget, managerStatusGateEnabledForLane, } from "./manager-status-gate.js";
|
|
@@ -647,11 +648,12 @@ async function settleManagerStatusGate(hqRoot, lane, manifestTurnId, at, deps, l
|
|
|
647
648
|
const readFlag = deps.readManagerStatusGateFlag ?? readLanesRegistryFlag;
|
|
648
649
|
let enabled = false;
|
|
649
650
|
try {
|
|
650
|
-
const
|
|
651
|
+
const resolution = resolveLaneCompany(hqRoot, lane);
|
|
652
|
+
const company = resolution.company ?? undefined;
|
|
651
653
|
enabled = await readFlag(MANAGER_STATUS_GATE_FLAG, {
|
|
652
654
|
company,
|
|
653
655
|
...(company ? {
|
|
654
|
-
companyUidSource:
|
|
656
|
+
companyUidSource: laneCompanyUidSource(resolution.source),
|
|
655
657
|
} : {}),
|
|
656
658
|
});
|
|
657
659
|
}
|
|
@@ -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
|
}[];
|