@indigoai-us/hq-cli 5.341.1 → 5.341.3
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 +17 -0
- package/assets/hqd/windows-pipe-host.ps1 +282 -0
- package/dist/bin/hq-auth-refresh.d.ts +1 -1
- package/dist/bin/hq-auth-refresh.js +11 -2
- package/dist/commands/billing-sponsor.js +20 -1
- package/dist/commands/core-derive-trigger-facts-native.d.ts +2 -0
- package/dist/commands/core-derive-trigger-facts-native.js +92 -25
- package/dist/commands/core-migrate-policy-triggers-native.d.ts +1 -0
- package/dist/commands/core-migrate-policy-triggers-native.js +26 -12
- package/dist/commands/doctor.js +2 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +17 -1
- package/dist/lib/aws-sdk-node-warning-output.js +2 -3
- package/dist/lib/billing/plan-lock.js +19 -2
- package/dist/lib/bot/owner-context.d.ts +2 -0
- package/dist/lib/bot/owner-context.js +19 -1
- package/dist/lib/daemon/flag-monitor.d.ts +23 -0
- package/dist/lib/daemon/flag-monitor.js +99 -0
- package/dist/lib/daemon/flag.d.ts +2 -0
- package/dist/lib/daemon/flag.js +10 -1
- package/dist/lib/daemon/protocol.d.ts +26 -3
- package/dist/lib/daemon/protocol.js +9 -5
- package/dist/lib/daemon/run.js +14 -16
- package/dist/lib/daemon/server.d.ts +23 -2
- package/dist/lib/daemon/server.js +397 -52
- package/dist/lib/daemon/windows-pipe-host.d.ts +17 -0
- package/dist/lib/daemon/windows-pipe-host.js +218 -0
- package/dist/lib/diagnostics.d.ts +9 -0
- package/dist/lib/diagnostics.js +12 -0
- package/dist/lib/doctor/checks/anywhere.d.ts +6 -1
- package/dist/lib/doctor/checks/anywhere.js +131 -13
- package/dist/lib/doctor/json-output.d.ts +2 -0
- package/dist/lib/doctor/json-output.js +1 -0
- package/dist/lib/doctor/registry.js +1 -3
- package/dist/lib/doctor/types.d.ts +4 -0
- package/dist/utils/package-use-lease.d.ts +4 -0
- package/dist/utils/package-use-lease.js +28 -0
- package/dist/utils/run-bundled-script.d.ts +2 -0
- package/dist/utils/run-bundled-script.js +10 -1
- package/dist/utils/vault-api.js +12 -12
- package/docs/anywhere-runtime.md +49 -5
- package/package.json +2 -1
|
@@ -57,16 +57,24 @@ function isDirectory(fileSystem, value) {
|
|
|
57
57
|
return false;
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
-
function executableInPath(name, env, fileSystem) {
|
|
60
|
+
export function executableInPath(name, env, fileSystem, platform = process.platform) {
|
|
61
|
+
const extensions = platform === "win32"
|
|
62
|
+
// spawnSync("flock", ...) without shell support can launch native images,
|
|
63
|
+
// but not batch files. Treating .cmd/.bat as available skips the fallback
|
|
64
|
+
// lock while the later spawn fails on Windows.
|
|
65
|
+
? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((extension) => /^\.(?:COM|EXE)$/i.test(extension))
|
|
66
|
+
: [""];
|
|
61
67
|
for (const directory of (env.PATH ?? "").split(path.delimiter)) {
|
|
62
68
|
if (!directory)
|
|
63
69
|
continue;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
for (const extension of extensions) {
|
|
71
|
+
try {
|
|
72
|
+
const stat = fileSystem.statSync(path.join(directory, `${name}${extension}`));
|
|
73
|
+
if (stat.isFile() && (platform === "win32" || (stat.mode & 0o111) !== 0))
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
catch { /* continue through PATH */ }
|
|
68
77
|
}
|
|
69
|
-
catch { /* continue through PATH */ }
|
|
70
78
|
}
|
|
71
79
|
return false;
|
|
72
80
|
}
|
|
@@ -79,7 +87,9 @@ function runNonblockingFlock(lockFd, cwd, pathValue) {
|
|
|
79
87
|
}
|
|
80
88
|
function frontmatterValue(content, key) {
|
|
81
89
|
let depth = 0;
|
|
82
|
-
for (const
|
|
90
|
+
for (const rawLine of content.split("\n")) {
|
|
91
|
+
// Git for Windows' awk reads CRLF as a line ending; POSIX awk keeps the CR.
|
|
92
|
+
const line = process.platform === "win32" ? rawLine.replace(/\r$/, "") : rawLine;
|
|
83
93
|
if (line === "---") {
|
|
84
94
|
depth += 1;
|
|
85
95
|
continue;
|
|
@@ -154,10 +164,11 @@ function deriveWhen(trigger, rawTags, enforcementRaw) {
|
|
|
154
164
|
function writeAtomic(fileSystem, target, bytes) {
|
|
155
165
|
const temporary = path.join(path.dirname(target), `.migrate-policy-triggers.${process.pid}.${Math.random().toString(16).slice(2)}`);
|
|
156
166
|
// The shell writes through mktemp, whose private mode survives the rename on both platforms.
|
|
157
|
-
const mode = 0o600;
|
|
167
|
+
const mode = process.platform === "win32" ? undefined : 0o600;
|
|
158
168
|
try {
|
|
159
|
-
fileSystem.writeFileSync(temporary, bytes, { encoding: "utf8", mode, flag: "wx" });
|
|
160
|
-
|
|
169
|
+
fileSystem.writeFileSync(temporary, bytes, { encoding: "utf8", ...(mode === undefined ? {} : { mode }), flag: "wx" });
|
|
170
|
+
if (mode !== undefined)
|
|
171
|
+
fileSystem.chmodSync(temporary, mode);
|
|
161
172
|
fileSystem.renameSync(temporary, target);
|
|
162
173
|
return true;
|
|
163
174
|
}
|
|
@@ -447,7 +458,7 @@ export function runMigratePolicyTriggers(args, deps) {
|
|
|
447
458
|
continue;
|
|
448
459
|
}
|
|
449
460
|
const anchor = original.split("\n").some((line) => line.startsWith("trigger:")) ? /^trigger:/ : /^id:/;
|
|
450
|
-
const lines = original.split("\n");
|
|
461
|
+
const lines = original.split("\n").map((line) => process.platform === "win32" ? line.replace(/\r$/, "") : line);
|
|
451
462
|
let inserted = false;
|
|
452
463
|
const rewritten = lines.flatMap((line) => {
|
|
453
464
|
if (!inserted && anchor.test(line)) {
|
|
@@ -471,7 +482,10 @@ export function runMigratePolicyTriggers(args, deps) {
|
|
|
471
482
|
if (!dryRun && stateReady && !errorCode) {
|
|
472
483
|
const stampTmp = path.join(path.dirname(stamp), `.last-success.${process.pid}.${Math.random().toString(16).slice(2)}`);
|
|
473
484
|
try {
|
|
474
|
-
fileSystem.writeFileSync(stampTmp, `${nowSeconds()}\n`, {
|
|
485
|
+
fileSystem.writeFileSync(stampTmp, `${nowSeconds()}\n`, {
|
|
486
|
+
...(process.platform === "win32" ? {} : { mode: 0o600 }),
|
|
487
|
+
flag: "wx",
|
|
488
|
+
});
|
|
475
489
|
fileSystem.renameSync(stampTmp, stamp);
|
|
476
490
|
}
|
|
477
491
|
catch {
|
package/dist/commands/doctor.js
CHANGED
|
@@ -37,7 +37,6 @@ import { isCompanyJournalSlug } from "../lib/doctor/checks/sync-health.js";
|
|
|
37
37
|
import { readCompaniesManifestMap } from "../lib/work-context/repo-remote.js";
|
|
38
38
|
import * as readline from "node:readline";
|
|
39
39
|
import { runSideEffectTests } from "../lib/doctor/deep/sandbox.js";
|
|
40
|
-
import { anywhereRuntimeEnabled } from "./anywhere-runtime-flag.js";
|
|
41
40
|
/**
|
|
42
41
|
* Marker directories that identify an HQ tree root. A directory is an HQ root
|
|
43
42
|
* only when it contains ALL of these. `.claude` + `core` are present in both
|
|
@@ -91,7 +90,7 @@ export async function runDoctor(options = {}) {
|
|
|
91
90
|
const write = options.stdout ?? ((chunk) => void process.stdout.write(chunk));
|
|
92
91
|
const writeErr = options.stderr ?? ((chunk) => void process.stderr.write(chunk));
|
|
93
92
|
const hqRoot = options.root ?? resolveHqRoot(options.cwd);
|
|
94
|
-
const standaloneAnywhere = !hqRoot && options.only?.length === 1 && options.only[0] === "anywhere"
|
|
93
|
+
const standaloneAnywhere = !hqRoot && options.only?.length === 1 && options.only[0] === "anywhere";
|
|
95
94
|
if (!hqRoot && !standaloneAnywhere) {
|
|
96
95
|
const from = options.cwd ?? process.cwd();
|
|
97
96
|
writeErr(`hq doctor: not inside an HQ tree.\n` +
|
|
@@ -126,6 +125,7 @@ export async function runDoctor(options = {}) {
|
|
|
126
125
|
// Anywhere checks inspect ~/.hq and are intentionally runnable from a foreign
|
|
127
126
|
// repo. No other family is selected in this standalone path.
|
|
128
127
|
hqRoot: hqRoot ?? "",
|
|
128
|
+
cwd: options.cwd ?? process.cwd(),
|
|
129
129
|
platform: { id: platform.id, evidence: platform.evidence },
|
|
130
130
|
sessionId: options.sessionId,
|
|
131
131
|
liveRuntimes: options.liveRuntimes === true,
|
package/dist/index.d.ts
CHANGED
|
@@ -4,8 +4,9 @@ import "./node-network-compat-fast.js";
|
|
|
4
4
|
import { isVersionRequest } from "./version-request.js";
|
|
5
5
|
import { isFastCoreRequest } from "./fast-scaffold.js";
|
|
6
6
|
type PackageUseLeaseLoader = () => Promise<void>;
|
|
7
|
+
type PackageUseUpdateProbe = () => Promise<boolean>;
|
|
7
8
|
declare function isNpmPackageEntry(entryUrl: string): boolean;
|
|
8
|
-
declare function dispatchWithPackageUseLease(entryUrl: string, dispatch: () => void, loadLease?: PackageUseLeaseLoader): void | Promise<void>;
|
|
9
|
+
declare function dispatchWithPackageUseLease(entryUrl: string, dispatch: () => void, loadLease?: PackageUseLeaseLoader, updateRequestPending?: PackageUseUpdateProbe): void | Promise<void>;
|
|
9
10
|
declare function runCliEntry(entryUrl: string, dispatch: () => void, loadLease?: PackageUseLeaseLoader): void | Promise<void>;
|
|
10
11
|
export declare const __test__: {
|
|
11
12
|
isVersionRequest: typeof isVersionRequest;
|
package/dist/index.js
CHANGED
|
@@ -140,14 +140,30 @@ function packageUseErrorClass(error) {
|
|
|
140
140
|
function dispatchWithPackageUseLease(entryUrl, dispatch, loadLease = async () => {
|
|
141
141
|
const { acquireCurrentPackageUseLease } = await import("./utils/package-use-lease.js");
|
|
142
142
|
acquireCurrentPackageUseLease();
|
|
143
|
+
}, updateRequestPending = async () => {
|
|
144
|
+
const { currentPackageUseUpdatePending } = await import("./utils/package-use-lease.js");
|
|
145
|
+
return currentPackageUseUpdatePending();
|
|
143
146
|
}) {
|
|
144
147
|
if (!isNpmPackageEntry(entryUrl)) {
|
|
145
148
|
dispatch();
|
|
146
149
|
return;
|
|
147
150
|
}
|
|
148
|
-
return loadLease().then(dispatch, (error) => {
|
|
151
|
+
return loadLease().then(dispatch, async (error) => {
|
|
149
152
|
if (isPackageUseUpdatePendingError(error))
|
|
150
153
|
throw error;
|
|
154
|
+
try {
|
|
155
|
+
if (await updateRequestPending()) {
|
|
156
|
+
const pending = new Error("The HQ CLI package is being updated; retry after the update finishes");
|
|
157
|
+
pending.name = "PackageUseUpdatePendingError";
|
|
158
|
+
throw pending;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch (probeError) {
|
|
162
|
+
if (isPackageUseUpdatePendingError(probeError))
|
|
163
|
+
throw probeError;
|
|
164
|
+
// A lease probe failure must not make the CLI unavailable. Keep the
|
|
165
|
+
// existing fail-open path when no live update request could be confirmed.
|
|
166
|
+
}
|
|
151
167
|
process.stderr.write(`hq-cli package-use lease unavailable (${packageUseErrorClass(error)}); continuing without lease\n`);
|
|
152
168
|
dispatch();
|
|
153
169
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { companyErrorsSdkWarningEnabled } from "./company-errors-sdk-warning-flag.js";
|
|
2
|
+
import { diagnosticsEnabled } from "./diagnostics.js";
|
|
2
3
|
export const AWS_SDK_NODE_VERSION_WARNING_ENV = "AWS_SDK_JS_NODE_VERSION_SUPPORT_WARNING_DISABLED";
|
|
3
4
|
/**
|
|
4
5
|
* Configure the AWS SDK's documented, warning-specific switch before loading
|
|
@@ -10,9 +11,7 @@ export function configureAwsSdkNodeVersionWarningOutput(options = {}) {
|
|
|
10
11
|
const enabled = options.enabled ?? companyErrorsSdkWarningEnabled();
|
|
11
12
|
if (!enabled)
|
|
12
13
|
return;
|
|
13
|
-
|
|
14
|
-
const debug = /^(1|true|yes)$/i.test(env.HQ_DEBUG?.trim() ?? "");
|
|
15
|
-
if (verbose || debug) {
|
|
14
|
+
if (diagnosticsEnabled(env, argv)) {
|
|
16
15
|
delete env[AWS_SDK_NODE_VERSION_WARNING_ENV];
|
|
17
16
|
}
|
|
18
17
|
else {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* missing data (hq-absent-field-never-means-constraining-value).
|
|
22
22
|
*/
|
|
23
23
|
import chalk from "chalk";
|
|
24
|
+
import { diagnosticsEnabled } from "../diagnostics.js";
|
|
24
25
|
import { vaultApiFetch } from "../../utils/vault-api.js";
|
|
25
26
|
import { parseFreeMonth } from "./free-month.js";
|
|
26
27
|
import { selectPlanLimitUpgradeUrl } from "./plan-limit-upgrade-url.js";
|
|
@@ -212,6 +213,16 @@ function statusFromUsage(companySlug, companyUid, body, upgradeCompanySlug) {
|
|
|
212
213
|
checkedAt: new Date().toISOString(),
|
|
213
214
|
};
|
|
214
215
|
}
|
|
216
|
+
function isSilentPlanLimitBodyError(error) {
|
|
217
|
+
return (error instanceof Error &&
|
|
218
|
+
(error.name === "AbortError" || error.name === "TimeoutError"));
|
|
219
|
+
}
|
|
220
|
+
function notePlanLimitBodyUnreadable(error) {
|
|
221
|
+
if (isSilentPlanLimitBodyError(error) || !diagnosticsEnabled())
|
|
222
|
+
return;
|
|
223
|
+
const errorName = error instanceof Error ? error.name : "Error";
|
|
224
|
+
console.error(`plan-limit body unreadable errorName=${errorName}`);
|
|
225
|
+
}
|
|
215
226
|
/**
|
|
216
227
|
* Read the live nag for one company from usage-limits. `/membership/me` is
|
|
217
228
|
* used only to resolve slug → companyUid. Returns null when the server did
|
|
@@ -226,7 +237,10 @@ export async function fetchPlanLimitStatus(token, companyRef, opts = {}) {
|
|
|
226
237
|
});
|
|
227
238
|
if (!res.ok)
|
|
228
239
|
return null;
|
|
229
|
-
const selected = selectCompanyMembership(await res.json().catch(() =>
|
|
240
|
+
const selected = selectCompanyMembership(await res.json().catch((error) => {
|
|
241
|
+
notePlanLimitBodyUnreadable(error);
|
|
242
|
+
return null;
|
|
243
|
+
}), companyRef);
|
|
230
244
|
if (!selected?.companyUid)
|
|
231
245
|
return null;
|
|
232
246
|
try {
|
|
@@ -238,7 +252,10 @@ export async function fetchPlanLimitStatus(token, companyRef, opts = {}) {
|
|
|
238
252
|
});
|
|
239
253
|
if (!usage.ok)
|
|
240
254
|
return null;
|
|
241
|
-
const body = await usage.json().catch(() =>
|
|
255
|
+
const body = await usage.json().catch((error) => {
|
|
256
|
+
notePlanLimitBodyUnreadable(error);
|
|
257
|
+
return null;
|
|
258
|
+
});
|
|
242
259
|
if (!body)
|
|
243
260
|
return null;
|
|
244
261
|
const selectedSlug = selected.companySlug ??
|
|
@@ -67,6 +67,8 @@ export declare class OwnerContextCache {
|
|
|
67
67
|
private value;
|
|
68
68
|
private at;
|
|
69
69
|
private pending;
|
|
70
|
+
/** True only after the wait ended before this lookup, so a later rejection is the hidden one. */
|
|
71
|
+
private waitEnded;
|
|
70
72
|
constructor(lookup: () => Promise<OwnerContext>, nowMs?: () => number, waitMs?: number, ownerUid?: string);
|
|
71
73
|
get(): Promise<OwnerContext>;
|
|
72
74
|
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* slugs, uids, roles) reaches the model; the owner's token never does. A failed
|
|
14
14
|
* lookup is reported as "could not check", never as "no companies".
|
|
15
15
|
*/
|
|
16
|
+
import { diagnosticsEnabled } from "../diagnostics.js";
|
|
16
17
|
import { ensureCognitoToken } from "../../utils/cognito-session.js";
|
|
17
18
|
import { resolveCallerPersonUid, vaultApiFetch } from "../../utils/vault-api.js";
|
|
18
19
|
function message(err) {
|
|
@@ -85,6 +86,12 @@ export const OWNER_CONTEXT_TTL_MS = 5 * 60_000;
|
|
|
85
86
|
export const OWNER_CONTEXT_RETRY_MS = 60_000;
|
|
86
87
|
/** A turn never waits longer than this for a refresh; the lookup keeps going in the background. */
|
|
87
88
|
const OWNER_CONTEXT_WAIT_MS = 8_000;
|
|
89
|
+
function noteOwnerContextLookupRejected(error) {
|
|
90
|
+
if (!diagnosticsEnabled())
|
|
91
|
+
return;
|
|
92
|
+
const errorName = error instanceof Error ? error.name : "Error";
|
|
93
|
+
console.error(`owner-context lookup failed errorName=${errorName}`);
|
|
94
|
+
}
|
|
88
95
|
export class OwnerContextCache {
|
|
89
96
|
lookup;
|
|
90
97
|
nowMs;
|
|
@@ -93,6 +100,8 @@ export class OwnerContextCache {
|
|
|
93
100
|
value = null;
|
|
94
101
|
at = 0;
|
|
95
102
|
pending = null;
|
|
103
|
+
/** True only after the wait ended before this lookup, so a later rejection is the hidden one. */
|
|
104
|
+
waitEnded = false;
|
|
96
105
|
constructor(lookup, nowMs = Date.now, waitMs = OWNER_CONTEXT_WAIT_MS, ownerUid = "") {
|
|
97
106
|
this.lookup = lookup;
|
|
98
107
|
this.nowMs = nowMs;
|
|
@@ -104,6 +113,7 @@ export class OwnerContextCache {
|
|
|
104
113
|
if (this.value && age < (this.value.status === "ok" ? OWNER_CONTEXT_TTL_MS : OWNER_CONTEXT_RETRY_MS))
|
|
105
114
|
return this.value;
|
|
106
115
|
if (!this.pending) {
|
|
116
|
+
this.waitEnded = false;
|
|
107
117
|
this.pending = this.lookup()
|
|
108
118
|
.then((v) => {
|
|
109
119
|
this.value = v;
|
|
@@ -113,7 +123,14 @@ export class OwnerContextCache {
|
|
|
113
123
|
.finally(() => {
|
|
114
124
|
this.pending = null;
|
|
115
125
|
});
|
|
116
|
-
|
|
126
|
+
// An in-time rejection is rethrown by the race below and leaves
|
|
127
|
+
// waitEnded false, so it is not logged. A rejection that lands after
|
|
128
|
+
// the wait already returned is otherwise invisible.
|
|
129
|
+
void this.pending.catch((error) => {
|
|
130
|
+
if (!this.waitEnded)
|
|
131
|
+
return;
|
|
132
|
+
noteOwnerContextLookupRejected(error);
|
|
133
|
+
});
|
|
117
134
|
}
|
|
118
135
|
let timer;
|
|
119
136
|
const slow = new Promise((resolve) => {
|
|
@@ -123,6 +140,7 @@ export class OwnerContextCache {
|
|
|
123
140
|
const got = await Promise.race([this.pending, slow]);
|
|
124
141
|
if (got)
|
|
125
142
|
return got;
|
|
143
|
+
this.waitEnded = true;
|
|
126
144
|
}
|
|
127
145
|
finally {
|
|
128
146
|
if (timer)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare const HQ_ANYWHERE_FLAG_REFRESH_INTERVAL_MS = 30000;
|
|
2
|
+
export declare const HQ_ANYWHERE_FLAG_MAX_STALENESS_MS = 60000;
|
|
3
|
+
export interface BoundedDaemonFlagMonitorOptions {
|
|
4
|
+
read: () => Promise<boolean>;
|
|
5
|
+
now?: () => number;
|
|
6
|
+
onChange?: (enabled: boolean) => void;
|
|
7
|
+
onReadError?: (error: unknown) => void;
|
|
8
|
+
}
|
|
9
|
+
export interface BoundedDaemonFlagMonitor {
|
|
10
|
+
enabled(): Promise<boolean>;
|
|
11
|
+
status(): {
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
lastSuccessfulReadAgeMs: number | null;
|
|
14
|
+
heldThroughReadError: boolean;
|
|
15
|
+
};
|
|
16
|
+
close(): void;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Refreshes a long-running daemon's flag independently of incoming requests.
|
|
20
|
+
* A failed read keeps the last successful decision only until its 60s age limit.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createBoundedDaemonFlagMonitor(options: BoundedDaemonFlagMonitorOptions): BoundedDaemonFlagMonitor;
|
|
23
|
+
//# sourceMappingURL=flag-monitor.d.ts.map
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
export const HQ_ANYWHERE_FLAG_REFRESH_INTERVAL_MS = 30_000;
|
|
3
|
+
export const HQ_ANYWHERE_FLAG_MAX_STALENESS_MS = 60_000;
|
|
4
|
+
/**
|
|
5
|
+
* Refreshes a long-running daemon's flag independently of incoming requests.
|
|
6
|
+
* A failed read keeps the last successful decision only until its 60s age limit.
|
|
7
|
+
*/
|
|
8
|
+
export function createBoundedDaemonFlagMonitor(options) {
|
|
9
|
+
const now = options.now ?? (() => performance.now());
|
|
10
|
+
let value;
|
|
11
|
+
let lastSuccessAt;
|
|
12
|
+
let lastAttemptAt;
|
|
13
|
+
let lastReadFailed = false;
|
|
14
|
+
let successVersion = 0;
|
|
15
|
+
let refreshInFlight;
|
|
16
|
+
let expiryTimer;
|
|
17
|
+
let closed = false;
|
|
18
|
+
const publish = (next) => {
|
|
19
|
+
const changed = value !== next;
|
|
20
|
+
value = next;
|
|
21
|
+
if (changed)
|
|
22
|
+
options.onChange?.(next);
|
|
23
|
+
};
|
|
24
|
+
const expireIfStale = () => {
|
|
25
|
+
if (!closed && lastSuccessAt !== undefined && now() - lastSuccessAt >= HQ_ANYWHERE_FLAG_MAX_STALENESS_MS) {
|
|
26
|
+
publish(false);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const refresh = () => {
|
|
30
|
+
if (closed)
|
|
31
|
+
return Promise.resolve(false);
|
|
32
|
+
if (refreshInFlight)
|
|
33
|
+
return refreshInFlight;
|
|
34
|
+
lastAttemptAt = now();
|
|
35
|
+
refreshInFlight = Promise.resolve().then(options.read).then((next) => {
|
|
36
|
+
if (closed)
|
|
37
|
+
return false;
|
|
38
|
+
lastSuccessAt = now();
|
|
39
|
+
lastReadFailed = false;
|
|
40
|
+
const thisSuccessVersion = ++successVersion;
|
|
41
|
+
publish(next);
|
|
42
|
+
if (expiryTimer)
|
|
43
|
+
clearTimeout(expiryTimer);
|
|
44
|
+
// Expire from the elapsed timer itself, not a wall-clock comparison.
|
|
45
|
+
expiryTimer = setTimeout(() => {
|
|
46
|
+
if (!closed && successVersion === thisSuccessVersion)
|
|
47
|
+
publish(false);
|
|
48
|
+
}, HQ_ANYWHERE_FLAG_MAX_STALENESS_MS);
|
|
49
|
+
expiryTimer.unref?.();
|
|
50
|
+
return next;
|
|
51
|
+
}).catch((error) => {
|
|
52
|
+
lastReadFailed = true;
|
|
53
|
+
options.onReadError?.(error);
|
|
54
|
+
expireIfStale();
|
|
55
|
+
return value ?? false;
|
|
56
|
+
}).finally(() => {
|
|
57
|
+
refreshInFlight = undefined;
|
|
58
|
+
});
|
|
59
|
+
return refreshInFlight;
|
|
60
|
+
};
|
|
61
|
+
const refreshTimer = setInterval(() => {
|
|
62
|
+
expireIfStale();
|
|
63
|
+
void refresh();
|
|
64
|
+
}, HQ_ANYWHERE_FLAG_REFRESH_INTERVAL_MS);
|
|
65
|
+
refreshTimer.unref?.();
|
|
66
|
+
void refresh();
|
|
67
|
+
return {
|
|
68
|
+
async enabled() {
|
|
69
|
+
expireIfStale();
|
|
70
|
+
if (lastSuccessAt !== undefined && now() - lastSuccessAt >= HQ_ANYWHERE_FLAG_MAX_STALENESS_MS) {
|
|
71
|
+
void refresh();
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
if (value === undefined)
|
|
75
|
+
return refresh();
|
|
76
|
+
if (lastAttemptAt === undefined || now() - lastAttemptAt >= HQ_ANYWHERE_FLAG_REFRESH_INTERVAL_MS) {
|
|
77
|
+
void refresh();
|
|
78
|
+
}
|
|
79
|
+
return value;
|
|
80
|
+
},
|
|
81
|
+
status() {
|
|
82
|
+
const age = lastSuccessAt === undefined ? null : Math.max(0, now() - lastSuccessAt);
|
|
83
|
+
const fresh = age !== null && age < HQ_ANYWHERE_FLAG_MAX_STALENESS_MS;
|
|
84
|
+
return {
|
|
85
|
+
enabled: value === true && fresh,
|
|
86
|
+
lastSuccessfulReadAgeMs: age,
|
|
87
|
+
heldThroughReadError: lastReadFailed && fresh,
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
close() {
|
|
91
|
+
closed = true;
|
|
92
|
+
if (refreshTimer)
|
|
93
|
+
clearInterval(refreshTimer);
|
|
94
|
+
if (expiryTimer)
|
|
95
|
+
clearTimeout(expiryTimer);
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
//# sourceMappingURL=flag-monitor.js.map
|
|
@@ -20,6 +20,8 @@ export interface ReadDaemonFlagOptions {
|
|
|
20
20
|
createClient?: (options: FlagClientOptions) => DaemonFlagClient;
|
|
21
21
|
getToken?: () => Promise<string>;
|
|
22
22
|
onError?: (error: unknown) => void;
|
|
23
|
+
/** Reject a failed registry read so the daemon monitor can distinguish it from an explicit OFF. */
|
|
24
|
+
throwOnError?: boolean;
|
|
23
25
|
}
|
|
24
26
|
export declare function readDaemonFlag(flagKey: string, options?: ReadDaemonFlagOptions): Promise<boolean>;
|
|
25
27
|
//# sourceMappingURL=flag.d.ts.map
|
package/dist/lib/daemon/flag.js
CHANGED
|
@@ -18,8 +18,10 @@ export async function readDaemonFlag(flagKey, options = {}) {
|
|
|
18
18
|
if (override !== null)
|
|
19
19
|
return override;
|
|
20
20
|
let failed = false;
|
|
21
|
+
let reportedError;
|
|
21
22
|
const onError = (error) => {
|
|
22
23
|
failed = true;
|
|
24
|
+
reportedError ??= error;
|
|
23
25
|
options.onError?.(error);
|
|
24
26
|
};
|
|
25
27
|
let client;
|
|
@@ -41,10 +43,17 @@ export async function readDaemonFlag(flagKey, options = {}) {
|
|
|
41
43
|
});
|
|
42
44
|
await client.ready();
|
|
43
45
|
const resolution = client.explain(flagKey, { fallback: false });
|
|
46
|
+
if (failed && options.throwOnError) {
|
|
47
|
+
throw new Error("hq-flags registry read failed", { cause: reportedError });
|
|
48
|
+
}
|
|
44
49
|
return !failed && resolution.source === "snapshot" && resolution.value === true;
|
|
45
50
|
}
|
|
46
51
|
catch (error) {
|
|
47
|
-
|
|
52
|
+
if (!failed)
|
|
53
|
+
onError(error);
|
|
54
|
+
if (options.throwOnError && failed) {
|
|
55
|
+
throw new Error("hq-flags registry read failed", { cause: reportedError ?? error });
|
|
56
|
+
}
|
|
48
57
|
return false;
|
|
49
58
|
}
|
|
50
59
|
finally {
|
|
@@ -7,8 +7,31 @@
|
|
|
7
7
|
* out of order, so clients match them by `id`.
|
|
8
8
|
*/
|
|
9
9
|
export declare const HQD_SOCKET_NAME = "hqd.sock";
|
|
10
|
-
export declare const HQD_OPS: readonly ["ping", "resolve", "session.open", "session.get", "session.close", "policy.check", "policy.index", "journal.append", "memory.get", "memory.put", "mesh.presence", "mcp.call", "auth.login", "auth.status", "shutdown"];
|
|
10
|
+
export declare const HQD_OPS: readonly ["ping", "health", "resolve", "session.open", "session.get", "session.close", "policy.check", "policy.index", "journal.append", "memory.get", "memory.put", "mesh.presence", "mcp.call", "auth.login", "auth.status", "shutdown"];
|
|
11
11
|
export type HqdOp = (typeof HQD_OPS)[number];
|
|
12
|
+
export type HqdHealthFallbackReason = "registry_unavailable" | "manifest_missing_or_malformed" | "company_not_in_manifest" | "no_match";
|
|
13
|
+
export interface HqdHealthResult {
|
|
14
|
+
reachable: boolean;
|
|
15
|
+
version: string | null;
|
|
16
|
+
uptimeMs: number | null;
|
|
17
|
+
flag: {
|
|
18
|
+
enabled: boolean | null;
|
|
19
|
+
lastSuccessfulReadAgeMs: number | null;
|
|
20
|
+
heldThroughReadError: boolean;
|
|
21
|
+
};
|
|
22
|
+
resolution: {
|
|
23
|
+
scope: "company" | "personal" | "unknown";
|
|
24
|
+
company: string | null;
|
|
25
|
+
fallbackReason: HqdHealthFallbackReason | null;
|
|
26
|
+
};
|
|
27
|
+
socket: {
|
|
28
|
+
path: string;
|
|
29
|
+
transport: "unix_socket" | "windows_named_pipe";
|
|
30
|
+
mode: string | null;
|
|
31
|
+
ownerMatchesUser: boolean | null;
|
|
32
|
+
userOnly: boolean | null;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
12
35
|
/** A request line larger than this is rejected and the connection closed. */
|
|
13
36
|
export declare const HQD_MAX_LINE_BYTES: number;
|
|
14
37
|
export declare const HQD_DEFAULT_REQUEST_TIMEOUT_MS = 2000;
|
|
@@ -31,10 +54,10 @@ export type HqdResponse = {
|
|
|
31
54
|
error: HqdError;
|
|
32
55
|
};
|
|
33
56
|
/**
|
|
34
|
-
* Socket path: HQ_HQD_SOCKET, else <HQ_REGISTRY_DIR or ~/.hq>/hqd.sock.
|
|
57
|
+
* Socket path: HQ_HQD_SOCKET, else <HQ_REGISTRY_DIR or ~/.hq>/daemon/hqd.sock.
|
|
35
58
|
* On Windows the socket is a per-user named pipe.
|
|
36
59
|
*/
|
|
37
|
-
export declare function hqdSocketPath(home?: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): string;
|
|
60
|
+
export declare function hqdSocketPath(home?: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, currentUserSid?: () => string | null): string;
|
|
38
61
|
export declare function encodeMessage(message: HqdRequest | HqdResponse): string;
|
|
39
62
|
export type ParsedLine = {
|
|
40
63
|
ok: true;
|
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
import * as net from "node:net";
|
|
10
10
|
import * as os from "node:os";
|
|
11
11
|
import * as path from "node:path";
|
|
12
|
+
import { readCurrentUserSid } from "../work-context/windows-acl.js";
|
|
12
13
|
export const HQD_SOCKET_NAME = "hqd.sock";
|
|
13
14
|
export const HQD_OPS = [
|
|
14
15
|
"ping",
|
|
16
|
+
"health",
|
|
15
17
|
"resolve",
|
|
16
18
|
"session.open",
|
|
17
19
|
"session.get",
|
|
@@ -31,19 +33,21 @@ export const HQD_OPS = [
|
|
|
31
33
|
export const HQD_MAX_LINE_BYTES = 1024 * 1024;
|
|
32
34
|
export const HQD_DEFAULT_REQUEST_TIMEOUT_MS = 2_000;
|
|
33
35
|
/**
|
|
34
|
-
* Socket path: HQ_HQD_SOCKET, else <HQ_REGISTRY_DIR or ~/.hq>/hqd.sock.
|
|
36
|
+
* Socket path: HQ_HQD_SOCKET, else <HQ_REGISTRY_DIR or ~/.hq>/daemon/hqd.sock.
|
|
35
37
|
* On Windows the socket is a per-user named pipe.
|
|
36
38
|
*/
|
|
37
|
-
export function hqdSocketPath(home = os.homedir(), env = process.env, platform = process.platform) {
|
|
39
|
+
export function hqdSocketPath(home = os.homedir(), env = process.env, platform = process.platform, currentUserSid = readCurrentUserSid) {
|
|
38
40
|
const override = env.HQ_HQD_SOCKET?.trim();
|
|
39
41
|
if (override)
|
|
40
42
|
return override;
|
|
41
43
|
if (platform === "win32") {
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
+
const userSid = currentUserSid();
|
|
45
|
+
if (!userSid)
|
|
46
|
+
throw new Error("Cannot determine the current Windows user SID for the hqd pipe name");
|
|
47
|
+
return `\\\\.\\pipe\\hqd-${userSid.toLowerCase()}`;
|
|
44
48
|
}
|
|
45
49
|
const dir = env.HQ_REGISTRY_DIR?.trim() ? path.resolve(env.HQ_REGISTRY_DIR.trim()) : path.join(home, ".hq");
|
|
46
|
-
return path.join(dir, HQD_SOCKET_NAME);
|
|
50
|
+
return path.join(dir, "daemon", HQD_SOCKET_NAME);
|
|
47
51
|
}
|
|
48
52
|
export function encodeMessage(message) {
|
|
49
53
|
return `${JSON.stringify(message)}\n`;
|
package/dist/lib/daemon/run.js
CHANGED
|
@@ -693,24 +693,22 @@ export async function runDaemon(opts) {
|
|
|
693
693
|
let hqdShutdown = null;
|
|
694
694
|
if (anywhereRuntimeEnabled()) {
|
|
695
695
|
const refusal = hqdStartupRefusal(platform, true);
|
|
696
|
-
|
|
697
|
-
|
|
696
|
+
try {
|
|
697
|
+
const startConfig = readDaemonConfig(paths.configPath);
|
|
698
|
+
hqd = await startHqdServer({
|
|
699
|
+
socketPath: hqdSocketPath(home, env),
|
|
700
|
+
registryPath: registryFile(registryDir(home, env)),
|
|
701
|
+
hqRoot: startConfig.hqRoot ?? (env.HQ_ROOT?.trim() || undefined),
|
|
702
|
+
log,
|
|
703
|
+
onShutdown: () => hqdShutdown?.(),
|
|
704
|
+
reportError: reportHqdCrash,
|
|
705
|
+
});
|
|
698
706
|
}
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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) {
|
|
707
|
+
catch (err) {
|
|
708
|
+
if (refusal && err.message === refusal)
|
|
709
|
+
log("error", refusal);
|
|
710
|
+
else
|
|
712
711
|
log("error", `hqd socket did not start; services run without it: ${err.message}`);
|
|
713
|
-
}
|
|
714
712
|
}
|
|
715
713
|
}
|
|
716
714
|
// US-012: 60s presence heartbeat with version and session count.
|
|
@@ -6,12 +6,26 @@
|
|
|
6
6
|
* machine. Sessions in any repo ask it which company they belong to and
|
|
7
7
|
* register themselves here.
|
|
8
8
|
*/
|
|
9
|
-
import
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import { readRegistry, type ResolveResult } from "../registry/registry.js";
|
|
11
|
+
import { readCompaniesManifestMap } from "../work-context/repo-remote.js";
|
|
10
12
|
import { SessionRegistry } from "./session-registry.js";
|
|
11
13
|
import { HqMcpBridge, type RunWorkerRequest } from "./ops/mcp.js";
|
|
12
14
|
import { type StdinMachineCredentials } from "./machine-login.js";
|
|
15
|
+
import { type WindowsPipeAclReport, type WindowsPipeHost } from "./windows-pipe-host.js";
|
|
13
16
|
export interface HqdServerOptions {
|
|
14
17
|
socketPath: string;
|
|
18
|
+
/** Test seam for selecting the Windows transport without changing host behavior. */
|
|
19
|
+
platform?: NodeJS.Platform;
|
|
20
|
+
/** Test seam for verifying socket ownership without changing the host uid. */
|
|
21
|
+
socketLstat?: (path: string) => fs.Stats;
|
|
22
|
+
/** Test seam for helper startup and its OS-read ACL report. */
|
|
23
|
+
windowsPipeHostFactory?: (socketPath: string) => Promise<{
|
|
24
|
+
host: WindowsPipeHost;
|
|
25
|
+
acl: WindowsPipeAclReport;
|
|
26
|
+
}>;
|
|
27
|
+
/** Test seam for rejecting an invalid helper ACL report. */
|
|
28
|
+
windowsPipeAclCheck?: (report: WindowsPipeAclReport) => WindowsPipeAclReport;
|
|
15
29
|
/** registry.json to resolve against (US-004). */
|
|
16
30
|
registryPath?: string;
|
|
17
31
|
hqRoot?: string;
|
|
@@ -26,6 +40,8 @@ export interface HqdServerOptions {
|
|
|
26
40
|
workMeshRoot?: string;
|
|
27
41
|
/** Test seam for the hq-anywhere-runtime default-off gate. */
|
|
28
42
|
mcpEnabled?: () => boolean | Promise<boolean>;
|
|
43
|
+
/** Test seam for the live hq-flags read used by the daemon's bounded runtime monitor. */
|
|
44
|
+
runtimeFlagReader?: () => Promise<boolean>;
|
|
29
45
|
/** Test seam for the hosted Streamable HTTP MCP transport. */
|
|
30
46
|
mcpBridge?: HqMcpBridge;
|
|
31
47
|
/** Test seam for machine login while keeping production credentials off disk. */
|
|
@@ -43,6 +59,11 @@ export interface HqdServer {
|
|
|
43
59
|
close(): Promise<void>;
|
|
44
60
|
}
|
|
45
61
|
/** Resolve cwd → company through the registry, cached until registry.json changes. */
|
|
46
|
-
export declare function createResolver(registryPath: string
|
|
62
|
+
export declare function createResolver(registryPath: string, options?: {
|
|
63
|
+
hqRoot?: string;
|
|
64
|
+
log?: HqdServerOptions["log"];
|
|
65
|
+
readRegistry?: typeof readRegistry;
|
|
66
|
+
readCompaniesManifest?: typeof readCompaniesManifestMap;
|
|
67
|
+
}): (cwd: string) => ResolveResult;
|
|
47
68
|
export declare function startHqdServer(opts: HqdServerOptions): Promise<HqdServer>;
|
|
48
69
|
//# sourceMappingURL=server.d.ts.map
|