@indigoai-us/hq-cli 5.115.1 → 5.115.2
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 +10 -0
- package/assets/scaffold/core/scripts/qmd-reindex-after-sync.sh +10 -87
- package/dist/commands/bot.js +1 -1
- package/dist/commands/index-cmd.d.ts +8 -1
- package/dist/commands/index-cmd.js +15 -5
- package/dist/commands/onboard-identity-guard.d.ts +1 -1
- package/dist/commands/onboard.js +1 -1
- package/dist/lib/bot/prompt.d.ts +8 -1
- package/dist/lib/bot/prompt.js +10 -2
- package/dist/lib/bot/run.js +1 -1
- package/dist/lib/core-utils/qmd-reindex-after-sync.d.ts +4 -1
- package/dist/lib/core-utils/qmd-reindex-after-sync.js +11 -2
- package/dist/lib/onboarding/checkpoint.d.ts +13 -0
- package/dist/lib/onboarding/checkpoint.js +44 -0
- package/dist/lib/onboarding/cli/onboard.d.ts +53 -0
- package/dist/lib/onboarding/cli/onboard.js +135 -0
- package/dist/lib/onboarding/cli/prompts.d.ts +28 -0
- package/dist/lib/onboarding/cli/prompts.js +83 -0
- package/dist/lib/onboarding/errors.d.ts +33 -0
- package/dist/lib/onboarding/errors.js +59 -0
- package/dist/lib/onboarding/index.d.ts +17 -0
- package/dist/lib/onboarding/index.js +16 -0
- package/dist/lib/onboarding/orchestrator.d.ts +47 -0
- package/dist/lib/onboarding/orchestrator.js +569 -0
- package/dist/lib/onboarding/types.d.ts +79 -0
- package/dist/lib/onboarding/types.js +8 -0
- package/dist/lib/search-index/background.js +2 -200
- package/dist/lib/search-index/embed-lock.d.ts +38 -0
- package/dist/lib/search-index/embed-lock.js +286 -0
- package/dist/lib/search-index/index.d.ts +1 -0
- package/dist/lib/search-index/index.js +4 -1
- package/package.json +1 -2
|
@@ -4,7 +4,7 @@ import * as path from 'node:path';
|
|
|
4
4
|
import { Sentry } from '../../sentry.js';
|
|
5
5
|
import { reconcileCollections as defaultReconcileCollections, resolveQmdBin as defaultResolveQmdBin, runQmd as defaultRunQmd, } from './index.js';
|
|
6
6
|
import { applyIndexSizeLimit as defaultApplyIndexSizeLimit } from './max-doc-bytes.js';
|
|
7
|
-
|
|
7
|
+
import { acquireEmbedLock as acquireLock, installEmbedLockCleanup, lockPath, lockRoot, lockState, releaseEmbedLock as releaseLock, } from './embed-lock.js';
|
|
8
8
|
const COMPLETE_NAME = 'qmd-reindex-bg.completed';
|
|
9
9
|
function errnoInfo(error) {
|
|
10
10
|
const e = error;
|
|
@@ -145,12 +145,6 @@ export function isHostedAgent(env = process.env) {
|
|
|
145
145
|
|| fs.existsSync('/etc/systemd/system/hq-agent-qmd-index.service')
|
|
146
146
|
|| fs.existsSync('/var/lib/hq-agent');
|
|
147
147
|
}
|
|
148
|
-
function lockRoot(home) {
|
|
149
|
-
return path.join(home, '.hq', 'locks');
|
|
150
|
-
}
|
|
151
|
-
function lockPath(home) {
|
|
152
|
-
return path.join(lockRoot(home), LOCK_NAME);
|
|
153
|
-
}
|
|
154
148
|
function completionPath(home) {
|
|
155
149
|
return path.join(lockRoot(home), COMPLETE_NAME);
|
|
156
150
|
}
|
|
@@ -165,16 +159,6 @@ function parseFields(file) {
|
|
|
165
159
|
return undefined;
|
|
166
160
|
}
|
|
167
161
|
}
|
|
168
|
-
function readOwner(directory) {
|
|
169
|
-
const fields = parseFields(path.join(directory, 'owner'));
|
|
170
|
-
if (!fields || !/^\d+$/.test(fields.pid ?? '') || !/^\d+$/.test(fields.ts ?? '') || !/^[A-Za-z0-9._-]+$/.test(fields.nonce ?? ''))
|
|
171
|
-
return undefined;
|
|
172
|
-
return { pid: Number(fields.pid), ts: Number(fields.ts), nonce: fields.nonce };
|
|
173
|
-
}
|
|
174
|
-
function graceSeconds(dependencies) {
|
|
175
|
-
const value = dependencies.env.QMD_HANDOFF_LOCK_GRACE_SEC;
|
|
176
|
-
return value && /^\d+$/.test(value) ? Number(value) : 5;
|
|
177
|
-
}
|
|
178
162
|
function dedupeSeconds(dependencies) {
|
|
179
163
|
const value = dependencies.env.QMD_HANDOFF_DEDUPE_SEC;
|
|
180
164
|
return value && /^\d+$/.test(value) ? Number(value) : 90;
|
|
@@ -197,187 +181,7 @@ function writeCompletion(home, dependencies) {
|
|
|
197
181
|
// Completion is a best-effort dedupe hint, never a worker failure.
|
|
198
182
|
}
|
|
199
183
|
}
|
|
200
|
-
function isOwnerlessLockWithinGrace(directory, dependencies) {
|
|
201
|
-
try {
|
|
202
|
-
const age = Math.max(0, dependencies.now() - Math.floor(fs.statSync(directory).mtimeMs / 1_000));
|
|
203
|
-
return age < graceSeconds(dependencies);
|
|
204
|
-
}
|
|
205
|
-
catch {
|
|
206
|
-
return true;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
function lockState(directory, dependencies) {
|
|
210
|
-
if (!fs.existsSync(directory))
|
|
211
|
-
return 'free';
|
|
212
|
-
const owner = readOwner(directory);
|
|
213
|
-
if (owner)
|
|
214
|
-
return dependencies.isProcessAlive(owner.pid) ? 'held' : 'stale';
|
|
215
|
-
return isOwnerlessLockWithinGrace(directory, dependencies) ? 'held' : 'stale';
|
|
216
|
-
}
|
|
217
|
-
function generation(directory) {
|
|
218
|
-
const owner = readOwner(directory);
|
|
219
|
-
if (owner)
|
|
220
|
-
return owner.nonce;
|
|
221
|
-
if (!fs.existsSync(directory))
|
|
222
|
-
return undefined;
|
|
223
|
-
return 'empty';
|
|
224
|
-
}
|
|
225
|
-
function removeDirectoryIfEmpty(directory) {
|
|
226
|
-
try {
|
|
227
|
-
fs.rmdirSync(directory);
|
|
228
|
-
}
|
|
229
|
-
catch { /* another claimant owns it, or it is already gone */ }
|
|
230
|
-
}
|
|
231
|
-
function claimWithinGrace(directory, dependencies) {
|
|
232
|
-
try {
|
|
233
|
-
const age = Math.max(0, dependencies.now() - Math.floor(fs.statSync(directory).mtimeMs / 1_000));
|
|
234
|
-
return age < Math.max(5, graceSeconds(dependencies));
|
|
235
|
-
}
|
|
236
|
-
catch {
|
|
237
|
-
return true;
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
function acquireClaim(home, observedGeneration, dependencies) {
|
|
241
|
-
const claim = path.join(lockRoot(home), `qmd-reindex-bg.claim.${observedGeneration}`);
|
|
242
|
-
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
243
|
-
try {
|
|
244
|
-
fs.mkdirSync(claim);
|
|
245
|
-
}
|
|
246
|
-
catch {
|
|
247
|
-
if (attempt === 1 || !recoverAbandonedClaim(claim, dependencies))
|
|
248
|
-
return undefined;
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
const claimant = path.join(claim, `c.${dependencies.pid}.${dependencies.random()}`);
|
|
252
|
-
try {
|
|
253
|
-
fs.mkdirSync(claimant);
|
|
254
|
-
// Test-only deterministic failure inject (unset in production) — shell
|
|
255
|
-
// parity with the script's claimant write_owner_record injection.
|
|
256
|
-
if (dependencies.env.QMD_FORCE_CLAIMANT_WRITE_FAIL)
|
|
257
|
-
throw new Error('forced claimant write failure');
|
|
258
|
-
fs.writeFileSync(path.join(claimant, 'owner'), `pid=${dependencies.pid}\nts=${dependencies.now()}\n`);
|
|
259
|
-
return { claim, claimant };
|
|
260
|
-
}
|
|
261
|
-
catch {
|
|
262
|
-
fs.rmSync(claimant, { recursive: true, force: true });
|
|
263
|
-
removeDirectoryIfEmpty(claim);
|
|
264
|
-
return undefined;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
return undefined;
|
|
268
|
-
}
|
|
269
|
-
/**
|
|
270
|
-
* Reclaim only exact dead claimant names, then remove the claim directory if
|
|
271
|
-
* empty. This mirrors the shell's no-fixed-path-reclaim rule: a peer that has
|
|
272
|
-
* recreated claim.G with a new marker makes rmdir fail and wins the race.
|
|
273
|
-
*/
|
|
274
|
-
function recoverAbandonedClaim(claim, dependencies) {
|
|
275
|
-
let entries;
|
|
276
|
-
try {
|
|
277
|
-
entries = fs.readdirSync(claim, { withFileTypes: true });
|
|
278
|
-
}
|
|
279
|
-
catch {
|
|
280
|
-
return true;
|
|
281
|
-
}
|
|
282
|
-
for (const entry of entries.filter((entry) => entry.name.startsWith('c.'))) {
|
|
283
|
-
const marker = path.join(claim, entry.name);
|
|
284
|
-
const fields = parseFields(path.join(marker, 'owner'));
|
|
285
|
-
const pid = fields && /^\d+$/.test(fields.pid ?? '') ? Number(fields.pid) : undefined;
|
|
286
|
-
if (pid !== undefined && dependencies.isProcessAlive(pid))
|
|
287
|
-
return false;
|
|
288
|
-
if (pid === undefined && claimWithinGrace(claim, dependencies))
|
|
289
|
-
return false;
|
|
290
|
-
const abandoned = `${claim}.stale-claim.${dependencies.pid}.${dependencies.random()}`;
|
|
291
|
-
try {
|
|
292
|
-
fs.renameSync(marker, abandoned);
|
|
293
|
-
fs.rmSync(abandoned, { recursive: true, force: true });
|
|
294
|
-
}
|
|
295
|
-
catch {
|
|
296
|
-
return false;
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
if (entries.length === 0 && claimWithinGrace(claim, dependencies))
|
|
300
|
-
return false;
|
|
301
|
-
removeDirectoryIfEmpty(claim);
|
|
302
|
-
return !fs.existsSync(claim);
|
|
303
|
-
}
|
|
304
|
-
function createAndPublishLock(home, dependencies) {
|
|
305
|
-
const directory = lockPath(home);
|
|
306
|
-
try {
|
|
307
|
-
fs.mkdirSync(directory);
|
|
308
|
-
}
|
|
309
|
-
catch {
|
|
310
|
-
return false;
|
|
311
|
-
}
|
|
312
|
-
const marker = path.join(directory, `acq.${dependencies.pid}.${dependencies.random()}`);
|
|
313
|
-
try {
|
|
314
|
-
fs.mkdirSync(marker);
|
|
315
|
-
}
|
|
316
|
-
catch {
|
|
317
|
-
removeDirectoryIfEmpty(directory);
|
|
318
|
-
return false;
|
|
319
|
-
}
|
|
320
|
-
const nonce = `${dependencies.pid}.${dependencies.random()}`;
|
|
321
|
-
const ownerFile = path.join(directory, 'owner');
|
|
322
|
-
const temporary = path.join(directory, `.owner.tmp.${dependencies.pid}.${dependencies.random()}`);
|
|
323
|
-
try {
|
|
324
|
-
// Test-only deterministic failure inject (unset in production) — shell
|
|
325
|
-
// parity: the script's write_owner_record honored the same variable.
|
|
326
|
-
if (dependencies.env.QMD_FORCE_OWNER_WRITE_FAIL)
|
|
327
|
-
throw new Error('forced owner write failure');
|
|
328
|
-
fs.writeFileSync(temporary, `pid=${dependencies.pid}\nts=${dependencies.now()}\nnonce=${nonce}\n`);
|
|
329
|
-
fs.renameSync(temporary, ownerFile);
|
|
330
|
-
dependencies.afterOwnerPublish?.(ownerFile);
|
|
331
|
-
const verified = readOwner(directory);
|
|
332
|
-
if (!verified || verified.pid !== dependencies.pid || verified.nonce !== nonce)
|
|
333
|
-
throw new Error('owner publish verification failed');
|
|
334
|
-
fs.rmSync(marker, { recursive: true, force: true });
|
|
335
|
-
return true;
|
|
336
|
-
}
|
|
337
|
-
catch {
|
|
338
|
-
// The marker identifies only the directory this process created. Do not
|
|
339
|
-
// recursively remove the fixed lock path after a failed publication.
|
|
340
|
-
fs.rmSync(marker, { recursive: true, force: true });
|
|
341
|
-
removeDirectoryIfEmpty(directory);
|
|
342
|
-
return false;
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
function claimAndReclaim(home, dependencies) {
|
|
346
|
-
const directory = lockPath(home);
|
|
347
|
-
const observedGeneration = generation(directory);
|
|
348
|
-
if (!observedGeneration || lockState(directory, dependencies) !== 'stale')
|
|
349
|
-
return false;
|
|
350
|
-
const acquired = acquireClaim(home, observedGeneration, dependencies);
|
|
351
|
-
if (!acquired)
|
|
352
|
-
return false;
|
|
353
|
-
try {
|
|
354
|
-
// Generation fencing: only move exactly the stale generation we observed.
|
|
355
|
-
if (generation(directory) !== observedGeneration || lockState(directory, dependencies) !== 'stale')
|
|
356
|
-
return false;
|
|
357
|
-
const abandoned = `${directory}.stale.${dependencies.pid}.${dependencies.random()}`;
|
|
358
|
-
fs.renameSync(directory, abandoned);
|
|
359
|
-
fs.rmSync(abandoned, { recursive: true, force: true });
|
|
360
|
-
return createAndPublishLock(home, dependencies);
|
|
361
|
-
}
|
|
362
|
-
catch {
|
|
363
|
-
return false;
|
|
364
|
-
}
|
|
365
|
-
finally {
|
|
366
|
-
fs.rmSync(acquired.claimant, { recursive: true, force: true });
|
|
367
|
-
removeDirectoryIfEmpty(acquired.claim);
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
function acquireLock(home, dependencies) {
|
|
371
|
-
fs.mkdirSync(lockRoot(home), { recursive: true });
|
|
372
|
-
return createAndPublishLock(home, dependencies) || claimAndReclaim(home, dependencies);
|
|
373
|
-
}
|
|
374
|
-
function releaseLock(home, dependencies) {
|
|
375
|
-
const directory = lockPath(home);
|
|
376
|
-
if (readOwner(directory)?.pid === dependencies.pid)
|
|
377
|
-
fs.rmSync(directory, { recursive: true, force: true });
|
|
378
|
-
}
|
|
379
184
|
export function installWorkerCleanup(cleanup, processEvents = process, exit = () => undefined) {
|
|
380
|
-
processEvents.once('exit', cleanup);
|
|
381
185
|
// Unlike Bash, Node cannot turn a SIGKILL or an already-defaulted signal into
|
|
382
186
|
// catchable cleanup. SIGINT/SIGTERM/SIGHUP are registered here (the shell
|
|
383
187
|
// worker trapped `INT TERM HUP`; a detached worker's controlling terminal
|
|
@@ -385,9 +189,7 @@ export function installWorkerCleanup(cleanup, processEvents = process, exit = ()
|
|
|
385
189
|
// process without releasing the lock) and the CLI's normal process exit then
|
|
386
190
|
// runs the same idempotent owner release. Exiting prevents a synchronous
|
|
387
191
|
// pipeline from continuing after it has released ownership.
|
|
388
|
-
|
|
389
|
-
processEvents.once('SIGTERM', () => { cleanup(); exit(0); });
|
|
390
|
-
processEvents.once('SIGHUP', () => { cleanup(); exit(0); });
|
|
192
|
+
installEmbedLockCleanup(cleanup, processEvents, exit);
|
|
391
193
|
}
|
|
392
194
|
function workerLogPath(env) {
|
|
393
195
|
return env.QMD_REINDEX_LOG
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export type EmbedLockDependencies = {
|
|
2
|
+
env: NodeJS.ProcessEnv;
|
|
3
|
+
now: () => number;
|
|
4
|
+
pid: number;
|
|
5
|
+
random: () => string;
|
|
6
|
+
isProcessAlive: (pid: number) => boolean;
|
|
7
|
+
/** Test seam for simulating a competing owner replacing the atomic record. */
|
|
8
|
+
afterOwnerPublish?: (ownerFile: string) => void;
|
|
9
|
+
};
|
|
10
|
+
export type EmbedLockProcessEvents = Pick<NodeJS.Process, 'once'> & {
|
|
11
|
+
removeListener?: NodeJS.Process['removeListener'];
|
|
12
|
+
};
|
|
13
|
+
export type EmbedLockRunResult = 'ran' | 'busy' | 'unavailable';
|
|
14
|
+
/**
|
|
15
|
+
* Serializes embeddings started through hq-cli only. A bare `qmd embed` (or
|
|
16
|
+
* another tool that does not call this module) cannot participate, because qmd
|
|
17
|
+
* is a third-party package and does not know about HQ's lock protocol.
|
|
18
|
+
*/
|
|
19
|
+
export declare const EMBED_LOCK_NAME = "qmd-reindex-bg.lock";
|
|
20
|
+
export declare function defaultEmbedLockDependencies(env?: NodeJS.ProcessEnv): EmbedLockDependencies;
|
|
21
|
+
export declare function lockRoot(home: string): string;
|
|
22
|
+
export declare function lockPath(home: string): string;
|
|
23
|
+
export declare function lockState(directory: string, dependencies: EmbedLockDependencies): 'held' | 'stale' | 'free';
|
|
24
|
+
export declare function acquireEmbedLock(home: string, dependencies: EmbedLockDependencies): boolean;
|
|
25
|
+
export declare function releaseEmbedLock(home: string, dependencies: EmbedLockDependencies): void;
|
|
26
|
+
/** Install signal and exit cleanup for a lock owner, returning an optional unregister hook. */
|
|
27
|
+
export declare function installEmbedLockCleanup(cleanup: () => void, processEvents?: EmbedLockProcessEvents, exit?: (code: number) => void): () => void;
|
|
28
|
+
/**
|
|
29
|
+
* Run one synchronous embed while owning the shared lock.
|
|
30
|
+
*
|
|
31
|
+
* This deliberately does not install signal handlers. `run` is a synchronous
|
|
32
|
+
* qmd child, so JavaScript cannot execute a cleanup handler until it returns;
|
|
33
|
+
* registering SIGINT/SIGTERM would therefore suppress Node's immediate default
|
|
34
|
+
* termination. An interrupted owner is safely reclaimed through the lock's
|
|
35
|
+
* existing staleness and owner-grace protocol.
|
|
36
|
+
*/
|
|
37
|
+
export declare function runWithEmbedLock(home: string, dependencies: EmbedLockDependencies, run: () => void): EmbedLockRunResult;
|
|
38
|
+
//# sourceMappingURL=embed-lock.d.ts.map
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Serializes embeddings started through hq-cli only. A bare `qmd embed` (or
|
|
5
|
+
* another tool that does not call this module) cannot participate, because qmd
|
|
6
|
+
* is a third-party package and does not know about HQ's lock protocol.
|
|
7
|
+
*/
|
|
8
|
+
export const EMBED_LOCK_NAME = 'qmd-reindex-bg.lock';
|
|
9
|
+
export function defaultEmbedLockDependencies(env = process.env) {
|
|
10
|
+
return {
|
|
11
|
+
env,
|
|
12
|
+
now: () => Math.floor(Date.now() / 1_000),
|
|
13
|
+
pid: process.pid,
|
|
14
|
+
random: () => Math.random().toString(36).slice(2),
|
|
15
|
+
isProcessAlive: (pid) => {
|
|
16
|
+
try {
|
|
17
|
+
process.kill(pid, 0);
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function lockRoot(home) {
|
|
27
|
+
return path.join(home, '.hq', 'locks');
|
|
28
|
+
}
|
|
29
|
+
export function lockPath(home) {
|
|
30
|
+
return path.join(lockRoot(home), EMBED_LOCK_NAME);
|
|
31
|
+
}
|
|
32
|
+
function parseFields(file) {
|
|
33
|
+
try {
|
|
34
|
+
return Object.fromEntries(fs.readFileSync(file, 'utf8').split('\n').flatMap((line) => {
|
|
35
|
+
const index = line.indexOf('=');
|
|
36
|
+
return index === -1 ? [] : [[line.slice(0, index), line.slice(index + 1)]];
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function readOwner(directory) {
|
|
44
|
+
const fields = parseFields(path.join(directory, 'owner'));
|
|
45
|
+
if (!fields || !/^\d+$/.test(fields.pid ?? '') || !/^\d+$/.test(fields.ts ?? '') || !/^[A-Za-z0-9._-]+$/.test(fields.nonce ?? ''))
|
|
46
|
+
return undefined;
|
|
47
|
+
return { pid: Number(fields.pid), ts: Number(fields.ts), nonce: fields.nonce };
|
|
48
|
+
}
|
|
49
|
+
function graceSeconds(dependencies) {
|
|
50
|
+
const value = dependencies.env.QMD_HANDOFF_LOCK_GRACE_SEC;
|
|
51
|
+
return value && /^\d+$/.test(value) ? Number(value) : 5;
|
|
52
|
+
}
|
|
53
|
+
function isOwnerlessLockWithinGrace(directory, dependencies) {
|
|
54
|
+
try {
|
|
55
|
+
const age = Math.max(0, dependencies.now() - Math.floor(fs.statSync(directory).mtimeMs / 1_000));
|
|
56
|
+
return age < graceSeconds(dependencies);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function lockState(directory, dependencies) {
|
|
63
|
+
if (!fs.existsSync(directory))
|
|
64
|
+
return 'free';
|
|
65
|
+
const owner = readOwner(directory);
|
|
66
|
+
if (owner)
|
|
67
|
+
return dependencies.isProcessAlive(owner.pid) ? 'held' : 'stale';
|
|
68
|
+
return isOwnerlessLockWithinGrace(directory, dependencies) ? 'held' : 'stale';
|
|
69
|
+
}
|
|
70
|
+
function generation(directory) {
|
|
71
|
+
const owner = readOwner(directory);
|
|
72
|
+
if (owner)
|
|
73
|
+
return owner.nonce;
|
|
74
|
+
if (!fs.existsSync(directory))
|
|
75
|
+
return undefined;
|
|
76
|
+
return 'empty';
|
|
77
|
+
}
|
|
78
|
+
function removeDirectoryIfEmpty(directory) {
|
|
79
|
+
try {
|
|
80
|
+
fs.rmdirSync(directory);
|
|
81
|
+
}
|
|
82
|
+
catch { /* another claimant owns it, or it is already gone */ }
|
|
83
|
+
}
|
|
84
|
+
function claimWithinGrace(directory, dependencies) {
|
|
85
|
+
try {
|
|
86
|
+
const age = Math.max(0, dependencies.now() - Math.floor(fs.statSync(directory).mtimeMs / 1_000));
|
|
87
|
+
return age < Math.max(5, graceSeconds(dependencies));
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function acquireClaim(home, observedGeneration, dependencies) {
|
|
94
|
+
const claim = path.join(lockRoot(home), `qmd-reindex-bg.claim.${observedGeneration}`);
|
|
95
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
96
|
+
try {
|
|
97
|
+
fs.mkdirSync(claim);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
if (attempt === 1 || !recoverAbandonedClaim(claim, dependencies))
|
|
101
|
+
return undefined;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const claimant = path.join(claim, `c.${dependencies.pid}.${dependencies.random()}`);
|
|
105
|
+
try {
|
|
106
|
+
fs.mkdirSync(claimant);
|
|
107
|
+
// Test-only deterministic failure inject (unset in production) — shell
|
|
108
|
+
// parity with the script's claimant write_owner_record injection.
|
|
109
|
+
if (dependencies.env.QMD_FORCE_CLAIMANT_WRITE_FAIL)
|
|
110
|
+
throw new Error('forced claimant write failure');
|
|
111
|
+
fs.writeFileSync(path.join(claimant, 'owner'), `pid=${dependencies.pid}\nts=${dependencies.now()}\n`);
|
|
112
|
+
return { claim, claimant };
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
fs.rmSync(claimant, { recursive: true, force: true });
|
|
116
|
+
removeDirectoryIfEmpty(claim);
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Reclaim only exact dead claimant names, then remove the claim directory if
|
|
124
|
+
* empty. This mirrors the shell's no-fixed-path-reclaim rule: a peer that has
|
|
125
|
+
* recreated claim.G with a new marker makes rmdir fail and wins the race.
|
|
126
|
+
*
|
|
127
|
+
* A process killed after the identity-safe rename and before its rmSync leaves
|
|
128
|
+
* a `*.stale-claim.*` directory behind. That name is intentionally forensic:
|
|
129
|
+
* it is no longer an active claim and records the interrupted reclaim for the
|
|
130
|
+
* feedback bundle, so no later acquirer should sweep it by a fixed path.
|
|
131
|
+
*/
|
|
132
|
+
function recoverAbandonedClaim(claim, dependencies) {
|
|
133
|
+
let entries;
|
|
134
|
+
try {
|
|
135
|
+
entries = fs.readdirSync(claim, { withFileTypes: true });
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
for (const entry of entries.filter((entry) => entry.name.startsWith('c.'))) {
|
|
141
|
+
const marker = path.join(claim, entry.name);
|
|
142
|
+
const fields = parseFields(path.join(marker, 'owner'));
|
|
143
|
+
const pid = fields && /^\d+$/.test(fields.pid ?? '') ? Number(fields.pid) : undefined;
|
|
144
|
+
if (pid !== undefined && dependencies.isProcessAlive(pid))
|
|
145
|
+
return false;
|
|
146
|
+
if (pid === undefined && claimWithinGrace(claim, dependencies))
|
|
147
|
+
return false;
|
|
148
|
+
const abandoned = `${claim}.stale-claim.${dependencies.pid}.${dependencies.random()}`;
|
|
149
|
+
try {
|
|
150
|
+
fs.renameSync(marker, abandoned);
|
|
151
|
+
fs.rmSync(abandoned, { recursive: true, force: true });
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (entries.length === 0 && claimWithinGrace(claim, dependencies))
|
|
158
|
+
return false;
|
|
159
|
+
removeDirectoryIfEmpty(claim);
|
|
160
|
+
return !fs.existsSync(claim);
|
|
161
|
+
}
|
|
162
|
+
function createAndPublishLock(home, dependencies) {
|
|
163
|
+
const directory = lockPath(home);
|
|
164
|
+
try {
|
|
165
|
+
fs.mkdirSync(directory);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
const marker = path.join(directory, `acq.${dependencies.pid}.${dependencies.random()}`);
|
|
171
|
+
try {
|
|
172
|
+
fs.mkdirSync(marker);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
removeDirectoryIfEmpty(directory);
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
const nonce = `${dependencies.pid}.${dependencies.random()}`;
|
|
179
|
+
const ownerFile = path.join(directory, 'owner');
|
|
180
|
+
const temporary = path.join(directory, `.owner.tmp.${dependencies.pid}.${dependencies.random()}`);
|
|
181
|
+
try {
|
|
182
|
+
// Test-only deterministic failure inject (unset in production) — shell
|
|
183
|
+
// parity: the script's write_owner_record honored the same variable.
|
|
184
|
+
if (dependencies.env.QMD_FORCE_OWNER_WRITE_FAIL)
|
|
185
|
+
throw new Error('forced owner write failure');
|
|
186
|
+
fs.writeFileSync(temporary, `pid=${dependencies.pid}\nts=${dependencies.now()}\nnonce=${nonce}\n`);
|
|
187
|
+
fs.renameSync(temporary, ownerFile);
|
|
188
|
+
dependencies.afterOwnerPublish?.(ownerFile);
|
|
189
|
+
const verified = readOwner(directory);
|
|
190
|
+
if (!verified || verified.pid !== dependencies.pid || verified.nonce !== nonce)
|
|
191
|
+
throw new Error('owner publish verification failed');
|
|
192
|
+
fs.rmSync(marker, { recursive: true, force: true });
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// The marker identifies only the directory this process created. Do not
|
|
197
|
+
// recursively remove the fixed lock path after a failed publication.
|
|
198
|
+
fs.rmSync(marker, { recursive: true, force: true });
|
|
199
|
+
removeDirectoryIfEmpty(directory);
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function claimAndReclaim(home, dependencies) {
|
|
204
|
+
const directory = lockPath(home);
|
|
205
|
+
const observedGeneration = generation(directory);
|
|
206
|
+
if (!observedGeneration || lockState(directory, dependencies) !== 'stale')
|
|
207
|
+
return false;
|
|
208
|
+
const acquired = acquireClaim(home, observedGeneration, dependencies);
|
|
209
|
+
if (!acquired)
|
|
210
|
+
return false;
|
|
211
|
+
try {
|
|
212
|
+
// Generation fencing: only move exactly the stale generation we observed.
|
|
213
|
+
if (generation(directory) !== observedGeneration || lockState(directory, dependencies) !== 'stale')
|
|
214
|
+
return false;
|
|
215
|
+
const abandoned = `${directory}.stale.${dependencies.pid}.${dependencies.random()}`;
|
|
216
|
+
fs.renameSync(directory, abandoned);
|
|
217
|
+
fs.rmSync(abandoned, { recursive: true, force: true });
|
|
218
|
+
return createAndPublishLock(home, dependencies);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
fs.rmSync(acquired.claimant, { recursive: true, force: true });
|
|
225
|
+
removeDirectoryIfEmpty(acquired.claim);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function tryAcquireEmbedLock(home, dependencies) {
|
|
229
|
+
try {
|
|
230
|
+
fs.mkdirSync(lockRoot(home), { recursive: true });
|
|
231
|
+
fs.accessSync(lockRoot(home), fs.constants.W_OK | fs.constants.X_OK);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return 'unavailable';
|
|
235
|
+
}
|
|
236
|
+
return createAndPublishLock(home, dependencies) || claimAndReclaim(home, dependencies)
|
|
237
|
+
? 'acquired'
|
|
238
|
+
: 'busy';
|
|
239
|
+
}
|
|
240
|
+
export function acquireEmbedLock(home, dependencies) {
|
|
241
|
+
return tryAcquireEmbedLock(home, dependencies) === 'acquired';
|
|
242
|
+
}
|
|
243
|
+
export function releaseEmbedLock(home, dependencies) {
|
|
244
|
+
const directory = lockPath(home);
|
|
245
|
+
if (readOwner(directory)?.pid === dependencies.pid)
|
|
246
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
247
|
+
}
|
|
248
|
+
/** Install signal and exit cleanup for a lock owner, returning an optional unregister hook. */
|
|
249
|
+
export function installEmbedLockCleanup(cleanup, processEvents = process, exit = (code) => process.exit(code)) {
|
|
250
|
+
const onExit = cleanup;
|
|
251
|
+
const onSignal = () => { cleanup(); exit(0); };
|
|
252
|
+
processEvents.once('exit', onExit);
|
|
253
|
+
processEvents.once('SIGINT', onSignal);
|
|
254
|
+
processEvents.once('SIGTERM', onSignal);
|
|
255
|
+
processEvents.once('SIGHUP', onSignal);
|
|
256
|
+
return () => {
|
|
257
|
+
processEvents.removeListener?.('exit', onExit);
|
|
258
|
+
processEvents.removeListener?.('SIGINT', onSignal);
|
|
259
|
+
processEvents.removeListener?.('SIGTERM', onSignal);
|
|
260
|
+
processEvents.removeListener?.('SIGHUP', onSignal);
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Run one synchronous embed while owning the shared lock.
|
|
265
|
+
*
|
|
266
|
+
* This deliberately does not install signal handlers. `run` is a synchronous
|
|
267
|
+
* qmd child, so JavaScript cannot execute a cleanup handler until it returns;
|
|
268
|
+
* registering SIGINT/SIGTERM would therefore suppress Node's immediate default
|
|
269
|
+
* termination. An interrupted owner is safely reclaimed through the lock's
|
|
270
|
+
* existing staleness and owner-grace protocol.
|
|
271
|
+
*/
|
|
272
|
+
export function runWithEmbedLock(home, dependencies, run) {
|
|
273
|
+
const acquired = tryAcquireEmbedLock(home, dependencies);
|
|
274
|
+
if (acquired === 'unavailable')
|
|
275
|
+
return 'unavailable';
|
|
276
|
+
if (acquired === 'busy')
|
|
277
|
+
return 'busy';
|
|
278
|
+
try {
|
|
279
|
+
run();
|
|
280
|
+
return 'ran';
|
|
281
|
+
}
|
|
282
|
+
finally {
|
|
283
|
+
releaseEmbedLock(home, dependencies);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
//# sourceMappingURL=embed-lock.js.map
|
|
@@ -375,6 +375,7 @@ export declare function lastQmdStoreEnsureOutcome(): QmdStoreEnsureOutcome | und
|
|
|
375
375
|
* An upstream-contract test pins this rule to the INSTALLED qmd so a bump that
|
|
376
376
|
* changes it turns CI red instead of silently restoring the noise.
|
|
377
377
|
*/
|
|
378
|
+
export declare function resolveQmdHome(env?: NodeJS.ProcessEnv): string;
|
|
378
379
|
export declare function resolveQmdStoreDir(env?: NodeJS.ProcessEnv, cwd?: string): string;
|
|
379
380
|
export type EnsureQmdStoreDirOptions = {
|
|
380
381
|
/**
|
|
@@ -428,13 +428,16 @@ export function lastQmdStoreEnsureOutcome() {
|
|
|
428
428
|
* An upstream-contract test pins this rule to the INSTALLED qmd so a bump that
|
|
429
429
|
* changes it turns CI red instead of silently restoring the noise.
|
|
430
430
|
*/
|
|
431
|
+
export function resolveQmdHome(env = process.env) {
|
|
432
|
+
return env.HOME || env.USERPROFILE || os.homedir() || '/tmp';
|
|
433
|
+
}
|
|
431
434
|
export function resolveQmdStoreDir(env = process.env, cwd = process.cwd()) {
|
|
432
435
|
const indexPath = env.INDEX_PATH;
|
|
433
436
|
// path.resolve leaves an absolute INDEX_PATH untouched and anchors a relative
|
|
434
437
|
// one to the child cwd, so the probed/stamped directory is the one qmd uses.
|
|
435
438
|
if (indexPath)
|
|
436
439
|
return path.dirname(path.resolve(cwd, indexPath));
|
|
437
|
-
const home = env
|
|
440
|
+
const home = resolveQmdHome(env);
|
|
438
441
|
const cacheDir = env.XDG_CACHE_HOME || path.join(home, '.cache');
|
|
439
442
|
return path.join(cacheDir, 'qmd');
|
|
440
443
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.115.
|
|
3
|
+
"version": "5.115.2",
|
|
4
4
|
"description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -36,7 +36,6 @@
|
|
|
36
36
|
"@aws-sdk/client-s3": "^3.1049.0",
|
|
37
37
|
"@indigoai-us/hq-cloud": "~6.16.41",
|
|
38
38
|
"@indigoai-us/hq-flags-client": "^0.1.2",
|
|
39
|
-
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
40
39
|
"@sentry/node": "^10.49.0",
|
|
41
40
|
"@tobilu/qmd": "2.5.3",
|
|
42
41
|
"ajv": "^8.20.0",
|