@indigoai-us/hq-cli 5.115.3 → 5.115.5

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.
@@ -0,0 +1,270 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { lockRoot } from './embed-lock.js';
5
+ export const DEFAULT_MAX_LOAD_PERCENT = 50;
6
+ /**
7
+ * Twelve requests is enough to avoid quietly starving a frequently requested
8
+ * index, while still leaving several opportunities for a busy machine to cool
9
+ * down before we spend CPU on an embed. The thirteenth request is forced.
10
+ */
11
+ export const MAX_CONSECUTIVE_DEFERRED_EMBEDS = 12;
12
+ /** Six hours bounds deferral even when indexing requests are infrequent. */
13
+ export const MAX_EMBED_DEFERRAL_SECONDS = 6 * 60 * 60;
14
+ const LOAD_GATE_STATE_NAME = 'qmd-embed-load-state';
15
+ const PROC_STAT = '/proc/stat';
16
+ const PROC_MEMINFO = '/proc/meminfo';
17
+ export class LoadGateConfigurationError extends Error {
18
+ constructor(value) {
19
+ super(`HQ_INDEX_MAX_LOAD_PERCENT must be an integer from 1 to 100, 0, or off; received ${JSON.stringify(value)}.`);
20
+ this.name = 'LoadGateConfigurationError';
21
+ }
22
+ }
23
+ function synchronousSleep(milliseconds) {
24
+ const slot = new Int32Array(new SharedArrayBuffer(4));
25
+ Atomics.wait(slot, 0, 0, milliseconds);
26
+ }
27
+ export function defaultLoadGateDependencies(env = process.env) {
28
+ return {
29
+ env,
30
+ now: () => Math.floor(Date.now() / 1_000),
31
+ readFile: (file) => fs.readFileSync(file, 'utf8'),
32
+ writeFile: (file, contents) => fs.writeFileSync(file, contents),
33
+ rename: (source, destination) => fs.renameSync(source, destination),
34
+ mkdir: (directory) => fs.mkdirSync(directory, { recursive: true }),
35
+ sleep: synchronousSleep,
36
+ cpus: () => os.cpus(),
37
+ // os.freemem() reports MemFree on Linux, which excludes reclaimable cache.
38
+ // Use it only where /proc/meminfo does not exist.
39
+ freemem: () => os.freemem(),
40
+ totalmem: () => os.totalmem(),
41
+ };
42
+ }
43
+ function errorCode(error) {
44
+ return error?.code;
45
+ }
46
+ function missing(error) {
47
+ return errorCode(error) === 'ENOENT';
48
+ }
49
+ export function parseProcStatAggregate(contents) {
50
+ const line = contents.split('\n').find((candidate) => /^cpu\s+/.test(candidate));
51
+ if (!line)
52
+ return undefined;
53
+ const values = line.trim().split(/\s+/).slice(1).map(Number);
54
+ if (values.length < 5 || values.some((value) => !Number.isFinite(value) || value < 0))
55
+ return undefined;
56
+ // Linux reports guest and guest_nice inside user and nice respectively.
57
+ const total = values.slice(0, 8).reduce((sum, value) => sum + value, 0);
58
+ const idle = values[3] + values[4];
59
+ return total > 0 && Number.isFinite(idle) ? { total, idle } : undefined;
60
+ }
61
+ export function cpuBusyPercent(before, after) {
62
+ const totalDelta = after.total - before.total;
63
+ const idleDelta = after.idle - before.idle;
64
+ if (totalDelta <= 0 || idleDelta < 0)
65
+ return undefined;
66
+ return Math.max(0, Math.min(100, (1 - idleDelta / totalDelta) * 100));
67
+ }
68
+ function cpuCountersFromCpus(cpus) {
69
+ let total = 0;
70
+ let idle = 0;
71
+ if (cpus.length === 0)
72
+ return undefined;
73
+ for (const cpu of cpus) {
74
+ const { user, nice, sys, idle: cpuIdle, irq } = cpu.times;
75
+ const values = [user, nice, sys, cpuIdle, irq];
76
+ if (values.some((value) => !Number.isFinite(value) || value < 0))
77
+ return undefined;
78
+ total += values.reduce((sum, value) => sum + value, 0);
79
+ idle += cpuIdle;
80
+ }
81
+ return total > 0 ? { total, idle } : undefined;
82
+ }
83
+ function fallbackCpuPercent(dependencies) {
84
+ const before = cpuCountersFromCpus(dependencies.cpus());
85
+ dependencies.sleep(200);
86
+ const after = cpuCountersFromCpus(dependencies.cpus());
87
+ const busy = before && after ? cpuBusyPercent(before, after) : undefined;
88
+ if (busy === undefined)
89
+ throw new Error('Cannot determine CPU busy time from os.cpus().');
90
+ return busy;
91
+ }
92
+ /** Sample aggregate CPU busy time over 200 ms, falling back only without /proc/stat. */
93
+ export function measureCpuPercent(dependencies) {
94
+ let first;
95
+ try {
96
+ first = dependencies.readFile(PROC_STAT);
97
+ }
98
+ catch (error) {
99
+ if (missing(error))
100
+ return fallbackCpuPercent(dependencies);
101
+ throw error;
102
+ }
103
+ dependencies.sleep(200);
104
+ let second;
105
+ try {
106
+ second = dependencies.readFile(PROC_STAT);
107
+ }
108
+ catch (error) {
109
+ if (missing(error))
110
+ return fallbackCpuPercent(dependencies);
111
+ throw error;
112
+ }
113
+ const before = parseProcStatAggregate(first);
114
+ const after = parseProcStatAggregate(second);
115
+ const busy = before && after ? cpuBusyPercent(before, after) : undefined;
116
+ if (busy === undefined)
117
+ throw new Error('Cannot determine CPU busy time from /proc/stat.');
118
+ return busy;
119
+ }
120
+ export function memAvailableFraction(contents) {
121
+ const fields = new Map();
122
+ for (const line of contents.split('\n')) {
123
+ const match = /^(MemTotal|MemAvailable):\s+(\d+)\s+kB$/.exec(line.trim());
124
+ if (match)
125
+ fields.set(match[1], Number(match[2]));
126
+ }
127
+ const total = fields.get('MemTotal');
128
+ const available = fields.get('MemAvailable');
129
+ if (total === undefined || available === undefined || total <= 0 || available < 0 || available > total)
130
+ return undefined;
131
+ return 1 - available / total;
132
+ }
133
+ /** Prefer Linux MemAvailable, because MemFree alone treats reclaimable cache as used memory. */
134
+ export function measureMemoryPercent(dependencies) {
135
+ try {
136
+ const fraction = memAvailableFraction(dependencies.readFile(PROC_MEMINFO));
137
+ if (fraction === undefined)
138
+ throw new Error('Cannot determine memory use from /proc/meminfo.');
139
+ return fraction * 100;
140
+ }
141
+ catch (error) {
142
+ if (!missing(error))
143
+ throw error;
144
+ }
145
+ const total = dependencies.totalmem();
146
+ const free = dependencies.freemem();
147
+ if (total <= 0 || free < 0 || free > total)
148
+ throw new Error('Cannot determine memory use.');
149
+ return (1 - free / total) * 100;
150
+ }
151
+ export function parseLoadThreshold(env) {
152
+ const value = env.HQ_INDEX_MAX_LOAD_PERCENT?.trim() || String(DEFAULT_MAX_LOAD_PERCENT);
153
+ if (value === '0' || value.toLowerCase() === 'off')
154
+ return undefined;
155
+ if (!/^[1-9]\d{0,2}$/.test(value) || Number(value) > 100)
156
+ throw new LoadGateConfigurationError(value);
157
+ return Number(value);
158
+ }
159
+ export function loadGateStatePath(home) {
160
+ return path.join(lockRoot(home), LOAD_GATE_STATE_NAME);
161
+ }
162
+ function readFields(file, dependencies) {
163
+ try {
164
+ const fields = {};
165
+ for (const line of dependencies.readFile(file).split('\n')) {
166
+ if (line === '')
167
+ continue;
168
+ const index = line.indexOf('=');
169
+ const key = line.slice(0, index);
170
+ if (index < 1 || !['skips', 'firstDeferredAt', 'lastSuccessfulEmbedAt'].includes(key) || fields[key] !== undefined)
171
+ return undefined;
172
+ fields[key] = line.slice(index + 1);
173
+ }
174
+ return fields;
175
+ }
176
+ catch (error) {
177
+ if (missing(error))
178
+ return undefined;
179
+ throw error;
180
+ }
181
+ }
182
+ function nonnegativeInteger(value) {
183
+ return value !== undefined && /^\d+$/.test(value) ? Number(value) : undefined;
184
+ }
185
+ function readState(home, dependencies) {
186
+ const fields = readFields(loadGateStatePath(home), dependencies);
187
+ if (!fields)
188
+ return { skips: 0 };
189
+ const skips = nonnegativeInteger(fields.skips);
190
+ const firstDeferredAt = nonnegativeInteger(fields.firstDeferredAt);
191
+ const lastSuccessfulEmbedAt = nonnegativeInteger(fields.lastSuccessfulEmbedAt);
192
+ if (skips === undefined
193
+ || (fields.firstDeferredAt !== undefined && firstDeferredAt === undefined)
194
+ || (fields.lastSuccessfulEmbedAt !== undefined && lastSuccessfulEmbedAt === undefined)
195
+ || (skips > 0 && firstDeferredAt === undefined)
196
+ || (skips === 0 && firstDeferredAt !== undefined))
197
+ return { skips: 0 };
198
+ return {
199
+ skips,
200
+ ...(firstDeferredAt === undefined ? {} : { firstDeferredAt }),
201
+ ...(lastSuccessfulEmbedAt === undefined ? {} : { lastSuccessfulEmbedAt }),
202
+ };
203
+ }
204
+ function writeState(home, state, dependencies) {
205
+ dependencies.mkdir(lockRoot(home));
206
+ const statePath = loadGateStatePath(home);
207
+ const temporaryPath = `${statePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
208
+ dependencies.writeFile(temporaryPath, [
209
+ `skips=${state.skips}`,
210
+ ...(state.firstDeferredAt === undefined ? [] : [`firstDeferredAt=${state.firstDeferredAt}`]),
211
+ ...(state.lastSuccessfulEmbedAt === undefined ? [] : [`lastSuccessfulEmbedAt=${state.lastSuccessfulEmbedAt}`]),
212
+ '',
213
+ ].join('\n'));
214
+ dependencies.rename(temporaryPath, statePath);
215
+ }
216
+ function rounded(percent) {
217
+ return Math.round(percent);
218
+ }
219
+ function trippedSignal(cpuPercent, memoryPercent, limit) {
220
+ if (cpuPercent >= limit)
221
+ return { signal: 'cpu', percent: rounded(cpuPercent) };
222
+ if (memoryPercent >= limit)
223
+ return { signal: 'memory', percent: rounded(memoryPercent) };
224
+ return undefined;
225
+ }
226
+ /**
227
+ * Decide immediately before qmd embed whether this host can absorb it. The
228
+ * caller must already own the shared embed lock, so state updates are atomic
229
+ * with respect to hq-cli's other embed entry points.
230
+ */
231
+ export function checkEmbedLoad(home, dependencies) {
232
+ const limit = parseLoadThreshold(dependencies.env);
233
+ if (limit === undefined)
234
+ return { action: 'embed', forced: false };
235
+ const cpuPercent = measureCpuPercent(dependencies);
236
+ const memoryPercent = measureMemoryPercent(dependencies);
237
+ const tripped = trippedSignal(cpuPercent, memoryPercent, limit);
238
+ if (!tripped)
239
+ return { action: 'embed', forced: false };
240
+ const now = dependencies.now();
241
+ const state = readState(home, dependencies);
242
+ const skips = state.skips + 1;
243
+ const deferredSince = state.firstDeferredAt ?? now;
244
+ const exceededSkipBound = skips > MAX_CONSECUTIVE_DEFERRED_EMBEDS;
245
+ const exceededTimeBound = now - deferredSince >= MAX_EMBED_DEFERRAL_SECONDS;
246
+ if (exceededSkipBound || exceededTimeBound) {
247
+ return {
248
+ action: 'embed',
249
+ forced: true,
250
+ notice: `hq: proceeding with qmd embed despite ${tripped.signal} ${tripped.percent}% (limit ${limit}%) because embeddings have been deferred too long.\n`,
251
+ };
252
+ }
253
+ writeState(home, {
254
+ skips,
255
+ firstDeferredAt: state.firstDeferredAt ?? now,
256
+ ...(state.lastSuccessfulEmbedAt === undefined ? {} : { lastSuccessfulEmbedAt: state.lastSuccessfulEmbedAt }),
257
+ }, dependencies);
258
+ return {
259
+ action: 'skip',
260
+ signal: tripped.signal,
261
+ percent: tripped.percent,
262
+ limit,
263
+ notice: `hq: skipping qmd embed — ${tripped.signal} ${tripped.percent}% (limit ${limit}%).\n`,
264
+ };
265
+ }
266
+ /** Reset the starvation counter only after qmd embed has actually returned successfully. */
267
+ export function recordSuccessfulEmbed(home, dependencies) {
268
+ writeState(home, { skips: 0, lastSuccessfulEmbedAt: dependencies.now() }, dependencies);
269
+ }
270
+ //# sourceMappingURL=load-gate.js.map
@@ -18,10 +18,26 @@ function isFile(target) {
18
18
  return false;
19
19
  }
20
20
  }
21
- /** A sorted `find -L … -name worker.yaml -type f` equivalent, including symlinked pack workers. */
21
+ /**
22
+ * A sorted `find -L … -name worker.yaml -type f` equivalent, including
23
+ * symlinked pack workers.
24
+ *
25
+ * Excluded subtrees are pruned at the descent, not filtered from the result.
26
+ * That distinction is the whole cost of this scan: a tenant's `repos/` holds
27
+ * source checkouts, so filtering afterwards means reading every `node_modules`
28
+ * and `.git` directory inside them — tens of thousands of directories on a real
29
+ * root — to produce matches that are discarded a moment later.
30
+ *
31
+ * Directory entries carry their own type, so the common case costs one
32
+ * `readdir` per directory instead of an extra `stat` per entry. Symlinks are
33
+ * the exception: their dirent type describes the link, so resolving one still
34
+ * needs a `stat`, which is what keeps this faithful to `find -L`.
35
+ */
22
36
  function workerYamlFiles(root, relativeRoot) {
23
37
  const output = [];
24
38
  const walk = (relative, ancestors) => {
39
+ if (isExcludedWorkerDirectory(relative))
40
+ return;
25
41
  const absolute = path.join(root, relative);
26
42
  if (!isDirectory(absolute))
27
43
  return;
@@ -35,20 +51,23 @@ function workerYamlFiles(root, relativeRoot) {
35
51
  if (ancestors.has(physical))
36
52
  return; // `find -L` reports loops; a bounded walk is safer here.
37
53
  const nextAncestors = new Set(ancestors).add(physical);
38
- let names;
54
+ let entries;
39
55
  try {
40
- names = fs.readdirSync(absolute).sort();
56
+ entries = fs.readdirSync(absolute, { withFileTypes: true });
41
57
  }
42
58
  catch {
43
59
  return;
44
60
  }
45
- for (const name of names) {
46
- const childRelative = path.join(relative, name);
61
+ entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
62
+ for (const entry of entries) {
63
+ const childRelative = path.join(relative, entry.name);
47
64
  const child = path.join(root, childRelative);
48
- if (name === 'worker.yaml' && isFile(child))
65
+ if (entry.name === 'worker.yaml' && (entry.isFile() || isFile(child))) {
49
66
  output.push(childRelative);
50
- else if (isDirectory(child))
67
+ }
68
+ else if (entry.isDirectory() || (entry.isSymbolicLink() && isDirectory(child))) {
51
69
  walk(childRelative, nextAncestors);
70
+ }
52
71
  }
53
72
  };
54
73
  walk(relativeRoot, new Set());
@@ -139,8 +158,8 @@ function withoutTimestamp(content) {
139
158
  * both `core` and `personal` — the checkout wins. The operator's actual worker
140
159
  * then silently vanishes from the registry every skill reads.
141
160
  */
142
- function isExcludedWorkerPath(relativeFile) {
143
- const normalized = relativeFile.replaceAll('\\', '/');
161
+ function isExcludedWorkerDirectory(relativeDirectory) {
162
+ const normalized = relativeDirectory.replaceAll('\\', '/');
144
163
  const [root, tenant, nested] = normalized.split('/');
145
164
  if (root !== 'companies')
146
165
  return /(?:^|\/)_overrides(?:\/|$)/.test(normalized);
@@ -150,6 +169,15 @@ function isExcludedWorkerPath(relativeFile) {
150
169
  return true;
151
170
  return /(?:^|\/)_overrides(?:\/|$)/.test(normalized);
152
171
  }
172
+ /**
173
+ * Every exclusion is a property of the directory holding the `worker.yaml`, so
174
+ * the file rule is the directory rule applied to its parent. Deriving it keeps
175
+ * the pruned descent and this final filter from ever disagreeing — a drift that
176
+ * would either resurrect a shadowing copy or silently drop a real worker.
177
+ */
178
+ function isExcludedWorkerPath(relativeFile) {
179
+ return isExcludedWorkerDirectory(path.dirname(relativeFile));
180
+ }
153
181
  /**
154
182
  * Generate core/workers/registry.yaml from worker.yaml files. Invalid workers
155
183
  * are quarantined but do not prevent all valid workers from being registered.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.115.3",
3
+ "version": "5.115.5",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {