@indigoai-us/hq-cli 5.345.201 → 5.345.203
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 +16 -0
- package/dist/command-catalog.generated.d.ts +24 -0
- package/dist/command-catalog.generated.js +26 -0
- package/dist/commands/capacity.js +10 -2
- package/dist/commands/daemon.d.ts +2 -1
- package/dist/commands/daemon.js +225 -4
- package/dist/commands/integrations-core.d.ts +3 -0
- package/dist/commands/integrations-core.js +11 -0
- package/dist/commands/integrations-manage.js +2 -2
- package/dist/commands/integrations.js +5 -5
- package/dist/commands/sync-core.js +2 -1
- package/dist/lib/bot/executive-settings.d.ts +1 -1
- package/dist/lib/bot/executive-settings.js +16 -6
- package/dist/lib/bot/jobs/meeting-detect.js +2 -2
- package/dist/lib/bot/jobs/meeting-rider.js +2 -2
- package/dist/lib/bot/jobs/signal-to-lane.d.ts +3 -2
- package/dist/lib/bot/jobs/signal-to-lane.js +31 -8
- package/dist/lib/bot/run.js +3 -3
- package/dist/lib/capacity/codex-availability.d.ts +6 -0
- package/dist/lib/capacity/codex-availability.js +33 -0
- package/dist/lib/capacity/ledger.d.ts +0 -11
- package/dist/lib/capacity/ledger.js +30 -3
- package/dist/lib/capacity/route.js +11 -5
- package/dist/lib/daemon/catalog.d.ts +3 -0
- package/dist/lib/daemon/catalog.js +15 -2
- package/dist/lib/daemon/handoff.d.ts +2 -0
- package/dist/lib/daemon/handoff.js +9 -2
- package/dist/lib/daemon/install.d.ts +99 -0
- package/dist/lib/daemon/install.js +316 -7
- package/dist/lib/daemon/migrate.d.ts +3 -1
- package/dist/lib/daemon/migrate.js +14 -2
- package/dist/lib/daemon/plist-refresh.d.ts +34 -0
- package/dist/lib/daemon/plist-refresh.js +62 -0
- package/dist/lib/daemon/run.js +25 -0
- package/dist/lib/daemon/selfcheck.d.ts +71 -0
- package/dist/lib/daemon/selfcheck.js +150 -0
- package/dist/lib/daemon/supervisor.d.ts +31 -0
- package/dist/lib/daemon/supervisor.js +130 -6
- package/dist/lib/daemon/unit-holds.js +2 -1
- package/dist/lib/service-manager/launchd.d.ts +25 -0
- package/dist/lib/service-manager/launchd.js +51 -3
- package/dist/lib/service-manager/migration.d.ts +14 -0
- package/dist/lib/service-manager/migration.js +34 -0
- package/dist/lib/service-manager/systemd.d.ts +13 -0
- package/dist/lib/service-manager/systemd.js +19 -0
- package/dist/lib/service-manager/types.d.ts +8 -0
- package/dist/lib/sync-core/reconcile.d.ts +4 -1
- package/dist/lib/sync-core/reconcile.js +12 -2
- package/dist/lib/sync-core/release.d.ts +23 -0
- package/dist/lib/sync-core/release.js +39 -0
- package/dist/main.js +3 -3
- package/dist/utils/cli-network-lifecycle.d.ts +9 -0
- package/dist/utils/cli-network-lifecycle.js +25 -1
- package/package.json +9 -8
|
@@ -18,8 +18,9 @@
|
|
|
18
18
|
import * as fs from "node:fs";
|
|
19
19
|
import * as os from "node:os";
|
|
20
20
|
import * as path from "node:path";
|
|
21
|
-
import { activateLaunchd, blockingSleep, deactivateLaunchd, defaultRunCommand, installLaunchdPlist, launchdIsLoaded, launchdPlistPath, renderLaunchdPlist, } from "../service-manager/launchd.js";
|
|
22
|
-
import { activateSystemd, deactivateSystemd, renderSystemdUserUnit, systemdUnitPath, } from "../service-manager/systemd.js";
|
|
21
|
+
import { activateLaunchd, blockingSleep, bootstrapLaunchd, launchdJobInfo, launchdServiceTarget, deactivateLaunchd, defaultRunCommand, installLaunchdPlist, launchdIsLoaded, launchdPlistPath, launchdPlistProgramArguments, renderLaunchdPlist, } from "../service-manager/launchd.js";
|
|
22
|
+
import { activateSystemd, deactivateSystemd, renderSystemdUserUnit, systemdUnitInfo, systemdUnitPath, } from "../service-manager/systemd.js";
|
|
23
|
+
import { appIndependentLaunchdPlist } from "../service-manager/migration.js";
|
|
23
24
|
import { escapeXml, shellQuote } from "../service-manager/types.js";
|
|
24
25
|
import { readPidLock } from "../mesh/live/daemon/pid-lock.js";
|
|
25
26
|
import { userToolDirs } from "./tools.js";
|
|
@@ -108,6 +109,7 @@ function daemonSpec(host) {
|
|
|
108
109
|
env: host.env ?? {},
|
|
109
110
|
restartSec: 10,
|
|
110
111
|
unitName: DAEMON_SYSTEMD_UNIT,
|
|
112
|
+
independentOfApp: true,
|
|
111
113
|
};
|
|
112
114
|
}
|
|
113
115
|
function serviceHost(host) {
|
|
@@ -454,16 +456,51 @@ export function installDaemonUnit(host, options, deps) {
|
|
|
454
456
|
});
|
|
455
457
|
}
|
|
456
458
|
catch (err) {
|
|
459
|
+
const run = deps.run ?? defaultRunCommand;
|
|
460
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
461
|
+
const installed = fs.existsSync(plan.unitPath);
|
|
462
|
+
// The reload gave up with the job unloaded: try the bootstrap again
|
|
463
|
+
// before reporting, since an unloaded agent never starts at login.
|
|
464
|
+
if (installed && !launchdIsLoaded(DAEMON_LAUNCHD_LABEL, run, host.uid)) {
|
|
465
|
+
const retried = retryLaunchdBootstrap(plan.unitPath, run, host.uid, deps.sleep);
|
|
466
|
+
if (retried.ok)
|
|
467
|
+
return { kind: plan.kind, unitPath: plan.unitPath, installed: true, running: true };
|
|
468
|
+
return { kind: plan.kind, unitPath: plan.unitPath, installed, running: false, error: `${message}\n${retried.error}` };
|
|
469
|
+
}
|
|
470
|
+
// The reload failed but launchd still runs the previous definition: a
|
|
471
|
+
// daemon is running under launchd. Say so, and that the new plist is only
|
|
472
|
+
// read at the next load (login, or hq daemon start after a bootout).
|
|
473
|
+
if (installed && launchdIsLoaded(DAEMON_LAUNCHD_LABEL, run, host.uid)) {
|
|
474
|
+
return {
|
|
475
|
+
kind: plan.kind,
|
|
476
|
+
unitPath: plan.unitPath,
|
|
477
|
+
installed: true,
|
|
478
|
+
running: true,
|
|
479
|
+
warning: `The daemon is still running under launchd with its previous definition. ${message}`,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
457
482
|
return {
|
|
458
483
|
kind: plan.kind,
|
|
459
484
|
unitPath: plan.unitPath,
|
|
460
|
-
installed
|
|
461
|
-
running:
|
|
462
|
-
error:
|
|
485
|
+
installed,
|
|
486
|
+
running: false,
|
|
487
|
+
error: message,
|
|
463
488
|
};
|
|
464
489
|
}
|
|
465
|
-
if (!plist.loaded)
|
|
466
|
-
|
|
490
|
+
if (!plist.loaded) {
|
|
491
|
+
// installLaunchdPlist found the job unloaded after activating it. That is
|
|
492
|
+
// a failure, never a quiet success: retry the bootstrap, then report it.
|
|
493
|
+
const retried = retryLaunchdBootstrap(plan.unitPath, deps.run ?? defaultRunCommand, host.uid, deps.sleep);
|
|
494
|
+
if (!retried.ok) {
|
|
495
|
+
return {
|
|
496
|
+
kind: plan.kind,
|
|
497
|
+
unitPath: plan.unitPath,
|
|
498
|
+
installed: true,
|
|
499
|
+
running: false,
|
|
500
|
+
error: retried.error,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
}
|
|
467
504
|
}
|
|
468
505
|
else if (plan.kind === "schtasks")
|
|
469
506
|
fs.writeFileSync(plan.unitPath, encodeWindowsTaskXml(plan.rendered));
|
|
@@ -507,6 +544,278 @@ export function installDaemonUnit(host, options, deps) {
|
|
|
507
544
|
};
|
|
508
545
|
}
|
|
509
546
|
}
|
|
547
|
+
/** Install-level bootstrap retries after launchd left the job unloaded. */
|
|
548
|
+
export const DAEMON_BOOTSTRAP_RETRIES = 3;
|
|
549
|
+
export const DAEMON_BOOTSTRAP_RETRY_MS = 2_000;
|
|
550
|
+
/**
|
|
551
|
+
* Bootstrap the daemon agent again when an install left it unloaded, up to
|
|
552
|
+
* DAEMON_BOOTSTRAP_RETRIES times, then kickstart it. Returns the error that
|
|
553
|
+
* says the agent is not loaded when every retry failed.
|
|
554
|
+
*/
|
|
555
|
+
export function retryLaunchdBootstrap(plistPath, run, uid, sleep = blockingSleep) {
|
|
556
|
+
let last = "";
|
|
557
|
+
for (let attempt = 1; attempt <= DAEMON_BOOTSTRAP_RETRIES; attempt++) {
|
|
558
|
+
const r = bootstrapLaunchd(plistPath, run, uid, { sleep });
|
|
559
|
+
if (r.ok && launchdJobInfo(DAEMON_LAUNCHD_LABEL, run, uid).loaded) {
|
|
560
|
+
run("launchctl", ["kickstart", launchdServiceTarget(plistPath, uid)]);
|
|
561
|
+
return { ok: true };
|
|
562
|
+
}
|
|
563
|
+
last = r.error ?? "launchd does not list the job after bootstrap";
|
|
564
|
+
if (attempt < DAEMON_BOOTSTRAP_RETRIES)
|
|
565
|
+
sleep(DAEMON_BOOTSTRAP_RETRY_MS);
|
|
566
|
+
}
|
|
567
|
+
const target = launchdServiceTarget(plistPath, uid);
|
|
568
|
+
return {
|
|
569
|
+
ok: false,
|
|
570
|
+
error: `launchctl bootstrap gui/${uid} ${plistPath} failed ${DAEMON_BOOTSTRAP_RETRIES} times: ${last}. ` +
|
|
571
|
+
`${target} is NOT loaded, so the daemon will not start at login. Retry with: hq daemon start`,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Upgrade a daemon LaunchAgent written by an older install (run through the
|
|
576
|
+
* HQ.app launcher and associated with the app's bundle, which launchd skips
|
|
577
|
+
* at login) to the app-independent form, and reload it when it is loaded.
|
|
578
|
+
*
|
|
579
|
+
* Nothing but `hq daemon install` used to rewrite the unit, so a machine that
|
|
580
|
+
* only ever self-updated kept the old plist. This runs from `hq daemon start`,
|
|
581
|
+
* and from the detached helper the managed daemon spawns at startup when it
|
|
582
|
+
* finds the old plist (see daemonPlistNeedsRefresh). It must not run inside
|
|
583
|
+
* the daemon's own job: the bootout would end the caller before the bootstrap.
|
|
584
|
+
*/
|
|
585
|
+
export function refreshDaemonPlist(host, deps) {
|
|
586
|
+
const plistPath = launchdPlistPath(host.home, { label: DAEMON_LAUNCHD_LABEL });
|
|
587
|
+
if (!fs.existsSync(plistPath))
|
|
588
|
+
return { action: "none", plistPath };
|
|
589
|
+
const source = fs.readFileSync(plistPath, "utf8");
|
|
590
|
+
let updated;
|
|
591
|
+
try {
|
|
592
|
+
updated = appIndependentLaunchdPlist(source);
|
|
593
|
+
}
|
|
594
|
+
catch (err) {
|
|
595
|
+
return { action: "current", plistPath, error: `${err instanceof Error ? err.message : String(err)}: ${plistPath}. Run: hq daemon install` };
|
|
596
|
+
}
|
|
597
|
+
if (updated === null) {
|
|
598
|
+
// The file is current, but an earlier reload may have failed and left
|
|
599
|
+
// launchd running the old definition: reload it so it matches the file.
|
|
600
|
+
if (!loadedJobRunsThroughApp(launchdJobInfo(DAEMON_LAUNCHD_LABEL, deps.run, host.uid)))
|
|
601
|
+
return { action: "current", plistPath };
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
const mode = fs.statSync(plistPath).mode & 0o777;
|
|
605
|
+
const tmp = `${plistPath}.${process.pid}.refresh.tmp`;
|
|
606
|
+
fs.writeFileSync(tmp, updated, { mode });
|
|
607
|
+
fs.renameSync(tmp, plistPath);
|
|
608
|
+
if (!launchdIsLoaded(DAEMON_LAUNCHD_LABEL, deps.run, host.uid))
|
|
609
|
+
return { action: "rewritten", plistPath };
|
|
610
|
+
}
|
|
611
|
+
try {
|
|
612
|
+
activateLaunchd(plistPath, deps.run, host.uid, { sleep: deps.sleep });
|
|
613
|
+
}
|
|
614
|
+
catch (err) {
|
|
615
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
616
|
+
if (launchdIsLoaded(DAEMON_LAUNCHD_LABEL, deps.run, host.uid)) {
|
|
617
|
+
// The old definition still runs; the rewritten file is what launchd reads at login.
|
|
618
|
+
return { action: "rewritten", plistPath, error: message };
|
|
619
|
+
}
|
|
620
|
+
const retried = retryLaunchdBootstrap(plistPath, deps.run, host.uid, deps.sleep);
|
|
621
|
+
if (!retried.ok)
|
|
622
|
+
return { action: "rewritten", plistPath, error: `${message}\n${retried.error}` };
|
|
623
|
+
}
|
|
624
|
+
return { action: "reloaded", plistPath };
|
|
625
|
+
}
|
|
626
|
+
/** launchd's loaded copy of the daemon job runs through the HQ.app launcher. */
|
|
627
|
+
function loadedJobRunsThroughApp(job) {
|
|
628
|
+
return job.loaded && job.arguments !== undefined && job.arguments.includes("--hq-launchd-task");
|
|
629
|
+
}
|
|
630
|
+
/** The daemon plist on disk still runs through HQ.app or names its bundle. */
|
|
631
|
+
export function daemonPlistNeedsRefresh(home) {
|
|
632
|
+
const plistPath = launchdPlistPath(home, { label: DAEMON_LAUNCHD_LABEL });
|
|
633
|
+
try {
|
|
634
|
+
return appIndependentLaunchdPlist(fs.readFileSync(plistPath, "utf8")) !== null;
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
export function describeSupervision(status) {
|
|
641
|
+
switch (status.supervision) {
|
|
642
|
+
case "managed":
|
|
643
|
+
return `running under the service manager (${status.detail})`;
|
|
644
|
+
case "unmanaged":
|
|
645
|
+
return `running outside the service manager (${status.detail}); it will not come back after a crash or restart. Run: hq daemon start`;
|
|
646
|
+
case "not-loaded":
|
|
647
|
+
return `not loaded (${status.detail}). Run: hq daemon start`;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Ask launchd, systemd or Task Scheduler whether the daemon's unit is loaded,
|
|
652
|
+
* and combine that with the pid lock. `daemonPid` is the live daemon from the
|
|
653
|
+
* lock (null when none runs).
|
|
654
|
+
*/
|
|
655
|
+
export function daemonServiceStatus(host, run, daemonPid) {
|
|
656
|
+
const classify = (base, pidsComparable = false) => {
|
|
657
|
+
// Loaded but not running (launchd throttling a restart) is still managed:
|
|
658
|
+
// the manager brings it back. A live daemon the manager does not report
|
|
659
|
+
// as running was started some other way. When the unit runs node directly
|
|
660
|
+
// its pid must be the daemon's: a different pid is a second daemon that
|
|
661
|
+
// lost the lock (for example to one the desktop app hosts) and is being
|
|
662
|
+
// respawned. An older install's plist runs the HQ.app launcher, whose pid
|
|
663
|
+
// is never the daemon's, so pids are not compared there.
|
|
664
|
+
let supervision;
|
|
665
|
+
if (!base.loaded)
|
|
666
|
+
supervision = daemonPid !== null ? "unmanaged" : "not-loaded";
|
|
667
|
+
else if (daemonPid !== null && !base.active)
|
|
668
|
+
supervision = "unmanaged";
|
|
669
|
+
else if (pidsComparable && daemonPid !== null && base.managerPid !== undefined && base.managerPid !== daemonPid) {
|
|
670
|
+
supervision = "unmanaged";
|
|
671
|
+
}
|
|
672
|
+
else
|
|
673
|
+
supervision = "managed";
|
|
674
|
+
return { ...base, supervision, daemonPid };
|
|
675
|
+
};
|
|
676
|
+
switch (host.platform) {
|
|
677
|
+
case "darwin": {
|
|
678
|
+
const plist = launchdPlistPath(host.home, { label: DAEMON_LAUNCHD_LABEL });
|
|
679
|
+
const unitInstalled = fs.existsSync(plist);
|
|
680
|
+
const job = launchdJobInfo(DAEMON_LAUNCHD_LABEL, run, host.uid);
|
|
681
|
+
const target = `gui/${host.uid}/${DAEMON_LAUNCHD_LABEL}`;
|
|
682
|
+
let fileNodeDirect;
|
|
683
|
+
try {
|
|
684
|
+
fileNodeDirect = unitInstalled && !launchdPlistProgramArguments(fs.readFileSync(plist, "utf8")).includes("--hq-launchd-task");
|
|
685
|
+
}
|
|
686
|
+
catch {
|
|
687
|
+
fileNodeDirect = false;
|
|
688
|
+
}
|
|
689
|
+
// What runs now is the loaded job, which differs from the file on disk
|
|
690
|
+
// after a rewrite whose reload failed: go by the job when launchd prints it.
|
|
691
|
+
const jobThroughApp = loadedJobRunsThroughApp(job);
|
|
692
|
+
const nodeDirect = job.loaded && job.arguments !== undefined ? !jobThroughApp : fileNodeDirect;
|
|
693
|
+
const warnings = [];
|
|
694
|
+
if (unitInstalled && daemonPlistNeedsRefresh(host.home)) {
|
|
695
|
+
warnings.push(`${plist} still runs through HQ.app, so launchd skips the daemon at login. Run: hq daemon start`);
|
|
696
|
+
}
|
|
697
|
+
else if (jobThroughApp) {
|
|
698
|
+
warnings.push(`launchd still runs the daemon's previous definition, which goes through HQ.app; ${plist} was rewritten but not reloaded. Run: hq daemon start`);
|
|
699
|
+
}
|
|
700
|
+
const pidMismatch = nodeDirect && job.pid !== undefined && daemonPid !== null && job.pid !== daemonPid;
|
|
701
|
+
const detail = job.loaded
|
|
702
|
+
? `launchd ${target}: ${job.state ?? "loaded"}${job.pid ? `, pid ${job.pid}` : ""}` +
|
|
703
|
+
(pidMismatch ? `, not the daemon holding the lock (pid ${daemonPid})` : "")
|
|
704
|
+
: `launchd has no ${target}${unitInstalled ? "" : ` and ${plist} is missing`}`;
|
|
705
|
+
return classify({
|
|
706
|
+
manager: "launchd",
|
|
707
|
+
unitInstalled,
|
|
708
|
+
loaded: job.loaded,
|
|
709
|
+
active: job.loaded && (job.pid !== undefined || job.state === "running"),
|
|
710
|
+
...(job.pid ? { managerPid: job.pid } : {}),
|
|
711
|
+
detail,
|
|
712
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
713
|
+
}, nodeDirect);
|
|
714
|
+
}
|
|
715
|
+
case "linux": {
|
|
716
|
+
const systemUnit = path.join(host.systemUnitDir, DAEMON_SYSTEMD_UNIT);
|
|
717
|
+
const userUnit = path.join(host.home, ".config", "systemd", "user", DAEMON_SYSTEMD_UNIT);
|
|
718
|
+
const scope = fs.existsSync(systemUnit) ? "system" : "user";
|
|
719
|
+
const unitInstalled = scope === "system" || fs.existsSync(userUnit);
|
|
720
|
+
const info = systemdUnitInfo(DAEMON_SYSTEMD_UNIT, run, scope);
|
|
721
|
+
return classify({
|
|
722
|
+
manager: scope === "system" ? "systemd-system" : "systemd-user",
|
|
723
|
+
unitInstalled,
|
|
724
|
+
loaded: unitInstalled && info.enabled,
|
|
725
|
+
active: info.active,
|
|
726
|
+
...(info.pid ? { managerPid: info.pid } : {}),
|
|
727
|
+
detail: `systemd ${scope} ${DAEMON_SYSTEMD_UNIT}: ${info.enabledState}, ${info.activeState}${info.pid ? `, pid ${info.pid}` : ""}`,
|
|
728
|
+
}, true);
|
|
729
|
+
}
|
|
730
|
+
case "win32": {
|
|
731
|
+
const task = windowsTaskName({ windowsSid: host.windowsSid, windowsUser: host.windowsUser });
|
|
732
|
+
const probe = run("schtasks.exe", ["/Query", "/TN", task, "/FO", "CSV", "/NH"]);
|
|
733
|
+
const loaded = probe.status === 0;
|
|
734
|
+
const active = loaded && /,\s*"?Running"?\s*$/i.test(probe.stdout.replace(/\0/g, "").trim());
|
|
735
|
+
return classify({
|
|
736
|
+
manager: "schtasks",
|
|
737
|
+
unitInstalled: loaded,
|
|
738
|
+
loaded,
|
|
739
|
+
active,
|
|
740
|
+
detail: loaded ? `Scheduled Task ${task}${active ? " is running" : " is not running"}` : `no Scheduled Task ${task}`,
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
default:
|
|
744
|
+
return { supervision: daemonPid !== null ? "unmanaged" : "not-loaded", manager: "none", unitInstalled: false, loaded: false, active: false, daemonPid, detail: `${host.platform} has no supported service manager` };
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* `hq daemon start`: load the daemon's OS unit when the manager does not have
|
|
749
|
+
* it, then start it. The unit file must already exist (the caller installs it
|
|
750
|
+
* first when it does not). Throws when the manager refuses.
|
|
751
|
+
*/
|
|
752
|
+
export function startDaemonUnit(host, deps) {
|
|
753
|
+
const sleep = deps.sleep ?? blockingSleep;
|
|
754
|
+
// An older install's plist runs through HQ.app and is skipped at login:
|
|
755
|
+
// rewrite it first, so the unit this loads or starts is the fixed one.
|
|
756
|
+
const previousPid = deps.daemonPid();
|
|
757
|
+
const refresh = host.platform === "darwin" ? refreshDaemonPlist(host, { run: deps.run, sleep }) : undefined;
|
|
758
|
+
const withRefresh = refresh && (refresh.action === "rewritten" || refresh.action === "reloaded" || refresh.error) ? { refresh } : {};
|
|
759
|
+
const waitForManaged = (action, previousPid) => {
|
|
760
|
+
const waitMs = deps.waitMs ?? 15_000;
|
|
761
|
+
const ready = (s) => s.supervision === "managed" && s.daemonPid !== null && (previousPid === null || s.daemonPid !== previousPid);
|
|
762
|
+
let status = daemonServiceStatus(host, deps.run, deps.daemonPid());
|
|
763
|
+
for (let waited = 0; waited < waitMs && !ready(status); waited += 500) {
|
|
764
|
+
sleep(500);
|
|
765
|
+
status = daemonServiceStatus(host, deps.run, deps.daemonPid());
|
|
766
|
+
}
|
|
767
|
+
return { action, status, ...withRefresh };
|
|
768
|
+
};
|
|
769
|
+
if (refresh?.action === "reloaded")
|
|
770
|
+
return waitForManaged("reloaded", previousPid);
|
|
771
|
+
const before = daemonServiceStatus(host, deps.run, deps.daemonPid());
|
|
772
|
+
if (before.supervision === "managed" && before.daemonPid !== null)
|
|
773
|
+
return { action: "already-running", status: before, ...withRefresh };
|
|
774
|
+
let action = "started";
|
|
775
|
+
switch (before.manager) {
|
|
776
|
+
case "launchd": {
|
|
777
|
+
const plist = launchdPlistPath(host.home, { label: DAEMON_LAUNCHD_LABEL });
|
|
778
|
+
const target = launchdServiceTarget(plist, host.uid);
|
|
779
|
+
if (!before.loaded) {
|
|
780
|
+
const r = retryLaunchdBootstrap(plist, deps.run, host.uid, sleep);
|
|
781
|
+
if (!r.ok)
|
|
782
|
+
throw new Error(r.error);
|
|
783
|
+
action = "loaded";
|
|
784
|
+
}
|
|
785
|
+
else {
|
|
786
|
+
const kick = deps.run("launchctl", ["kickstart", target]);
|
|
787
|
+
if (kick.status !== 0)
|
|
788
|
+
throw commandError(`launchctl kickstart ${target}`, kick);
|
|
789
|
+
}
|
|
790
|
+
break;
|
|
791
|
+
}
|
|
792
|
+
case "systemd-user":
|
|
793
|
+
case "systemd-system": {
|
|
794
|
+
const prefix = before.manager === "systemd-user" ? ["--user"] : [];
|
|
795
|
+
if (before.manager === "systemd-system" && !host.isRoot) {
|
|
796
|
+
throw new Error("The daemon runs as a system unit on this box; starting it needs root. Run: sudo hq daemon start");
|
|
797
|
+
}
|
|
798
|
+
deps.run("systemctl", [...prefix, "daemon-reload"]);
|
|
799
|
+
const enable = deps.run("systemctl", [...prefix, "enable", "--now", DAEMON_SYSTEMD_UNIT]);
|
|
800
|
+
if (enable.status !== 0)
|
|
801
|
+
throw commandError(["systemctl", ...prefix, "enable", "--now", DAEMON_SYSTEMD_UNIT].join(" "), enable);
|
|
802
|
+
if (!before.loaded)
|
|
803
|
+
action = "loaded";
|
|
804
|
+
break;
|
|
805
|
+
}
|
|
806
|
+
case "schtasks": {
|
|
807
|
+
const task = windowsTaskName(host);
|
|
808
|
+
const r = deps.run("schtasks.exe", ["/Run", "/TN", task]);
|
|
809
|
+
if (r.status !== 0)
|
|
810
|
+
throw commandError("schtasks /Run", r);
|
|
811
|
+
break;
|
|
812
|
+
}
|
|
813
|
+
case "none":
|
|
814
|
+
throw new Error(`${host.platform} has no supported service manager; start the daemon with: hq daemon run`);
|
|
815
|
+
}
|
|
816
|
+
// Wait for the manager's daemon to take the lock.
|
|
817
|
+
return waitForManaged(action, null);
|
|
818
|
+
}
|
|
510
819
|
/** A lock record names a different daemon than `previous` (new pid or new start time). */
|
|
511
820
|
function isNewDaemon(now, previous) {
|
|
512
821
|
return !previous || now.pid !== previous.pid || now.ts !== previous.ts;
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* user can edit), and a restore checks each one lands in the system unit
|
|
17
17
|
* folder, so nobody but root decides what gets installed there.
|
|
18
18
|
*/
|
|
19
|
-
import type
|
|
19
|
+
import { type RunCommand, type SleepFn } from "../service-manager/launchd.js";
|
|
20
20
|
import { type LegacyUnit } from "./legacy.js";
|
|
21
21
|
export interface AdoptedUnit extends LegacyUnit {
|
|
22
22
|
/** Where the unit's definition (plist, unit file, task XML) was moved. */
|
|
@@ -43,6 +43,8 @@ export interface MigrateDeps {
|
|
|
43
43
|
uid: number;
|
|
44
44
|
};
|
|
45
45
|
now?: () => number;
|
|
46
|
+
/** Blocking sleep for launchd bootstrap retries (tests inject). */
|
|
47
|
+
sleep?: SleepFn;
|
|
46
48
|
/** Tests inject a failing writer. */
|
|
47
49
|
writeRecord?: (recordPath: string, units: AdoptedUnit[]) => void;
|
|
48
50
|
}
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import * as fs from "node:fs";
|
|
20
20
|
import * as path from "node:path";
|
|
21
|
+
import { bootstrapLaunchd } from "../service-manager/launchd.js";
|
|
21
22
|
import { legacyMeshUnitsToRetire } from "./legacy.js";
|
|
22
23
|
import { ensurePrivateDir } from "./paths.js";
|
|
23
24
|
export function readAdoptedUnits(recordPath) {
|
|
@@ -102,7 +103,16 @@ function adoptOne(unit, deps) {
|
|
|
102
103
|
}
|
|
103
104
|
if (!fs.existsSync(unit.path))
|
|
104
105
|
return { movedTo: unit.path, definitionMissing: true };
|
|
105
|
-
|
|
106
|
+
try {
|
|
107
|
+
moveFile(unit.path, to);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
// The agent is booted out but its plist did not move: load it again,
|
|
111
|
+
// so a failed takeover never leaves the service off.
|
|
112
|
+
const back = bootstrapLaunchd(unit.path, deps.run, deps.uid, { sleep: deps.sleep });
|
|
113
|
+
const reloaded = back.ok ? "it was loaded again" : `loading it again also failed (${back.error}); load it with: launchctl bootstrap gui/${deps.uid} ${unit.path}`;
|
|
114
|
+
throw new Error(`${err.message}; ${reloaded}`, { cause: err });
|
|
115
|
+
}
|
|
106
116
|
return { movedTo: to };
|
|
107
117
|
}
|
|
108
118
|
case "systemd-user":
|
|
@@ -198,7 +208,9 @@ function restoreOne(unit, deps) {
|
|
|
198
208
|
if (unit.definitionMissing)
|
|
199
209
|
throw new Error("the original LaunchAgent plist is missing; this unit cannot be restored");
|
|
200
210
|
moveBack(unit, unit.path);
|
|
201
|
-
|
|
211
|
+
const loaded = bootstrapLaunchd(unit.path, deps.run, deps.uid, { sleep: deps.sleep });
|
|
212
|
+
if (!loaded.ok)
|
|
213
|
+
throw new Error(`launchctl bootstrap gui/${deps.uid} ${unit.path} failed after ${loaded.attempts} attempts: ${loaded.error}`);
|
|
202
214
|
return;
|
|
203
215
|
}
|
|
204
216
|
case "systemd-user":
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upgrade an older install's daemon LaunchAgent without a manual
|
|
3
|
+
* `hq daemon install`.
|
|
4
|
+
*
|
|
5
|
+
* Self-update and `hq update` restart the daemon through a control request and
|
|
6
|
+
* never rewrite its unit, so a Mac that only ever self-updated keeps the plist
|
|
7
|
+
* that runs through HQ.app and names its bundle, which launchd skips at login.
|
|
8
|
+
* When the managed daemon starts and finds that plist, it spawns a detached
|
|
9
|
+
* `hq daemon start`, which rewrites the plist and reloads it (see
|
|
10
|
+
* refreshDaemonPlist). The helper runs in its own session, outside the
|
|
11
|
+
* daemon's job, so the bootout that ends this daemon does not end the helper
|
|
12
|
+
* before it bootstraps the new definition.
|
|
13
|
+
*/
|
|
14
|
+
/** At most one helper per this long, so a rewrite that keeps failing cannot loop. */
|
|
15
|
+
export declare const PLIST_REFRESH_RETRY_MS: number;
|
|
16
|
+
export declare const PLIST_REFRESH_MARKER = "plist-refresh.json";
|
|
17
|
+
export type PlistRefreshOutcome = "not-applicable" | "current" | "recent" | "spawned";
|
|
18
|
+
export interface PlistRefreshDeps {
|
|
19
|
+
platform: NodeJS.Platform;
|
|
20
|
+
/** Started by launchd (`daemon run --managed`) and not hosted by an app. */
|
|
21
|
+
managedByLaunchd: boolean;
|
|
22
|
+
home: string;
|
|
23
|
+
/** ~/.hq/daemon: holds the marker and the helper's log. */
|
|
24
|
+
daemonRoot: string;
|
|
25
|
+
nodeBinary: string;
|
|
26
|
+
hqBinary: string;
|
|
27
|
+
env: NodeJS.ProcessEnv;
|
|
28
|
+
now?: () => number;
|
|
29
|
+
needsRefresh?: (home: string) => boolean;
|
|
30
|
+
spawnHelper?: (command: string, args: string[], logPath: string, env: NodeJS.ProcessEnv) => void;
|
|
31
|
+
log: (level: "info" | "warn", line: string) => void;
|
|
32
|
+
}
|
|
33
|
+
export declare function scheduleDaemonPlistRefresh(deps: PlistRefreshDeps): PlistRefreshOutcome;
|
|
34
|
+
//# sourceMappingURL=plist-refresh.d.ts.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upgrade an older install's daemon LaunchAgent without a manual
|
|
3
|
+
* `hq daemon install`.
|
|
4
|
+
*
|
|
5
|
+
* Self-update and `hq update` restart the daemon through a control request and
|
|
6
|
+
* never rewrite its unit, so a Mac that only ever self-updated keeps the plist
|
|
7
|
+
* that runs through HQ.app and names its bundle, which launchd skips at login.
|
|
8
|
+
* When the managed daemon starts and finds that plist, it spawns a detached
|
|
9
|
+
* `hq daemon start`, which rewrites the plist and reloads it (see
|
|
10
|
+
* refreshDaemonPlist). The helper runs in its own session, outside the
|
|
11
|
+
* daemon's job, so the bootout that ends this daemon does not end the helper
|
|
12
|
+
* before it bootstraps the new definition.
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import { daemonPlistNeedsRefresh } from "./install.js";
|
|
18
|
+
import { ensurePrivateDir } from "./paths.js";
|
|
19
|
+
/** At most one helper per this long, so a rewrite that keeps failing cannot loop. */
|
|
20
|
+
export const PLIST_REFRESH_RETRY_MS = 60 * 60_000;
|
|
21
|
+
export const PLIST_REFRESH_MARKER = "plist-refresh.json";
|
|
22
|
+
function defaultSpawnHelper(command, args, logPath, env) {
|
|
23
|
+
const fd = fs.openSync(logPath, "a", 0o600);
|
|
24
|
+
try {
|
|
25
|
+
const child = spawn(command, args, { detached: true, stdio: ["ignore", fd, fd], env });
|
|
26
|
+
child.unref();
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
fs.closeSync(fd);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function scheduleDaemonPlistRefresh(deps) {
|
|
33
|
+
if (deps.platform !== "darwin" || !deps.managedByLaunchd)
|
|
34
|
+
return "not-applicable";
|
|
35
|
+
if (!(deps.needsRefresh ?? daemonPlistNeedsRefresh)(deps.home))
|
|
36
|
+
return "current";
|
|
37
|
+
const now = (deps.now ?? Date.now)();
|
|
38
|
+
const marker = path.join(deps.daemonRoot, PLIST_REFRESH_MARKER);
|
|
39
|
+
try {
|
|
40
|
+
const last = JSON.parse(fs.readFileSync(marker, "utf8"));
|
|
41
|
+
if (typeof last.attemptedAt === "number" && now - last.attemptedAt < PLIST_REFRESH_RETRY_MS) {
|
|
42
|
+
deps.log("warn", "the daemon LaunchAgent still runs through HQ.app; a rewrite was tried within the last hour. Run: hq daemon start");
|
|
43
|
+
return "recent";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// No marker yet.
|
|
48
|
+
}
|
|
49
|
+
ensurePrivateDir(path.join(deps.daemonRoot, "logs"));
|
|
50
|
+
const tmp = `${marker}.${process.pid}.tmp`;
|
|
51
|
+
fs.writeFileSync(tmp, `${JSON.stringify({ attemptedAt: now })}\n`, { mode: 0o600 });
|
|
52
|
+
fs.renameSync(tmp, marker);
|
|
53
|
+
// launchd marks its jobs' processes with XPC_SERVICE_NAME; the helper is not the job.
|
|
54
|
+
const env = { ...deps.env };
|
|
55
|
+
delete env.XPC_SERVICE_NAME;
|
|
56
|
+
const logPath = path.join(deps.daemonRoot, "logs", "plist-refresh.log");
|
|
57
|
+
(deps.spawnHelper ?? defaultSpawnHelper)(deps.nodeBinary, [deps.hqBinary, "daemon", "start"], logPath, env);
|
|
58
|
+
deps.log("info", "the daemon LaunchAgent runs through HQ.app, which launchd skips at login; started `hq daemon start` to rewrite and reload it " +
|
|
59
|
+
`(log: ${logPath})`);
|
|
60
|
+
return "spawned";
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=plist-refresh.js.map
|
package/dist/lib/daemon/run.js
CHANGED
|
@@ -36,6 +36,7 @@ import { createLanesReconcileWatcher } from "./lanes-reconcile-watcher.js";
|
|
|
36
36
|
import { findLegacyUnits, legacyMeshUnitsToRetire, legacyOwnerLookup, } from "./legacy.js";
|
|
37
37
|
import { readLegacySyncEnv } from "./legacy-sync-env.js";
|
|
38
38
|
import { readVaultSyncSwitches, unreadableSwitchesWarning, writeVaultSyncSwitches } from "../vault-sync-switches.js";
|
|
39
|
+
import { scheduleDaemonPlistRefresh } from "./plist-refresh.js";
|
|
39
40
|
import { acquireDaemonLock, isProcessAlive, processCommandLine, releaseDaemonLock } from "./lock.js";
|
|
40
41
|
import { retireLegacyMeshUnits } from "./migrate.js";
|
|
41
42
|
import { daemonDir as meshDaemonDir, daemonPidPath as meshDaemonPidPath } from "../mesh/live/daemon/paths.js";
|
|
@@ -658,6 +659,21 @@ export async function runDaemon(opts) {
|
|
|
658
659
|
}
|
|
659
660
|
const hostNote = opts.host ? `, hosted by the ${opts.host} app` : "";
|
|
660
661
|
log("info", `starting hq daemon (pid ${process.pid}) as ${machineType} (${detected.detail})${hostNote}`);
|
|
662
|
+
try {
|
|
663
|
+
scheduleDaemonPlistRefresh({
|
|
664
|
+
platform,
|
|
665
|
+
managedByLaunchd: opts.managed === true && opts.host === undefined,
|
|
666
|
+
home,
|
|
667
|
+
daemonRoot: paths.root,
|
|
668
|
+
nodeBinary: opts.nodeBinary,
|
|
669
|
+
hqBinary: opts.hqBinary,
|
|
670
|
+
env,
|
|
671
|
+
log,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
catch (err) {
|
|
675
|
+
log("warn", `could not check the daemon LaunchAgent: ${err instanceof Error ? err.message : String(err)}`);
|
|
676
|
+
}
|
|
661
677
|
// On Linux, services can outlive a daemon (KillMode=process). Adopt the ones
|
|
662
678
|
// the previous daemon handed over and stop any others it left behind before
|
|
663
679
|
// starting anything, so a service never runs twice.
|
|
@@ -1264,6 +1280,15 @@ export async function runDaemon(opts) {
|
|
|
1264
1280
|
read: () => readUnitHolds(paths.holdsPath),
|
|
1265
1281
|
write: (holds) => writeUnitHolds(paths.holdsPath, holds),
|
|
1266
1282
|
},
|
|
1283
|
+
pidAlive: (pid) => {
|
|
1284
|
+
try {
|
|
1285
|
+
process.kill(pid, 0);
|
|
1286
|
+
return true;
|
|
1287
|
+
}
|
|
1288
|
+
catch (err) {
|
|
1289
|
+
return err.code === "EPERM";
|
|
1290
|
+
}
|
|
1291
|
+
},
|
|
1267
1292
|
exitOutputTail: (unitId) => {
|
|
1268
1293
|
// An adopted unit was started by an earlier daemon; its output start is unknown.
|
|
1269
1294
|
const offset = unitLogOffsets.get(unitId);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon's sync-engine self-check (`hq daemon service sync-core-selfcheck`).
|
|
3
|
+
*
|
|
4
|
+
* It reads the supervisor's state and holds and finds engine units
|
|
5
|
+
* (`sync-core:<vault>`) that have been held, or failing without a heartbeat,
|
|
6
|
+
* for more than ATTENTION_AFTER_MS. When there are any, it sends one DM to the
|
|
7
|
+
* person signed in on this machine, at most once a day per machine whichever
|
|
8
|
+
* units are affected, naming every affected vault in that one DM. The 16-hour outage of 2026-11-03 was
|
|
9
|
+
* found by hand; this makes the next one reach the owner.
|
|
10
|
+
*/
|
|
11
|
+
import type { DaemonState, PersistedHolds } from "./supervisor.js";
|
|
12
|
+
/** An engine unit that has been held or failing this long needs a person. */
|
|
13
|
+
export declare const ATTENTION_AFTER_MS: number;
|
|
14
|
+
/** This machine sends at most one self-check DM per this long. */
|
|
15
|
+
export declare const REPORT_INTERVAL_MS: number;
|
|
16
|
+
export interface EngineUnitProblem {
|
|
17
|
+
unitId: string;
|
|
18
|
+
/** Epoch ms since when the unit has been held or failing. */
|
|
19
|
+
since: number;
|
|
20
|
+
/** One line for the DM. */
|
|
21
|
+
what: string;
|
|
22
|
+
}
|
|
23
|
+
/** Engine units held, or failing without a heartbeat, for more than ATTENTION_AFTER_MS at `now`. */
|
|
24
|
+
export declare function engineUnitsNeedingAttention(state: DaemonState | null, holds: PersistedHolds, now: number,
|
|
25
|
+
/** False for a vault that is skipped on this machine or not on the rollout. */
|
|
26
|
+
wanted?: (vaultUid: string) => boolean): EngineUnitProblem[];
|
|
27
|
+
export interface SelfCheckRecord {
|
|
28
|
+
lastSentAt: number;
|
|
29
|
+
/** Sorted unit ids last reported (for the log; the rate limit ignores them). */
|
|
30
|
+
units: string[];
|
|
31
|
+
}
|
|
32
|
+
export declare function readSelfCheckRecord(file: string): SelfCheckRecord | null;
|
|
33
|
+
export declare function writeSelfCheckRecord(file: string, record: SelfCheckRecord): void;
|
|
34
|
+
/**
|
|
35
|
+
* Send now: something is wrong and this machine has not sent a self-check DM
|
|
36
|
+
* in the last day. Which units are affected does not matter, so units that
|
|
37
|
+
* take turns failing cannot send a DM on every change.
|
|
38
|
+
*/
|
|
39
|
+
export declare function shouldReport(problems: EngineUnitProblem[], last: SelfCheckRecord | null, now: number): boolean;
|
|
40
|
+
/** The DM text for the owner. Plain, with the commands that fix each case. */
|
|
41
|
+
export declare function selfCheckMessage(problems: EngineUnitProblem[], machine: string,
|
|
42
|
+
/** A name for a vault (company slug, "personal"), when known. */
|
|
43
|
+
vaultName?: (vaultUid: string) => string | undefined): {
|
|
44
|
+
body: string;
|
|
45
|
+
details: string;
|
|
46
|
+
};
|
|
47
|
+
export interface SelfCheckDeps {
|
|
48
|
+
statePath: string;
|
|
49
|
+
holdsPath: string;
|
|
50
|
+
recordPath: string;
|
|
51
|
+
now: () => number;
|
|
52
|
+
machine: string;
|
|
53
|
+
readState: () => DaemonState | null;
|
|
54
|
+
readHolds: () => PersistedHolds;
|
|
55
|
+
/** False for a vault that is skipped here or not on the rollout. Default: every vault. */
|
|
56
|
+
wanted?: (vaultUid: string) => boolean;
|
|
57
|
+
/** A name for a vault, for the DM. */
|
|
58
|
+
vaultName?: (vaultUid: string) => string | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* Send the DM to the person signed in on this machine; resolves to who it
|
|
61
|
+
* went to, or null when no person is signed in (an agent identity).
|
|
62
|
+
*/
|
|
63
|
+
sendToOwner: (message: {
|
|
64
|
+
body: string;
|
|
65
|
+
details: string;
|
|
66
|
+
}) => Promise<string | null>;
|
|
67
|
+
log: (line: string) => void;
|
|
68
|
+
}
|
|
69
|
+
export type SelfCheckOutcome = "healthy" | "reported" | "already-reported" | "no-owner";
|
|
70
|
+
export declare function runSelfCheck(deps: SelfCheckDeps): Promise<SelfCheckOutcome>;
|
|
71
|
+
//# sourceMappingURL=selfcheck.d.ts.map
|