@indigoai-us/hq-cli 5.345.33 → 5.345.34
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 +7 -0
- package/dist/commands/cloud-provision.d.ts +2 -0
- package/dist/commands/lanes.js +18 -14
- package/dist/commands/reindex.js +11 -8
- package/dist/lib/daemon/remote-control.js +62 -3
- package/dist/lib/index-render/shared.d.ts +3 -1
- package/dist/lib/index-render/shared.js +5 -2
- package/dist/lib/lanes/depth-cap.d.ts +7 -0
- package/dist/lib/lanes/depth-cap.js +41 -0
- package/dist/lib/lanes/reconcile.d.ts +1 -1
- package/dist/lib/lanes/reconcile.js +16 -5
- package/dist/lib/mesh/live/daemon/install.d.ts +7 -4
- package/dist/lib/mesh/live/daemon/install.js +144 -47
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.345.34] — 2026-10-07
|
|
6
|
+
- Protect live remote-control tmux sessions with socket-scoped creator identity before cleanup.
|
|
7
|
+
|
|
8
|
+
- Cap the shipped hook health checker at 20 seconds when hq reindex runs from a lifecycle hook while keeping the 60-second interactive bound.
|
|
9
|
+
- Allow each company to configure its lane depth cap from 1 through 8. Invalid values fall back to the default cap of 4 with a stderr warning.
|
|
10
|
+
- Mesh services now use the stable HQ launcher after package updates or relocation. Failed service-file replacement or reload restores the previous unit.
|
|
11
|
+
|
|
5
12
|
## [5.345.33] — 2026-10-06
|
|
6
13
|
- Map each PRD ledger state to a Board column and send its original value in `ledgerState`; `hq mesh project reconcile` reports status drift and notes when the Board omits that field.
|
|
7
14
|
|
|
@@ -47,6 +47,8 @@ export interface CompanyConfig {
|
|
|
47
47
|
companySlug: string;
|
|
48
48
|
bucketName: string;
|
|
49
49
|
vaultApiUrl: string;
|
|
50
|
+
/** Optional per-company worker lane depth limit (1 through 8). */
|
|
51
|
+
maxLaneDepth?: number;
|
|
50
52
|
}
|
|
51
53
|
/** Final stdout JSON shape. Consumers (designate-team, AppBar) parse this. */
|
|
52
54
|
export interface ProvisionResult {
|
package/dist/commands/lanes.js
CHANGED
|
@@ -33,6 +33,7 @@ import { registerLaneReviewCommand } from "./lanes-review.js";
|
|
|
33
33
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
34
34
|
import { withCliFeatureUsed } from "../utils/cli-telemetry.js";
|
|
35
35
|
import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
|
|
36
|
+
import { laneDepthCap } from "../lib/lanes/depth-cap.js";
|
|
36
37
|
import { isSsmRunCommandProcess } from "../lib/lanes/ssm-command-context.js";
|
|
37
38
|
import { runResidentCommandWithPackageLease } from "../utils/resident-package-preload.js";
|
|
38
39
|
import { readLanesFlagLocalOverride, readLanesRegistryFlag, readLanesRegistryFlagFailOff } from "../lib/lanes/registry-flag.js";
|
|
@@ -2070,12 +2071,12 @@ function seniorLaneChain(hqRoot, ref, ctx, visited = new Set(), override) {
|
|
|
2070
2071
|
const depth = isOverridden ? override.depth : derivedLaneDepth(hqRoot, lane.senior, ctx, override);
|
|
2071
2072
|
return [...prefix, { lane_id: lane.lane_id, depth }];
|
|
2072
2073
|
}
|
|
2073
|
-
function maxLaneDepthError(depth, chain, description, extraDetails = {}) {
|
|
2074
|
-
const parentLane = [...chain].reverse().find((entry) => entry.depth <
|
|
2074
|
+
function maxLaneDepthError(depth, chain, description, cap, extraDetails = {}) {
|
|
2075
|
+
const parentLane = [...chain].reverse().find((entry) => entry.depth < cap.max);
|
|
2075
2076
|
const nextStep = parentLane
|
|
2076
2077
|
? `Ask your senior lane ${parentLane.lane_id} to create this worker instead, or re-parent this lane under a shallower senior.`
|
|
2077
2078
|
: "Ask a shallower senior to create this lane, or re-parent it to a shallower senior.";
|
|
2078
|
-
return new LanesError("max_lane_depth", `${description}: senior chain length ${depth} exceeds cap ${
|
|
2079
|
+
return new LanesError("max_lane_depth", `${description}: senior chain length ${depth} exceeds cap ${cap.max} (${cap.source === "company" ? "company config" : "default config"})`, { ...extraDetails, depth, max: cap.max, source: cap.source, senior_chain: chain, next_step: nextStep });
|
|
2079
2080
|
}
|
|
2080
2081
|
function callerDepth(hqRoot, ctx) {
|
|
2081
2082
|
const laneId = ctx.env.HQ_LANE_ID?.trim();
|
|
@@ -3068,21 +3069,22 @@ async function runCreate(ctx, opts) {
|
|
|
3068
3069
|
}
|
|
3069
3070
|
assertKnownOutpost(requested, known);
|
|
3070
3071
|
}
|
|
3072
|
+
let company = companyFlag
|
|
3073
|
+
? await ctx.resolveCompany(companyFlag)
|
|
3074
|
+
: null;
|
|
3075
|
+
const depthCap = laneDepthCap(hqRoot, company?.slug);
|
|
3071
3076
|
const depth = callerDepth(hqRoot, ctx);
|
|
3072
|
-
if (depth >=
|
|
3077
|
+
if (depth >= depthCap.max) {
|
|
3073
3078
|
const callerLaneId = ctx.env.HQ_LANE_ID?.trim();
|
|
3074
3079
|
const chain = callerLaneId
|
|
3075
3080
|
? seniorLaneChain(hqRoot, { kind: "lane", id: callerLaneId }, ctx)
|
|
3076
3081
|
: seniorLaneChain(hqRoot, sessionIdFromEnv(ctx.env)
|
|
3077
3082
|
? { kind: "session", id: sessionIdFromEnv(ctx.env) }
|
|
3078
3083
|
: { kind: "user" }, ctx);
|
|
3079
|
-
throw maxLaneDepthError(depth + 1, chain, `A depth-${depth} caller may not spawn a depth-${depth + 1} lane
|
|
3084
|
+
throw maxLaneDepthError(depth + 1, chain, `A depth-${depth} caller may not spawn a depth-${depth + 1} lane`, depthCap);
|
|
3080
3085
|
}
|
|
3081
3086
|
const senior = opts.senior ? parseSeniorRef(opts.senior) : defaultSenior(ctx);
|
|
3082
3087
|
assertSeniorExists(hqRoot, senior, ctx);
|
|
3083
|
-
let company = companyFlag
|
|
3084
|
-
? await ctx.resolveCompany(companyFlag)
|
|
3085
|
-
: null;
|
|
3086
3088
|
const seniorCompany = companyOfSenior(hqRoot, senior, ctx);
|
|
3087
3089
|
if (company === null && seniorCompany.binding === "company") {
|
|
3088
3090
|
const seniorCompanyIdentifier = seniorCompany.slug ?? (seniorCompany.uid
|
|
@@ -3104,8 +3106,8 @@ async function runCreate(ctx, opts) {
|
|
|
3104
3106
|
}
|
|
3105
3107
|
const hireGrant = requestedHireGrant ?? 0;
|
|
3106
3108
|
const laneDepth = derivedLaneDepth(hqRoot, senior, ctx);
|
|
3107
|
-
if (laneDepth >
|
|
3108
|
-
throw maxLaneDepthError(laneDepth, seniorLaneChain(hqRoot, senior, ctx), `Creating lane at depth ${laneDepth} is refused
|
|
3109
|
+
if (laneDepth > depthCap.max) {
|
|
3110
|
+
throw maxLaneDepthError(laneDepth, seniorLaneChain(hqRoot, senior, ctx), `Creating lane at depth ${laneDepth} is refused`, depthCap);
|
|
3109
3111
|
}
|
|
3110
3112
|
// A no-company lane must not call company resolution (openMeshTransport).
|
|
3111
3113
|
if (storyIds.length > 0 && ctx.storyLedgerEnabled()) {
|
|
@@ -7249,10 +7251,11 @@ function runReparentLocked(ctx, laneId, opts, finishedChildrenDoNotCount = false
|
|
|
7249
7251
|
throw new LanesError("senior_cycle", `Re-parent would create a cycle through ${laneId}`, { lane_id: laneId, senior: nextSenior });
|
|
7250
7252
|
}
|
|
7251
7253
|
const newDepth = derivedLaneDepth(hqRoot, nextSenior, ctx);
|
|
7252
|
-
|
|
7254
|
+
const depthCap = laneDepthCap(hqRoot, lane.company?.slug);
|
|
7255
|
+
if (newDepth > depthCap.max) {
|
|
7253
7256
|
const chain = seniorLaneChain(hqRoot, nextSenior, ctx);
|
|
7254
7257
|
chain.push({ lane_id: lane.lane_id, depth: newDepth });
|
|
7255
|
-
throw maxLaneDepthError(newDepth, chain, `Re-parent would put lane at depth ${newDepth}
|
|
7258
|
+
throw maxLaneDepthError(newDepth, chain, `Re-parent would put lane at depth ${newDepth}`, depthCap);
|
|
7256
7259
|
}
|
|
7257
7260
|
const pending = pendingQuestionsForLane(hqRoot, laneId);
|
|
7258
7261
|
const oldDepth = derivedLaneDepth(hqRoot, lane.senior, ctx);
|
|
@@ -7269,10 +7272,11 @@ function runReparentLocked(ctx, laneId, opts, finishedChildrenDoNotCount = false
|
|
|
7269
7272
|
assertLaneRunsHereForOperatorMutation(ctx, other, "reparent descendant of");
|
|
7270
7273
|
const oldDescendantDepth = derivedLaneDepth(hqRoot, other.senior, ctx);
|
|
7271
7274
|
const descendantDepth = derivedLaneDepth(hqRoot, other.senior, ctx, override);
|
|
7272
|
-
|
|
7275
|
+
const descendantCap = laneDepthCap(hqRoot, other.company?.slug);
|
|
7276
|
+
if (descendantDepth > descendantCap.max) {
|
|
7273
7277
|
const chain = seniorLaneChain(hqRoot, other.senior, ctx, new Set(), override);
|
|
7274
7278
|
chain.push({ lane_id: other.lane_id, depth: descendantDepth });
|
|
7275
|
-
throw maxLaneDepthError(descendantDepth, chain, `Re-parent would put descendant ${other.lane_id} at depth ${descendantDepth}`, { lane_id: other.lane_id });
|
|
7279
|
+
throw maxLaneDepthError(descendantDepth, chain, `Re-parent would put descendant ${other.lane_id} at depth ${descendantDepth}`, descendantCap, { lane_id: other.lane_id });
|
|
7276
7280
|
}
|
|
7277
7281
|
if (descendantDepth > oldDescendantDepth &&
|
|
7278
7282
|
pendingQuestionsForLane(hqRoot, other.lane_id).length > 0) {
|
package/dist/commands/reindex.js
CHANGED
|
@@ -47,6 +47,9 @@ const WORKTREE_HOOK_SWEEP_BUDGET_MS = 5_000;
|
|
|
47
47
|
* bounded, which is the point.
|
|
48
48
|
*/
|
|
49
49
|
const HOOK_CHECK_TIMEOUT_MS = 60_000;
|
|
50
|
+
// Leave ten seconds inside the desktop's 30-second package-lease wait for
|
|
51
|
+
// process startup and cleanup when reindex runs from a lifecycle hook.
|
|
52
|
+
const HOOK_CHECK_FROM_HOOK_TIMEOUT_MS = 20_000;
|
|
50
53
|
/** Ceiling override, in milliseconds. Present so tests need not wait a minute. */
|
|
51
54
|
function hookCheckTimeoutMs() {
|
|
52
55
|
const raw = process.env.HQ_HOOK_CHECK_TIMEOUT_MS?.trim();
|
|
@@ -367,11 +370,11 @@ function hasCommandHook(value) {
|
|
|
367
370
|
* an older, unscoped checker — report healthy while the drift only this checker
|
|
368
371
|
* detects goes unseen.
|
|
369
372
|
*/
|
|
370
|
-
function runShippedHookCheck(hqRoot) {
|
|
373
|
+
function runShippedHookCheck(hqRoot, timeoutLimitMs) {
|
|
371
374
|
const checker = path.join(hqRoot, HOOK_CHECK_RELATIVE_PATH);
|
|
372
375
|
if (!fs.existsSync(checker))
|
|
373
376
|
return undefined;
|
|
374
|
-
const budgetMs = hookCheckTimeoutMs();
|
|
377
|
+
const budgetMs = Math.min(hookCheckTimeoutMs(), timeoutLimitMs ?? Number.POSITIVE_INFINITY);
|
|
375
378
|
try {
|
|
376
379
|
const result = spawnSync('bash', [checker, '--root', hqRoot], {
|
|
377
380
|
encoding: 'utf8',
|
|
@@ -406,7 +409,7 @@ function runShippedHookCheck(hqRoot) {
|
|
|
406
409
|
* reindex fires on every Stop / PostToolUse hook, that was the single most
|
|
407
410
|
* expensive discarded result in the command.
|
|
408
411
|
*/
|
|
409
|
-
function inspectHookHealth(hqRoot) {
|
|
412
|
+
function inspectHookHealth(hqRoot, timeoutLimitMs) {
|
|
410
413
|
const settingsPath = path.join(hqRoot, '.claude', 'settings.json');
|
|
411
414
|
if (!fs.existsSync(settingsPath)) {
|
|
412
415
|
return { state: 'extreme', reason: '.claude/settings.json is missing' };
|
|
@@ -435,7 +438,7 @@ function inspectHookHealth(hqRoot) {
|
|
|
435
438
|
reason: `missing command hook wiring for ${HOOK_EVENTS.filter((event) => !commandEvents.includes(event)).join(', ')}`,
|
|
436
439
|
};
|
|
437
440
|
}
|
|
438
|
-
const checkerResult = runShippedHookCheck(hqRoot);
|
|
441
|
+
const checkerResult = runShippedHookCheck(hqRoot, timeoutLimitMs);
|
|
439
442
|
if (checkerResult !== undefined && checkerResult.status !== 0) {
|
|
440
443
|
return {
|
|
441
444
|
state: 'minor',
|
|
@@ -491,12 +494,12 @@ function runSilently(operation) {
|
|
|
491
494
|
* configuration is repaired only after a successful reindex; partial and
|
|
492
495
|
* malformed configurations remain untouched and receive recovery guidance.
|
|
493
496
|
*/
|
|
494
|
-
function repairExtremeHookDrift(hqRoot, allowRepair = true) {
|
|
497
|
+
function repairExtremeHookDrift(hqRoot, allowRepair = true, timeoutLimitMs) {
|
|
495
498
|
// `hq reindex` can still be invoked from an arbitrary directory. Never turn
|
|
496
499
|
// an absent settings file there into a rescue attempt.
|
|
497
500
|
if (!isHqRoot(hqRoot))
|
|
498
501
|
return;
|
|
499
|
-
const health = inspectHookHealth(hqRoot);
|
|
502
|
+
const health = inspectHookHealth(hqRoot, timeoutLimitMs);
|
|
500
503
|
if (health.state === 'healthy')
|
|
501
504
|
return;
|
|
502
505
|
printHookHealthWarning(hqRoot, health.reason);
|
|
@@ -510,7 +513,7 @@ function repairExtremeHookDrift(hqRoot, allowRepair = true) {
|
|
|
510
513
|
paths: ['.claude'],
|
|
511
514
|
assumeYes: true,
|
|
512
515
|
}));
|
|
513
|
-
const afterRepair = inspectHookHealth(hqRoot);
|
|
516
|
+
const afterRepair = inspectHookHealth(hqRoot, timeoutLimitMs);
|
|
514
517
|
if (result.status === 0 && afterRepair.state === 'healthy') {
|
|
515
518
|
console.log('reindex: repaired HQ hook config');
|
|
516
519
|
return;
|
|
@@ -691,7 +694,7 @@ export function registerReindexCommand(program) {
|
|
|
691
694
|
const sweepStartedAt = Date.now();
|
|
692
695
|
removeStaleHqWorktrees(hqRoot, sweepStartedAt, opts.fromHook ? sweepStartedAt + WORKTREE_HOOK_SWEEP_BUDGET_MS : Number.POSITIVE_INFINITY);
|
|
693
696
|
const { status } = reindex({ repoRoot: opts.repoRoot });
|
|
694
|
-
repairExtremeHookDrift(hqRoot, status === 0);
|
|
697
|
+
repairExtremeHookDrift(hqRoot, status === 0, opts.fromHook ? HOOK_CHECK_FROM_HOOK_TIMEOUT_MS : undefined);
|
|
695
698
|
if (status === 0) {
|
|
696
699
|
// Only safe once reindex actually ran: a refused --from-hook reindex
|
|
697
700
|
// means a sync/rescue holds the shared operation lock and may be
|
|
@@ -24,6 +24,7 @@ import * as fs from "node:fs";
|
|
|
24
24
|
import * as os from "node:os";
|
|
25
25
|
import * as path from "node:path";
|
|
26
26
|
const TMUX_SOCKET = "hq-daemon";
|
|
27
|
+
const TMUX_CREATOR_OPTION = "@hq-remote-control-creator";
|
|
27
28
|
export const TMUX_SESSION = "remote-control";
|
|
28
29
|
export const CODEX_TMUX_SESSION = "codex-remote-control";
|
|
29
30
|
export function remoteControlArgs(opts) {
|
|
@@ -204,7 +205,56 @@ function safePaneTail(output) {
|
|
|
204
205
|
}
|
|
205
206
|
export async function runRemoteControlHost(deps) {
|
|
206
207
|
const tmux = (...args) => deps.run(deps.tmux, ["-L", TMUX_SOCKET, ...args]);
|
|
207
|
-
const
|
|
208
|
+
const invokerPid = process.pid;
|
|
209
|
+
const killSession = () => {
|
|
210
|
+
if (tmux("has-session", "-t", deps.session).status !== 0)
|
|
211
|
+
return true;
|
|
212
|
+
const rawCreator = tmux("show-option", "-qv", "-t", deps.session, TMUX_CREATOR_OPTION);
|
|
213
|
+
let creator = null;
|
|
214
|
+
let legacyRecordReason = "no creator record";
|
|
215
|
+
if (rawCreator.status === 0 && rawCreator.stdout.trim() !== "") {
|
|
216
|
+
try {
|
|
217
|
+
const value = JSON.parse(rawCreator.stdout);
|
|
218
|
+
if (Number.isSafeInteger(value.pid) && value.pid > 0 && typeof value.cwd === "string") {
|
|
219
|
+
creator = { pid: value.pid, cwd: value.cwd };
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
legacyRecordReason = "malformed creator record";
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
legacyRecordReason = "malformed creator record";
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
else if (rawCreator.status !== 0) {
|
|
230
|
+
legacyRecordReason = "malformed creator record";
|
|
231
|
+
}
|
|
232
|
+
if (!creator) {
|
|
233
|
+
deps.log(`remote-control tmux session has ${legacyRecordReason}; using bridge-owner liveness fallback; invoker pid=${invokerPid} cwd=${JSON.stringify(deps.cwd)}`);
|
|
234
|
+
const owner = foreignClaudeOwner(deps);
|
|
235
|
+
if (owner !== null) {
|
|
236
|
+
deps.log(`remote-control refused to kill legacy tmux session with live Claude bridge owner pid=${owner}; invoker pid=${invokerPid} cwd=${JSON.stringify(deps.cwd)}`);
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
const panePid = tmux("display-message", "-p", "-t", deps.session, "#{pane_pid}");
|
|
240
|
+
const paneCwd = tmux("display-message", "-p", "-t", deps.session, "#{pane_current_path}");
|
|
241
|
+
const victimPid = panePid.status === 0 && /^\d+$/.test(panePid.stdout.trim())
|
|
242
|
+
? panePid.stdout.trim()
|
|
243
|
+
: "unknown";
|
|
244
|
+
const victimCwd = paneCwd.status === 0 && paneCwd.stdout.trim() !== ""
|
|
245
|
+
? paneCwd.stdout.trim()
|
|
246
|
+
: "unknown";
|
|
247
|
+
deps.log(`remote-control kill tmux session victim pid=${victimPid} cwd=${JSON.stringify(victimCwd)} invoker pid=${invokerPid} cwd=${JSON.stringify(deps.cwd)}`);
|
|
248
|
+
return tmux("kill-session", "-t", deps.session).status === 0;
|
|
249
|
+
}
|
|
250
|
+
const sameInvoker = creator.pid === invokerPid && creator.cwd === deps.cwd;
|
|
251
|
+
if (!sameInvoker && (deps.pidAlive ?? processAlive)(creator.pid)) {
|
|
252
|
+
deps.log(`remote-control refused to kill live tmux session creator pid=${creator.pid} cwd=${JSON.stringify(creator.cwd)}; invoker pid=${invokerPid} cwd=${JSON.stringify(deps.cwd)}`);
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
deps.log(`remote-control kill tmux session victim pid=${creator.pid} cwd=${JSON.stringify(creator.cwd)} invoker pid=${invokerPid} cwd=${JSON.stringify(deps.cwd)}`);
|
|
256
|
+
return tmux("kill-session", "-t", deps.session).status === 0;
|
|
257
|
+
};
|
|
208
258
|
/** When the session last printed anything, in ms; null when tmux cannot say. */
|
|
209
259
|
const lastActivity = () => {
|
|
210
260
|
const r = tmux("display-message", "-p", "-t", deps.session, "#{window_activity}");
|
|
@@ -213,7 +263,8 @@ export async function runRemoteControlHost(deps) {
|
|
|
213
263
|
};
|
|
214
264
|
// Remove this daemon's leftover private session before checking the process
|
|
215
265
|
// pointer, so its previous command is not mistaken for a foreign owner.
|
|
216
|
-
killSession()
|
|
266
|
+
if (!killSession())
|
|
267
|
+
return 1;
|
|
217
268
|
let waitingFor = null;
|
|
218
269
|
for (;;) {
|
|
219
270
|
if (deps.signal.aborted)
|
|
@@ -233,6 +284,14 @@ export async function runRemoteControlHost(deps) {
|
|
|
233
284
|
deps.log(`could not create the tmux session: ${created.stderr.trim() || `tmux exited ${created.status}`}`);
|
|
234
285
|
return 1;
|
|
235
286
|
}
|
|
287
|
+
const creatorRecord = JSON.stringify({ pid: invokerPid, cwd: deps.cwd });
|
|
288
|
+
const recorded = tmux("set-option", "-t", deps.session, TMUX_CREATOR_OPTION, creatorRecord);
|
|
289
|
+
if (recorded.status !== 0) {
|
|
290
|
+
deps.log(`remote-control could not record session creator; invoker pid=${invokerPid} cwd=${JSON.stringify(deps.cwd)}`);
|
|
291
|
+
deps.log(`remote-control kill tmux session victim pid=${invokerPid} cwd=${JSON.stringify(deps.cwd)} invoker pid=${invokerPid} cwd=${JSON.stringify(deps.cwd)}`);
|
|
292
|
+
tmux("kill-session", "-t", deps.session);
|
|
293
|
+
return 1;
|
|
294
|
+
}
|
|
236
295
|
const retained = tmux("set-window-option", "-t", deps.session, "remain-on-exit", "on");
|
|
237
296
|
const started = retained.status === 0
|
|
238
297
|
? tmux("respawn-pane", "-k", "-t", deps.session, ...deps.argv)
|
|
@@ -242,7 +301,7 @@ export async function runRemoteControlHost(deps) {
|
|
|
242
301
|
deps.log(`could not start the tmux session: ${started.stderr.trim() || `tmux exited ${started.status}`}`);
|
|
243
302
|
return 1;
|
|
244
303
|
}
|
|
245
|
-
deps.log(`${deps.label} started in ${deps.cwd}`);
|
|
304
|
+
deps.log(`${deps.label} started in ${JSON.stringify(deps.cwd)} (creator pid ${invokerPid})`);
|
|
246
305
|
const startedWith = deps.binaryFingerprint();
|
|
247
306
|
let updatedAt = null;
|
|
248
307
|
for (;;) {
|
|
@@ -28,7 +28,9 @@ export declare function titleize(slug: string): string;
|
|
|
28
28
|
export declare function date(now?: Date): string;
|
|
29
29
|
export declare function timestamp(now?: Date): string;
|
|
30
30
|
/** Write via a same-directory temporary file, then atomically replace the destination. */
|
|
31
|
-
export declare function atomicWrite(file: string, content: string
|
|
31
|
+
export declare function atomicWrite(file: string, content: string | Buffer, options?: {
|
|
32
|
+
mode?: number;
|
|
33
|
+
}): void;
|
|
32
34
|
export declare function write(context: RenderContext, relative: string, content: string): string;
|
|
33
35
|
/** Drop keep fences, the generated stamp, and the rolling date so two generated bodies can be compared. */
|
|
34
36
|
export declare function generatedFingerprint(content: string): string;
|
|
@@ -78,11 +78,14 @@ export function titleize(slug) {
|
|
|
78
78
|
export function date(now = new Date()) { return now.toISOString().slice(0, 10); }
|
|
79
79
|
export function timestamp(now = new Date()) { return now.toISOString().replace(/\.\d{3}Z$/, "Z"); }
|
|
80
80
|
/** Write via a same-directory temporary file, then atomically replace the destination. */
|
|
81
|
-
export function atomicWrite(file, content) {
|
|
81
|
+
export function atomicWrite(file, content, options = {}) {
|
|
82
82
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
83
83
|
const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`);
|
|
84
84
|
try {
|
|
85
|
-
|
|
85
|
+
if (typeof content === "string")
|
|
86
|
+
fs.writeFileSync(temporary, content, { encoding: "utf8", mode: options.mode });
|
|
87
|
+
else
|
|
88
|
+
fs.writeFileSync(temporary, content, { mode: options.mode });
|
|
86
89
|
fs.renameSync(temporary, file);
|
|
87
90
|
}
|
|
88
91
|
catch (error) {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface LaneDepthCap {
|
|
2
|
+
max: number;
|
|
3
|
+
source: "default" | "company";
|
|
4
|
+
}
|
|
5
|
+
/** Read the optional maxLaneDepth from companies/<slug>/.hq/config.json. */
|
|
6
|
+
export declare function laneDepthCap(hqRoot: string, companySlug?: string | null): LaneDepthCap;
|
|
7
|
+
//# sourceMappingURL=depth-cap.d.ts.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { LanesError } from "./errors.js";
|
|
4
|
+
import { MAX_LANE_DEPTH } from "./constants.js";
|
|
5
|
+
/** Read the optional maxLaneDepth from companies/<slug>/.hq/config.json. */
|
|
6
|
+
export function laneDepthCap(hqRoot, companySlug) {
|
|
7
|
+
if (!companySlug)
|
|
8
|
+
return { max: MAX_LANE_DEPTH, source: "default" };
|
|
9
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(companySlug)) {
|
|
10
|
+
throw new LanesError("lane_depth_config_invalid", `Invalid company slug for lane depth config: ${JSON.stringify(companySlug)}`, { company: companySlug });
|
|
11
|
+
}
|
|
12
|
+
const file = path.join(hqRoot, "companies", companySlug, ".hq", "config.json");
|
|
13
|
+
let raw;
|
|
14
|
+
try {
|
|
15
|
+
raw = fs.readFileSync(file, "utf8");
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
if (err.code === "ENOENT")
|
|
19
|
+
return { max: MAX_LANE_DEPTH, source: "default" };
|
|
20
|
+
throw new LanesError("lane_depth_config_unreadable", `Failed to read company lane depth config ${file}: ${err instanceof Error ? err.message : String(err)}`, { company: companySlug, path: file });
|
|
21
|
+
}
|
|
22
|
+
let parsed;
|
|
23
|
+
try {
|
|
24
|
+
parsed = JSON.parse(raw);
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
throw new LanesError("lane_depth_config_invalid", `Invalid JSON in company config ${file}: ${err instanceof Error ? err.message : String(err)}`, { company: companySlug, path: file });
|
|
28
|
+
}
|
|
29
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
30
|
+
throw new LanesError("lane_depth_config_invalid", `Company config ${file} must be a JSON object`, { company: companySlug, path: file });
|
|
31
|
+
}
|
|
32
|
+
const value = parsed.maxLaneDepth;
|
|
33
|
+
if (value === undefined)
|
|
34
|
+
return { max: MAX_LANE_DEPTH, source: "default" };
|
|
35
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 8) {
|
|
36
|
+
process.stderr.write(`hq lanes: lane_depth_cap_invalid company=${companySlug} maxLaneDepth=${JSON.stringify(value)}; using default ${MAX_LANE_DEPTH}\n`);
|
|
37
|
+
return { max: MAX_LANE_DEPTH, source: "default" };
|
|
38
|
+
}
|
|
39
|
+
return { max: value, source: "company" };
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=depth-cap.js.map
|
|
@@ -132,7 +132,7 @@ export declare function timeoutPath(hqRoot: string, laneId: string): string;
|
|
|
132
132
|
export declare function laneRunsHere(lane: LaneRecord, host: ReconcileHost): boolean;
|
|
133
133
|
export declare function agedAdvanceCount(question: QuestionRecord): number;
|
|
134
134
|
export declare function currentHopStartedAtMs(question: QuestionRecord): number;
|
|
135
|
-
export declare function shouldAgeQuestion(question: QuestionRecord, audience: SeniorRef, nowMs: number, ageWindowMs?: number): boolean;
|
|
135
|
+
export declare function shouldAgeQuestion(question: QuestionRecord, audience: SeniorRef, nowMs: number, ageWindowMs?: number, maxAgedAdvances?: number): boolean;
|
|
136
136
|
export declare function shouldAdvanceAudienceEnded(audience: SeniorRef, liveness: LivenessState | null): boolean;
|
|
137
137
|
export declare function readTimeoutDeadline(hqRoot: string, laneId: string): {
|
|
138
138
|
deadline_at: string;
|
|
@@ -14,6 +14,7 @@ import { effectiveAccountHome } from "./account-home.js";
|
|
|
14
14
|
import { enforceRegistryApprovalGate } from "./approval-gate.js";
|
|
15
15
|
import { loadLanesConfig, providerQuotaDefaultHoldMs } from "./config.js";
|
|
16
16
|
import { MAX_LANE_DEPTH } from "./constants.js";
|
|
17
|
+
import { laneDepthCap } from "./depth-cap.js";
|
|
17
18
|
import { AccountResolutionError, LanesError, isLaneVersionConflict } from "./errors.js";
|
|
18
19
|
import { loadWorkerProfile } from "./profile.js";
|
|
19
20
|
import { terminalChildResumeHireGrantError, withHireGrantAdmission, } from "./grants.js";
|
|
@@ -155,12 +156,12 @@ export function currentHopStartedAtMs(question) {
|
|
|
155
156
|
const ms = Date.parse(raw);
|
|
156
157
|
return Number.isFinite(ms) ? ms : Number.NEGATIVE_INFINITY;
|
|
157
158
|
}
|
|
158
|
-
export function shouldAgeQuestion(question, audience, nowMs, ageWindowMs = QUESTION_AGE_MS) {
|
|
159
|
+
export function shouldAgeQuestion(question, audience, nowMs, ageWindowMs = QUESTION_AGE_MS, maxAgedAdvances = MAX_AGED_ADVANCES) {
|
|
159
160
|
if (question.status !== "pending")
|
|
160
161
|
return false;
|
|
161
162
|
if (audience.kind === "user")
|
|
162
163
|
return false;
|
|
163
|
-
if (agedAdvanceCount(question) >=
|
|
164
|
+
if (agedAdvanceCount(question) >= maxAgedAdvances)
|
|
164
165
|
return false;
|
|
165
166
|
const started = currentHopStartedAtMs(question);
|
|
166
167
|
if (started === Number.NEGATIVE_INFINITY)
|
|
@@ -1121,16 +1122,26 @@ export async function runPassBody(hqRoot, deps) {
|
|
|
1121
1122
|
}
|
|
1122
1123
|
continue;
|
|
1123
1124
|
}
|
|
1124
|
-
|
|
1125
|
+
let maxAgedAdvances;
|
|
1126
|
+
try {
|
|
1127
|
+
maxAgedAdvances = laneDepthCap(hqRoot, question.company?.slug).max * 2;
|
|
1128
|
+
}
|
|
1129
|
+
catch (err) {
|
|
1130
|
+
if (!(err instanceof LanesError))
|
|
1131
|
+
throw err;
|
|
1132
|
+
maxAgedAdvances = MAX_AGED_ADVANCES;
|
|
1133
|
+
process.stderr.write(`hq lanes reconcile: lane depth config failed for company=${JSON.stringify(question.company?.slug)}; using default ${MAX_LANE_DEPTH}: ${err.message}\n`);
|
|
1134
|
+
}
|
|
1135
|
+
if (shouldAgeQuestion(question, audience, nowMs, questionAgeWindowMs(audience), maxAgedAdvances)) {
|
|
1125
1136
|
const nextHop = question.hop + 1;
|
|
1126
1137
|
const nextAudience = resolveQuestionAudience({ ...question, hop: nextHop }, readLaneSenior, readSessionSenior);
|
|
1127
1138
|
const result = casQuestionIf(hqRoot, question.question_id, (current) => {
|
|
1128
1139
|
if (current.status !== "pending" ||
|
|
1129
|
-
agedAdvanceCount(current) >=
|
|
1140
|
+
agedAdvanceCount(current) >= maxAgedAdvances) {
|
|
1130
1141
|
return false;
|
|
1131
1142
|
}
|
|
1132
1143
|
const currentAudience = resolveQuestionAudience(current, readLaneSenior, readSessionSenior);
|
|
1133
|
-
return shouldAgeQuestion(current, currentAudience, nowMs, questionAgeWindowMs(currentAudience));
|
|
1144
|
+
return shouldAgeQuestion(current, currentAudience, nowMs, questionAgeWindowMs(currentAudience), maxAgedAdvances);
|
|
1134
1145
|
}, (current) => {
|
|
1135
1146
|
const entry = {
|
|
1136
1147
|
hop: current.hop + 1,
|
|
@@ -55,6 +55,8 @@ export interface InstallPaths {
|
|
|
55
55
|
* host has no conhost (the task then runs node directly).
|
|
56
56
|
*/
|
|
57
57
|
windowsHeadlessLauncher?: string;
|
|
58
|
+
/** Windows command processor used to execute the stable npm .cmd shim. */
|
|
59
|
+
windowsCommandProcessor?: string;
|
|
58
60
|
/** When true, render the root/ec2-user system unit instead of user unit. */
|
|
59
61
|
systemUser?: string;
|
|
60
62
|
/** Verified HQ root to persist and pass to the installed service. */
|
|
@@ -102,7 +104,6 @@ export declare function restartDaemonServiceIfInstalled(opts?: {
|
|
|
102
104
|
env?: NodeJS.ProcessEnv;
|
|
103
105
|
existsSync?: typeof fs.existsSync;
|
|
104
106
|
readFileSync?: typeof fs.readFileSync;
|
|
105
|
-
writeFileSync?: typeof fs.writeFileSync;
|
|
106
107
|
run?: (command: string, args: string[]) => {
|
|
107
108
|
status: number | null;
|
|
108
109
|
stdout?: string;
|
|
@@ -119,7 +120,7 @@ export declare function detectPlatform(platform?: NodeJS.Platform): DaemonInstal
|
|
|
119
120
|
* dies with MODULE_NOT_FOUND on the next restart. Prefer the live npm-global
|
|
120
121
|
* binary when both exist.
|
|
121
122
|
*/
|
|
122
|
-
export declare function preferStableHqBinary(entry: string): string;
|
|
123
|
+
export declare function preferStableHqBinary(entry: string, platform?: DaemonInstallPlatform, existsSync?: typeof fs.existsSync): string;
|
|
123
124
|
/**
|
|
124
125
|
* Absolute path of the CLI entry that is executing this process.
|
|
125
126
|
* realpath of argv[1] only — never a PATH lookup of `hq`.
|
|
@@ -153,7 +154,6 @@ export declare function installDaemonIfStale(opts: {
|
|
|
153
154
|
run?: RunCommand;
|
|
154
155
|
uid?: number;
|
|
155
156
|
sleep?: SleepFn;
|
|
156
|
-
writeFileSync?: typeof fs.writeFileSync;
|
|
157
157
|
mkdirSync?: typeof fs.mkdirSync;
|
|
158
158
|
reloadSystemd?: () => {
|
|
159
159
|
status: number | null;
|
|
@@ -193,6 +193,10 @@ export declare function buildInstallPaths(opts: {
|
|
|
193
193
|
windowsUser?: string;
|
|
194
194
|
/** Windows headless launcher override (tests); defaults to %SystemRoot%\System32\conhost.exe when present. */
|
|
195
195
|
windowsHeadlessLauncher?: string | null;
|
|
196
|
+
/** Platform override for unit path and command rendering tests. */
|
|
197
|
+
platform?: DaemonInstallPlatform;
|
|
198
|
+
/** Filesystem seam for stable hq shim resolution tests. */
|
|
199
|
+
existsSync?: typeof fs.existsSync;
|
|
196
200
|
}): InstallPaths;
|
|
197
201
|
/** `DOMAIN\user` for the current account, as Task Scheduler expects. */
|
|
198
202
|
export declare function resolveWindowsUser(env?: NodeJS.ProcessEnv): string;
|
|
@@ -277,7 +281,6 @@ export declare function installDaemonService(opts: {
|
|
|
277
281
|
paths?: InstallPaths;
|
|
278
282
|
/** When set, write the system unit template (does not sudo-install). */
|
|
279
283
|
systemUser?: string;
|
|
280
|
-
writeFileSync?: typeof fs.writeFileSync;
|
|
281
284
|
mkdirSync?: typeof fs.mkdirSync;
|
|
282
285
|
/** Optional: actually load the service (disabled in tests). */
|
|
283
286
|
activate?: (platform: DaemonInstallPlatform, dest: string) => void;
|
|
@@ -16,6 +16,7 @@ import * as os from "node:os";
|
|
|
16
16
|
import * as path from "node:path";
|
|
17
17
|
import { spawnSync } from "node:child_process";
|
|
18
18
|
import semver from "semver";
|
|
19
|
+
import { atomicWrite } from "../../../index-render/shared.js";
|
|
19
20
|
import { resolveDaemonAssetDir } from "./log.js";
|
|
20
21
|
import { LAUNCHD_LABEL, SYSTEMD_UNIT_NAME, WINDOWS_TASK_NAME, daemonDir, daemonLogPath, daemonPidPath, } from "./paths.js";
|
|
21
22
|
import { isInstallableHqRoot, readDaemonConfig, registerTrustedHqRoot, verifyHqRoot } from "./config.js";
|
|
@@ -123,17 +124,25 @@ function migrateLegacyDaemonUnit(opts) {
|
|
|
123
124
|
// The rendered command only has to name a binary; install already resolved one.
|
|
124
125
|
}
|
|
125
126
|
if (platform === "darwin") {
|
|
126
|
-
|
|
127
|
+
replaceUnitFile(unitPath, renderLaunchdPlist(paths), 0o600, () => {
|
|
128
|
+
activateLaunchd(unitPath, asRunCommand(opts.run), opts.uid, { sleep: opts.sleep });
|
|
129
|
+
});
|
|
127
130
|
return;
|
|
128
131
|
}
|
|
129
132
|
if (platform === "linux") {
|
|
130
|
-
|
|
131
|
-
|
|
133
|
+
replaceUnitFile(unitPath, renderSystemdUserUnit(paths), 0o600, () => {
|
|
134
|
+
const result = opts.run("systemctl", ["--user", "daemon-reload"]);
|
|
135
|
+
if (result.status !== 0)
|
|
136
|
+
throw new Error("systemctl --user daemon-reload failed while repairing the mesh unit");
|
|
137
|
+
});
|
|
132
138
|
return;
|
|
133
139
|
}
|
|
134
140
|
const rendered = renderWindowsTaskXml(paths);
|
|
135
|
-
|
|
136
|
-
|
|
141
|
+
replaceUnitFile(unitPath, encodeWindowsTaskXml(rendered), 0o600, () => {
|
|
142
|
+
const result = opts.run("schtasks.exe", ["/Create", "/TN", WINDOWS_TASK_NAME, "/XML", unitPath, "/F"]);
|
|
143
|
+
if (result.status !== 0)
|
|
144
|
+
throw new Error("schtasks /Create failed while repairing the mesh unit");
|
|
145
|
+
});
|
|
137
146
|
}
|
|
138
147
|
/**
|
|
139
148
|
* Restart the installed user daemon after a CLI upgrade. This is deliberately
|
|
@@ -155,7 +164,6 @@ export function restartDaemonServiceIfInstalled(opts = {}) {
|
|
|
155
164
|
const home = opts.home ?? os.homedir();
|
|
156
165
|
const existsSync = opts.existsSync ?? fs.existsSync;
|
|
157
166
|
const readFileSync = opts.readFileSync ?? fs.readFileSync;
|
|
158
|
-
const writeFileSync = opts.writeFileSync ?? fs.writeFileSync;
|
|
159
167
|
const run = opts.run ?? ((command, args) => {
|
|
160
168
|
const result = spawnSync(command, args, { encoding: "utf8" });
|
|
161
169
|
return {
|
|
@@ -165,7 +173,17 @@ export function restartDaemonServiceIfInstalled(opts = {}) {
|
|
|
165
173
|
};
|
|
166
174
|
});
|
|
167
175
|
try {
|
|
168
|
-
migrateLegacyDaemonUnit({
|
|
176
|
+
migrateLegacyDaemonUnit({
|
|
177
|
+
platform,
|
|
178
|
+
home,
|
|
179
|
+
hqRoot: opts.hqRoot,
|
|
180
|
+
env: opts.env,
|
|
181
|
+
existsSync,
|
|
182
|
+
readFileSync,
|
|
183
|
+
run,
|
|
184
|
+
uid: opts.uid,
|
|
185
|
+
sleep: opts.sleep,
|
|
186
|
+
});
|
|
169
187
|
if (platform === "darwin") {
|
|
170
188
|
const plist = path.join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
171
189
|
if (!existsSync(plist))
|
|
@@ -239,14 +257,46 @@ export function detectPlatform(platform = process.platform) {
|
|
|
239
257
|
* dies with MODULE_NOT_FOUND on the next restart. Prefer the live npm-global
|
|
240
258
|
* binary when both exist.
|
|
241
259
|
*/
|
|
242
|
-
export function preferStableHqBinary(entry) {
|
|
243
|
-
const
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
260
|
+
export function preferStableHqBinary(entry, platform = detectPlatform(), existsSync = fs.existsSync) {
|
|
261
|
+
const api = platform === "win32" ? path.win32 : path;
|
|
262
|
+
const sep = api.sep;
|
|
263
|
+
const stale = `${sep}toolchain${sep}node${sep}bin${sep}`;
|
|
264
|
+
const stable = `${sep}toolchain${sep}npm-global${sep}bin${sep}`;
|
|
265
|
+
if (entry.includes(stale)) {
|
|
266
|
+
const candidate = entry.replace(stale, stable);
|
|
267
|
+
if (candidate !== entry && existsSync(candidate))
|
|
268
|
+
return api.resolve(candidate);
|
|
269
|
+
}
|
|
270
|
+
const normalized = entry.replace(/\\/g, "/");
|
|
271
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
272
|
+
let packageAt = -1;
|
|
273
|
+
for (let i = 0; i < parts.length - 2; i += 1) {
|
|
274
|
+
if (parts[i]?.toLowerCase() === "node_modules"
|
|
275
|
+
&& parts[i + 1]?.toLowerCase() === "@indigoai-us"
|
|
276
|
+
&& parts[i + 2]?.toLowerCase() === "hq-cli") {
|
|
277
|
+
packageAt = i;
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (packageAt >= 0) {
|
|
282
|
+
const prefixParts = parts.slice(0, packageAt);
|
|
283
|
+
let prefix;
|
|
284
|
+
if (platform === "win32") {
|
|
285
|
+
const root = path.win32.parse(entry).root;
|
|
286
|
+
const rootParts = root.replace(/\\+$/, "").split(/[\\/]+/).filter(Boolean);
|
|
287
|
+
prefix = path.win32.resolve(root, ...prefixParts.slice(rootParts.length));
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
if (prefixParts.at(-1) === "lib")
|
|
291
|
+
prefixParts.pop();
|
|
292
|
+
prefix = path.posix.resolve(`/${prefixParts.join("/")}`);
|
|
293
|
+
}
|
|
294
|
+
const shim = platform === "win32"
|
|
295
|
+
? path.win32.join(prefix, "hq.cmd")
|
|
296
|
+
: path.posix.join(prefix, "bin", "hq");
|
|
297
|
+
if (existsSync(shim))
|
|
298
|
+
return api.resolve(shim);
|
|
299
|
+
}
|
|
250
300
|
return entry;
|
|
251
301
|
}
|
|
252
302
|
/**
|
|
@@ -346,7 +396,6 @@ export function installDaemonIfStale(opts) {
|
|
|
346
396
|
platform,
|
|
347
397
|
paths: opts.paths,
|
|
348
398
|
systemUser: opts.systemUser,
|
|
349
|
-
writeFileSync: opts.writeFileSync,
|
|
350
399
|
mkdirSync: opts.mkdirSync,
|
|
351
400
|
reloadSystemd: opts.reloadSystemd,
|
|
352
401
|
schtasks: opts.schtasks,
|
|
@@ -357,9 +406,6 @@ export function installDaemonIfStale(opts) {
|
|
|
357
406
|
uid: opts.uid,
|
|
358
407
|
sleep: opts.sleep,
|
|
359
408
|
});
|
|
360
|
-
if (platform === "linux" && !opts.systemUser && !opts.paths.systemUser) {
|
|
361
|
-
activateSystemd(SYSTEMD_UNIT_NAME, run);
|
|
362
|
-
}
|
|
363
409
|
return {
|
|
364
410
|
reinstalled: true,
|
|
365
411
|
message: `${installed.message} Restarted with ${opts.paths.nodeBinary} ${opts.paths.hqBinary}.`,
|
|
@@ -413,7 +459,8 @@ export function buildInstallPaths(opts) {
|
|
|
413
459
|
const home = opts.home ?? os.homedir();
|
|
414
460
|
const env = opts.env ?? process.env;
|
|
415
461
|
const ddir = daemonDir(opts.meshRoot, home, env);
|
|
416
|
-
const
|
|
462
|
+
const platform = opts.platform ?? detectPlatform();
|
|
463
|
+
const hqBinary = preferStableHqBinary(opts.hqBinary ?? resolveHqBinary(opts.argv, env), platform, opts.existsSync ?? fs.existsSync);
|
|
417
464
|
const nodeBinary = opts.nodeBinary ?? resolveNodeBinary(opts.execPath, env);
|
|
418
465
|
return {
|
|
419
466
|
home,
|
|
@@ -430,6 +477,7 @@ export function buildInstallPaths(opts) {
|
|
|
430
477
|
windowsHeadlessLauncher: opts.windowsHeadlessLauncher === undefined
|
|
431
478
|
? resolveWindowsHeadlessLauncher(env)
|
|
432
479
|
: (opts.windowsHeadlessLauncher ?? undefined),
|
|
480
|
+
windowsCommandProcessor: path.win32.join(env.SystemRoot ?? "C:\\Windows", "System32", "cmd.exe"),
|
|
433
481
|
systemUser: opts.systemUser,
|
|
434
482
|
hqRoot: opts.hqRoot,
|
|
435
483
|
hqRootSource: opts.hqRootSource,
|
|
@@ -480,7 +528,12 @@ export function windowsLogonNotice(paths) {
|
|
|
480
528
|
function describeWindowsLaunchMode(paths) {
|
|
481
529
|
return windowsLaunchMode(paths) === "conhost-headless"
|
|
482
530
|
? `mode: conhost --headless via ${paths.windowsHeadlessLauncher}`
|
|
483
|
-
:
|
|
531
|
+
: isWindowsCommandShim(paths.hqBinary)
|
|
532
|
+
? "mode: direct command processor (conhost --headless unavailable on this host; a console window may appear at logon)"
|
|
533
|
+
: "mode: direct node (conhost --headless unavailable on this host; a console window may appear at logon)";
|
|
534
|
+
}
|
|
535
|
+
function isWindowsCommandShim(file) {
|
|
536
|
+
return path.win32.extname(file).toLowerCase() === ".cmd";
|
|
484
537
|
}
|
|
485
538
|
function safeUsername() {
|
|
486
539
|
try {
|
|
@@ -601,13 +654,19 @@ WantedBy=multi-user.target
|
|
|
601
654
|
*/
|
|
602
655
|
export function renderWindowsTaskXml(paths) {
|
|
603
656
|
const user = escapeXml(paths.windowsUser);
|
|
604
|
-
const
|
|
657
|
+
const stableShim = isWindowsCommandShim(paths.hqBinary);
|
|
605
658
|
const hqArg = windowsArgQuote(paths.hqBinary);
|
|
606
|
-
const
|
|
659
|
+
const commandProcessor = paths.windowsCommandProcessor ?? path.win32.join("C:\\Windows", "System32", "cmd.exe");
|
|
660
|
+
const command = stableShim ? (paths.windowsHeadlessLauncher ?? commandProcessor) : (paths.windowsHeadlessLauncher ?? paths.nodeBinary);
|
|
607
661
|
const rootArgs = paths.hqRoot ? ` --hq-root ${windowsArgQuote(paths.hqRoot)}` : "";
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
662
|
+
const commandLine = `${hqArg} mesh daemon run${rootArgs}`;
|
|
663
|
+
const args = stableShim
|
|
664
|
+
? paths.windowsHeadlessLauncher
|
|
665
|
+
? `--headless ${windowsArgQuote(commandProcessor)} /d /s /c "${commandLine}"`
|
|
666
|
+
: `/d /s /c "${commandLine}"`
|
|
667
|
+
: paths.windowsHeadlessLauncher
|
|
668
|
+
? `--headless ${windowsArgQuote(paths.nodeBinary)} ${hqArg} mesh daemon run${rootArgs}`
|
|
669
|
+
: `${hqArg} mesh daemon run${rootArgs}`;
|
|
611
670
|
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
612
671
|
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
613
672
|
<RegistrationInfo>
|
|
@@ -769,7 +828,7 @@ export function parseWindowsTaskHqRoot(xml) {
|
|
|
769
828
|
if (quoted)
|
|
770
829
|
return quoted[1].replace(/\\"/g, '"');
|
|
771
830
|
const bare = /--hq-root (\S+)/.exec(args);
|
|
772
|
-
return bare ? bare[1] : null;
|
|
831
|
+
return bare ? bare[1].replace(/"+$/, "") : null;
|
|
773
832
|
}
|
|
774
833
|
/**
|
|
775
834
|
* The HQ root the installed Windows task actually carries: the task XML on
|
|
@@ -812,11 +871,40 @@ function shellQuote(value) {
|
|
|
812
871
|
return value;
|
|
813
872
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
814
873
|
}
|
|
874
|
+
function replaceUnitFile(file, content, mode, reload) {
|
|
875
|
+
let previous = null;
|
|
876
|
+
let previousMode = mode;
|
|
877
|
+
try {
|
|
878
|
+
previous = fs.readFileSync(file);
|
|
879
|
+
previousMode = fs.statSync(file).mode & 0o777;
|
|
880
|
+
}
|
|
881
|
+
catch (error) {
|
|
882
|
+
if (error.code !== "ENOENT")
|
|
883
|
+
throw error;
|
|
884
|
+
}
|
|
885
|
+
try {
|
|
886
|
+
atomicWrite(file, content, { mode });
|
|
887
|
+
reload?.();
|
|
888
|
+
}
|
|
889
|
+
catch (error) {
|
|
890
|
+
try {
|
|
891
|
+
if (previous) {
|
|
892
|
+
atomicWrite(file, previous, { mode: previousMode });
|
|
893
|
+
}
|
|
894
|
+
else {
|
|
895
|
+
fs.rmSync(file, { force: true });
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
catch (rollbackError) {
|
|
899
|
+
throw new AggregateError([error, rollbackError], `Unit replacement and rollback failed: ${file}`);
|
|
900
|
+
}
|
|
901
|
+
throw error;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
815
904
|
export function installDaemonService(opts) {
|
|
816
905
|
const platform = opts.platform ?? detectPlatform();
|
|
817
906
|
const paths = opts.paths ??
|
|
818
907
|
buildInstallPaths({ systemUser: opts.systemUser });
|
|
819
|
-
const writeFileSync = opts.writeFileSync ?? fs.writeFileSync;
|
|
820
908
|
const mkdirSync = opts.mkdirSync ?? fs.mkdirSync;
|
|
821
909
|
mkdirSync(paths.daemonDir, { recursive: true, mode: 0o700 });
|
|
822
910
|
registerTrustedHqRoot(paths.daemonDir, paths.hqRoot);
|
|
@@ -832,13 +920,12 @@ export function installDaemonService(opts) {
|
|
|
832
920
|
if (platform === "darwin") {
|
|
833
921
|
const rendered = renderLaunchdPlist(paths);
|
|
834
922
|
mkdirSync(paths.launchAgentsDir, { recursive: true });
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
}
|
|
923
|
+
replaceUnitFile(paths.plistPath, rendered, 0o600, () => {
|
|
924
|
+
if (opts.run)
|
|
925
|
+
activateLaunchd(paths.plistPath, opts.run, opts.uid, { sleep: opts.sleep });
|
|
926
|
+
else
|
|
927
|
+
opts.activate?.(platform, paths.plistPath);
|
|
928
|
+
});
|
|
842
929
|
return {
|
|
843
930
|
platform,
|
|
844
931
|
action: "install",
|
|
@@ -856,7 +943,7 @@ export function installDaemonService(opts) {
|
|
|
856
943
|
});
|
|
857
944
|
// Write under daemon dir as a template the operator can sudo-install.
|
|
858
945
|
const dest = path.join(paths.daemonDir, SYSTEMD_UNIT_NAME);
|
|
859
|
-
|
|
946
|
+
replaceUnitFile(dest, rendered, 0o644);
|
|
860
947
|
return {
|
|
861
948
|
platform,
|
|
862
949
|
action: "install",
|
|
@@ -870,9 +957,18 @@ export function installDaemonService(opts) {
|
|
|
870
957
|
}
|
|
871
958
|
const rendered = renderSystemdUserUnit(paths);
|
|
872
959
|
mkdirSync(paths.systemdUserDir, { recursive: true });
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
960
|
+
replaceUnitFile(paths.systemdUnitPath, rendered, 0o600, () => {
|
|
961
|
+
const result = (opts.reloadSystemd ?? (() => {
|
|
962
|
+
const reload = spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
|
|
963
|
+
return { status: reload.status };
|
|
964
|
+
}))();
|
|
965
|
+
if (result.status !== 0)
|
|
966
|
+
throw new Error("systemctl --user daemon-reload failed while installing the mesh unit");
|
|
967
|
+
if (opts.run)
|
|
968
|
+
activateSystemd(SYSTEMD_UNIT_NAME, opts.run);
|
|
969
|
+
else
|
|
970
|
+
opts.activate?.(platform, paths.systemdUnitPath);
|
|
971
|
+
});
|
|
876
972
|
return {
|
|
877
973
|
platform,
|
|
878
974
|
action: "install",
|
|
@@ -885,14 +981,15 @@ export function installDaemonService(opts) {
|
|
|
885
981
|
if (platform === "win32") {
|
|
886
982
|
const schtasks = opts.schtasks ?? defaultSchtasks;
|
|
887
983
|
const rendered = renderWindowsTaskXml(paths);
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
984
|
+
replaceUnitFile(paths.windowsTaskXmlPath, encodeWindowsTaskXml(rendered), 0o600, () => {
|
|
985
|
+
const created = schtasks([
|
|
986
|
+
"/Create", "/TN", WINDOWS_TASK_NAME, "/XML", paths.windowsTaskXmlPath, "/F",
|
|
987
|
+
]);
|
|
988
|
+
if (created.status !== 0) {
|
|
989
|
+
throw new Error(`schtasks /Create failed for ${WINDOWS_TASK_NAME} (exit ${created.status ?? "signal"}): ` +
|
|
990
|
+
`${(created.stderr || created.stdout).trim()}`);
|
|
991
|
+
}
|
|
992
|
+
});
|
|
896
993
|
// /F replaced any previous registration without ending its instance; stop
|
|
897
994
|
// the recorded daemon so the fresh task instance can take the pid lock.
|
|
898
995
|
schtasks(["/End", "/TN", WINDOWS_TASK_NAME]);
|