@indigoai-us/hq-cli 5.322.0 → 5.324.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/assets/scaffold/core/scripts/derive-trigger-facts.sh +263 -0
- package/assets/scaffold/core/scripts/lib/transcript-tail.sh +98 -0
- package/assets/scaffold/core/scripts/lib/trigger-fact-text.awk +86 -0
- package/dist/bin/hq-auth-refresh.d.ts +5 -1
- package/dist/bin/hq-auth-refresh.js +33 -1
- package/dist/command-catalog.generated.d.ts +138 -6
- package/dist/command-catalog.generated.js +178 -6
- package/dist/command-registration-plan.d.ts +24 -0
- package/dist/command-registration-plan.js +4 -0
- package/dist/commands/anywhere-runtime-flag.d.ts +9 -0
- package/dist/commands/anywhere-runtime-flag.js +22 -0
- package/dist/commands/cloud.js +23 -4
- package/dist/commands/core-derive-trigger-facts-command.d.ts +3 -0
- package/dist/commands/core-derive-trigger-facts-command.js +47 -0
- package/dist/commands/core-derive-trigger-facts-native.d.ts +21 -0
- package/dist/commands/core-derive-trigger-facts-native.js +492 -0
- package/dist/commands/core.js +6 -0
- package/dist/commands/daemon.js +39 -2
- package/dist/commands/db-provision.js +6 -1
- package/dist/commands/doctor.js +8 -4
- package/dist/commands/feedback.d.ts +2 -0
- package/dist/commands/feedback.js +10 -0
- package/dist/commands/install-global.d.ts +46 -0
- package/dist/commands/install-global.js +674 -0
- package/dist/commands/link.d.ts +30 -0
- package/dist/commands/link.js +184 -0
- package/dist/commands/mcp-status.d.ts +6 -0
- package/dist/commands/mcp-status.js +21 -4
- package/dist/commands/pkg-install.js +20 -1
- package/dist/commands/update.d.ts +19 -0
- package/dist/commands/update.js +52 -6
- package/dist/fast-core.d.ts +2 -1
- package/dist/fast-core.js +12 -8
- package/dist/fast-scaffold.js +13 -0
- package/dist/index.js +9 -7
- package/dist/lib/cli-invite-nudge.d.ts +25 -0
- package/dist/lib/cli-invite-nudge.js +152 -0
- package/dist/lib/core-utils/timeout-guard-native-flags.d.ts +6 -0
- package/dist/lib/core-utils/timeout-guard-native-flags.js +30 -0
- package/dist/lib/core-utils/timeout-guard-native.d.ts +0 -3
- package/dist/lib/core-utils/timeout-guard-native.js +4 -30
- package/dist/lib/core-utils/timeout-guard.d.ts +2 -0
- package/dist/lib/core-utils/timeout-guard.js +2 -1
- package/dist/lib/daemon/convergence.d.ts +42 -0
- package/dist/lib/daemon/convergence.js +66 -0
- package/dist/lib/daemon/heartbeat.d.ts +43 -0
- package/dist/lib/daemon/heartbeat.js +70 -0
- package/dist/lib/daemon/ops/journal.d.ts +29 -0
- package/dist/lib/daemon/ops/journal.js +141 -0
- package/dist/lib/daemon/ops/memory.d.ts +20 -0
- package/dist/lib/daemon/ops/memory.js +65 -0
- package/dist/lib/daemon/ops/mesh.d.ts +38 -0
- package/dist/lib/daemon/ops/mesh.js +70 -0
- package/dist/lib/daemon/ops/policy.d.ts +78 -0
- package/dist/lib/daemon/ops/policy.js +283 -0
- package/dist/lib/daemon/protocol.d.ts +68 -0
- package/dist/lib/daemon/protocol.js +154 -0
- package/dist/lib/daemon/run.d.ts +10 -0
- package/dist/lib/daemon/run.js +85 -3
- package/dist/lib/daemon/server.d.ts +36 -0
- package/dist/lib/daemon/server.js +306 -0
- package/dist/lib/daemon/session-registry.d.ts +30 -0
- package/dist/lib/daemon/session-registry.js +42 -0
- package/dist/lib/doctor/checks/anywhere.d.ts +38 -0
- package/dist/lib/doctor/checks/anywhere.js +182 -0
- package/dist/lib/doctor/registry.js +6 -0
- package/dist/lib/first-sync-next-step.d.ts +10 -1
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/install/claude-targets.d.ts +47 -0
- package/dist/lib/install/claude-targets.js +203 -0
- package/dist/lib/install/codex-targets.d.ts +57 -0
- package/dist/lib/install/codex-targets.js +187 -0
- package/dist/lib/mesh/live/session-seq.js +62 -9
- package/dist/lib/registry/normalize-remote.d.ts +21 -0
- package/dist/lib/registry/normalize-remote.js +68 -0
- package/dist/lib/registry/registry.d.ts +75 -0
- package/dist/lib/registry/registry.js +261 -0
- package/dist/main.js +3 -1
- package/dist/utils/cli-telemetry.d.ts +11 -0
- package/dist/utils/cli-telemetry.js +76 -0
- package/dist/utils/cognito-session.d.ts +5 -1
- package/dist/utils/cognito-session.js +8 -12
- package/dist/utils/feedback-diagnostics.d.ts +2 -0
- package/dist/utils/run-bundled-script.d.ts +7 -4
- package/dist/utils/run-bundled-script.js +47 -12
- package/dist/utils/version-check.d.ts +2 -0
- package/dist/utils/version-check.js +7 -0
- package/package.json +2 -2
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hqd wire protocol (hq-anywhere US-008).
|
|
3
|
+
*
|
|
4
|
+
* One Unix domain socket (a named pipe on Windows) carrying newline-delimited
|
|
5
|
+
* JSON. Requests are `{id, op, args}`; each gets exactly one response
|
|
6
|
+
* `{id, ok: true, result}` or `{id, ok: false, error}`. Responses may arrive
|
|
7
|
+
* out of order, so clients match them by `id`.
|
|
8
|
+
*/
|
|
9
|
+
import * as net from "node:net";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
export const HQD_SOCKET_NAME = "hqd.sock";
|
|
13
|
+
export const HQD_OPS = [
|
|
14
|
+
"ping",
|
|
15
|
+
"resolve",
|
|
16
|
+
"session.open",
|
|
17
|
+
"session.get",
|
|
18
|
+
"session.close",
|
|
19
|
+
"policy.check",
|
|
20
|
+
"policy.index",
|
|
21
|
+
"journal.append",
|
|
22
|
+
"memory.get",
|
|
23
|
+
"memory.put",
|
|
24
|
+
"mesh.presence",
|
|
25
|
+
"shutdown",
|
|
26
|
+
];
|
|
27
|
+
/** A request line larger than this is rejected and the connection closed. */
|
|
28
|
+
export const HQD_MAX_LINE_BYTES = 1024 * 1024;
|
|
29
|
+
/**
|
|
30
|
+
* Socket path: HQ_HQD_SOCKET, else <HQ_REGISTRY_DIR or ~/.hq>/hqd.sock.
|
|
31
|
+
* On Windows the socket is a per-user named pipe.
|
|
32
|
+
*/
|
|
33
|
+
export function hqdSocketPath(home = os.homedir(), env = process.env, platform = process.platform) {
|
|
34
|
+
const override = env.HQ_HQD_SOCKET?.trim();
|
|
35
|
+
if (override)
|
|
36
|
+
return override;
|
|
37
|
+
if (platform === "win32") {
|
|
38
|
+
const user = (env.USERNAME ?? os.userInfo().username).replace(/[^A-Za-z0-9_.-]/g, "-");
|
|
39
|
+
return `\\\\.\\pipe\\hqd-${user}`;
|
|
40
|
+
}
|
|
41
|
+
const dir = env.HQ_REGISTRY_DIR?.trim() ? path.resolve(env.HQ_REGISTRY_DIR.trim()) : path.join(home, ".hq");
|
|
42
|
+
return path.join(dir, HQD_SOCKET_NAME);
|
|
43
|
+
}
|
|
44
|
+
export function encodeMessage(message) {
|
|
45
|
+
return `${JSON.stringify(message)}\n`;
|
|
46
|
+
}
|
|
47
|
+
/** Validate one request line. Never throws. */
|
|
48
|
+
export function parseRequestLine(line) {
|
|
49
|
+
let raw;
|
|
50
|
+
try {
|
|
51
|
+
raw = JSON.parse(line);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return { ok: false, id: null, error: { code: "bad_json", message: "request is not valid JSON" } };
|
|
55
|
+
}
|
|
56
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
57
|
+
return { ok: false, id: null, error: { code: "bad_request", message: "request must be a JSON object" } };
|
|
58
|
+
}
|
|
59
|
+
const obj = raw;
|
|
60
|
+
const id = typeof obj.id === "string" || typeof obj.id === "number" ? obj.id : null;
|
|
61
|
+
if (id === null)
|
|
62
|
+
return { ok: false, id: null, error: { code: "bad_request", message: "request needs a string or number id" } };
|
|
63
|
+
if (typeof obj.op !== "string" || !obj.op)
|
|
64
|
+
return { ok: false, id, error: { code: "bad_request", message: "request needs an op" } };
|
|
65
|
+
if (obj.args !== undefined && (obj.args === null || typeof obj.args !== "object" || Array.isArray(obj.args))) {
|
|
66
|
+
return { ok: false, id, error: { code: "bad_request", message: "args must be an object" } };
|
|
67
|
+
}
|
|
68
|
+
return { ok: true, request: { id, op: obj.op, args: obj.args ?? {} } };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Split a byte stream into lines. Returns a push function that hands each
|
|
72
|
+
* complete line to `onLine`, and reports overflow when a partial line grows
|
|
73
|
+
* past `maxBytes`.
|
|
74
|
+
*/
|
|
75
|
+
export function createLineSplitter(onLine, onOverflow, maxBytes = HQD_MAX_LINE_BYTES) {
|
|
76
|
+
let buffer = "";
|
|
77
|
+
let overflowed = false;
|
|
78
|
+
return (chunk) => {
|
|
79
|
+
if (overflowed)
|
|
80
|
+
return;
|
|
81
|
+
buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
82
|
+
let nl = buffer.indexOf("\n");
|
|
83
|
+
while (nl !== -1) {
|
|
84
|
+
const line = buffer.slice(0, nl).replace(/\r$/, "");
|
|
85
|
+
buffer = buffer.slice(nl + 1);
|
|
86
|
+
if (Buffer.byteLength(line, "utf8") > maxBytes) {
|
|
87
|
+
overflowed = true;
|
|
88
|
+
buffer = "";
|
|
89
|
+
onOverflow();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (line.trim())
|
|
93
|
+
onLine(line);
|
|
94
|
+
nl = buffer.indexOf("\n");
|
|
95
|
+
}
|
|
96
|
+
if (Buffer.byteLength(buffer, "utf8") > maxBytes) {
|
|
97
|
+
overflowed = true;
|
|
98
|
+
buffer = "";
|
|
99
|
+
onOverflow();
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
export class HqdClientError extends Error {
|
|
104
|
+
code;
|
|
105
|
+
constructor(code, message) {
|
|
106
|
+
super(message);
|
|
107
|
+
this.code = code;
|
|
108
|
+
this.name = "HqdClientError";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
let nextClientId = 0;
|
|
112
|
+
/**
|
|
113
|
+
* Send one request and wait for its response. A daemon that is not running
|
|
114
|
+
* fails fast with the socket error code (ENOENT or ECONNREFUSED).
|
|
115
|
+
*/
|
|
116
|
+
export function hqdRequest(op, args = {}, opts = {}) {
|
|
117
|
+
const socketPath = opts.socketPath ?? hqdSocketPath();
|
|
118
|
+
const timeoutMs = opts.timeoutMs ?? 2_000;
|
|
119
|
+
const id = `c${process.pid}-${(nextClientId += 1)}`;
|
|
120
|
+
return new Promise((resolve, reject) => {
|
|
121
|
+
const socket = net.createConnection(socketPath);
|
|
122
|
+
let settled = false;
|
|
123
|
+
const finish = (fn) => {
|
|
124
|
+
if (settled)
|
|
125
|
+
return;
|
|
126
|
+
settled = true;
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
socket.destroy();
|
|
129
|
+
fn();
|
|
130
|
+
};
|
|
131
|
+
const timer = setTimeout(() => finish(() => reject(new HqdClientError("ETIMEDOUT", `hqd did not answer ${op} within ${timeoutMs}ms`))), timeoutMs);
|
|
132
|
+
const push = createLineSplitter((line) => {
|
|
133
|
+
let msg;
|
|
134
|
+
try {
|
|
135
|
+
msg = JSON.parse(line);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
finish(() => reject(new HqdClientError("bad_response", "hqd sent a line that is not JSON")));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (msg.id !== id)
|
|
142
|
+
return;
|
|
143
|
+
if (msg.ok)
|
|
144
|
+
finish(() => resolve(msg.result));
|
|
145
|
+
else
|
|
146
|
+
finish(() => reject(new HqdClientError(msg.error.code, msg.error.message)));
|
|
147
|
+
}, () => finish(() => reject(new HqdClientError("bad_response", "hqd response too large"))));
|
|
148
|
+
socket.on("connect", () => socket.write(encodeMessage({ id, op, args })));
|
|
149
|
+
socket.on("data", push);
|
|
150
|
+
socket.on("error", (err) => finish(() => reject(new HqdClientError(err.code ?? "ESOCKET", `hqd is not reachable at ${socketPath}: ${err.message}`))));
|
|
151
|
+
socket.on("close", () => finish(() => reject(new HqdClientError("ECONNRESET", "hqd closed the connection before answering"))));
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
//# sourceMappingURL=protocol.js.map
|
package/dist/lib/daemon/run.d.ts
CHANGED
|
@@ -7,11 +7,21 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { type UnitPlan } from "./catalog.js";
|
|
9
9
|
import { type LegacyUnit } from "./legacy.js";
|
|
10
|
+
import { type DaemonLockResult } from "./lock.js";
|
|
10
11
|
import { type MachineType } from "./machine-type.js";
|
|
11
12
|
import { type HostApp } from "./supervisor.js";
|
|
12
13
|
export declare const LOG_ROTATE_BYTES: number;
|
|
13
14
|
/** Exit code for "restart me": non-zero so every service manager relaunches (Windows tasks only restart on failure). */
|
|
14
15
|
export declare const RESTART_EXIT_CODE = 75;
|
|
16
|
+
export interface DaemonLockConflictOutcome {
|
|
17
|
+
exitCode: 0 | 1;
|
|
18
|
+
stdout?: string;
|
|
19
|
+
stderr?: string;
|
|
20
|
+
}
|
|
21
|
+
/** Preserve the legacy lock failure unless Anywhere explicitly enables the no-op behavior. */
|
|
22
|
+
export declare function daemonLockConflictOutcome(lock: DaemonLockResult, anywhereEnabled: boolean): DaemonLockConflictOutcome | null;
|
|
23
|
+
/** Node's Windows named-pipe API uses a default DACL that is not user-scoped. */
|
|
24
|
+
export declare function hqdStartupRefusal(platform: NodeJS.Platform, anywhereEnabled: boolean): string | null;
|
|
15
25
|
/** Route an updater request through the daemon's existing graceful handoff. */
|
|
16
26
|
export declare function handoffForPackageUpdateRequest(requested: boolean, restartDaemon: () => void): void;
|
|
17
27
|
export interface RunDaemonOptions {
|
package/dist/lib/daemon/run.js
CHANGED
|
@@ -12,6 +12,7 @@ import * as os from "node:os";
|
|
|
12
12
|
import * as path from "node:path";
|
|
13
13
|
import { loadCachedTokens } from "@indigoai-us/hq-cloud";
|
|
14
14
|
import { CLI_VERSION } from "../../cli-version.js";
|
|
15
|
+
import { anywhereRuntimeEnabled } from "../../commands/anywhere-runtime-flag.js";
|
|
15
16
|
import { currentPackageUseUpdateRequested } from "../../utils/package-use-lease.js";
|
|
16
17
|
import { isLocalDependencyInstall, resolveRunningInstall } from "../../utils/version-gate.js";
|
|
17
18
|
import { agentKitPaths } from "../agent-kit/paths.js";
|
|
@@ -26,9 +27,13 @@ import { acquireDaemonLock, isProcessAlive, processCommandLine, releaseDaemonLoc
|
|
|
26
27
|
import { retireLegacyMeshUnits } from "./migrate.js";
|
|
27
28
|
import { daemonDir as meshDaemonDir, daemonPidPath as meshDaemonPidPath } from "../mesh/live/daemon/paths.js";
|
|
28
29
|
import { readPidLock } from "../mesh/live/daemon/pid-lock.js";
|
|
30
|
+
import { registryDir, registryFile } from "../registry/registry.js";
|
|
29
31
|
import { daemonRootRefusal } from "./install.js";
|
|
30
32
|
import { detectMachineType } from "./machine-type.js";
|
|
31
33
|
import { appendPrivate, daemonPaths, ensurePrivateDir, openPrivateAppend } from "./paths.js";
|
|
34
|
+
import { hqdSocketPath } from "./protocol.js";
|
|
35
|
+
import { startHqdServer } from "./server.js";
|
|
36
|
+
import { heartbeatFile, startHeartbeat } from "./heartbeat.js";
|
|
32
37
|
import { createProtocolTail, ensureProtocolFifo, openProtocolWriter } from "./protocol-tail.js";
|
|
33
38
|
import { takeControlRequests } from "./requests.js";
|
|
34
39
|
import { currentHqCloud, hqCloudRunners } from "./hq-cloud.js";
|
|
@@ -40,6 +45,26 @@ import { userToolDirs } from "./tools.js";
|
|
|
40
45
|
export const LOG_ROTATE_BYTES = 5 * 1024 * 1024;
|
|
41
46
|
/** Exit code for "restart me": non-zero so every service manager relaunches (Windows tasks only restart on failure). */
|
|
42
47
|
export const RESTART_EXIT_CODE = 75;
|
|
48
|
+
/** Preserve the legacy lock failure unless Anywhere explicitly enables the no-op behavior. */
|
|
49
|
+
export function daemonLockConflictOutcome(lock, anywhereEnabled) {
|
|
50
|
+
if (lock.ok)
|
|
51
|
+
return null;
|
|
52
|
+
if (lock.owner && anywhereEnabled) {
|
|
53
|
+
return { exitCode: 0, stdout: `hq daemon is already running (pid ${lock.owner.pid}).\n` };
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
exitCode: 1,
|
|
57
|
+
stderr: lock.owner
|
|
58
|
+
? `hq daemon is already running (pid ${lock.owner.pid}).\n`
|
|
59
|
+
: "another hq daemon is starting up.\n",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Node's Windows named-pipe API uses a default DACL that is not user-scoped. */
|
|
63
|
+
export function hqdStartupRefusal(platform, anywhereEnabled) {
|
|
64
|
+
if (platform !== "win32" || !anywhereEnabled)
|
|
65
|
+
return null;
|
|
66
|
+
return "hqd is disabled on Windows because the default named-pipe ACL grants read access to Everyone and Anonymous accounts.";
|
|
67
|
+
}
|
|
43
68
|
/** Route an updater request through the daemon's existing graceful handoff. */
|
|
44
69
|
export function handoffForPackageUpdateRequest(requested, restartDaemon) {
|
|
45
70
|
if (requested)
|
|
@@ -280,9 +305,13 @@ export async function runDaemon(opts) {
|
|
|
280
305
|
const paths = daemonPaths(home, env);
|
|
281
306
|
const log = makeLogger(paths, opts.foreground === true);
|
|
282
307
|
const lock = acquireDaemonLock(paths.lockPath);
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
308
|
+
const lockConflict = daemonLockConflictOutcome(lock, !lock.ok && lock.owner ? anywhereRuntimeEnabled(env) : false);
|
|
309
|
+
if (lockConflict) {
|
|
310
|
+
if (lockConflict.stdout)
|
|
311
|
+
process.stdout.write(lockConflict.stdout);
|
|
312
|
+
if (lockConflict.stderr)
|
|
313
|
+
process.stderr.write(lockConflict.stderr);
|
|
314
|
+
return lockConflict.exitCode;
|
|
286
315
|
}
|
|
287
316
|
const initial = readDaemonConfig(paths.configPath);
|
|
288
317
|
const detected = detectMachineType(undefined, {
|
|
@@ -659,6 +688,45 @@ export async function runDaemon(opts) {
|
|
|
659
688
|
clearTimeout(timer);
|
|
660
689
|
return on;
|
|
661
690
|
};
|
|
691
|
+
// hqd: the session/company socket every runtime talks to (hq-anywhere US-008).
|
|
692
|
+
let hqd = null;
|
|
693
|
+
let hqdShutdown = null;
|
|
694
|
+
if (anywhereRuntimeEnabled()) {
|
|
695
|
+
const refusal = hqdStartupRefusal(platform, true);
|
|
696
|
+
if (refusal) {
|
|
697
|
+
log("error", refusal);
|
|
698
|
+
}
|
|
699
|
+
else {
|
|
700
|
+
try {
|
|
701
|
+
const startConfig = readDaemonConfig(paths.configPath);
|
|
702
|
+
hqd = await startHqdServer({
|
|
703
|
+
socketPath: hqdSocketPath(home, env),
|
|
704
|
+
registryPath: registryFile(registryDir(home, env)),
|
|
705
|
+
hqRoot: startConfig.hqRoot ?? (env.HQ_ROOT?.trim() || undefined),
|
|
706
|
+
log,
|
|
707
|
+
onShutdown: () => hqdShutdown?.(),
|
|
708
|
+
reportError: reportHqdCrash,
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
catch (err) {
|
|
712
|
+
log("error", `hqd socket did not start; services run without it: ${err.message}`);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
// US-012: 60s presence heartbeat with version and session count.
|
|
717
|
+
const liveHqd = hqd;
|
|
718
|
+
const stopHeartbeat = liveHqd
|
|
719
|
+
? startHeartbeat({
|
|
720
|
+
version: CLI_VERSION,
|
|
721
|
+
sessionCount: () => liveHqd.sessions.size,
|
|
722
|
+
file: heartbeatFile(home, env),
|
|
723
|
+
log,
|
|
724
|
+
})
|
|
725
|
+
: () => { };
|
|
726
|
+
const closeHqd = () => {
|
|
727
|
+
stopHeartbeat();
|
|
728
|
+
void hqd?.close().catch((err) => log("warn", `hqd socket did not close cleanly: ${err.message}`));
|
|
729
|
+
};
|
|
662
730
|
return await new Promise((resolve) => {
|
|
663
731
|
let stopping = false;
|
|
664
732
|
let restartPending = false;
|
|
@@ -667,6 +735,7 @@ export async function runDaemon(opts) {
|
|
|
667
735
|
return;
|
|
668
736
|
stopping = true;
|
|
669
737
|
log("info", `${why}; stopping services`);
|
|
738
|
+
closeHqd();
|
|
670
739
|
// The supervisor escalates to SIGKILL after STOP_ESCALATION_MS; the extra
|
|
671
740
|
// margin only covers a child that survives even that.
|
|
672
741
|
let capTimer;
|
|
@@ -691,6 +760,7 @@ export async function runDaemon(opts) {
|
|
|
691
760
|
if (stopping)
|
|
692
761
|
return;
|
|
693
762
|
stopping = true;
|
|
763
|
+
closeHqd();
|
|
694
764
|
const released = supervisor.release();
|
|
695
765
|
// What these readers have not read stays in the FIFO for the next daemon.
|
|
696
766
|
for (const tail of protocolTails)
|
|
@@ -748,6 +818,7 @@ export async function runDaemon(opts) {
|
|
|
748
818
|
return true;
|
|
749
819
|
});
|
|
750
820
|
};
|
|
821
|
+
hqdShutdown = () => shutdown("hqd shutdown request");
|
|
751
822
|
process.on("SIGTERM", () => shutdown("received SIGTERM"));
|
|
752
823
|
process.on("SIGINT", () => shutdown("received SIGINT"));
|
|
753
824
|
if (platform !== "win32")
|
|
@@ -757,9 +828,20 @@ export async function runDaemon(opts) {
|
|
|
757
828
|
}
|
|
758
829
|
catch (err) {
|
|
759
830
|
log("error", `supervisor failed to start: ${err.message}`);
|
|
831
|
+
closeHqd();
|
|
760
832
|
releaseDaemonLock(paths.lockPath);
|
|
761
833
|
resolve(1);
|
|
762
834
|
}
|
|
763
835
|
});
|
|
764
836
|
}
|
|
837
|
+
/** Report an hqd op crash to the CLI's existing Sentry integration, tagged with the failing op. */
|
|
838
|
+
function reportHqdCrash(op, err) {
|
|
839
|
+
void import("../../sentry.js").then(({ Sentry }) => {
|
|
840
|
+
Sentry.withScope((scope) => {
|
|
841
|
+
scope.setTag("component", "hqd");
|
|
842
|
+
scope.setTag("hqd.op", op);
|
|
843
|
+
Sentry.captureException(err);
|
|
844
|
+
});
|
|
845
|
+
}, (importErr) => process.stderr.write(`hqd could not load Sentry to report a ${op} failure: ${String(importErr)}\n`));
|
|
846
|
+
}
|
|
765
847
|
//# sourceMappingURL=run.js.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hqd socket server (hq-anywhere US-008).
|
|
3
|
+
*
|
|
4
|
+
* Hosted inside the `hq daemon run` supervisor, which already runs the mesh
|
|
5
|
+
* drainer as one of its services, so there is still one resident process per
|
|
6
|
+
* machine. Sessions in any repo ask it which company they belong to and
|
|
7
|
+
* register themselves here.
|
|
8
|
+
*/
|
|
9
|
+
import { type ResolveResult } from "../registry/registry.js";
|
|
10
|
+
import { SessionRegistry } from "./session-registry.js";
|
|
11
|
+
export interface HqdServerOptions {
|
|
12
|
+
socketPath: string;
|
|
13
|
+
/** registry.json to resolve against (US-004). */
|
|
14
|
+
registryPath?: string;
|
|
15
|
+
hqRoot?: string;
|
|
16
|
+
version?: string;
|
|
17
|
+
now?: () => Date;
|
|
18
|
+
/** Called after a `shutdown` request has been answered. */
|
|
19
|
+
onShutdown?: () => void;
|
|
20
|
+
log?: (level: "info" | "warn" | "error", message: string) => void;
|
|
21
|
+
/** Environment for policy scripts and the mesh spool (tests sandbox HOME here). */
|
|
22
|
+
env?: NodeJS.ProcessEnv;
|
|
23
|
+
/** Work-mesh root override for mesh.presence (tests). */
|
|
24
|
+
workMeshRoot?: string;
|
|
25
|
+
/** Crash reporter for internal op failures (US-012); run.ts wires Sentry here. */
|
|
26
|
+
reportError?: (op: string, err: unknown) => void;
|
|
27
|
+
}
|
|
28
|
+
export interface HqdServer {
|
|
29
|
+
socketPath: string;
|
|
30
|
+
sessions: SessionRegistry;
|
|
31
|
+
close(): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
/** Resolve cwd → company through the registry, cached until registry.json changes. */
|
|
34
|
+
export declare function createResolver(registryPath: string): (cwd: string) => ResolveResult;
|
|
35
|
+
export declare function startHqdServer(opts: HqdServerOptions): Promise<HqdServer>;
|
|
36
|
+
//# sourceMappingURL=server.d.ts.map
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hqd socket server (hq-anywhere US-008).
|
|
3
|
+
*
|
|
4
|
+
* Hosted inside the `hq daemon run` supervisor, which already runs the mesh
|
|
5
|
+
* drainer as one of its services, so there is still one resident process per
|
|
6
|
+
* machine. Sessions in any repo ask it which company they belong to and
|
|
7
|
+
* register themselves here.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as net from "node:net";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { CLI_VERSION } from "../../cli-version.js";
|
|
13
|
+
import { canonicalDir, readRegistry, registryFile, resolveCompanyForDir } from "../registry/registry.js";
|
|
14
|
+
import { createLineSplitter, encodeMessage, parseRequestLine } from "./protocol.js";
|
|
15
|
+
import { SessionRegistry } from "./session-registry.js";
|
|
16
|
+
import { journalAppend, JournalOpError } from "./ops/journal.js";
|
|
17
|
+
import { memoryGet, memoryPut, MemoryOpError } from "./ops/memory.js";
|
|
18
|
+
import { meshPresence, MeshOpError } from "./ops/mesh.js";
|
|
19
|
+
import { PolicyIndex, PolicyOpError, policyCheck } from "./ops/policy.js";
|
|
20
|
+
class OpError extends Error {
|
|
21
|
+
code;
|
|
22
|
+
constructor(code, message) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.code = code;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function stringArg(args, name, required = true) {
|
|
28
|
+
const value = args[name];
|
|
29
|
+
if (typeof value === "string" && value.trim())
|
|
30
|
+
return value;
|
|
31
|
+
if (value === undefined && !required)
|
|
32
|
+
return undefined;
|
|
33
|
+
throw new OpError("bad_args", `${name} must be a non-empty string`);
|
|
34
|
+
}
|
|
35
|
+
/** Resolve cwd → company through the registry, cached until registry.json changes. */
|
|
36
|
+
export function createResolver(registryPath) {
|
|
37
|
+
const cache = new Map();
|
|
38
|
+
let stamp = "";
|
|
39
|
+
return (cwd) => {
|
|
40
|
+
let current = "missing";
|
|
41
|
+
try {
|
|
42
|
+
const st = fs.statSync(registryPath);
|
|
43
|
+
current = `${st.mtimeMs}:${st.size}:${st.ino}`;
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
if (err.code !== "ENOENT")
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
if (current !== stamp) {
|
|
50
|
+
cache.clear();
|
|
51
|
+
stamp = current;
|
|
52
|
+
}
|
|
53
|
+
const key = canonicalDir(cwd);
|
|
54
|
+
const hit = cache.get(key);
|
|
55
|
+
if (hit)
|
|
56
|
+
return hit;
|
|
57
|
+
const result = resolveCompanyForDir(readRegistry(registryPath), key);
|
|
58
|
+
cache.set(key, result);
|
|
59
|
+
return result;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Is something answering on this socket? */
|
|
63
|
+
function socketIsLive(socketPath) {
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
const probe = net.createConnection(socketPath);
|
|
66
|
+
const done = (live) => {
|
|
67
|
+
probe.destroy();
|
|
68
|
+
resolve(live);
|
|
69
|
+
};
|
|
70
|
+
probe.once("connect", () => done(true));
|
|
71
|
+
probe.once("error", () => done(false));
|
|
72
|
+
setTimeout(() => done(false), 500).unref();
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
export async function startHqdServer(opts) {
|
|
76
|
+
const { socketPath } = opts;
|
|
77
|
+
const isPipe = process.platform === "win32";
|
|
78
|
+
const startedAtMs = (opts.now ?? (() => new Date()))().getTime();
|
|
79
|
+
const sessions = new SessionRegistry(opts.now);
|
|
80
|
+
const resolve = createResolver(opts.registryPath ?? registryFile());
|
|
81
|
+
const log = opts.log ?? (() => { });
|
|
82
|
+
const clock = opts.now ?? (() => new Date());
|
|
83
|
+
const env = opts.env ?? process.env;
|
|
84
|
+
const policyIndex = opts.hqRoot ? new PolicyIndex(opts.hqRoot, log) : null;
|
|
85
|
+
const requireRoot = () => {
|
|
86
|
+
if (!opts.hqRoot)
|
|
87
|
+
throw new OpError("no_hq_root", "hqd has no HQ root configured (set hqRoot in daemon config or HQ_ROOT)");
|
|
88
|
+
return opts.hqRoot;
|
|
89
|
+
};
|
|
90
|
+
/** Company for a call: explicit arg, else the open session's, else the registry's answer for cwd. */
|
|
91
|
+
const companyFor = (args) => {
|
|
92
|
+
const explicit = stringArg(args, "company", false);
|
|
93
|
+
if (explicit)
|
|
94
|
+
return explicit;
|
|
95
|
+
const runtime = stringArg(args, "runtime", false);
|
|
96
|
+
const sessionId = stringArg(args, "sessionId", false);
|
|
97
|
+
const session = runtime && sessionId ? sessions.get(runtime, sessionId) : null;
|
|
98
|
+
if (session?.company)
|
|
99
|
+
return session.company;
|
|
100
|
+
const cwd = stringArg(args, "cwd", false);
|
|
101
|
+
return cwd ? (resolve(cwd).company ?? undefined) : undefined;
|
|
102
|
+
};
|
|
103
|
+
if (!isPipe) {
|
|
104
|
+
fs.mkdirSync(path.dirname(socketPath), { recursive: true, mode: 0o700 });
|
|
105
|
+
if (fs.existsSync(socketPath)) {
|
|
106
|
+
if (await socketIsLive(socketPath))
|
|
107
|
+
throw new Error(`hqd is already listening on ${socketPath}`);
|
|
108
|
+
fs.unlinkSync(socketPath);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** Synchronous ops answer in request order; only policy.check returns a promise. */
|
|
112
|
+
const handle = (req) => {
|
|
113
|
+
const args = req.args ?? {};
|
|
114
|
+
switch (req.op) {
|
|
115
|
+
case "ping":
|
|
116
|
+
return {
|
|
117
|
+
version: opts.version ?? CLI_VERSION,
|
|
118
|
+
hqRoot: opts.hqRoot ?? null,
|
|
119
|
+
uptimeMs: clock().getTime() - startedAtMs,
|
|
120
|
+
pid: process.pid,
|
|
121
|
+
sessions: sessions.size,
|
|
122
|
+
};
|
|
123
|
+
case "resolve": {
|
|
124
|
+
const cwd = stringArg(args, "cwd");
|
|
125
|
+
return resolve(cwd);
|
|
126
|
+
}
|
|
127
|
+
case "session.open": {
|
|
128
|
+
const runtime = stringArg(args, "runtime");
|
|
129
|
+
const sessionId = stringArg(args, "sessionId");
|
|
130
|
+
const cwd = stringArg(args, "cwd");
|
|
131
|
+
const explicit = stringArg(args, "company", false);
|
|
132
|
+
const company = explicit ?? resolve(cwd).company;
|
|
133
|
+
return sessions.open({ runtime, sessionId, cwd: canonicalDir(cwd), company });
|
|
134
|
+
}
|
|
135
|
+
case "session.get": {
|
|
136
|
+
const runtime = stringArg(args, "runtime", false);
|
|
137
|
+
const sessionId = stringArg(args, "sessionId", false);
|
|
138
|
+
if (!sessionId) {
|
|
139
|
+
const all = sessions.list();
|
|
140
|
+
return { sessions: runtime ? all.filter((s) => s.runtime === runtime) : all };
|
|
141
|
+
}
|
|
142
|
+
if (!runtime)
|
|
143
|
+
throw new OpError("bad_args", "runtime is required with sessionId");
|
|
144
|
+
return { session: sessions.get(runtime, sessionId) };
|
|
145
|
+
}
|
|
146
|
+
case "session.close": {
|
|
147
|
+
const runtime = stringArg(args, "runtime");
|
|
148
|
+
const sessionId = stringArg(args, "sessionId");
|
|
149
|
+
return { closed: sessions.close(runtime, sessionId) };
|
|
150
|
+
}
|
|
151
|
+
case "policy.check": {
|
|
152
|
+
const hqRoot = requireRoot();
|
|
153
|
+
const company = companyFor(args);
|
|
154
|
+
if (!company)
|
|
155
|
+
throw new OpError("bad_args", "company is required (or a cwd linked to a company)");
|
|
156
|
+
const input = args.input;
|
|
157
|
+
if (input !== undefined && (input === null || typeof input !== "object" || Array.isArray(input))) {
|
|
158
|
+
throw new OpError("bad_args", "input must be an object");
|
|
159
|
+
}
|
|
160
|
+
return policyCheck({
|
|
161
|
+
company,
|
|
162
|
+
event: stringArg(args, "event", false),
|
|
163
|
+
tool: stringArg(args, "tool"),
|
|
164
|
+
input: input,
|
|
165
|
+
sessionId: stringArg(args, "sessionId", false),
|
|
166
|
+
cwd: stringArg(args, "cwd", false),
|
|
167
|
+
}, { hqRoot, index: policyIndex, env, now: clock });
|
|
168
|
+
}
|
|
169
|
+
case "policy.index": {
|
|
170
|
+
requireRoot();
|
|
171
|
+
const company = stringArg(args, "company", false);
|
|
172
|
+
return { version: policyIndex.version, policies: policyIndex.list(company) };
|
|
173
|
+
}
|
|
174
|
+
case "journal.append": {
|
|
175
|
+
const company = companyFor(args);
|
|
176
|
+
if (!company)
|
|
177
|
+
throw new OpError("bad_args", "company is required (or a cwd linked to a company)");
|
|
178
|
+
return journalAppend({ company, project: stringArg(args, "project"), section: stringArg(args, "section"), text: stringArg(args, "text") }, requireRoot(), clock());
|
|
179
|
+
}
|
|
180
|
+
case "memory.get":
|
|
181
|
+
return memoryGet(requireRoot(), stringArg(args, "sessionId"), stringArg(args, "key", false));
|
|
182
|
+
case "memory.put":
|
|
183
|
+
return memoryPut(requireRoot(), stringArg(args, "sessionId"), stringArg(args, "key"), args.value, clock());
|
|
184
|
+
case "mesh.presence": {
|
|
185
|
+
const harness = stringArg(args, "harness", false) ?? stringArg(args, "runtime");
|
|
186
|
+
return meshPresence({
|
|
187
|
+
sessionId: stringArg(args, "sessionId"),
|
|
188
|
+
harness,
|
|
189
|
+
kind: stringArg(args, "kind"),
|
|
190
|
+
cwd: stringArg(args, "cwd", false),
|
|
191
|
+
company: companyFor(args),
|
|
192
|
+
project: stringArg(args, "project", false),
|
|
193
|
+
task: stringArg(args, "task", false),
|
|
194
|
+
taskId: stringArg(args, "taskId", false),
|
|
195
|
+
status: stringArg(args, "status", false),
|
|
196
|
+
summary: stringArg(args, "summary", false),
|
|
197
|
+
reason: stringArg(args, "reason", false),
|
|
198
|
+
}, { hqRoot: opts.hqRoot, workMeshRoot: opts.workMeshRoot, env, now: clock });
|
|
199
|
+
}
|
|
200
|
+
case "shutdown":
|
|
201
|
+
return { stopping: true };
|
|
202
|
+
default:
|
|
203
|
+
throw new OpError("unknown_op", `unknown op ${req.op}`);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
const respond = (socket, msg) => {
|
|
207
|
+
if (!socket.destroyed)
|
|
208
|
+
socket.write(encodeMessage(msg));
|
|
209
|
+
};
|
|
210
|
+
const clients = new Set();
|
|
211
|
+
const server = net.createServer((socket) => {
|
|
212
|
+
clients.add(socket);
|
|
213
|
+
socket.on("close", () => clients.delete(socket));
|
|
214
|
+
socket.on("error", (err) => log("warn", `hqd client error: ${err.message}`));
|
|
215
|
+
const push = createLineSplitter((line) => {
|
|
216
|
+
const parsed = parseRequestLine(line);
|
|
217
|
+
if (!parsed.ok) {
|
|
218
|
+
respond(socket, { id: parsed.id, ok: false, error: parsed.error });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const req = parsed.request;
|
|
222
|
+
const succeed = (result) => {
|
|
223
|
+
respond(socket, { id: req.id, ok: true, result });
|
|
224
|
+
if (req.op === "shutdown") {
|
|
225
|
+
log("info", "hqd received a shutdown request");
|
|
226
|
+
setImmediate(() => opts.onShutdown?.());
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
const fail = (err) => {
|
|
230
|
+
const error = err instanceof OpError ||
|
|
231
|
+
err instanceof PolicyOpError ||
|
|
232
|
+
err instanceof JournalOpError ||
|
|
233
|
+
err instanceof MemoryOpError ||
|
|
234
|
+
err instanceof MeshOpError
|
|
235
|
+
? { code: err.code, message: err.message }
|
|
236
|
+
: { code: "internal", message: err instanceof Error ? err.message : String(err) };
|
|
237
|
+
if (error.code === "internal") {
|
|
238
|
+
log("error", `hqd ${req.op} failed: ${error.message}`);
|
|
239
|
+
try {
|
|
240
|
+
opts.reportError?.(req.op, err);
|
|
241
|
+
}
|
|
242
|
+
catch (reportErr) {
|
|
243
|
+
log("warn", `hqd could not report the ${req.op} failure: ${reportErr.message}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
respond(socket, { id: req.id, ok: false, error });
|
|
247
|
+
};
|
|
248
|
+
let result;
|
|
249
|
+
try {
|
|
250
|
+
result = handle(req);
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
fail(err);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (result instanceof Promise)
|
|
257
|
+
result.then(succeed, fail);
|
|
258
|
+
else
|
|
259
|
+
succeed(result);
|
|
260
|
+
}, () => {
|
|
261
|
+
respond(socket, { id: null, ok: false, error: { code: "too_large", message: "request line too large" } });
|
|
262
|
+
socket.end();
|
|
263
|
+
});
|
|
264
|
+
socket.on("data", push);
|
|
265
|
+
});
|
|
266
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
267
|
+
server.once("error", rejectListen);
|
|
268
|
+
// Bind with a user-only umask so the socket is never briefly group/world accessible.
|
|
269
|
+
const prior = isPipe ? null : process.umask(0o177);
|
|
270
|
+
try {
|
|
271
|
+
server.listen(socketPath, () => {
|
|
272
|
+
server.off("error", rejectListen);
|
|
273
|
+
resolveListen();
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
if (prior !== null)
|
|
278
|
+
process.umask(prior);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
if (!isPipe)
|
|
282
|
+
fs.chmodSync(socketPath, 0o600);
|
|
283
|
+
log("info", `hqd listening on ${socketPath}`);
|
|
284
|
+
let closing = null;
|
|
285
|
+
return {
|
|
286
|
+
socketPath,
|
|
287
|
+
sessions,
|
|
288
|
+
close: () => (closing ??= new Promise((done) => {
|
|
289
|
+
policyIndex?.close();
|
|
290
|
+
server.close(() => done());
|
|
291
|
+
// Answered-but-idle clients would otherwise hold close() open.
|
|
292
|
+
for (const client of clients)
|
|
293
|
+
client.destroy();
|
|
294
|
+
if (!isPipe) {
|
|
295
|
+
try {
|
|
296
|
+
fs.unlinkSync(socketPath);
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
if (err.code !== "ENOENT")
|
|
300
|
+
log("warn", `could not remove ${socketPath}: ${err.message}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
})),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
//# sourceMappingURL=server.js.map
|