@indigoai-us/hq-cli 5.303.0 → 5.305.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 +15 -0
- package/dist/command-catalog.generated.d.ts +1 -1
- package/dist/command-catalog.generated.js +3 -1
- package/dist/command-registration-plan.js +8 -0
- package/dist/commands/lanes.d.ts +2 -0
- package/dist/commands/lanes.js +34 -6
- package/dist/commands/mcp-status.js +1 -0
- package/dist/commands/members.js +0 -1
- package/dist/commands/people.d.ts +3 -0
- package/dist/commands/people.js +27 -3
- package/dist/index.js +6 -1
- package/dist/lazy-commands.js +5 -2
- package/dist/lib/aws-sdk-node-warning-output.d.ts +12 -0
- package/dist/lib/aws-sdk-node-warning-output.js +22 -0
- package/dist/lib/company-errors-sdk-warning-flag.d.ts +5 -0
- package/dist/lib/company-errors-sdk-warning-flag.js +8 -0
- package/dist/lib/doctor/checks/work-context.js +2 -2
- package/dist/lib/doctor/deep/executor.d.ts +4 -10
- package/dist/lib/doctor/deep/executor.js +1 -1
- package/dist/lib/doctor/deep/parity.d.ts +2 -29
- package/dist/lib/doctor/deep/parity.js +7 -7
- package/dist/lib/doctor/deep/sandbox.d.ts +7 -25
- package/dist/lib/doctor/deep/sandbox.js +4 -4
- package/dist/lib/lanes/keep-alive.js +13 -2
- package/dist/lib/mesh/live/daemon/doctor.js +2 -2
- package/dist/lib/mesh/live/flush.d.ts +2 -2
- package/dist/lib/mesh/live/flush.js +49 -29
- package/dist/lib/mesh/live/spool.d.ts +6 -0
- package/dist/lib/mesh/live/spool.js +203 -4
- package/dist/lib/work-context/bind-best.js +13 -1
- package/dist/main.js +16 -3
- package/dist/utils/self-update.d.ts +4 -0
- package/dist/utils/self-update.js +46 -9
- package/dist/utils/version-check.d.ts +7 -0
- package/dist/utils/version-check.js +30 -1
- package/dist/utils/version-gate.d.ts +28 -10
- package/dist/utils/version-gate.js +159 -29
- package/dist/utils/version-report.d.ts +2 -1
- package/dist/utils/version-report.js +8 -1
- package/package.json +1 -1
- package/dist/lib/work-context/index.d.ts +0 -26
- package/dist/lib/work-context/index.js +0 -22
|
@@ -3,7 +3,7 @@ import * as path from "node:path";
|
|
|
3
3
|
import { LanesError } from "./errors.js";
|
|
4
4
|
import { dropboxInboxHasMessages } from "./dropbox.js";
|
|
5
5
|
import { appendCappedHistory, listQuestionIds, readQuestion, retryUpdateLane } from "./store.js";
|
|
6
|
-
import { isIsoDateTime } from "./types.js";
|
|
6
|
+
import { isTerminalLaneState, isIsoDateTime, } from "./types.js";
|
|
7
7
|
export const LEAD_LANE_KEEP_ALIVE_FLAG = "lanes.lead-keep-alive";
|
|
8
8
|
export const LEAD_LANE_KEEP_ALIVE_DEFAULT_ENABLED = false;
|
|
9
9
|
export const LEAD_LANE_KEEP_ALIVE_NEXT_CHECK_FLAG = "lanes.keep-alive-next-check-at";
|
|
@@ -254,7 +254,10 @@ export function keepAliveSupersededBy(lane, lanes, lineageAware = LANE_SUPERSESS
|
|
|
254
254
|
return lanes.find((candidate) => {
|
|
255
255
|
if (candidate.lane_id === lane.lane_id || Date.parse(candidate.created_at) <= createdAt)
|
|
256
256
|
return false;
|
|
257
|
-
if (candidate.state === "cancelled" ||
|
|
257
|
+
if (candidate.state === "cancelled" ||
|
|
258
|
+
candidate.state === "killed" ||
|
|
259
|
+
candidate.state === "failed" ||
|
|
260
|
+
(lane.state === "running" && isTerminalLaneState(candidate.state)))
|
|
258
261
|
return false;
|
|
259
262
|
const candidateCompany = companyOf(candidate) ?? null;
|
|
260
263
|
const sameCompany = laneCompany === null
|
|
@@ -270,8 +273,16 @@ export function keepAliveSupersededBy(lane, lanes, lineageAware = LANE_SUPERSESS
|
|
|
270
273
|
const sameLaneSenior = (lane.senior.kind === "lane" || lane.senior.kind === "session") &&
|
|
271
274
|
candidate.senior.kind === lane.senior.kind &&
|
|
272
275
|
candidate.senior.id === lane.senior.id;
|
|
276
|
+
const differentKnownSenior = lane.senior.kind === "lane" &&
|
|
277
|
+
candidate.senior.kind === "lane" &&
|
|
278
|
+
typeof lane.senior.id === "string" &&
|
|
279
|
+
lane.senior.id.trim().length > 0 &&
|
|
280
|
+
typeof candidate.senior.id === "string" &&
|
|
281
|
+
candidate.senior.id.trim().length > 0 &&
|
|
282
|
+
candidate.senior.id !== lane.senior.id;
|
|
273
283
|
const sameProfile = lane.worker_profile !== undefined &&
|
|
274
284
|
candidate.worker_profile === lane.worker_profile &&
|
|
285
|
+
!(lane.state === "running" && differentKnownSenior) &&
|
|
275
286
|
!(lineageAware && sameLaneSenior);
|
|
276
287
|
const projectLead = lane.project_id !== undefined &&
|
|
277
288
|
candidate.project_id === lane.project_id &&
|
|
@@ -11,7 +11,7 @@ import { readSessionState } from "../../../work-context/state.js";
|
|
|
11
11
|
import { workContextRoot } from "../../../work-context/paths.js";
|
|
12
12
|
import { outboxQuarantineStatusEnabled } from "../../../work-context/outbox-quarantine-status-flag.js";
|
|
13
13
|
import { formatDroppedPresenceCompany } from "./credentials.js";
|
|
14
|
-
import { countJsonlLines, countUnattributedEvents, } from "../spool.js";
|
|
14
|
+
import { countDistinctHeldEvents, countJsonlLines, countUnattributedEvents, } from "../spool.js";
|
|
15
15
|
import { workMeshDeadLetterPath, workMeshHeldPath, workMeshRoot, workMeshSpoolPath, } from "../paths.js";
|
|
16
16
|
import { daemonDir, daemonPidPath } from "./paths.js";
|
|
17
17
|
import { readEmitState } from "../emit.js";
|
|
@@ -178,7 +178,7 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
178
178
|
const heldPath = workMeshHeldPath(meshRoot);
|
|
179
179
|
const deadPath = workMeshDeadLetterPath(meshRoot);
|
|
180
180
|
const spoolDepth = countJsonlLines(spoolPath);
|
|
181
|
-
const heldCount =
|
|
181
|
+
const heldCount = countDistinctHeldEvents(heldPath);
|
|
182
182
|
const trustedRoots = readDaemonConfig(dir).trustedHqRoots;
|
|
183
183
|
const outboxOperations = listOutboxOperations(ctxRoot);
|
|
184
184
|
const heldWithNoLocalEvidence = readHeldSessions(meshRoot).filter((ref) => {
|
|
@@ -14,7 +14,7 @@ import { type SessionEventsPoster } from "./session-events-client.js";
|
|
|
14
14
|
export declare const HELD_TTL_MS: number;
|
|
15
15
|
/** Re-attempt held events at most this often (spool still flushes every cycle). */
|
|
16
16
|
export declare const HELD_RETRY_INTERVAL_MS: number;
|
|
17
|
-
/** Cap held
|
|
17
|
+
/** Cap distinct held events; oldest overflow is dead-lettered with HELD_OVERFLOW. */
|
|
18
18
|
export declare const HELD_MAX_LINES = 20000;
|
|
19
19
|
/**
|
|
20
20
|
* Re-check an unbound session against the project catalog at most this often.
|
|
@@ -46,7 +46,7 @@ export interface FlushDeps {
|
|
|
46
46
|
lastHeldRetryAtMs?: number;
|
|
47
47
|
/** Invoked when this flush claims held, so the daemon can advance its timestamp. */
|
|
48
48
|
onHeldClaimed?: (atMs: number) => void;
|
|
49
|
-
/** Max
|
|
49
|
+
/** Max distinct eventIds retained in held.jsonl after a flush. Default HELD_MAX_LINES. */
|
|
50
50
|
heldMaxLines?: number;
|
|
51
51
|
/** Optional logger (daemon wires this); used once per overflow trim. */
|
|
52
52
|
log?: (message: string) => void;
|
|
@@ -22,7 +22,7 @@ import { defaultSleep, FLUSH_MAX_ATTEMPTS, fullJitterDelayMs, } from "./backoff.
|
|
|
22
22
|
import { LOCAL_ONLY_FIELDS, stripLocalOnlyFields } from "./format-spool-line.js";
|
|
23
23
|
import { enrichSessionEndEvent } from "./report-summary.js";
|
|
24
24
|
import { SESSION_EVENTS_BATCH_MAX, WRITABLE_CONTEXT_STATUSES, parseSessionEventsBatchAck, } from "./session-events-client.js";
|
|
25
|
-
import { appendDeadLetterLine, appendDroppedReceipt,
|
|
25
|
+
import { appendDeadLetterLine, appendDroppedReceipt, appendHeldLinesIfAbsent, appendSpoolLine, claimHeld, claimSpool, countDistinctHeldEvents, countJsonlLines, recoverOrphanClaims, removeClaimFile, } from "./spool.js";
|
|
26
26
|
import { workMeshHeldPath, workMeshRoot } from "./paths.js";
|
|
27
27
|
/** Bound a replayed session backlog so one flush cannot burst hundreds of events. */
|
|
28
28
|
const SESSION_EVENTS_MAX_PER_SESSION_PER_FLUSH = 25;
|
|
@@ -31,7 +31,7 @@ const MAX_RETRY_AFTER_MS = 5 * 60 * 1000;
|
|
|
31
31
|
export const HELD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
32
32
|
/** Re-attempt held events at most this often (spool still flushes every cycle). */
|
|
33
33
|
export const HELD_RETRY_INTERVAL_MS = 5 * 60 * 1000;
|
|
34
|
-
/** Cap held
|
|
34
|
+
/** Cap distinct held events; oldest overflow is dead-lettered with HELD_OVERFLOW. */
|
|
35
35
|
export const HELD_MAX_LINES = 20_000;
|
|
36
36
|
/**
|
|
37
37
|
* Re-check an unbound session against the project catalog at most this often.
|
|
@@ -279,11 +279,9 @@ export async function flushSessionEvents(deps) {
|
|
|
279
279
|
state = readSessionState(sessionId, deps.workContextRoot) ?? state;
|
|
280
280
|
if (!allowed) {
|
|
281
281
|
const heldAt = now().toISOString();
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
summary.held += 1;
|
|
282
|
+
summary.held += appendHeldLinesIfAbsent(remainingItems.map((item) => heldEnvelope(item.event, "NEEDS_COMPANY", item.heldAt ?? heldAt)), deps.workMeshRoot);
|
|
283
|
+
for (const item of remainingItems)
|
|
285
284
|
markDisposed(item.event);
|
|
286
|
-
}
|
|
287
285
|
continue;
|
|
288
286
|
}
|
|
289
287
|
}
|
|
@@ -306,11 +304,9 @@ export async function flushSessionEvents(deps) {
|
|
|
306
304
|
}
|
|
307
305
|
if (decision.action === "hold") {
|
|
308
306
|
const heldAt = now().toISOString();
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
summary.held += 1;
|
|
307
|
+
summary.held += appendHeldLinesIfAbsent(remainingItems.map((item) => heldEnvelope(item.event, decision.heldReason, item.heldAt ?? heldAt)), deps.workMeshRoot);
|
|
308
|
+
for (const item of remainingItems)
|
|
312
309
|
markDisposed(item.event);
|
|
313
|
-
}
|
|
314
310
|
continue;
|
|
315
311
|
}
|
|
316
312
|
// Post a bounded portion of this session's remaining backlog (after any
|
|
@@ -368,11 +364,9 @@ export async function flushSessionEvents(deps) {
|
|
|
368
364
|
deps.log?.(`work-mesh: session ${sessionId} project missing (HTTP 404); downgraded to needs_project and held events`);
|
|
369
365
|
}
|
|
370
366
|
const heldAt = now().toISOString();
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
summary.held += 1;
|
|
367
|
+
summary.held += appendHeldLinesIfAbsent(originalChunk.map((item) => heldEnvelope(item.event, "NEEDS_PROJECT", item.heldAt ?? heldAt)), deps.workMeshRoot);
|
|
368
|
+
for (const item of originalChunk)
|
|
374
369
|
markDisposed(item.event);
|
|
375
|
-
}
|
|
376
370
|
continue;
|
|
377
371
|
}
|
|
378
372
|
if (!result.retryable) {
|
|
@@ -429,15 +423,17 @@ export async function flushSessionEvents(deps) {
|
|
|
429
423
|
* flusher (`hq mesh flush` beside the daemon) may have appended to a fresh
|
|
430
424
|
* held.jsonl after our claim renamed the old one away.
|
|
431
425
|
* - The overflow claim file is removed only after every dead letter and every
|
|
432
|
-
* retained line is on disk.
|
|
433
|
-
*
|
|
434
|
-
*
|
|
426
|
+
* retained line is on disk. Retained rows are idempotent by eventId, so a
|
|
427
|
+
* retry after an interrupted append is safe; the claim stays until recovery
|
|
428
|
+
* can finish, preserving the append-before-remove ordering.
|
|
435
429
|
*/
|
|
436
430
|
function trimHeldOverflow(meshRoot, maxLines, now, summary) {
|
|
437
431
|
if (maxLines <= 0)
|
|
438
432
|
return 0;
|
|
439
433
|
const heldPath = workMeshHeldPath(meshRoot);
|
|
440
|
-
|
|
434
|
+
const lineCount = countJsonlLines(heldPath);
|
|
435
|
+
const distinctCount = countDistinctHeldEvents(heldPath);
|
|
436
|
+
if (lineCount <= maxLines && lineCount === distinctCount)
|
|
441
437
|
return 0;
|
|
442
438
|
const claim = claimHeld(meshRoot, `${now().getTime()}.overflow`);
|
|
443
439
|
if (!claim.claimPath)
|
|
@@ -447,15 +443,26 @@ function trimHeldOverflow(meshRoot, maxLines, now, summary) {
|
|
|
447
443
|
return 0;
|
|
448
444
|
}
|
|
449
445
|
try {
|
|
450
|
-
|
|
451
|
-
|
|
446
|
+
const alreadyHeldIds = readHeldEventIds(heldPath);
|
|
447
|
+
const seenClaimIds = new Set();
|
|
448
|
+
const uniqueLines = claim.lines.filter((line) => {
|
|
449
|
+
const eventId = heldEventIdOfLine(line);
|
|
450
|
+
if (!eventId)
|
|
451
|
+
return true;
|
|
452
|
+
if (alreadyHeldIds.has(eventId) || seenClaimIds.has(eventId))
|
|
453
|
+
return false;
|
|
454
|
+
seenClaimIds.add(eventId);
|
|
455
|
+
return true;
|
|
456
|
+
});
|
|
457
|
+
if (uniqueLines.length <= maxLines) {
|
|
458
|
+
appendHeldLinesOnce(meshRoot, uniqueLines);
|
|
452
459
|
removeClaimFile(claim.claimPath);
|
|
453
460
|
return 0;
|
|
454
461
|
}
|
|
455
|
-
const overflow =
|
|
462
|
+
const overflow = uniqueLines.length - maxLines;
|
|
456
463
|
// Oldest by heldAt: retries regroup held by session, so file order is not
|
|
457
464
|
// chronological. Unparseable lines sort first (dead-lettered first).
|
|
458
|
-
const ordered =
|
|
465
|
+
const ordered = uniqueLines
|
|
459
466
|
.map((line, idx) => ({ line, idx, atMs: heldAtMs(line) }))
|
|
460
467
|
.sort((a, b) => a.atMs - b.atMs || a.idx - b.idx);
|
|
461
468
|
const toDead = ordered.slice(0, overflow);
|
|
@@ -475,7 +482,7 @@ function trimHeldOverflow(meshRoot, maxLines, now, summary) {
|
|
|
475
482
|
}
|
|
476
483
|
summary.deadLettered += 1;
|
|
477
484
|
}
|
|
478
|
-
|
|
485
|
+
appendHeldLinesOnce(meshRoot, toKeep);
|
|
479
486
|
summary.heldOverflow = overflow;
|
|
480
487
|
removeClaimFile(claim.claimPath);
|
|
481
488
|
return overflow;
|
|
@@ -496,12 +503,25 @@ function heldAtMs(line) {
|
|
|
496
503
|
const ms = Date.parse(heldAt);
|
|
497
504
|
return Number.isFinite(ms) ? ms : 0;
|
|
498
505
|
}
|
|
499
|
-
function
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
506
|
+
function appendHeldLinesOnce(meshRoot, lines) {
|
|
507
|
+
appendHeldLinesIfAbsent(lines, meshRoot);
|
|
508
|
+
}
|
|
509
|
+
function heldEventIdOfLine(line) {
|
|
510
|
+
const parsed = parseLine(line);
|
|
511
|
+
return parsed ? eventIdOf(unwrapHeld(parsed).event) : null;
|
|
512
|
+
}
|
|
513
|
+
function readHeldEventIds(filePath) {
|
|
514
|
+
if (!fs.existsSync(filePath))
|
|
515
|
+
return new Set();
|
|
516
|
+
const ids = new Set();
|
|
517
|
+
for (const line of fs.readFileSync(filePath, "utf8").split("\n")) {
|
|
518
|
+
if (!line.trim())
|
|
519
|
+
continue;
|
|
520
|
+
const id = heldEventIdOfLine(line);
|
|
521
|
+
if (id)
|
|
522
|
+
ids.add(id);
|
|
523
|
+
}
|
|
524
|
+
return ids;
|
|
505
525
|
}
|
|
506
526
|
/**
|
|
507
527
|
* Account a 2xx batch: dead-letter rejected eventIds with their codes;
|
|
@@ -19,6 +19,10 @@ export declare function ensureSpoolFile(filePath: string): void;
|
|
|
19
19
|
export declare function appendJsonlLine(filePath: string, line: string): void;
|
|
20
20
|
export declare function appendSpoolLine(line: string, root?: string): string;
|
|
21
21
|
export declare function appendHeldLine(line: string, root?: string): string;
|
|
22
|
+
/** Append a held envelope only when its eventId is not already held. */
|
|
23
|
+
export declare function appendHeldLineIfAbsent(line: string, root?: string): boolean;
|
|
24
|
+
/** Append a deduplicated held batch under one file lock and one write. */
|
|
25
|
+
export declare function appendHeldLinesIfAbsent(lines: string[], root?: string): number;
|
|
22
26
|
export declare function appendDeadLetterLine(line: string, root?: string): string;
|
|
23
27
|
export declare function appendDroppedReceipt(receipt: Record<string, unknown>, root?: string): string;
|
|
24
28
|
export interface ClaimResult {
|
|
@@ -49,6 +53,8 @@ export declare function recoverOrphanClaims(root: string): {
|
|
|
49
53
|
export declare function removeClaimFile(claimPath: string | null): void;
|
|
50
54
|
export declare function deadLetterNonEmpty(root?: string): boolean;
|
|
51
55
|
export declare function countJsonlLines(filePath: string): number;
|
|
56
|
+
/** Count held eventIds once; legacy rows without an eventId remain line-counted. */
|
|
57
|
+
export declare function countDistinctHeldEvents(filePath: string): number;
|
|
52
58
|
/** Local queues that still lack a usable company attribution. */
|
|
53
59
|
export interface UnattributedEventCounts {
|
|
54
60
|
spool: number;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
7
|
import * as path from "node:path";
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
8
9
|
import { ensureOwnerDir } from "../../work-context/atomic.js";
|
|
9
10
|
import { workMeshClaimPath, workMeshDeadLetterPath, workMeshDroppedReceiptPath, workMeshHeldPath, workMeshRoot, workMeshSpoolPath, } from "./paths.js";
|
|
10
11
|
/** Linux PIPE_BUF is typically 4096; keep a conservative ceiling for one line. */
|
|
@@ -65,12 +66,160 @@ export function appendSpoolLine(line, root) {
|
|
|
65
66
|
}
|
|
66
67
|
export function appendHeldLine(line, root) {
|
|
67
68
|
const held = workMeshHeldPath(root ?? workMeshRoot());
|
|
68
|
-
|
|
69
|
+
appendHeldLineIfAbsent(line, root);
|
|
69
70
|
return held;
|
|
70
71
|
}
|
|
72
|
+
/** Append a held envelope only when its eventId is not already held. */
|
|
73
|
+
export function appendHeldLineIfAbsent(line, root) {
|
|
74
|
+
return appendHeldLinesIfAbsent([line], root) === 1;
|
|
75
|
+
}
|
|
76
|
+
/** Append a deduplicated held batch under one file lock and one write. */
|
|
77
|
+
export function appendHeldLinesIfAbsent(lines, root) {
|
|
78
|
+
const heldPath = workMeshHeldPath(root ?? workMeshRoot());
|
|
79
|
+
ensureSpoolFile(heldPath);
|
|
80
|
+
return withJsonlLock(heldPath, () => {
|
|
81
|
+
const seen = readEventIds(heldPath);
|
|
82
|
+
const append = [];
|
|
83
|
+
for (const line of lines) {
|
|
84
|
+
const eventId = heldEventId(line);
|
|
85
|
+
if (eventId) {
|
|
86
|
+
if (seen.has(eventId))
|
|
87
|
+
continue;
|
|
88
|
+
seen.add(eventId);
|
|
89
|
+
}
|
|
90
|
+
append.push(line);
|
|
91
|
+
}
|
|
92
|
+
appendJsonlLines(heldPath, append);
|
|
93
|
+
return append.length;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
function heldEventId(line) {
|
|
97
|
+
try {
|
|
98
|
+
const parsed = JSON.parse(line);
|
|
99
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
100
|
+
return null;
|
|
101
|
+
const record = parsed;
|
|
102
|
+
const event = record.event && typeof record.event === "object" && !Array.isArray(record.event)
|
|
103
|
+
? record.event
|
|
104
|
+
: record;
|
|
105
|
+
return typeof event.eventId === "string" && event.eventId.trim() ? event.eventId.trim() : null;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function readEventIds(filePath) {
|
|
112
|
+
const ids = new Set();
|
|
113
|
+
let raw;
|
|
114
|
+
try {
|
|
115
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (error.code === "ENOENT")
|
|
119
|
+
return ids;
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
for (const line of raw.split("\n")) {
|
|
123
|
+
if (!line.trim())
|
|
124
|
+
continue;
|
|
125
|
+
const eventId = heldEventId(line);
|
|
126
|
+
if (eventId)
|
|
127
|
+
ids.add(eventId);
|
|
128
|
+
}
|
|
129
|
+
return ids;
|
|
130
|
+
}
|
|
131
|
+
function appendJsonlLines(filePath, lines) {
|
|
132
|
+
if (lines.length === 0)
|
|
133
|
+
return;
|
|
134
|
+
ensureSpoolFile(filePath);
|
|
135
|
+
fs.appendFileSync(filePath, `${lines.join("\n")}\n`, { encoding: "utf8", mode: 0o600 });
|
|
136
|
+
chmodOwnerFile(filePath);
|
|
137
|
+
}
|
|
138
|
+
const JSONL_LOCK_STALE_MS = 5 * 60_000;
|
|
139
|
+
const JSONL_LOCK_TIMEOUT_MS = 5_000;
|
|
140
|
+
/** Serialize check-and-append with claims; stale locks expire after five minutes. */
|
|
141
|
+
function withJsonlLock(filePath, fn) {
|
|
142
|
+
const lockPath = `${filePath}.lock`;
|
|
143
|
+
const token = randomBytes(12).toString("hex");
|
|
144
|
+
const deadline = Date.now() + JSONL_LOCK_TIMEOUT_MS;
|
|
145
|
+
const waitCell = new Int32Array(new SharedArrayBuffer(4));
|
|
146
|
+
while (true) {
|
|
147
|
+
try {
|
|
148
|
+
const fd = fs.openSync(lockPath, "wx", 0o600);
|
|
149
|
+
try {
|
|
150
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid, token, at: Date.now() }));
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
fs.closeSync(fd);
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (error.code !== "EEXIST")
|
|
159
|
+
throw error;
|
|
160
|
+
let stale = false;
|
|
161
|
+
try {
|
|
162
|
+
const before = fs.statSync(lockPath);
|
|
163
|
+
stale = Date.now() - before.mtimeMs >= JSONL_LOCK_STALE_MS;
|
|
164
|
+
if (stale) {
|
|
165
|
+
const after = fs.statSync(lockPath);
|
|
166
|
+
if (before.dev === after.dev && before.ino === after.ino && after.mtimeMs === before.mtimeMs) {
|
|
167
|
+
fs.unlinkSync(lockPath);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
stale = false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch (statError) {
|
|
175
|
+
if (statError.code !== "ENOENT")
|
|
176
|
+
throw statError;
|
|
177
|
+
}
|
|
178
|
+
if (stale)
|
|
179
|
+
continue;
|
|
180
|
+
if (Date.now() >= deadline)
|
|
181
|
+
throw new SpoolWriteError(`Timed out waiting for ${path.basename(filePath)} lock`);
|
|
182
|
+
Atomics.wait(waitCell, 0, 0, 10);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
let result;
|
|
186
|
+
let operationError;
|
|
187
|
+
let operationFailed = false;
|
|
188
|
+
try {
|
|
189
|
+
result = fn();
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
operationError = error;
|
|
193
|
+
operationFailed = true;
|
|
194
|
+
}
|
|
195
|
+
let cleanupError;
|
|
196
|
+
try {
|
|
197
|
+
const record = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
198
|
+
if (record.token === token)
|
|
199
|
+
fs.unlinkSync(lockPath);
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
if (error.code !== "ENOENT")
|
|
203
|
+
cleanupError = error;
|
|
204
|
+
}
|
|
205
|
+
if (operationFailed && cleanupError !== undefined) {
|
|
206
|
+
throw new AggregateError([operationError, cleanupError], "JSONL write and lock cleanup failed");
|
|
207
|
+
}
|
|
208
|
+
if (operationFailed)
|
|
209
|
+
throw operationError;
|
|
210
|
+
if (cleanupError !== undefined)
|
|
211
|
+
throw cleanupError;
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
71
214
|
export function appendDeadLetterLine(line, root) {
|
|
72
215
|
const dl = workMeshDeadLetterPath(root ?? workMeshRoot());
|
|
73
|
-
|
|
216
|
+
withJsonlLock(dl, () => {
|
|
217
|
+
ensureSpoolFile(dl);
|
|
218
|
+
const ids = readEventIds(dl);
|
|
219
|
+
const eventId = heldEventId(line);
|
|
220
|
+
if (!eventId || !ids.has(eventId))
|
|
221
|
+
appendJsonlLine(dl, line);
|
|
222
|
+
});
|
|
74
223
|
return dl;
|
|
75
224
|
}
|
|
76
225
|
export function appendDroppedReceipt(receipt, root) {
|
|
@@ -108,7 +257,8 @@ export function claimSpool(root, ts = `${Date.now()}`) {
|
|
|
108
257
|
}
|
|
109
258
|
export function claimHeld(root, ts = `${Date.now()}`) {
|
|
110
259
|
ensureOwnerDir(root);
|
|
111
|
-
|
|
260
|
+
const heldPath = workMeshHeldPath(root);
|
|
261
|
+
return withJsonlLock(heldPath, () => claimJsonlByRename(heldPath, workMeshClaimPath("held", ts, root)));
|
|
112
262
|
}
|
|
113
263
|
/** Match leftover claim files: spool.<ts>.claimed / held.<ts>.claimed (and held.<ts>.held.claimed). */
|
|
114
264
|
const ORPHAN_CLAIM_RE = /^(spool|held)\..+\.claimed$/;
|
|
@@ -195,6 +345,31 @@ export function countJsonlLines(filePath) {
|
|
|
195
345
|
}
|
|
196
346
|
return n;
|
|
197
347
|
}
|
|
348
|
+
/** Count held eventIds once; legacy rows without an eventId remain line-counted. */
|
|
349
|
+
export function countDistinctHeldEvents(filePath) {
|
|
350
|
+
let raw;
|
|
351
|
+
try {
|
|
352
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
// A concurrent claim can rename held.jsonl between existence and read checks.
|
|
356
|
+
return 0;
|
|
357
|
+
}
|
|
358
|
+
const seen = new Set();
|
|
359
|
+
let count = 0;
|
|
360
|
+
for (const line of raw.split("\n")) {
|
|
361
|
+
if (!line.trim())
|
|
362
|
+
continue;
|
|
363
|
+
const eventId = heldEventId(line);
|
|
364
|
+
if (eventId) {
|
|
365
|
+
if (seen.has(eventId))
|
|
366
|
+
continue;
|
|
367
|
+
seen.add(eventId);
|
|
368
|
+
}
|
|
369
|
+
count += 1;
|
|
370
|
+
}
|
|
371
|
+
return count;
|
|
372
|
+
}
|
|
198
373
|
function record(value) {
|
|
199
374
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
200
375
|
? value
|
|
@@ -258,8 +433,32 @@ function countUnattributedJsonlLines(filePath) {
|
|
|
258
433
|
export function countUnattributedEvents(root) {
|
|
259
434
|
const meshRoot = root ?? workMeshRoot();
|
|
260
435
|
const spool = countUnattributedJsonlLines(workMeshSpoolPath(meshRoot));
|
|
261
|
-
const held =
|
|
436
|
+
const held = countUnattributedHeldEvents(workMeshHeldPath(meshRoot));
|
|
262
437
|
const deadLetter = countUnattributedJsonlLines(workMeshDeadLetterPath(meshRoot));
|
|
263
438
|
return { spool, held, deadLetter, total: spool + held + deadLetter };
|
|
264
439
|
}
|
|
440
|
+
function countUnattributedHeldEvents(filePath) {
|
|
441
|
+
let raw;
|
|
442
|
+
try {
|
|
443
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
// A concurrent held claim may rename this file between the read and open.
|
|
447
|
+
return 0;
|
|
448
|
+
}
|
|
449
|
+
const seen = new Set();
|
|
450
|
+
let count = 0;
|
|
451
|
+
for (const line of raw.split("\n")) {
|
|
452
|
+
if (!line.trim() || !isUnattributedLine(line))
|
|
453
|
+
continue;
|
|
454
|
+
const eventId = heldEventId(line);
|
|
455
|
+
if (eventId) {
|
|
456
|
+
if (seen.has(eventId))
|
|
457
|
+
continue;
|
|
458
|
+
seen.add(eventId);
|
|
459
|
+
}
|
|
460
|
+
count += 1;
|
|
461
|
+
}
|
|
462
|
+
return count;
|
|
463
|
+
}
|
|
265
464
|
//# sourceMappingURL=spool.js.map
|
|
@@ -57,6 +57,17 @@ function readJson(filePath) {
|
|
|
57
57
|
return null;
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
+
function isInactiveProject(hqRoot, project) {
|
|
61
|
+
if (!hqRoot || !project.prdPath)
|
|
62
|
+
return false;
|
|
63
|
+
const state = readJson(path.join(hqRoot, "workspace", "orchestrator", project.projectId, "state.json"));
|
|
64
|
+
const status = typeof state?.status === "string" ? state.status.trim().toLowerCase() : "";
|
|
65
|
+
if (status !== "archived" && status !== "retired")
|
|
66
|
+
return false;
|
|
67
|
+
if (typeof state?.prd_path !== "string" || !state.prd_path.trim())
|
|
68
|
+
return false;
|
|
69
|
+
return path.resolve(hqRoot, state.prd_path) === path.resolve(project.prdPath);
|
|
70
|
+
}
|
|
60
71
|
function reposFromMetadata(metadata) {
|
|
61
72
|
if (!metadata || typeof metadata !== "object")
|
|
62
73
|
return [];
|
|
@@ -225,7 +236,8 @@ export function refineProjectBinding(input) {
|
|
|
225
236
|
const hint = input.hint?.trim();
|
|
226
237
|
if (!hint)
|
|
227
238
|
return { action: "keep" };
|
|
228
|
-
const catalog = discoverProjectsCached(input, input.nowMs ?? Date.now())
|
|
239
|
+
const catalog = discoverProjectsCached(input, input.nowMs ?? Date.now())
|
|
240
|
+
.filter((project) => !isInactiveProject(input.hqRoot?.trim(), project));
|
|
229
241
|
const exact = catalog.find((row) => row.projectId === hint);
|
|
230
242
|
if (exact?.hasBoard) {
|
|
231
243
|
return { action: "bind", projectId: hint, reason: "exact slug has a Board" };
|
package/dist/main.js
CHANGED
|
@@ -38,7 +38,7 @@ import { DEFAULT_COGNITO } from "./utils/cognito-session.js";
|
|
|
38
38
|
import { isCompanySelectionError } from "./utils/company-selection-error.js";
|
|
39
39
|
import { canOfferTeamUpgrade, formatPlanGateError, offerTeamUpgrade, offerUpgradeLink, planGateErrorFromVaultError, } from "./utils/plan-gate-error.js";
|
|
40
40
|
import { upgradeToTeam } from "./utils/team-upgrade.js";
|
|
41
|
-
import { refreshVersionCache, staleAgainstCachedLatest, } from "./utils/version-check.js";
|
|
41
|
+
import { printVersionAndNudge, refreshVersionCache, staleAgainstCachedLatest, } from "./utils/version-check.js";
|
|
42
42
|
import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
|
|
43
43
|
import { autoUpdateAndReexec } from "./utils/self-update.js";
|
|
44
44
|
import { CLI_VERSION } from "./cli-version.js";
|
|
@@ -161,7 +161,8 @@ const program = new Command();
|
|
|
161
161
|
program
|
|
162
162
|
.name("hq")
|
|
163
163
|
.description("HQ management CLI — modules, packages, and cloud sync")
|
|
164
|
-
.version(CLI_VERSION)
|
|
164
|
+
.version(CLI_VERSION)
|
|
165
|
+
.option("--verbose", "Show verbose diagnostics, including SDK runtime warnings");
|
|
165
166
|
program.hook("preAction", async () => {
|
|
166
167
|
// A host that pinned HQ_AGENT_DIR has declared that it IS that agent, so
|
|
167
168
|
// every command — `hq dm`, `hq secrets`, not only `hq agent …` — must
|
|
@@ -206,6 +207,7 @@ export async function runCli() {
|
|
|
206
207
|
// already did the work, so this process only has to carry its exit status
|
|
207
208
|
// out (after the finally block's telemetry, hence not process.exit here).
|
|
208
209
|
let reexecStatus = null;
|
|
210
|
+
let versionNudgeHandled = false;
|
|
209
211
|
try {
|
|
210
212
|
runGlobalNetworkLifecycle(process.argv, () => Sentry.addBreadcrumb({
|
|
211
213
|
category: "command",
|
|
@@ -248,6 +250,15 @@ export async function runCli() {
|
|
|
248
250
|
});
|
|
249
251
|
if (versionAction === "reexec")
|
|
250
252
|
return;
|
|
253
|
+
// Commander exits immediately for its built-in --version action, before a
|
|
254
|
+
// nudge can be appended. Handle the global version-only form here so the
|
|
255
|
+
// installed semver stays the first stdout line and optional guidance follows.
|
|
256
|
+
if (process.argv.length === 3 &&
|
|
257
|
+
["--version", "-V"].includes(process.argv[2] ?? "")) {
|
|
258
|
+
versionNudgeHandled = true;
|
|
259
|
+
await printVersionAndNudge();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
251
262
|
// Register every contributor for the selected root only. Root help and
|
|
252
263
|
// unknown-command recovery use generated metadata, so they keep the full
|
|
253
264
|
// Commander surface without evaluating every command implementation.
|
|
@@ -298,7 +309,9 @@ export async function runCli() {
|
|
|
298
309
|
const limitNoticeExposureFlushes = takeCliLimitNoticeExposures().map((exposure) => emitCliLimitNoticeExposure(exposure, releaseHealthTimeoutMs));
|
|
299
310
|
const limitNoticeEngagementFlushes = takeCliLimitNoticeEngagements().map((engagement) => emitCliLimitNoticeEngagement(engagement, releaseHealthTimeoutMs));
|
|
300
311
|
await settleWithin([
|
|
301
|
-
|
|
312
|
+
...(versionNudgeHandled
|
|
313
|
+
? []
|
|
314
|
+
: [refreshVersionCache({ timeoutMs: releaseHealthTimeoutMs })]),
|
|
302
315
|
Sentry.flush(releaseHealthTimeoutMs),
|
|
303
316
|
...firstSyncOutcomeFlushes,
|
|
304
317
|
...inviteTeammateExposureFlushes,
|
|
@@ -137,6 +137,10 @@ export interface SelfUpdateDeps {
|
|
|
137
137
|
currentVersion?: string;
|
|
138
138
|
fetchLatest?: () => Promise<string | null>;
|
|
139
139
|
resolveInstall?: () => RunningInstall;
|
|
140
|
+
/** Permission preflight for an npm global prefix (tests inject a read-only result). */
|
|
141
|
+
canWriteNpmInstall?: (install: RunningInstall) => boolean;
|
|
142
|
+
/** Home directory used for the durable per-install permission notice marker. */
|
|
143
|
+
homeDir?: string;
|
|
140
144
|
/** Same-filesystem install rename, injectable for rollback tests. */
|
|
141
145
|
renamePath?: typeof fs.renameSync;
|
|
142
146
|
runner?: (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => UpdateResult;
|