@indigoai-us/hq-cli 5.345.42 → 5.345.44
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 +18 -0
- package/dist/command-catalog.generated.d.ts +2 -2
- package/dist/command-catalog.generated.js +2 -2
- package/dist/commands/db-remote-deps.js +4 -0
- package/dist/commands/install-global.d.ts +8 -1
- package/dist/commands/install-global.js +128 -25
- package/dist/commands/lanes-checks.d.ts +1 -0
- package/dist/commands/lanes-checks.js +6 -1
- package/dist/commands/lanes-message.js +1 -0
- package/dist/commands/lanes-report.d.ts +2 -1
- package/dist/commands/lanes-report.js +63 -6
- package/dist/commands/lanes-resume.js +3 -0
- package/dist/commands/lanes.js +218 -49
- package/dist/index.js +43 -7
- package/dist/lib/anywhere-person-setting.d.ts +2 -0
- package/dist/lib/anywhere-person-setting.js +2 -1
- package/dist/lib/anywhere-runtime-fast-flag.js +8 -2
- package/dist/lib/core-utils/sentry-report.js +8 -1
- package/dist/lib/daemon/anywhere-listener.d.ts +16 -0
- package/dist/lib/daemon/anywhere-listener.js +79 -0
- package/dist/lib/daemon/run.js +30 -23
- package/dist/lib/db/control-plane.d.ts +5 -0
- package/dist/lib/db/control-plane.js +7 -0
- package/dist/lib/doctor/checks/anywhere.d.ts +10 -0
- package/dist/lib/doctor/checks/anywhere.js +45 -17
- package/dist/lib/flag-registry.d.ts +4 -0
- package/dist/lib/flag-registry.js +43 -2
- package/dist/lib/flag-snapshot-file.d.ts +17 -0
- package/dist/lib/flag-snapshot-file.js +153 -37
- package/dist/lib/lanes/flag-keys.d.ts +5 -0
- package/dist/lib/lanes/flag-keys.js +5 -0
- package/dist/lib/lanes/grants.js +1 -1
- package/dist/lib/lanes/reconcile.d.ts +2 -0
- package/dist/lib/lanes/reconcile.js +39 -1
- package/dist/lib/lanes/spawn.d.ts +4 -0
- package/dist/lib/lanes/spawn.js +132 -6
- package/dist/lib/lanes/store.js +35 -0
- package/dist/lib/lanes/tracker-report.d.ts +46 -0
- package/dist/lib/lanes/tracker-report.js +85 -1
- package/dist/lib/lanes/types.d.ts +3 -1
- package/dist/lib/lanes/types.js +1 -0
- package/dist/main.js +5 -1
- package/dist/startup-registration.d.ts +3 -0
- package/dist/startup-registration.js +11 -4
- package/dist/utils/resident-package-preload.js +5 -1
- package/package.json +1 -1
package/dist/lib/daemon/run.js
CHANGED
|
@@ -35,6 +35,7 @@ import { detectMachineType } from "./machine-type.js";
|
|
|
35
35
|
import { appendPrivate, daemonPaths, ensurePrivateDir, openPrivateAppend } from "./paths.js";
|
|
36
36
|
import { hqdSocketPath } from "./protocol.js";
|
|
37
37
|
import { startHqdServer } from "./server.js";
|
|
38
|
+
import { startHqdRuntimeListener } from "./anywhere-listener.js";
|
|
38
39
|
import { heartbeatFile, startHeartbeat } from "./heartbeat.js";
|
|
39
40
|
import { createProtocolTail, ensureProtocolFifo, openProtocolWriter } from "./protocol-tail.js";
|
|
40
41
|
import { takeControlRequests } from "./requests.js";
|
|
@@ -835,14 +836,19 @@ export async function runDaemon(opts) {
|
|
|
835
836
|
return on;
|
|
836
837
|
};
|
|
837
838
|
// hqd: the session/company socket every runtime talks to (hq-anywhere US-008).
|
|
838
|
-
let hqd = null;
|
|
839
839
|
let hqdShutdown = null;
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
840
|
+
let stopHeartbeat = () => { };
|
|
841
|
+
const hqdListener = await startHqdRuntimeListener({
|
|
842
|
+
isEnabled: async () => shouldStartHqd(await anywhereRuntimeAllowed(env, {
|
|
843
|
+
refreshFlag: async () => {
|
|
844
|
+
const { refreshFlagRegistrySnapshot } = await import("../flag-registry.js");
|
|
845
|
+
await refreshFlagRegistrySnapshot(5_000);
|
|
846
|
+
},
|
|
847
|
+
onError: (name) => log("warn", `hqd person setting read failed (${name}); runtime remains off`),
|
|
848
|
+
})),
|
|
849
|
+
startServer: async () => {
|
|
844
850
|
const startConfig = readDaemonConfig(paths.configPath);
|
|
845
|
-
|
|
851
|
+
return startHqdServer({
|
|
846
852
|
socketPath: hqdSocketPath(home, env),
|
|
847
853
|
registryPath: registryFile(registryDir(home, env)),
|
|
848
854
|
hqRoot: startConfig.hqRoot ?? (env.HQ_ROOT?.trim() || undefined),
|
|
@@ -850,24 +856,25 @@ export async function runDaemon(opts) {
|
|
|
850
856
|
onShutdown: () => hqdShutdown?.(),
|
|
851
857
|
reportError: reportHqdCrash,
|
|
852
858
|
});
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
}
|
|
867
|
-
:
|
|
859
|
+
},
|
|
860
|
+
onServerChange: (server) => {
|
|
861
|
+
stopHeartbeat();
|
|
862
|
+
stopHeartbeat = () => { };
|
|
863
|
+
if (!server)
|
|
864
|
+
return;
|
|
865
|
+
// US-012: 60s presence heartbeat with version and session count.
|
|
866
|
+
stopHeartbeat = startHeartbeat({
|
|
867
|
+
version: CLI_VERSION,
|
|
868
|
+
sessionCount: () => server.sessions.size,
|
|
869
|
+
file: heartbeatFile(home, env),
|
|
870
|
+
log,
|
|
871
|
+
});
|
|
872
|
+
},
|
|
873
|
+
intervalMs: 60_000,
|
|
874
|
+
log: (error) => log("error", `hqd socket listener update failed: ${error.message ?? String(error)}`),
|
|
875
|
+
});
|
|
868
876
|
const closeHqd = () => {
|
|
869
|
-
|
|
870
|
-
void hqd?.close().catch((err) => log("warn", `hqd socket did not close cleanly: ${err.message}`));
|
|
877
|
+
void hqdListener.close().catch((err) => log("warn", `hqd socket did not close cleanly: ${err.message}`));
|
|
871
878
|
};
|
|
872
879
|
return await new Promise((resolve) => {
|
|
873
880
|
let stopping = false;
|
|
@@ -130,4 +130,9 @@ export declare class ControlPlaneDbClient {
|
|
|
130
130
|
appId: string;
|
|
131
131
|
}): Promise<RemoteDestroyAppResponse>;
|
|
132
132
|
}
|
|
133
|
+
export type ControlPlaneHttpError = Error & {
|
|
134
|
+
status?: number;
|
|
135
|
+
code?: string;
|
|
136
|
+
reason?: string;
|
|
137
|
+
};
|
|
133
138
|
//# sourceMappingURL=control-plane.d.ts.map
|
|
@@ -48,6 +48,8 @@ export class ControlPlaneDbClient {
|
|
|
48
48
|
err.status = res.status;
|
|
49
49
|
if (typeof payload?.code === "string")
|
|
50
50
|
err.code = payload.code;
|
|
51
|
+
if (typeof payload?.reason === "string")
|
|
52
|
+
err.reason = payload.reason;
|
|
51
53
|
throw err;
|
|
52
54
|
}
|
|
53
55
|
return JSON.parse(text);
|
|
@@ -145,12 +147,15 @@ export class ControlPlaneDbClient {
|
|
|
145
147
|
function httpError(label, status, text) {
|
|
146
148
|
let msg = `${label} failed (${status})`;
|
|
147
149
|
let code;
|
|
150
|
+
let reason;
|
|
148
151
|
try {
|
|
149
152
|
const j = JSON.parse(text);
|
|
150
153
|
if (typeof j.error === "string")
|
|
151
154
|
msg = j.error;
|
|
152
155
|
if (typeof j.code === "string")
|
|
153
156
|
code = j.code;
|
|
157
|
+
if (typeof j.reason === "string")
|
|
158
|
+
reason = j.reason;
|
|
154
159
|
}
|
|
155
160
|
catch {
|
|
156
161
|
/* keep */
|
|
@@ -159,6 +164,8 @@ function httpError(label, status, text) {
|
|
|
159
164
|
err.status = status;
|
|
160
165
|
if (code)
|
|
161
166
|
err.code = code;
|
|
167
|
+
if (reason)
|
|
168
|
+
err.reason = reason;
|
|
162
169
|
return err;
|
|
163
170
|
}
|
|
164
171
|
//# sourceMappingURL=control-plane.js.map
|
|
@@ -13,6 +13,8 @@ import type { CheckFamily } from "../types.js";
|
|
|
13
13
|
export declare const REMEDIATION: {
|
|
14
14
|
readonly installClaude: "hq install --global --runtime claude";
|
|
15
15
|
readonly installCodex: "hq install --global --runtime codex";
|
|
16
|
+
readonly repairClaudeSkills: "hq uninstall --global --runtime claude && hq install --global --runtime claude";
|
|
17
|
+
readonly repairCodexSkills: "hq uninstall --global --runtime codex && hq install --global --runtime codex";
|
|
16
18
|
readonly daemonInstall: "hq daemon install";
|
|
17
19
|
readonly daemonRestart: "hq daemon restart --daemon";
|
|
18
20
|
readonly link: "hq link <repo-dir> <company>";
|
|
@@ -22,6 +24,13 @@ interface DaemonServiceState {
|
|
|
22
24
|
loaded: boolean;
|
|
23
25
|
detail: string;
|
|
24
26
|
}
|
|
27
|
+
export interface DaemonServiceProbeOptions {
|
|
28
|
+
platform?: NodeJS.Platform;
|
|
29
|
+
uid?: number;
|
|
30
|
+
launchdProbe?: (target: string) => boolean;
|
|
31
|
+
/** Test seam for the desktop app's live managed daemon state. */
|
|
32
|
+
hostedDaemonProbe?: () => boolean;
|
|
33
|
+
}
|
|
25
34
|
export interface AnywhereDeps {
|
|
26
35
|
home?: string;
|
|
27
36
|
env?: NodeJS.ProcessEnv;
|
|
@@ -40,5 +49,6 @@ export interface AnywhereDeps {
|
|
|
40
49
|
}
|
|
41
50
|
export declare function createAnywhereFamily(deps?: AnywhereDeps): CheckFamily;
|
|
42
51
|
export declare const anywhereFamily: CheckFamily;
|
|
52
|
+
export declare function defaultServiceState(home: string, options?: DaemonServiceProbeOptions): DaemonServiceState;
|
|
43
53
|
export {};
|
|
44
54
|
//# sourceMappingURL=anywhere.d.ts.map
|
|
@@ -21,6 +21,8 @@ import { GROK_MODE } from "../../install/grok-targets.js";
|
|
|
21
21
|
import { HqdClientError, hqdRequest } from "../../daemon/protocol.js";
|
|
22
22
|
import { daemonPresenceLabel, heartbeatFile, readHeartbeat } from "../../daemon/heartbeat.js";
|
|
23
23
|
import { DAEMON_LAUNCHD_LABEL, DAEMON_SYSTEMD_UNIT, DAEMON_WINDOWS_TASK } from "../../daemon/install.js";
|
|
24
|
+
import { hostedDaemonRunning } from "../../daemon/handoff.js";
|
|
25
|
+
import { daemonPaths } from "../../daemon/paths.js";
|
|
24
26
|
import { hqdSocketPath } from "../../daemon/protocol.js";
|
|
25
27
|
import { readRegistry, registryDir, registryFile } from "../../registry/registry.js";
|
|
26
28
|
import { anywhereRuntimeEnabled } from "../../../commands/anywhere-runtime-flag.js";
|
|
@@ -29,6 +31,8 @@ const ANYWHERE_FAMILY_ID = "anywhere";
|
|
|
29
31
|
export const REMEDIATION = {
|
|
30
32
|
installClaude: "hq install --global --runtime claude",
|
|
31
33
|
installCodex: "hq install --global --runtime codex",
|
|
34
|
+
repairClaudeSkills: "hq uninstall --global --runtime claude && hq install --global --runtime claude",
|
|
35
|
+
repairCodexSkills: "hq uninstall --global --runtime codex && hq install --global --runtime codex",
|
|
32
36
|
daemonInstall: "hq daemon install",
|
|
33
37
|
daemonRestart: "hq daemon restart --daemon",
|
|
34
38
|
link: "hq link <repo-dir> <company>",
|
|
@@ -137,13 +141,14 @@ async function runAnywhereChecks(context, deps = {}) {
|
|
|
137
141
|
else
|
|
138
142
|
out.push(result("PASS", "daemon-installed", `daemon service is installed and loaded (${service.detail})`));
|
|
139
143
|
const running = health.reachable ? health.version : null;
|
|
144
|
+
const daemonFix = service.installed ? REMEDIATION.daemonRestart : REMEDIATION.daemonInstall;
|
|
140
145
|
if (health.reachable)
|
|
141
146
|
out.push(result("PASS", "socket", `hqd answered on ${socketPath}`, undefined, socketPath));
|
|
142
147
|
else
|
|
143
|
-
out.push(result("FAIL", "socket", `hqd is not reachable on ${socketPath}`,
|
|
148
|
+
out.push(result("FAIL", "socket", `hqd is not reachable on ${socketPath}`, daemonFix, socketPath));
|
|
144
149
|
const cliVersion = deps.cliVersion ?? CLI_VERSION;
|
|
145
150
|
if (running === null)
|
|
146
|
-
out.push(result("FAIL", "version", `cannot compare versions: hqd did not answer (hq-cli is ${cliVersion})`,
|
|
151
|
+
out.push(result("FAIL", "version", `cannot compare versions: hqd did not answer (hq-cli is ${cliVersion})`, daemonFix));
|
|
147
152
|
else if (running !== cliVersion)
|
|
148
153
|
out.push(result("FAIL", "version", `hqd runs ${running} but hq-cli ${cliVersion} is installed`, REMEDIATION.daemonRestart));
|
|
149
154
|
else
|
|
@@ -159,9 +164,9 @@ async function runAnywhereChecks(context, deps = {}) {
|
|
|
159
164
|
if (pluginInstall)
|
|
160
165
|
out.push(result("PASS", "claude-skills", `HQ Claude skills are provided by plugin ${pluginInstall.pluginId}`));
|
|
161
166
|
else
|
|
162
|
-
out.push(checkSkillLinks("claude", claude.skillsDir, REMEDIATION.installClaude));
|
|
167
|
+
out.push(checkSkillLinks("claude", claude.skillsDir, REMEDIATION.installClaude, REMEDIATION.repairClaudeSkills));
|
|
163
168
|
if (mcp.runtimes.codex.installed)
|
|
164
|
-
out.push(checkSkillLinks("codex", codex.skillsDir, REMEDIATION.installCodex));
|
|
169
|
+
out.push(checkSkillLinks("codex", codex.skillsDir, REMEDIATION.installCodex, REMEDIATION.repairCodexSkills));
|
|
165
170
|
return out;
|
|
166
171
|
}
|
|
167
172
|
function unreachableHealth(socketPath) {
|
|
@@ -321,7 +326,7 @@ function checkRegistration(runtimeName, status, runtimeState, fix) {
|
|
|
321
326
|
return result("FAIL", id, `${runtimeName} hq MCP entry does not use absolute paths that exist (${status.target ?? ""})`, fix);
|
|
322
327
|
return result("PASS", id, `hq MCP server registered in ${runtimeName} with absolute paths`);
|
|
323
328
|
}
|
|
324
|
-
function checkSkillLinks(runtime, dir, fix) {
|
|
329
|
+
function checkSkillLinks(runtime, dir, fix, repair = fix) {
|
|
325
330
|
const id = `${runtime}-skills`;
|
|
326
331
|
let names;
|
|
327
332
|
try {
|
|
@@ -335,19 +340,42 @@ function checkSkillLinks(runtime, dir, fix) {
|
|
|
335
340
|
return result("WARN", id, `no hq-* skill links in ${dir}`, fix, dir);
|
|
336
341
|
const broken = links.filter((n) => !fs.existsSync(path.join(dir, n)));
|
|
337
342
|
if (broken.length > 0)
|
|
338
|
-
return result("FAIL", id, `${broken.length} broken skill links in ${dir}: ${broken.slice(0, 5).join(", ")}`,
|
|
343
|
+
return result("FAIL", id, `${broken.length} broken skill links in ${dir}: ${broken.slice(0, 5).join(", ")}`, repair, dir);
|
|
339
344
|
return result("PASS", id, `${links.length} hq-* skill links in ${dir} resolve`, undefined, dir);
|
|
340
345
|
}
|
|
341
|
-
function defaultServiceState(home) {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
const
|
|
347
|
-
const
|
|
348
|
-
|
|
346
|
+
export function defaultServiceState(home, options = {}) {
|
|
347
|
+
const platform = options.platform ?? process.platform;
|
|
348
|
+
if (platform === "darwin") {
|
|
349
|
+
const launchAgents = path.join(home, "Library", "LaunchAgents");
|
|
350
|
+
const nativePlist = path.join(launchAgents, `${DAEMON_LAUNCHD_LABEL}.plist`);
|
|
351
|
+
const desktopLabel = "ai.indigo.hq-sync-menubar";
|
|
352
|
+
const desktopPlist = path.join(launchAgents, `${desktopLabel}.plist`);
|
|
353
|
+
const uid = options.uid ?? process.getuid?.() ?? 0;
|
|
354
|
+
const probe = options.launchdProbe ?? ((target) => spawnSync("launchctl", ["print", target], { encoding: "utf8", timeout: 5_000 }).status === 0);
|
|
355
|
+
const hasNative = fs.existsSync(nativePlist);
|
|
356
|
+
const hasDesktop = fs.existsSync(desktopPlist);
|
|
357
|
+
const nativeTarget = `gui/${uid}/${DAEMON_LAUNCHD_LABEL}`;
|
|
358
|
+
if (hasNative && probe(nativeTarget)) {
|
|
359
|
+
return { installed: true, loaded: true, detail: `launchd ${DAEMON_LAUNCHD_LABEL}` };
|
|
360
|
+
}
|
|
361
|
+
const desktopTarget = `gui/${uid}/${desktopLabel}`;
|
|
362
|
+
const desktopHostedDaemon = options.hostedDaemonProbe
|
|
363
|
+
? options.hostedDaemonProbe()
|
|
364
|
+
: hostedDaemonRunning(daemonPaths(home));
|
|
365
|
+
if (desktopHostedDaemon) {
|
|
366
|
+
return { installed: true, loaded: true, detail: "HQ Desktop-hosted hqd is running (host=desktop)" };
|
|
367
|
+
}
|
|
368
|
+
if (hasNative)
|
|
369
|
+
return { installed: true, loaded: false, detail: `launchd ${DAEMON_LAUNCHD_LABEL}` };
|
|
370
|
+
if (!hasDesktop)
|
|
371
|
+
return { installed: false, loaded: false, detail: `no ${nativePlist} or live desktop-hosted hqd` };
|
|
372
|
+
const desktopAgentLoaded = hasDesktop && probe(desktopTarget);
|
|
373
|
+
const desktopDetail = hasDesktop
|
|
374
|
+
? `HQ Desktop LaunchAgent ${desktopLabel}${desktopAgentLoaded ? " is loaded" : " is not loaded"}, but no live desktop-hosted hqd was found`
|
|
375
|
+
: "no live desktop-hosted hqd was found";
|
|
376
|
+
return { installed: false, loaded: false, detail: `no ${nativePlist}; ${desktopDetail}` };
|
|
349
377
|
}
|
|
350
|
-
if (
|
|
378
|
+
if (platform === "linux") {
|
|
351
379
|
const unit = path.join(home, ".config", "systemd", "user", DAEMON_SYSTEMD_UNIT);
|
|
352
380
|
const systemUnit = path.join("/etc/systemd/system", DAEMON_SYSTEMD_UNIT);
|
|
353
381
|
if (!fs.existsSync(unit) && !fs.existsSync(systemUnit))
|
|
@@ -356,13 +384,13 @@ function defaultServiceState(home) {
|
|
|
356
384
|
const probe = spawnSync("systemctl", [...(systemScope ? [] : ["--user"]), "is-active", DAEMON_SYSTEMD_UNIT], { encoding: "utf8", timeout: 5_000 });
|
|
357
385
|
return { installed: true, loaded: probe.status === 0, detail: `systemd ${systemScope ? "system" : "user"} ${DAEMON_SYSTEMD_UNIT}` };
|
|
358
386
|
}
|
|
359
|
-
if (
|
|
387
|
+
if (platform === "win32") {
|
|
360
388
|
const probe = spawnSync("schtasks.exe", ["/Query", "/TN", DAEMON_WINDOWS_TASK, "/FO", "CSV", "/NH"], { encoding: "utf8", timeout: 5_000 });
|
|
361
389
|
if (probe.status !== 0)
|
|
362
390
|
return { installed: false, loaded: false, detail: `no Scheduled Task ${DAEMON_WINDOWS_TASK}` };
|
|
363
391
|
const loaded = /,\s*"?Running"?\s*$/i.test(probe.stdout.trim());
|
|
364
392
|
return { installed: true, loaded, detail: `Scheduled Task ${DAEMON_WINDOWS_TASK}${loaded ? " is running" : " is not running"}` };
|
|
365
393
|
}
|
|
366
|
-
return { installed: false, loaded: false, detail: `${
|
|
394
|
+
return { installed: false, loaded: false, detail: `${platform}: service probe not available` };
|
|
367
395
|
}
|
|
368
396
|
//# sourceMappingURL=anywhere.js.map
|
|
@@ -31,6 +31,8 @@ export interface FlagRegistryDependencies {
|
|
|
31
31
|
sentry?: FlagRegistrySentry;
|
|
32
32
|
/** Test seam for exercising the no-source fail-open path. */
|
|
33
33
|
workerSource?: string;
|
|
34
|
+
/** Start another bounded one-shot worker after an earlier process refresh. */
|
|
35
|
+
forceRefresh?: boolean;
|
|
34
36
|
captureWorkerFailure?: (error: Error, tags: Record<string, string>) => void;
|
|
35
37
|
}
|
|
36
38
|
export interface CompanyFlagGateDependencies {
|
|
@@ -54,6 +56,8 @@ export declare function createProcessSnapshotReader(companyIdentifiers: string[]
|
|
|
54
56
|
* null and the caller's exact legacy fallback decides the gate.
|
|
55
57
|
*/
|
|
56
58
|
export declare function kickFlagRegistryReadiness(dependencies?: FlagRegistryDependencies): FlagReader | null;
|
|
59
|
+
/** Refresh the registry snapshot for a long-running process and wait briefly for its result. */
|
|
60
|
+
export declare function refreshFlagRegistrySnapshot(timeoutMs?: number): Promise<void>;
|
|
57
61
|
/**
|
|
58
62
|
* Evaluate a registry-backed gate without changing its outage default.
|
|
59
63
|
* `isEnabled` itself is synchronous and snapshot-only; any reader failure
|
|
@@ -11,7 +11,7 @@ import { resolveFlagRegistryEndpoint, } from "./flag-registry-endpoint.js";
|
|
|
11
11
|
import { FLAG_REGISTRY_WORKER_SOURCE } from "./flag-registry-worker-source.generated.js";
|
|
12
12
|
import { resolveProcessFlagCompanyContext } from "./process-flag-company-context.js";
|
|
13
13
|
import { registerFlagRegistryRefreshWaiter } from "./flag-registry-refresh-wait.js";
|
|
14
|
-
import { flagSnapshotWriteFailureTags, readFlagSnapshotFile, writeFlagSnapshotFile, } from "./flag-snapshot-file.js";
|
|
14
|
+
import { flagSnapshotWriteFailureTags, readFlagSnapshotFile, writePersonFlagSnapshotFile, writeFlagSnapshotFile, } from "./flag-snapshot-file.js";
|
|
15
15
|
function captureFlagRegistryException(error, tags, sentry) {
|
|
16
16
|
if (!sentry)
|
|
17
17
|
return;
|
|
@@ -50,6 +50,7 @@ let refreshCompanyUid;
|
|
|
50
50
|
let refreshHasCompanyContext = false;
|
|
51
51
|
let refreshPendingSnapshot = null;
|
|
52
52
|
let refreshSentry;
|
|
53
|
+
let refreshDependencies = {};
|
|
53
54
|
/** Wait briefly for the current company refresh to persist or report completion. */
|
|
54
55
|
export async function waitForFlagRegistryRefresh(timeoutMs = 500, companyUid) {
|
|
55
56
|
const resolvedCompanyUid = normalizedCompanyUid(companyUid);
|
|
@@ -70,6 +71,21 @@ export async function waitForFlagRegistryRefresh(timeoutMs = 500, companyUid) {
|
|
|
70
71
|
completeRefresh?.();
|
|
71
72
|
completeRefresh = null;
|
|
72
73
|
}
|
|
74
|
+
else if (refreshPendingSnapshot && !refreshHasCompanyContext) {
|
|
75
|
+
try {
|
|
76
|
+
writePersonFlagSnapshotFile(refreshPendingSnapshot.flags);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
captureFlagRegistryException(error instanceof Error ? error : new Error(String(error)), {
|
|
80
|
+
component: "flag-snapshot-cache-write",
|
|
81
|
+
company_uid_present: "false",
|
|
82
|
+
...flagSnapshotWriteFailureTags(error),
|
|
83
|
+
}, refreshSentry);
|
|
84
|
+
}
|
|
85
|
+
refreshPendingSnapshot = null;
|
|
86
|
+
completeRefresh?.();
|
|
87
|
+
completeRefresh = null;
|
|
88
|
+
}
|
|
73
89
|
if (!completeRefresh)
|
|
74
90
|
return;
|
|
75
91
|
const worker = refreshWorker;
|
|
@@ -123,8 +139,10 @@ function processSnapshotReader(companyIdentifiers) {
|
|
|
123
139
|
* null and the caller's exact legacy fallback decides the gate.
|
|
124
140
|
*/
|
|
125
141
|
export function kickFlagRegistryReadiness(dependencies = {}) {
|
|
126
|
-
if (processReader)
|
|
142
|
+
if (processReader && !dependencies.forceRefresh)
|
|
127
143
|
return processReader;
|
|
144
|
+
if (!dependencies.forceRefresh)
|
|
145
|
+
refreshDependencies = { ...dependencies };
|
|
128
146
|
const endpoint = resolveFlagRegistryEndpoint(dependencies.endpoint, process.env);
|
|
129
147
|
const companyScope = dependencies.companyScope ?? explicitCompanyScopeFromArgv(dependencies.argv ?? []);
|
|
130
148
|
const companyEnv = companyScope
|
|
@@ -233,6 +251,16 @@ export function kickFlagRegistryReadiness(dependencies = {}) {
|
|
|
233
251
|
finishRefresh();
|
|
234
252
|
}
|
|
235
253
|
else if (!refreshHasCompanyContext) {
|
|
254
|
+
try {
|
|
255
|
+
writePersonFlagSnapshotFile(value.flags);
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
captureFlagRegistryException(error instanceof Error ? error : new Error(String(error)), {
|
|
259
|
+
component: "flag-snapshot-cache-write",
|
|
260
|
+
company_uid_present: "false",
|
|
261
|
+
...flagSnapshotWriteFailureTags(error),
|
|
262
|
+
}, sentry);
|
|
263
|
+
}
|
|
236
264
|
refreshPendingSnapshot = null;
|
|
237
265
|
finishRefresh();
|
|
238
266
|
}
|
|
@@ -258,6 +286,18 @@ export function kickFlagRegistryReadiness(dependencies = {}) {
|
|
|
258
286
|
}
|
|
259
287
|
return processReader;
|
|
260
288
|
}
|
|
289
|
+
/** Refresh the registry snapshot for a long-running process and wait briefly for its result. */
|
|
290
|
+
export async function refreshFlagRegistrySnapshot(timeoutMs = 5_000) {
|
|
291
|
+
const refreshIsPending = () => completeRefresh !== null;
|
|
292
|
+
if (refreshIsPending()) {
|
|
293
|
+
await waitForFlagRegistryRefresh(timeoutMs);
|
|
294
|
+
if (refreshIsPending())
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
processReader = null;
|
|
298
|
+
kickFlagRegistryReadiness({ ...refreshDependencies, forceRefresh: true });
|
|
299
|
+
await waitForFlagRegistryRefresh(timeoutMs);
|
|
300
|
+
}
|
|
261
301
|
/**
|
|
262
302
|
* Evaluate a registry-backed gate without changing its outage default.
|
|
263
303
|
* `isEnabled` itself is synchronous and snapshot-only; any reader failure
|
|
@@ -348,5 +388,6 @@ export function _resetFlagRegistryForTests() {
|
|
|
348
388
|
refreshHasCompanyContext = false;
|
|
349
389
|
refreshPendingSnapshot = null;
|
|
350
390
|
refreshSentry = undefined;
|
|
391
|
+
refreshDependencies = {};
|
|
351
392
|
}
|
|
352
393
|
//# sourceMappingURL=flag-registry.js.map
|
|
@@ -7,8 +7,25 @@ export interface PersistedFlagSnapshot {
|
|
|
7
7
|
fetched_at: string;
|
|
8
8
|
flags: Record<string, boolean>;
|
|
9
9
|
}
|
|
10
|
+
export interface PersistedPersonFlagSnapshot {
|
|
11
|
+
scope: "person";
|
|
12
|
+
fetched_at: string;
|
|
13
|
+
flags: Record<string, boolean>;
|
|
14
|
+
}
|
|
10
15
|
export declare function _resetFlagSnapshotCacheForTests(): void;
|
|
11
16
|
export declare function flagSnapshotPath(companyUid: string, env?: NodeJS.ProcessEnv): string | undefined;
|
|
17
|
+
export declare function personFlagSnapshotPath(env?: NodeJS.ProcessEnv): string;
|
|
18
|
+
/** Persist the no-company flag resolution without storing identity or token data. */
|
|
19
|
+
export declare function writePersonFlagSnapshotFile(flags: Readonly<Record<string, unknown>>, options?: {
|
|
20
|
+
env?: NodeJS.ProcessEnv;
|
|
21
|
+
fetchedAt?: string;
|
|
22
|
+
}): string;
|
|
23
|
+
/** Read only a fresh, correctly scoped person snapshot. Every invalid state is a cache miss. */
|
|
24
|
+
export declare function readPersonFlagSnapshotFile(options?: {
|
|
25
|
+
env?: NodeJS.ProcessEnv;
|
|
26
|
+
now?: number;
|
|
27
|
+
maxAgeMs?: number;
|
|
28
|
+
}): PersistedPersonFlagSnapshot | null;
|
|
12
29
|
/** Write just the validated flag decisions and company scope, never auth data. */
|
|
13
30
|
export declare function writeFlagSnapshotFile(companyUid: string, flags: Readonly<Record<string, unknown>>, options?: {
|
|
14
31
|
env?: NodeJS.ProcessEnv;
|
|
@@ -102,19 +102,169 @@ function fallbackSnapshotPath(companyUid, create) {
|
|
|
102
102
|
}
|
|
103
103
|
return path.join(directories[2], `snapshot-${companyUid}.json`);
|
|
104
104
|
}
|
|
105
|
+
function fallbackPersonSnapshotPath(create) {
|
|
106
|
+
if (typeof process.getuid !== "function")
|
|
107
|
+
return undefined;
|
|
108
|
+
const uid = process.getuid();
|
|
109
|
+
const root = path.join(os.tmpdir(), `hq-cli-flag-snapshots-${uid}`);
|
|
110
|
+
const directories = [root, path.join(root, "hq"), path.join(root, "hq", "flags")];
|
|
111
|
+
for (const directory of directories) {
|
|
112
|
+
try {
|
|
113
|
+
if (create)
|
|
114
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
115
|
+
const stat = fs.lstatSync(directory);
|
|
116
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== uid)
|
|
117
|
+
return undefined;
|
|
118
|
+
if (create)
|
|
119
|
+
fs.chmodSync(directory, 0o700);
|
|
120
|
+
else if ((stat.mode & 0o077) !== 0)
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return path.join(directories[2], "snapshot-person.json");
|
|
128
|
+
}
|
|
105
129
|
export function flagSnapshotPath(companyUid, env = process.env) {
|
|
106
130
|
if (!COMPANY_UID_RE.test(companyUid))
|
|
107
131
|
return undefined;
|
|
108
132
|
const cacheHome = env.XDG_CACHE_HOME?.trim() || path.join(os.homedir(), ".cache");
|
|
109
133
|
return path.join(cacheHome, "hq", "flags", `snapshot-${companyUid}.json`);
|
|
110
134
|
}
|
|
135
|
+
export function personFlagSnapshotPath(env = process.env) {
|
|
136
|
+
const cacheHome = env.XDG_CACHE_HOME?.trim() || path.join(os.homedir(), ".cache");
|
|
137
|
+
return path.join(cacheHome, "hq", "flags", "snapshot-person.json");
|
|
138
|
+
}
|
|
139
|
+
function normalizedFlagValues(flags) {
|
|
140
|
+
return Object.fromEntries(Object.entries(flags).filter((entry) => Boolean(entry[0].trim()) && typeof entry[1] === "boolean"));
|
|
141
|
+
}
|
|
142
|
+
function writeSnapshotAt(destination, snapshot, cacheRootKind) {
|
|
143
|
+
const directory = path.dirname(destination);
|
|
144
|
+
const context = (step) => ({
|
|
145
|
+
step,
|
|
146
|
+
cacheRootKind,
|
|
147
|
+
directory,
|
|
148
|
+
});
|
|
149
|
+
atWriteStep(context("mkdir"), () => fs.mkdirSync(directory, { recursive: true, mode: 0o700 }));
|
|
150
|
+
const temporary = `${destination}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
151
|
+
let fd;
|
|
152
|
+
try {
|
|
153
|
+
fd = atWriteStep(context("open_tmp"), () => fs.openSync(temporary, "wx", 0o600));
|
|
154
|
+
atWriteStep(context("write_tmp"), () => {
|
|
155
|
+
fs.writeFileSync(fd, `${JSON.stringify(snapshot)}\n`, "utf8");
|
|
156
|
+
fs.fsyncSync(fd);
|
|
157
|
+
fs.closeSync(fd);
|
|
158
|
+
fd = undefined;
|
|
159
|
+
});
|
|
160
|
+
atWriteStep(context("rename"), () => fs.renameSync(temporary, destination));
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
if (fd !== undefined)
|
|
164
|
+
fs.closeSync(fd);
|
|
165
|
+
try {
|
|
166
|
+
fs.unlinkSync(temporary);
|
|
167
|
+
}
|
|
168
|
+
catch (cleanupError) {
|
|
169
|
+
if (cleanupError.code !== "ENOENT")
|
|
170
|
+
throw cleanupError;
|
|
171
|
+
}
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
return destination;
|
|
175
|
+
}
|
|
176
|
+
function writePersonFallback(snapshot, originalError) {
|
|
177
|
+
const fallbackFile = fallbackPersonSnapshotPath(true);
|
|
178
|
+
if (!fallbackFile) {
|
|
179
|
+
if (originalError) {
|
|
180
|
+
markFallbackFailed(originalError);
|
|
181
|
+
throw originalError;
|
|
182
|
+
}
|
|
183
|
+
const error = Object.assign(new Error("Flag snapshot fallback directory is unavailable"), { code: "EACCES" });
|
|
184
|
+
markFallbackFailed(error);
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
const written = writeSnapshotAt(fallbackFile, snapshot, "home");
|
|
189
|
+
fallbackCacheUsed = true;
|
|
190
|
+
return written;
|
|
191
|
+
}
|
|
192
|
+
catch (fallbackError) {
|
|
193
|
+
if (originalError) {
|
|
194
|
+
markFallbackFailed(originalError);
|
|
195
|
+
throw originalError;
|
|
196
|
+
}
|
|
197
|
+
markFallbackFailed(fallbackError);
|
|
198
|
+
throw fallbackError;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** Persist the no-company flag resolution without storing identity or token data. */
|
|
202
|
+
export function writePersonFlagSnapshotFile(flags, options = {}) {
|
|
203
|
+
const env = options.env ?? process.env;
|
|
204
|
+
const snapshot = {
|
|
205
|
+
scope: "person",
|
|
206
|
+
fetched_at: options.fetchedAt ?? new Date().toISOString(),
|
|
207
|
+
flags: normalizedFlagValues(flags),
|
|
208
|
+
};
|
|
209
|
+
if (!Number.isFinite(Date.parse(snapshot.fetched_at))) {
|
|
210
|
+
throw new Error("Cannot persist a flag snapshot with an invalid fetched_at timestamp");
|
|
211
|
+
}
|
|
212
|
+
if (fallbackCacheUsed)
|
|
213
|
+
return writePersonFallback(snapshot);
|
|
214
|
+
try {
|
|
215
|
+
return writeSnapshotAt(personFlagSnapshotPath(env), snapshot, env.XDG_CACHE_HOME?.trim() ? "xdg" : "home");
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
if (!isPermissionFailure(error))
|
|
219
|
+
throw error;
|
|
220
|
+
return writePersonFallback(snapshot, error);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** Read only a fresh, correctly scoped person snapshot. Every invalid state is a cache miss. */
|
|
224
|
+
export function readPersonFlagSnapshotFile(options = {}) {
|
|
225
|
+
const env = options.env ?? process.env;
|
|
226
|
+
const files = [personFlagSnapshotPath(env)];
|
|
227
|
+
const fallback = fallbackPersonSnapshotPath(false);
|
|
228
|
+
if (fallbackCacheUsed && fallback)
|
|
229
|
+
files.unshift(fallback);
|
|
230
|
+
else if (fallback)
|
|
231
|
+
files.push(fallback);
|
|
232
|
+
for (const file of files) {
|
|
233
|
+
let parsed;
|
|
234
|
+
try {
|
|
235
|
+
parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
241
|
+
continue;
|
|
242
|
+
const value = parsed;
|
|
243
|
+
if (Object.keys(value).sort().join(",") !== "fetched_at,flags,scope" ||
|
|
244
|
+
value.scope !== "person" ||
|
|
245
|
+
typeof value.fetched_at !== "string" ||
|
|
246
|
+
!value.flags || typeof value.flags !== "object" || Array.isArray(value.flags))
|
|
247
|
+
continue;
|
|
248
|
+
const fetchedAt = Date.parse(value.fetched_at);
|
|
249
|
+
const age = (options.now ?? Date.now()) - fetchedAt;
|
|
250
|
+
if (!Number.isFinite(fetchedAt) || age < 0 || age > (options.maxAgeMs ?? FLAG_SNAPSHOT_MAX_AGE_MS))
|
|
251
|
+
continue;
|
|
252
|
+
const flags = value.flags;
|
|
253
|
+
if (!Object.values(flags).every((enabled) => typeof enabled === "boolean"))
|
|
254
|
+
continue;
|
|
255
|
+
if (file === fallback && fallback)
|
|
256
|
+
fallbackCacheUsed = true;
|
|
257
|
+
return { scope: "person", fetched_at: value.fetched_at, flags: flags };
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
111
261
|
/** Write just the validated flag decisions and company scope, never auth data. */
|
|
112
262
|
export function writeFlagSnapshotFile(companyUid, flags, options = {}) {
|
|
113
263
|
const env = options.env ?? process.env;
|
|
114
264
|
const file = flagSnapshotPath(companyUid, env);
|
|
115
265
|
if (!file)
|
|
116
266
|
throw new Error("Cannot persist a flag snapshot without a valid company uid");
|
|
117
|
-
const normalizedFlags =
|
|
267
|
+
const normalizedFlags = normalizedFlagValues(flags);
|
|
118
268
|
const snapshot = {
|
|
119
269
|
company_uid: companyUid,
|
|
120
270
|
fetched_at: options.fetchedAt ?? new Date().toISOString(),
|
|
@@ -124,40 +274,6 @@ export function writeFlagSnapshotFile(companyUid, flags, options = {}) {
|
|
|
124
274
|
throw new Error("Cannot persist a flag snapshot with an invalid fetched_at timestamp");
|
|
125
275
|
}
|
|
126
276
|
const cacheRootKind = env.XDG_CACHE_HOME?.trim() ? "xdg" : "home";
|
|
127
|
-
const writeAt = (destination) => {
|
|
128
|
-
const directory = path.dirname(destination);
|
|
129
|
-
const context = (step) => ({
|
|
130
|
-
step,
|
|
131
|
-
cacheRootKind,
|
|
132
|
-
directory,
|
|
133
|
-
});
|
|
134
|
-
atWriteStep(context("mkdir"), () => fs.mkdirSync(directory, { recursive: true, mode: 0o700 }));
|
|
135
|
-
const temporary = `${destination}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
136
|
-
let fd;
|
|
137
|
-
try {
|
|
138
|
-
fd = atWriteStep(context("open_tmp"), () => fs.openSync(temporary, "wx", 0o600));
|
|
139
|
-
atWriteStep(context("write_tmp"), () => {
|
|
140
|
-
fs.writeFileSync(fd, `${JSON.stringify(snapshot)}\n`, "utf8");
|
|
141
|
-
fs.fsyncSync(fd);
|
|
142
|
-
fs.closeSync(fd);
|
|
143
|
-
fd = undefined;
|
|
144
|
-
});
|
|
145
|
-
atWriteStep(context("rename"), () => fs.renameSync(temporary, destination));
|
|
146
|
-
}
|
|
147
|
-
catch (error) {
|
|
148
|
-
if (fd !== undefined)
|
|
149
|
-
fs.closeSync(fd);
|
|
150
|
-
try {
|
|
151
|
-
fs.unlinkSync(temporary);
|
|
152
|
-
}
|
|
153
|
-
catch (cleanupError) {
|
|
154
|
-
if (cleanupError.code !== "ENOENT")
|
|
155
|
-
throw cleanupError;
|
|
156
|
-
}
|
|
157
|
-
throw error;
|
|
158
|
-
}
|
|
159
|
-
return destination;
|
|
160
|
-
};
|
|
161
277
|
const writeFallback = (originalError) => {
|
|
162
278
|
const fallbackFile = fallbackSnapshotPath(companyUid, true);
|
|
163
279
|
if (!fallbackFile) {
|
|
@@ -170,7 +286,7 @@ export function writeFlagSnapshotFile(companyUid, flags, options = {}) {
|
|
|
170
286
|
throw error;
|
|
171
287
|
}
|
|
172
288
|
try {
|
|
173
|
-
const written =
|
|
289
|
+
const written = writeSnapshotAt(fallbackFile, snapshot, cacheRootKind);
|
|
174
290
|
fallbackCacheUsed = true;
|
|
175
291
|
return written;
|
|
176
292
|
}
|
|
@@ -186,7 +302,7 @@ export function writeFlagSnapshotFile(companyUid, flags, options = {}) {
|
|
|
186
302
|
if (fallbackCacheUsed)
|
|
187
303
|
return writeFallback();
|
|
188
304
|
try {
|
|
189
|
-
return
|
|
305
|
+
return writeSnapshotAt(file, snapshot, cacheRootKind);
|
|
190
306
|
}
|
|
191
307
|
catch (error) {
|
|
192
308
|
if (!isPermissionFailure(error))
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/** hq-flags keys used by lane command behavior. */
|
|
2
|
+
export declare const LANE_FOCUS_DASHBOARD_FLAG = "lanes-focus-dashboard";
|
|
2
3
|
export declare const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
|
|
4
|
+
/** Enables same-provider automatic lane recovery after provider quota exhaustion. */
|
|
5
|
+
export declare const LANE_QUOTA_ACCOUNT_FAILOVER_FLAG = "lanes.quota-account-failover";
|
|
3
6
|
/** Gives live senior lanes Claude admission precedence and reserves host capacity for them. */
|
|
4
7
|
export declare const LANE_LEAD_ADMISSION_PRIORITY_FLAG = "lanes.lead-admission-priority";
|
|
5
8
|
/** Refresh provider/model/effort from a worker profile when a done lane takes a new story. */
|
|
@@ -24,4 +27,6 @@ export declare const LEAD_AUTOPILOT_HEADLINE_STALENESS_FLAG = "lanes.autopilot-h
|
|
|
24
27
|
export declare const LEAD_AUTOPILOT_OUTPOST_NATIVE_LEADS_FLAG = "lanes.autopilot-outpost-native-leads";
|
|
25
28
|
/** Allows applying an operator-reviewed repair of messages stranded in bare-ULID dropboxes. */
|
|
26
29
|
export declare const LANE_ORPHANED_BARE_ID_DROPBOX_REPAIR_FLAG = "lanes.orphaned-bare-id-dropbox-repair";
|
|
30
|
+
/** Allows a Claude lane to leave an exhausted profile account pin for an eligible account. */
|
|
31
|
+
export declare const LANE_PROFILE_PIN_EXHAUSTED_FALLBACK_FLAG = "lanes.profile-pin-exhausted-fallback";
|
|
27
32
|
//# sourceMappingURL=flag-keys.d.ts.map
|