@indigoai-us/hq-cli 5.345.41 → 5.345.43
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 +12 -0
- package/dist/command-catalog.generated.d.ts +7 -3
- package/dist/command-catalog.generated.js +8 -3
- package/dist/commands/db-remote-deps.js +3 -0
- package/dist/commands/db-usage.js +11 -4
- package/dist/commands/install-global.d.ts +8 -1
- package/dist/commands/install-global.js +128 -25
- package/dist/commands/lanes-message.js +1 -0
- package/dist/commands/lanes-resume.js +3 -0
- package/dist/commands/lanes.js +203 -44
- 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/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 +46 -0
- package/dist/lib/db/control-plane.js +14 -0
- package/dist/lib/db/usage-format.d.ts +4 -1
- package/dist/lib/db/usage-format.js +66 -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 +2 -0
- package/dist/lib/lanes/flag-keys.js +2 -0
- package/dist/lib/lanes/types.d.ts +1 -1
- package/dist/utils/resident-package-preload.js +5 -1
- package/package.json +1 -1
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import type { RemoteUsageResponse } from "./control-plane.js";
|
|
1
|
+
import type { RemoteAppUsage, RemoteAppUsageResponse, RemoteUsageResponse } from "./control-plane.js";
|
|
2
2
|
export declare function formatBytes(bytes: number): string;
|
|
3
3
|
export declare function formatUsage(usage: RemoteUsageResponse): string;
|
|
4
|
+
export declare function formatAppUsage(usage: RemoteAppUsage): string;
|
|
5
|
+
export declare function formatAppUsageResponse(response: RemoteAppUsageResponse): string;
|
|
6
|
+
export declare function formatCompanyApps(apps: RemoteAppUsage[]): string;
|
|
4
7
|
//# sourceMappingURL=usage-format.d.ts.map
|
|
@@ -36,6 +36,72 @@ export function formatUsage(usage) {
|
|
|
36
36
|
lines.push(`Over the cap for ${over.join(" and ")}.`);
|
|
37
37
|
else if (usage.pctDpu >= 80 || usage.pctStorage >= 80)
|
|
38
38
|
lines.push("Close to a cap (80% or more).");
|
|
39
|
+
if (usage.companyCluster === false) {
|
|
40
|
+
lines.splice(1, 2, "This company has no shared database. Each app has its own, listed below.");
|
|
41
|
+
}
|
|
42
|
+
const apps = formatCompanyApps(usage.apps ?? []);
|
|
43
|
+
if (apps)
|
|
44
|
+
lines.push(apps);
|
|
39
45
|
return lines.join("\n");
|
|
40
46
|
}
|
|
47
|
+
function dollars(cents) {
|
|
48
|
+
return `$${(cents / 100).toFixed(2)}`;
|
|
49
|
+
}
|
|
50
|
+
function count(value) {
|
|
51
|
+
return Math.round(value).toLocaleString("en-US");
|
|
52
|
+
}
|
|
53
|
+
function gbMonth(value) {
|
|
54
|
+
return `${value.toFixed(2)} GB-month`;
|
|
55
|
+
}
|
|
56
|
+
const STATE_LINES = {
|
|
57
|
+
ok: "Within the included amounts.",
|
|
58
|
+
warn: "Close to the included amount (80% or more). Use above it is billed as overage.",
|
|
59
|
+
overage: "Above the included amount. The extra use is billed as overage.",
|
|
60
|
+
locked: "At the ceiling. The database is read-only: reads work, writes are refused. Move this app to a larger service, or contact HQ support to raise the limit.",
|
|
61
|
+
};
|
|
62
|
+
export function formatAppUsage(usage) {
|
|
63
|
+
const lines = [
|
|
64
|
+
`Database usage for app ${usage.appId}, ${usage.period}`,
|
|
65
|
+
`Compute: ${count(usage.dpu.used)} of ${count(usage.dpu.included)} included units (${percent(usage.dpu.pct)}), overage ${count(usage.dpu.overage)}, ceiling ${count(usage.dpu.ceiling)}`,
|
|
66
|
+
`Storage: ${formatBytes(usage.storage.usedBytes)} of ${formatBytes(usage.storage.includedBytes)} included (${percent(usage.storage.pct)}), month average ${formatBytes(usage.storage.billedBytes)}, overage ${gbMonth(usage.storage.overageGbMonth)}, ceiling ${formatBytes(usage.storage.ceilingBytes)}`,
|
|
67
|
+
STATE_LINES[usage.locked ? "locked" : usage.state],
|
|
68
|
+
];
|
|
69
|
+
if (usage.estimate) {
|
|
70
|
+
const e = usage.estimate;
|
|
71
|
+
lines.push(`Estimated charge this month: ${dollars(e.totalCents)} (${dollars(e.baseCents)} base, ${dollars(e.dpuOverageCents)} compute overage, ${dollars(e.storageOverageCents)} storage overage)`);
|
|
72
|
+
}
|
|
73
|
+
else if (usage.exempt) {
|
|
74
|
+
lines.push("Not billed: this company is exempt from database app charges.");
|
|
75
|
+
}
|
|
76
|
+
if (usage.ended)
|
|
77
|
+
lines.push("This app was deleted. Usage up to deletion still counts for the month.");
|
|
78
|
+
lines.push(usage.measuredAt === null
|
|
79
|
+
? "Not measured yet, so usage shows as 0. Check again in about 15 minutes."
|
|
80
|
+
: `Last measured ${usage.measuredAt}. Numbers can lag by a few minutes.`);
|
|
81
|
+
return lines.join("\n");
|
|
82
|
+
}
|
|
83
|
+
export function formatAppUsageResponse(response) {
|
|
84
|
+
if (response.mode === "company-cluster") {
|
|
85
|
+
return [
|
|
86
|
+
`App ${response.appId} uses the shared company database, so these are the company numbers.`,
|
|
87
|
+
formatUsage(response),
|
|
88
|
+
].join("\n");
|
|
89
|
+
}
|
|
90
|
+
return formatAppUsage(response);
|
|
91
|
+
}
|
|
92
|
+
export function formatCompanyApps(apps) {
|
|
93
|
+
if (apps.length === 0)
|
|
94
|
+
return "";
|
|
95
|
+
const rows = apps.map((app) => {
|
|
96
|
+
const note = app.locked
|
|
97
|
+
? ", read-only"
|
|
98
|
+
: app.state === "warn"
|
|
99
|
+
? ", close to the included amount"
|
|
100
|
+
: app.state === "overage"
|
|
101
|
+
? ", over the included amount"
|
|
102
|
+
: "";
|
|
103
|
+
return ` ${app.appId}: ${count(app.dpu.used)} of ${count(app.dpu.included)} compute (${percent(app.dpu.pct)}), ${formatBytes(app.storage.usedBytes)} of ${formatBytes(app.storage.includedBytes)} storage (${percent(app.storage.pct)})${note}${app.ended ? ", deleted" : ""}`;
|
|
104
|
+
});
|
|
105
|
+
return ["Apps with their own database:", ...rows, "Run hq db usage --app <name> for one app."].join("\n");
|
|
106
|
+
}
|
|
41
107
|
//# sourceMappingURL=usage-format.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))
|
|
@@ -24,4 +24,6 @@ export declare const LEAD_AUTOPILOT_HEADLINE_STALENESS_FLAG = "lanes.autopilot-h
|
|
|
24
24
|
export declare const LEAD_AUTOPILOT_OUTPOST_NATIVE_LEADS_FLAG = "lanes.autopilot-outpost-native-leads";
|
|
25
25
|
/** Allows applying an operator-reviewed repair of messages stranded in bare-ULID dropboxes. */
|
|
26
26
|
export declare const LANE_ORPHANED_BARE_ID_DROPBOX_REPAIR_FLAG = "lanes.orphaned-bare-id-dropbox-repair";
|
|
27
|
+
/** Allows a Claude lane to leave an exhausted profile account pin for an eligible account. */
|
|
28
|
+
export declare const LANE_PROFILE_PIN_EXHAUSTED_FALLBACK_FLAG = "lanes.profile-pin-exhausted-fallback";
|
|
27
29
|
//# sourceMappingURL=flag-keys.d.ts.map
|
|
@@ -24,4 +24,6 @@ export const LEAD_AUTOPILOT_HEADLINE_STALENESS_FLAG = "lanes.autopilot-headline-
|
|
|
24
24
|
export const LEAD_AUTOPILOT_OUTPOST_NATIVE_LEADS_FLAG = "lanes.autopilot-outpost-native-leads";
|
|
25
25
|
/** Allows applying an operator-reviewed repair of messages stranded in bare-ULID dropboxes. */
|
|
26
26
|
export const LANE_ORPHANED_BARE_ID_DROPBOX_REPAIR_FLAG = "lanes.orphaned-bare-id-dropbox-repair";
|
|
27
|
+
/** Allows a Claude lane to leave an exhausted profile account pin for an eligible account. */
|
|
28
|
+
export const LANE_PROFILE_PIN_EXHAUSTED_FALLBACK_FLAG = "lanes.profile-pin-exhausted-fallback";
|
|
27
29
|
//# sourceMappingURL=flag-keys.js.map
|
|
@@ -785,7 +785,7 @@ export interface AccountHistoryEntry {
|
|
|
785
785
|
from: string | null;
|
|
786
786
|
to: string;
|
|
787
787
|
at: string;
|
|
788
|
-
reason: "first_assignment" | "model_change" | "usage_limit" | "account_unavailable" | "recorded_account_logged_out" | "load_balance" | "keep_alive_resume" | "operator_move";
|
|
788
|
+
reason: "first_assignment" | "model_change" | "usage_limit" | "account_unavailable" | "recorded_account_logged_out" | "load_balance" | "keep_alive_resume" | "operator_move" | "profile_pin_exhausted";
|
|
789
789
|
}
|
|
790
790
|
export interface FailedResumeHistoryEntry {
|
|
791
791
|
at: string;
|
|
@@ -45,7 +45,10 @@ export const RESIDENT_PACKAGE_MODULES = {
|
|
|
45
45
|
coreJobRun: ["../lib/flag-registry.js"],
|
|
46
46
|
"daemon-call": ["../lib/daemon/protocol.js"],
|
|
47
47
|
daemon: [
|
|
48
|
-
"../sentry.js",
|
|
48
|
+
"../sentry.js",
|
|
49
|
+
"../lib/doctor/client-health-reason-codes-flag.js",
|
|
50
|
+
"@indigoai-us/hq-flags-client",
|
|
51
|
+
"../lib/flag-registry.js",
|
|
49
52
|
"../utils/cognito-session.js",
|
|
50
53
|
"../lib/plan-limit-nag.js",
|
|
51
54
|
"../utils/plan-gate-error.js",
|
|
@@ -207,6 +210,7 @@ const LOADERS = {
|
|
|
207
210
|
await import("@indigoai-us/hq-flags-client");
|
|
208
211
|
preloadSentrySdk();
|
|
209
212
|
},
|
|
213
|
+
() => import("../lib/flag-registry.js"),
|
|
210
214
|
() => import("../utils/cognito-session.js"),
|
|
211
215
|
() => import("../lib/plan-limit-nag.js"),
|
|
212
216
|
() => import("../utils/plan-gate-error.js"),
|