@indigoai-us/hq-cli 5.182.0 → 5.184.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 +17 -0
- package/dist/command-catalog.generated.d.ts +3 -0
- package/dist/command-catalog.generated.js +4 -0
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +100 -6
- package/dist/commands/mesh.js +18 -1
- package/dist/lib/core-utils/hook-timeout-debug-context-flag.d.ts +20 -0
- package/dist/lib/core-utils/hook-timeout-debug-context-flag.js +80 -0
- package/dist/lib/core-utils/sentry-report.d.ts +24 -2
- package/dist/lib/core-utils/sentry-report.js +254 -16
- package/dist/lib/doctor/checks/sync-health.js +13 -0
- package/dist/lib/doctor/client-health-reason-codes-flag.d.ts +26 -0
- package/dist/lib/doctor/client-health-reason-codes-flag.js +94 -0
- package/dist/lib/doctor/fix/apply.d.ts +5 -0
- package/dist/lib/doctor/fix/apply.js +28 -3
- package/dist/lib/doctor/fix/json-output.d.ts +20 -0
- package/dist/lib/doctor/fix/json-output.js +22 -0
- package/dist/lib/doctor/fix/refresh-sync-outcome.d.ts +12 -0
- package/dist/lib/doctor/fix/refresh-sync-outcome.js +25 -0
- package/dist/lib/doctor/json-output.d.ts +7 -1
- package/dist/lib/doctor/json-output.js +6 -2
- package/dist/lib/doctor/types.d.ts +5 -0
- package/dist/lib/doctor/types.js +15 -0
- package/dist/lib/mesh/live/daemon/index.d.ts +1 -1
- package/dist/lib/mesh/live/daemon/index.js +1 -1
- package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +7 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.js +25 -3
- package/dist/lib/mesh/live/emit-client.d.ts +6 -0
- package/dist/lib/mesh/live/emit-client.js +8 -0
- package/dist/lib/mesh/live/enqueue.d.ts +5 -0
- package/dist/lib/mesh/live/enqueue.js +19 -0
- package/dist/lib/mesh/live/flush.js +5 -3
- package/dist/lib/mesh/live/format-spool-line.d.ts +5 -2
- package/dist/lib/mesh/live/format-spool-line.js +10 -0
- package/dist/lib/mesh/live/report-summary.d.ts +45 -0
- package/dist/lib/mesh/live/report-summary.js +222 -0
- package/dist/lib/mesh/live/session-event.schema.json +24 -1
- package/dist/lib/mesh/live/validate-session-event.d.ts +1 -1
- package/dist/lib/mesh/live/validate-session-event.js +1 -1
- package/package.json +1 -1
|
@@ -23,6 +23,7 @@ import * as Sentry from "@sentry/node";
|
|
|
23
23
|
import { CLI_VERSION } from "../../cli-version.js";
|
|
24
24
|
import { beforeSend } from "../../sentry-before-send.js";
|
|
25
25
|
import { resolveLiveRoot } from "../../utils/hq-roots.js";
|
|
26
|
+
import { hookTimeoutDebugContextEnabled } from "./hook-timeout-debug-context-flag.js";
|
|
26
27
|
export const DEFAULT_SENTRY_REPORT_TIMEOUT_MS = 750;
|
|
27
28
|
export const MAX_SENTRY_REPORT_TIMEOUT_MS = 5_000;
|
|
28
29
|
export const MAX_SENTRY_REPORT_PAYLOAD_BYTES = 2_048;
|
|
@@ -47,6 +48,8 @@ const HOOK_TIMEOUT_EXCEEDED_FINGERPRINT = "hook-timeout-exceeded";
|
|
|
47
48
|
const HOOK_TIMEOUT_WARNING_FINGERPRINT = "hook-timeout";
|
|
48
49
|
const HOOK_TIMEOUT_MACHINE_WIDE_FINGERPRINT = "hq-hook-slow";
|
|
49
50
|
const MAX_HOOK_SEQUENCE_ENTRIES = 20;
|
|
51
|
+
const MAX_HOOK_TIMEOUT_DEBUG_PHASES = 24;
|
|
52
|
+
const MAX_HOOK_TIMEOUT_DEBUG_MS = 86_400_000;
|
|
50
53
|
const HOOK_TIMEOUT_RATE_LIMIT_WINDOW_MS = 10 * 60 * 1_000;
|
|
51
54
|
const RATE_LIMIT_LOCK_WAIT_MS = 500;
|
|
52
55
|
const RATE_LIMIT_LOCK_POLL_MS = 5;
|
|
@@ -65,6 +68,72 @@ const KNOWN_HOOK_SCRIPTS = new Set([
|
|
|
65
68
|
"45-lanes-senior-monitor.sh",
|
|
66
69
|
"lanes-senior-monitor-stop-gate.sh",
|
|
67
70
|
]);
|
|
71
|
+
const DEBUG_CONTEXT_HOOK_NAMES = new Set([
|
|
72
|
+
...KNOWN_HOOK_SCRIPTS,
|
|
73
|
+
"reindex.sh",
|
|
74
|
+
]);
|
|
75
|
+
const DEBUG_CONTEXT_CHILD_NAMES = new Set([
|
|
76
|
+
...DEBUG_CONTEXT_HOOK_NAMES,
|
|
77
|
+
"bash",
|
|
78
|
+
"bash.exe",
|
|
79
|
+
"sh",
|
|
80
|
+
"cmd",
|
|
81
|
+
"cmd.exe",
|
|
82
|
+
"powershell",
|
|
83
|
+
"powershell.exe",
|
|
84
|
+
"wmic.exe",
|
|
85
|
+
"jq",
|
|
86
|
+
"jq.exe",
|
|
87
|
+
"node",
|
|
88
|
+
"node.exe",
|
|
89
|
+
"git",
|
|
90
|
+
"git.exe",
|
|
91
|
+
"python",
|
|
92
|
+
"python.exe",
|
|
93
|
+
"python3",
|
|
94
|
+
"python3.exe",
|
|
95
|
+
"true",
|
|
96
|
+
"sleep",
|
|
97
|
+
"cat",
|
|
98
|
+
"grep",
|
|
99
|
+
"sed",
|
|
100
|
+
"awk",
|
|
101
|
+
"find",
|
|
102
|
+
"timeout",
|
|
103
|
+
"curl",
|
|
104
|
+
"gh",
|
|
105
|
+
"hq",
|
|
106
|
+
"npm",
|
|
107
|
+
"pnpm",
|
|
108
|
+
"npx",
|
|
109
|
+
]);
|
|
110
|
+
const DEBUG_CONTEXT_HOOK_EVENTS = new Set([
|
|
111
|
+
"PreToolUse",
|
|
112
|
+
"PostToolUse",
|
|
113
|
+
"UserPromptSubmit",
|
|
114
|
+
"SessionStart",
|
|
115
|
+
"SessionEnd",
|
|
116
|
+
"Stop",
|
|
117
|
+
"PreCompact",
|
|
118
|
+
"Notification",
|
|
119
|
+
"PermissionRequest",
|
|
120
|
+
"SubagentStart",
|
|
121
|
+
"SubagentStop",
|
|
122
|
+
"TaskCompleted",
|
|
123
|
+
"TeammateIdle",
|
|
124
|
+
]);
|
|
125
|
+
const DEBUG_CONTEXT_PHASES = new Set([
|
|
126
|
+
"startup",
|
|
127
|
+
"source",
|
|
128
|
+
"config_load",
|
|
129
|
+
"policy_load",
|
|
130
|
+
"external_command",
|
|
131
|
+
"output_write",
|
|
132
|
+
"wait",
|
|
133
|
+
"child_wait",
|
|
134
|
+
"probe",
|
|
135
|
+
"parse",
|
|
136
|
+
]);
|
|
68
137
|
/**
|
|
69
138
|
* The only hook diagnostics that can be attached as metadata. The list omits
|
|
70
139
|
* command text, prompts, payloads, company names, and repositories. `hook_path`
|
|
@@ -96,6 +165,7 @@ export const ALLOWED_SENTRY_REPORT_METADATA_KEYS = [
|
|
|
96
165
|
"shell",
|
|
97
166
|
"nproc",
|
|
98
167
|
"hook_sequence",
|
|
168
|
+
"hook_timeout_debug_context",
|
|
99
169
|
];
|
|
100
170
|
const ALLOWED_METADATA_KEYS = new Set(ALLOWED_SENTRY_REPORT_METADATA_KEYS);
|
|
101
171
|
const SENTRY_REPORT_METADATA_KEY_HEADROOM = 2;
|
|
@@ -107,6 +177,7 @@ const defaultDependencies = {
|
|
|
107
177
|
sentry: Sentry,
|
|
108
178
|
stdout: process.stdout,
|
|
109
179
|
stderr: process.stderr,
|
|
180
|
+
timeoutDebugContextEnabled: (signal) => hookTimeoutDebugContextEnabled({}, signal),
|
|
110
181
|
};
|
|
111
182
|
/**
|
|
112
183
|
* Read no more than the accepted event size. Returning early keeps a malformed
|
|
@@ -153,6 +224,90 @@ function isHookSequenceEntry(value) {
|
|
|
153
224
|
Number.isFinite(value.ms) &&
|
|
154
225
|
value.ms >= 0);
|
|
155
226
|
}
|
|
227
|
+
function boundedDebugName(value, known) {
|
|
228
|
+
if (typeof value !== "string" || value.length === 0)
|
|
229
|
+
return "other";
|
|
230
|
+
return known.has(basenameFromPath(value)) ? basenameFromPath(value) : "other";
|
|
231
|
+
}
|
|
232
|
+
function boundedDebugPhase(value) {
|
|
233
|
+
return typeof value === "string" && DEBUG_CONTEXT_PHASES.has(value) ? value : "other";
|
|
234
|
+
}
|
|
235
|
+
function boundedDebugHookEvent(value) {
|
|
236
|
+
return typeof value === "string" && DEBUG_CONTEXT_HOOK_EVENTS.has(value) ? value : "other";
|
|
237
|
+
}
|
|
238
|
+
function boundedDebugMilliseconds(value, allowNegative = false) {
|
|
239
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
240
|
+
return null;
|
|
241
|
+
const bounded = allowNegative
|
|
242
|
+
? Math.abs(value) <= MAX_HOOK_TIMEOUT_DEBUG_MS
|
|
243
|
+
: value >= 0 && value <= MAX_HOOK_TIMEOUT_DEBUG_MS;
|
|
244
|
+
return bounded ? value : null;
|
|
245
|
+
}
|
|
246
|
+
function boundedDebugProbe(value) {
|
|
247
|
+
if (value === "unavailable")
|
|
248
|
+
return value;
|
|
249
|
+
return boundedDebugMilliseconds(value) ?? undefined;
|
|
250
|
+
}
|
|
251
|
+
function boundedDebugPhaseTimings(value) {
|
|
252
|
+
if (!Array.isArray(value) || value.length > MAX_HOOK_TIMEOUT_DEBUG_PHASES)
|
|
253
|
+
return null;
|
|
254
|
+
const timings = [];
|
|
255
|
+
for (const entry of value) {
|
|
256
|
+
if (!isRecord(entry))
|
|
257
|
+
return null;
|
|
258
|
+
const elapsed = boundedDebugMilliseconds(entry.elapsed_ms);
|
|
259
|
+
if (elapsed === null)
|
|
260
|
+
return null;
|
|
261
|
+
const timing = {
|
|
262
|
+
phase: boundedDebugPhase(entry.phase),
|
|
263
|
+
elapsed_ms: elapsed,
|
|
264
|
+
};
|
|
265
|
+
timings.push(timing);
|
|
266
|
+
}
|
|
267
|
+
return timings;
|
|
268
|
+
}
|
|
269
|
+
/** Return only the closed, bounded shell-to-CLI context shape. */
|
|
270
|
+
function boundedHookTimeoutDebugContext(value) {
|
|
271
|
+
if (!isRecord(value))
|
|
272
|
+
return null;
|
|
273
|
+
const budget = boundedDebugMilliseconds(value.budget_ms);
|
|
274
|
+
const elapsed = boundedDebugMilliseconds(value.elapsed_ms);
|
|
275
|
+
const remaining = boundedDebugMilliseconds(value.remaining_ms, true);
|
|
276
|
+
const childElapsed = boundedDebugMilliseconds(value.waiting_child_elapsed_ms);
|
|
277
|
+
const phaseTimings = boundedDebugPhaseTimings(value.phase_timings);
|
|
278
|
+
if (budget === null ||
|
|
279
|
+
elapsed === null ||
|
|
280
|
+
remaining === null ||
|
|
281
|
+
childElapsed === null ||
|
|
282
|
+
phaseTimings === null) {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
const loadAverage = boundedDebugProbe(value.load_average);
|
|
286
|
+
const spawnMs = boundedDebugProbe(value.spawn_ms);
|
|
287
|
+
const processCount = boundedDebugProbe(value.process_count);
|
|
288
|
+
const hasLoadAverage = typeof loadAverage === "number";
|
|
289
|
+
const hasWindowsProbe = spawnMs !== undefined && processCount !== undefined;
|
|
290
|
+
if (!hasLoadAverage && !hasWindowsProbe)
|
|
291
|
+
return null;
|
|
292
|
+
if (typeof processCount === "number" &&
|
|
293
|
+
(!Number.isInteger(processCount) || processCount > 1_000_000)) {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
hook_name: boundedDebugName(value.hook_name, DEBUG_CONTEXT_HOOK_NAMES),
|
|
298
|
+
hook_event: boundedDebugHookEvent(value.hook_event),
|
|
299
|
+
budget_ms: budget,
|
|
300
|
+
elapsed_ms: elapsed,
|
|
301
|
+
remaining_ms: remaining,
|
|
302
|
+
phase_timings: phaseTimings,
|
|
303
|
+
wait_point: boundedDebugPhase(value.wait_point),
|
|
304
|
+
waiting_child_basename: boundedDebugName(value.waiting_child_basename, DEBUG_CONTEXT_CHILD_NAMES),
|
|
305
|
+
waiting_child_elapsed_ms: childElapsed,
|
|
306
|
+
...(loadAverage !== undefined ? { load_average: loadAverage } : {}),
|
|
307
|
+
...(spawnMs !== undefined ? { spawn_ms: spawnMs } : {}),
|
|
308
|
+
...(processCount !== undefined ? { process_count: processCount } : {}),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
156
311
|
function boundedHookSequence(value) {
|
|
157
312
|
if (!Array.isArray(value))
|
|
158
313
|
return null;
|
|
@@ -168,6 +323,8 @@ function boundedHookSequence(value) {
|
|
|
168
323
|
function isAllowedMetadataValue(key, value) {
|
|
169
324
|
if (key === "hook_sequence")
|
|
170
325
|
return boundedHookSequence(value) !== null;
|
|
326
|
+
if (key === "hook_timeout_debug_context")
|
|
327
|
+
return boundedHookTimeoutDebugContext(value) !== null;
|
|
171
328
|
if (!isScalarMetadataValue(value))
|
|
172
329
|
return false;
|
|
173
330
|
if (key === "tool_name" || key === "session_id" || key === "hook_path") {
|
|
@@ -236,9 +393,10 @@ export function parseSentryReportEvent(stdin) {
|
|
|
236
393
|
}
|
|
237
394
|
if (!isAllowedMetadataValue(key, rawMetadata[key]))
|
|
238
395
|
return invalidInput();
|
|
239
|
-
metadata[key] =
|
|
240
|
-
key
|
|
241
|
-
|
|
396
|
+
metadata[key] = key === "hook_sequence"
|
|
397
|
+
? boundedHookSequence(rawMetadata[key])
|
|
398
|
+
: key === "hook_timeout_debug_context"
|
|
399
|
+
? boundedHookTimeoutDebugContext(rawMetadata[key])
|
|
242
400
|
: rawMetadata[key];
|
|
243
401
|
}
|
|
244
402
|
if (typeof metadata.slow_child === "string") {
|
|
@@ -319,6 +477,11 @@ function elapsedBucket(elapsedMs) {
|
|
|
319
477
|
return "20-60s";
|
|
320
478
|
return ">=60s";
|
|
321
479
|
}
|
|
480
|
+
function remainingBucket(remainingMs) {
|
|
481
|
+
return remainingMs < 0
|
|
482
|
+
? `overdue-${elapsedBucket(Math.abs(remainingMs))}`
|
|
483
|
+
: elapsedBucket(remainingMs);
|
|
484
|
+
}
|
|
322
485
|
function loadBucket(loadAverage) {
|
|
323
486
|
if (loadAverage === undefined)
|
|
324
487
|
return "unknown";
|
|
@@ -368,6 +531,53 @@ function spawnMsBucket(metadata) {
|
|
|
368
531
|
return "500-999ms";
|
|
369
532
|
return ">=1000ms";
|
|
370
533
|
}
|
|
534
|
+
function debugSpawnMsBucket(value) {
|
|
535
|
+
if (typeof value !== "number")
|
|
536
|
+
return "unavailable";
|
|
537
|
+
if (value < 100)
|
|
538
|
+
return "<100ms";
|
|
539
|
+
if (value < 500)
|
|
540
|
+
return "100-499ms";
|
|
541
|
+
if (value < 1_000)
|
|
542
|
+
return "500-999ms";
|
|
543
|
+
return ">=1000ms";
|
|
544
|
+
}
|
|
545
|
+
function processCountBucket(value) {
|
|
546
|
+
if (typeof value !== "number")
|
|
547
|
+
return "unavailable";
|
|
548
|
+
if (value < 5)
|
|
549
|
+
return "0-4";
|
|
550
|
+
if (value < 17)
|
|
551
|
+
return "5-16";
|
|
552
|
+
if (value < 65)
|
|
553
|
+
return "17-64";
|
|
554
|
+
return "65+";
|
|
555
|
+
}
|
|
556
|
+
function hookTimeoutDebugContextTags(context) {
|
|
557
|
+
const tags = {
|
|
558
|
+
timeout_debug_hook_name: context.hook_name,
|
|
559
|
+
timeout_debug_hook_event: context.hook_event,
|
|
560
|
+
timeout_debug_budget_bucket: elapsedBucket(context.budget_ms),
|
|
561
|
+
timeout_debug_elapsed_bucket: elapsedBucket(context.elapsed_ms),
|
|
562
|
+
timeout_debug_remaining_bucket: remainingBucket(context.remaining_ms),
|
|
563
|
+
timeout_debug_wait_point: context.wait_point,
|
|
564
|
+
timeout_debug_waiting_child: context.waiting_child_basename,
|
|
565
|
+
timeout_debug_waiting_child_elapsed_bucket: elapsedBucket(context.waiting_child_elapsed_ms),
|
|
566
|
+
timeout_debug_load_bucket: context.load_average === "unavailable"
|
|
567
|
+
? "unavailable"
|
|
568
|
+
: loadBucket(context.load_average),
|
|
569
|
+
timeout_debug_spawn_ms_bucket: debugSpawnMsBucket(context.spawn_ms),
|
|
570
|
+
timeout_debug_process_count_bucket: processCountBucket(context.process_count),
|
|
571
|
+
};
|
|
572
|
+
const phaseElapsed = new Map();
|
|
573
|
+
for (const timing of context.phase_timings) {
|
|
574
|
+
phaseElapsed.set(timing.phase, Math.max(phaseElapsed.get(timing.phase) ?? 0, timing.elapsed_ms));
|
|
575
|
+
}
|
|
576
|
+
for (const [phase, elapsedMs] of phaseElapsed) {
|
|
577
|
+
tags[`timeout_debug_phase_${phase}_bucket`] = elapsedBucket(elapsedMs);
|
|
578
|
+
}
|
|
579
|
+
return tags;
|
|
580
|
+
}
|
|
371
581
|
function safeSlowChild(metadata) {
|
|
372
582
|
const value = metadata.slow_child;
|
|
373
583
|
if (typeof value !== "string" || value.length === 0)
|
|
@@ -464,10 +674,10 @@ function sentryEnvelope(event, overrides, additionalTags = {}) {
|
|
|
464
674
|
};
|
|
465
675
|
}
|
|
466
676
|
/** Build the same scrubbed event projection that the live SDK path uses. */
|
|
467
|
-
function scrubbedSentryEnvelope(event, overrides) {
|
|
677
|
+
function scrubbedSentryEnvelope(event, overrides, additionalTags = {}) {
|
|
468
678
|
return beforeSend({
|
|
469
679
|
message: event.message,
|
|
470
|
-
...sentryEnvelope(event, overrides),
|
|
680
|
+
...sentryEnvelope(event, overrides, additionalTags),
|
|
471
681
|
}, {});
|
|
472
682
|
}
|
|
473
683
|
/**
|
|
@@ -740,19 +950,47 @@ export async function reportSentryEvent(stdin, options = {}, dependencies = defa
|
|
|
740
950
|
const noun = result.droppedMetadataKeys === 1 ? "key" : "keys";
|
|
741
951
|
dependencies.stderr.write(`Dropped ${result.droppedMetadataKeys} unsupported Sentry report metadata ${noun}.\n`);
|
|
742
952
|
}
|
|
743
|
-
|
|
953
|
+
let event = result.event;
|
|
954
|
+
let debugContextTags = {};
|
|
955
|
+
const debugContext = event.metadata.hook_timeout_debug_context;
|
|
956
|
+
if (debugContext && isHookTimeoutEvent(event)) {
|
|
957
|
+
let enabled;
|
|
958
|
+
try {
|
|
959
|
+
enabled =
|
|
960
|
+
(await dependencies.timeoutDebugContextEnabled?.(options.signal)) ?? false;
|
|
961
|
+
}
|
|
962
|
+
catch {
|
|
963
|
+
// An optional diagnostic flag read must never suppress the base report.
|
|
964
|
+
enabled = false;
|
|
965
|
+
}
|
|
966
|
+
if (enabled) {
|
|
967
|
+
debugContextTags = hookTimeoutDebugContextTags(debugContext);
|
|
968
|
+
}
|
|
969
|
+
else {
|
|
970
|
+
const metadata = { ...event.metadata };
|
|
971
|
+
delete metadata.hook_timeout_debug_context;
|
|
972
|
+
event = { ...event, metadata };
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
else if (debugContext) {
|
|
976
|
+
const metadata = { ...event.metadata };
|
|
977
|
+
delete metadata.hook_timeout_debug_context;
|
|
978
|
+
event = { ...event, metadata };
|
|
979
|
+
}
|
|
980
|
+
result.event = event;
|
|
981
|
+
const overrides = hookTimeoutExceededOverrides(event);
|
|
744
982
|
if (options.dryRun) {
|
|
745
|
-
const scrubbed = scrubbedSentryEnvelope(
|
|
983
|
+
const scrubbed = scrubbedSentryEnvelope(event, overrides, debugContextTags);
|
|
746
984
|
if (scrubbed)
|
|
747
985
|
dependencies.stdout.write(`${JSON.stringify(scrubbed)}\n`);
|
|
748
986
|
return result;
|
|
749
987
|
}
|
|
750
|
-
if (isHookTimeoutEvent(
|
|
751
|
-
addHookSequenceBreadcrumbs(
|
|
752
|
-
const kind = hookTimeoutKind(
|
|
988
|
+
if (isHookTimeoutEvent(event)) {
|
|
989
|
+
addHookSequenceBreadcrumbs(event, dependencies);
|
|
990
|
+
const kind = hookTimeoutKind(event);
|
|
753
991
|
let rateLimit;
|
|
754
992
|
try {
|
|
755
|
-
rateLimit = timeoutRateLimitLease(
|
|
993
|
+
rateLimit = timeoutRateLimitLease(event, kind, options, dependencies);
|
|
756
994
|
}
|
|
757
995
|
catch {
|
|
758
996
|
dependencies.stderr.write("Sentry timeout rate limit state unavailable; sending without rate limiting.\n");
|
|
@@ -764,14 +1002,14 @@ export async function reportSentryEvent(stdin, options = {}, dependencies = defa
|
|
|
764
1002
|
};
|
|
765
1003
|
}
|
|
766
1004
|
if (!rateLimit.capture) {
|
|
767
|
-
return reportSuppressedTimeout(result,
|
|
1005
|
+
return reportSuppressedTimeout(result, event, timeoutMs, dependencies, kind);
|
|
768
1006
|
}
|
|
769
1007
|
const tags = rateLimit.suppressedSinceLast > 0
|
|
770
1008
|
? { suppressed_since_last: String(rateLimit.suppressedSinceLast) }
|
|
771
1009
|
: {};
|
|
772
|
-
const envelope = sentryEnvelope(
|
|
1010
|
+
const envelope = sentryEnvelope(event, overrides, { ...debugContextTags, ...tags });
|
|
773
1011
|
try {
|
|
774
|
-
dependencies.sentry.captureMessage(
|
|
1012
|
+
dependencies.sentry.captureMessage(event.message, envelope);
|
|
775
1013
|
rateLimit.commitCapture();
|
|
776
1014
|
if (await flushWithin(dependencies.sentry, timeoutMs))
|
|
777
1015
|
return result;
|
|
@@ -783,9 +1021,9 @@ export async function reportSentryEvent(stdin, options = {}, dependencies = defa
|
|
|
783
1021
|
dependencies.stderr.write("Sentry report failed.\n");
|
|
784
1022
|
return invalidInput();
|
|
785
1023
|
}
|
|
786
|
-
const envelope = sentryEnvelope(
|
|
1024
|
+
const envelope = sentryEnvelope(event, overrides, debugContextTags);
|
|
787
1025
|
try {
|
|
788
|
-
dependencies.sentry.captureMessage(
|
|
1026
|
+
dependencies.sentry.captureMessage(event.message, envelope);
|
|
789
1027
|
if (await flushWithin(dependencies.sentry, timeoutMs))
|
|
790
1028
|
return result;
|
|
791
1029
|
}
|
|
@@ -223,6 +223,7 @@ function versionResults(context, deps) {
|
|
|
223
223
|
results.push({
|
|
224
224
|
status: "WARN",
|
|
225
225
|
checkId: "sync.versions.core",
|
|
226
|
+
reasonCode: "core-version-unavailable",
|
|
226
227
|
target: path.join(context.hqRoot, "core", "core.yaml"),
|
|
227
228
|
message: "hq-core version could not be read from core/core.yaml — the scaffold may be missing or pre-v12.",
|
|
228
229
|
remediation: "Run /update-hq to (re)install the hq-core scaffold.",
|
|
@@ -282,6 +283,7 @@ function updateAvailabilityResult(context, deps) {
|
|
|
282
283
|
{
|
|
283
284
|
status: "WARN",
|
|
284
285
|
checkId: "sync.update.core",
|
|
286
|
+
reasonCode: "core-update-available",
|
|
285
287
|
message: `hq-core update available: local v${core}, latest v${latest}.`,
|
|
286
288
|
remediation: "Run /update-hq in a fresh session to upgrade.",
|
|
287
289
|
},
|
|
@@ -373,6 +375,7 @@ function journalResults(deps, journals) {
|
|
|
373
375
|
return {
|
|
374
376
|
status: "NA",
|
|
375
377
|
checkId,
|
|
378
|
+
reasonCode: "no-local-tree",
|
|
376
379
|
target: entry.path,
|
|
377
380
|
message: `Sync journal '${entry.slug}' is unused on this machine — there is no companies/${entry.slug} directory, so this journal is not a live sync target.`,
|
|
378
381
|
};
|
|
@@ -382,6 +385,7 @@ function journalResults(deps, journals) {
|
|
|
382
385
|
return {
|
|
383
386
|
status: "WARN",
|
|
384
387
|
checkId,
|
|
388
|
+
reasonCode: "never-synced",
|
|
385
389
|
target: entry.path,
|
|
386
390
|
message: `Sync journal '${entry.slug}' exists but has never recorded a successful sync.`,
|
|
387
391
|
remediation: "Run /hq-sync (or `hq sync`) to complete a first sync.",
|
|
@@ -392,6 +396,7 @@ function journalResults(deps, journals) {
|
|
|
392
396
|
return {
|
|
393
397
|
status: "UNKNOWN",
|
|
394
398
|
checkId,
|
|
399
|
+
reasonCode: "invalid-last-sync",
|
|
395
400
|
target: entry.path,
|
|
396
401
|
message: `Sync journal '${entry.slug}' has an unparseable lastSync (${lastSync}).`,
|
|
397
402
|
};
|
|
@@ -402,6 +407,7 @@ function journalResults(deps, journals) {
|
|
|
402
407
|
return {
|
|
403
408
|
status: "WARN",
|
|
404
409
|
checkId,
|
|
410
|
+
reasonCode: "stale-threshold",
|
|
405
411
|
target: entry.path,
|
|
406
412
|
message: `Sync journal '${entry.slug}' is stale: last successful sync ${days} day${days === 1 ? "" : "s"} ago (${lastSync}).`,
|
|
407
413
|
remediation: "Run /hq-sync (or `hq sync`) and check the sync runner.",
|
|
@@ -443,6 +449,7 @@ function manifestResults(deps, journals) {
|
|
|
443
449
|
{
|
|
444
450
|
status: "NA",
|
|
445
451
|
checkId: "sync.manifest",
|
|
452
|
+
reasonCode: "manifest-unavailable",
|
|
446
453
|
message: `${MANIFEST_UNAVAILABLE_REASON} — upgrade hq-cloud to audit manifest uploads.`,
|
|
447
454
|
},
|
|
448
455
|
];
|
|
@@ -456,6 +463,7 @@ function manifestResults(deps, journals) {
|
|
|
456
463
|
{
|
|
457
464
|
status: "UNKNOWN",
|
|
458
465
|
checkId: "sync.manifest",
|
|
466
|
+
reasonCode: "manifest-state-unavailable",
|
|
459
467
|
message: `Manifest upload state could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
|
460
468
|
remediation: "Check that the HQ state directory is readable, then re-run `hq doctor`.",
|
|
461
469
|
},
|
|
@@ -469,6 +477,7 @@ function manifestResults(deps, journals) {
|
|
|
469
477
|
{
|
|
470
478
|
status: "NA",
|
|
471
479
|
checkId: "sync.manifest",
|
|
480
|
+
reasonCode: "no-manifest-scopes",
|
|
472
481
|
message: "No manifest-auditable sync scopes on this machine — nothing to reconcile.",
|
|
473
482
|
},
|
|
474
483
|
];
|
|
@@ -480,6 +489,7 @@ function manifestResults(deps, journals) {
|
|
|
480
489
|
return {
|
|
481
490
|
status: "UNTESTED",
|
|
482
491
|
checkId,
|
|
492
|
+
reasonCode: "never-uploaded",
|
|
483
493
|
message: `Scope '${entry.scopeKey}' has never uploaded a sync manifest — expected until the first pass runs.`,
|
|
484
494
|
remediation: "Run `hq sync manifest` to upload one now.",
|
|
485
495
|
};
|
|
@@ -489,6 +499,7 @@ function manifestResults(deps, journals) {
|
|
|
489
499
|
return {
|
|
490
500
|
status: "UNKNOWN",
|
|
491
501
|
checkId,
|
|
502
|
+
reasonCode: "invalid-upload-time",
|
|
492
503
|
message: `Scope '${entry.scopeKey}' has an unparseable last manifest upload time (${entry.lastUploadAt}).`,
|
|
493
504
|
};
|
|
494
505
|
}
|
|
@@ -501,6 +512,7 @@ function manifestResults(deps, journals) {
|
|
|
501
512
|
return {
|
|
502
513
|
status: "WARN",
|
|
503
514
|
checkId,
|
|
515
|
+
reasonCode: "stale-threshold",
|
|
504
516
|
message: `Scope '${entry.scopeKey}' last uploaded a sync manifest ${days} day${days === 1 ? "" : "s"} ago (${entry.lastUploadAt}) — the daily audit pass is not running.`,
|
|
505
517
|
remediation: "Run `hq sync manifest` and check HQ_SYNC_MANIFEST_DISABLED and the sync runner.",
|
|
506
518
|
};
|
|
@@ -532,6 +544,7 @@ function unresolvedResults(deps, journals) {
|
|
|
532
544
|
return slugs.map((slug) => ({
|
|
533
545
|
status: "UNTESTED",
|
|
534
546
|
checkId: `sync.manifest.unresolved.${slug}`,
|
|
547
|
+
reasonCode: "unresolved-company-uid",
|
|
535
548
|
message: `Company scope '${slug}' has a sync journal but its companyUid could not be resolved locally — its manifest uploads were not checked.`,
|
|
536
549
|
remediation: `Run \`hq sync manifest --scope ${slug}\` to audit it explicitly.`,
|
|
537
550
|
}));
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type FlagClient, type FlagClientOptions } from "@indigoai-us/hq-flags-client";
|
|
2
|
+
export declare const CLIENT_HEALTH_REASON_CODES_FLAG = "client-health-sync-reason-codes";
|
|
3
|
+
export declare const CLIENT_HEALTH_REASON_CODES_FLAG_FALLBACK = false;
|
|
4
|
+
export declare const CLIENT_HEALTH_REASON_CODES_FLAG_TIMEOUT_MS = 150;
|
|
5
|
+
type ClientHealthFlagClient = Pick<FlagClient, "ready" | "isEnabled" | "close">;
|
|
6
|
+
export interface ClientHealthReasonCodesFlagDependencies {
|
|
7
|
+
env?: NodeJS.ProcessEnv;
|
|
8
|
+
createClient?: (options: FlagClientOptions) => ClientHealthFlagClient;
|
|
9
|
+
getToken?: FlagClientOptions["getToken"];
|
|
10
|
+
fetch?: typeof fetch;
|
|
11
|
+
reportFailure?: (error: unknown) => void;
|
|
12
|
+
}
|
|
13
|
+
export interface ClientHealthReasonCodesCompany {
|
|
14
|
+
companyUid: string;
|
|
15
|
+
companySlug?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the privacy gate from hq-flags with a bounded request. Missing
|
|
19
|
+
* identity, service configuration, and every lookup failure keep diagnostics
|
|
20
|
+
* off so a flag outage cannot delay the doctor's health result.
|
|
21
|
+
*/
|
|
22
|
+
export declare function clientHealthReasonCodesEnabled(dependencies?: ClientHealthReasonCodesFlagDependencies, callerSignal?: AbortSignal): Promise<boolean>;
|
|
23
|
+
/** Resolve the same default-off gate for one explicitly identified company. */
|
|
24
|
+
export declare function clientHealthReasonCodesEnabledForCompany(company: ClientHealthReasonCodesCompany, dependencies?: ClientHealthReasonCodesFlagDependencies, callerSignal?: AbortSignal): Promise<boolean>;
|
|
25
|
+
export {};
|
|
26
|
+
//# sourceMappingURL=client-health-reason-codes-flag.d.ts.map
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { loadCachedTokens } from "@indigoai-us/hq-cloud";
|
|
2
|
+
import { createFlagClient, } from "@indigoai-us/hq-flags-client";
|
|
3
|
+
export const CLIENT_HEALTH_REASON_CODES_FLAG = "client-health-sync-reason-codes";
|
|
4
|
+
export const CLIENT_HEALTH_REASON_CODES_FLAG_FALLBACK = false;
|
|
5
|
+
export const CLIENT_HEALTH_REASON_CODES_FLAG_TIMEOUT_MS = 150;
|
|
6
|
+
function safeErrorClass(error) {
|
|
7
|
+
const name = error instanceof Error ? error.name : "UnknownError";
|
|
8
|
+
return /^[A-Za-z][A-Za-z0-9]{0,63}$/.test(name) ? name : "UnknownError";
|
|
9
|
+
}
|
|
10
|
+
function reportFlagFailure(error) {
|
|
11
|
+
process.stderr.write(`HQ client-health reason-code flag lookup failed (${safeErrorClass(error)}); using default off.\n`);
|
|
12
|
+
}
|
|
13
|
+
function combineSignals(first, second) {
|
|
14
|
+
return first ? AbortSignal.any([first, second]) : second;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the privacy gate from hq-flags with a bounded request. Missing
|
|
18
|
+
* identity, service configuration, and every lookup failure keep diagnostics
|
|
19
|
+
* off so a flag outage cannot delay the doctor's health result.
|
|
20
|
+
*/
|
|
21
|
+
export async function clientHealthReasonCodesEnabled(dependencies = {}, callerSignal) {
|
|
22
|
+
const env = dependencies.env ?? process.env;
|
|
23
|
+
const companyUid = env.HQ_COMPANY_UID?.trim() || "";
|
|
24
|
+
if (!/^cmp_[A-Za-z0-9]{3,128}$/.test(companyUid)) {
|
|
25
|
+
return CLIENT_HEALTH_REASON_CODES_FLAG_FALLBACK;
|
|
26
|
+
}
|
|
27
|
+
return clientHealthReasonCodesEnabledForCompany({
|
|
28
|
+
companyUid,
|
|
29
|
+
companySlug: env.HQ_COMPANY_SLUG?.trim() || undefined,
|
|
30
|
+
}, dependencies, callerSignal);
|
|
31
|
+
}
|
|
32
|
+
/** Resolve the same default-off gate for one explicitly identified company. */
|
|
33
|
+
export async function clientHealthReasonCodesEnabledForCompany(company, dependencies = {}, callerSignal) {
|
|
34
|
+
const env = dependencies.env ?? process.env;
|
|
35
|
+
const endpoint = env.HQ_FLAGS_API_URL?.trim() || "";
|
|
36
|
+
const companyUid = company.companyUid.trim();
|
|
37
|
+
if (!endpoint || !/^cmp_[A-Za-z0-9]{3,128}$/.test(companyUid)) {
|
|
38
|
+
return CLIENT_HEALTH_REASON_CODES_FLAG_FALLBACK;
|
|
39
|
+
}
|
|
40
|
+
const companySlug = company.companySlug?.trim() || "";
|
|
41
|
+
const deadline = AbortSignal.timeout(CLIENT_HEALTH_REASON_CODES_FLAG_TIMEOUT_MS);
|
|
42
|
+
const signal = callerSignal ? AbortSignal.any([callerSignal, deadline]) : deadline;
|
|
43
|
+
const doFetch = dependencies.fetch ?? globalThis.fetch;
|
|
44
|
+
let client;
|
|
45
|
+
let failed = false;
|
|
46
|
+
let failureReported = false;
|
|
47
|
+
let enabled = CLIENT_HEALTH_REASON_CODES_FLAG_FALLBACK;
|
|
48
|
+
const reportFailureOnce = (error) => {
|
|
49
|
+
failed = true;
|
|
50
|
+
if (failureReported)
|
|
51
|
+
return;
|
|
52
|
+
failureReported = true;
|
|
53
|
+
(dependencies.reportFailure ?? reportFlagFailure)(error);
|
|
54
|
+
};
|
|
55
|
+
try {
|
|
56
|
+
client = (dependencies.createClient ?? createFlagClient)({
|
|
57
|
+
endpoint,
|
|
58
|
+
companyUid,
|
|
59
|
+
...(companySlug && /^[A-Za-z0-9][A-Za-z0-9-]{0,63}$/.test(companySlug)
|
|
60
|
+
? { companyIdentifiers: [companySlug] }
|
|
61
|
+
: {}),
|
|
62
|
+
env: {},
|
|
63
|
+
getToken: dependencies.getToken ?? (() => loadCachedTokens()?.idToken ?? ""),
|
|
64
|
+
fetch: (input, init) => doFetch(input, {
|
|
65
|
+
...init,
|
|
66
|
+
signal: combineSignals(init?.signal ?? undefined, signal),
|
|
67
|
+
}),
|
|
68
|
+
refreshIntervalMs: 0,
|
|
69
|
+
requestTimeoutMs: CLIENT_HEALTH_REASON_CODES_FLAG_TIMEOUT_MS,
|
|
70
|
+
onError: reportFailureOnce,
|
|
71
|
+
});
|
|
72
|
+
await client.ready();
|
|
73
|
+
if (!failed && !signal.aborted) {
|
|
74
|
+
enabled = client.isEnabled(CLIENT_HEALTH_REASON_CODES_FLAG, {
|
|
75
|
+
fallback: CLIENT_HEALTH_REASON_CODES_FLAG_FALLBACK,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
reportFailureOnce(error);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
try {
|
|
84
|
+
client?.close();
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
reportFailureOnce(error);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return failed || signal.aborted
|
|
91
|
+
? CLIENT_HEALTH_REASON_CODES_FLAG_FALLBACK
|
|
92
|
+
: enabled;
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=client-health-reason-codes-flag.js.map
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
import type { CheckResult, DoctorStatus } from "../types.js";
|
|
32
32
|
import { type GateProfile } from "../hook-gate-profiles.js";
|
|
33
33
|
import { type FixClass } from "./remediation.js";
|
|
34
|
+
import { type RefreshSyncOutcome } from "./refresh-sync-outcome.js";
|
|
34
35
|
/** The tree subtrees whose uncommitted changes block a `--fix` run. */
|
|
35
36
|
export declare const HOOK_CONFIG_DIRS: readonly [".claude", ".codex", ".grok"];
|
|
36
37
|
/** Options for {@link applyFixes}. Everything is injectable for hermetic tests. */
|
|
@@ -91,6 +92,8 @@ export interface RefreshSyncTarget {
|
|
|
91
92
|
export interface RefreshSyncResult {
|
|
92
93
|
/** Whether the pull exited 0. */
|
|
93
94
|
ok: boolean;
|
|
95
|
+
/** Closed enum for machine-readable client-health diagnostics. */
|
|
96
|
+
outcome: RefreshSyncOutcome;
|
|
94
97
|
/** One-line summary for the post-fix report. */
|
|
95
98
|
message: string;
|
|
96
99
|
}
|
|
@@ -108,6 +111,8 @@ export interface AppliedFix {
|
|
|
108
111
|
summary: string;
|
|
109
112
|
/** The status of the re-run check for this target, or null if not found. */
|
|
110
113
|
postStatus: DoctorStatus | null;
|
|
114
|
+
/** Present for refresh-sync plans; contains no command output or path data. */
|
|
115
|
+
refreshSyncOutcome?: RefreshSyncOutcome;
|
|
111
116
|
}
|
|
112
117
|
/** The outcome of an {@link applyFixes} run. */
|
|
113
118
|
export interface ApplyFixesResult {
|