mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
fix(cli,core,studio): close 15 review findings + 2 R5 blockers
R5 blockers
- Negative install-state latch was cached for the process lifetime, but
only `true` is monotonic across processes. A long-lived preview server
held a stale `false` and could re-enrol after another process tripped
the breaker. Only the positive is cached now; `false` re-reads.
- The real breaker writer used writeConfig(), which collapses
{ok:true, mirrored:false} to success, so a run that mirrored nothing
reported done with the latch only on the erasable store. It consumes
writeConfigWithResult and retries until both stores carry it.
Bucketing integrity
- Storage-restricted Studio profiles all bucketed on the literal
"anonymous": computed against the shipped hash, 100% of them were
enrolled in calibration-50 rather than 50%, and they merged into one
PostHog person. Per-session random id instead — persists nothing.
- bucketSeed had read/write authority backwards: install-state is
write-once authoritative, but readConfig took config.json's blindly, so
the stores could hold different seeds until a re-mint flipped every
cohort. Merged on read, like the latch.
- An unwritable ~/.hyperframes with no config.json re-minted per call,
re-rolling the seed on every command, and the "cohorts will not be
stable" warning was unreachable on that path.
- A corrupt PRE-MOVE state file was never deleted, so a machine reset
with `rm -rf ~/.hyperframes` reported predecessorFound/stateFileCorrupt
forever — poisoning the exact metric this work exists to produce.
Opt-out honoring
- CLI canary decisions memoized per process, so `hyperframes telemetry
disable` during a running preview server was ignored for hours while
the server kept serving pre-opt-out decisions. The memo is keyed on the
telemetry posture.
- shouldTrack() memoized, contradicting policy.ts's documented "not
memoized" contract that policy.test.ts asserts.
- The Studio override path resolved the bucket unit eagerly as an
argument, minting and PERSISTING a tracking id for an opted-out profile
— a value evaluateCanary discards unread.
- Storage reads could throw out of telemetry into a post-commit catch
block, reporting an already-committed edit as failed.
- readConfig printed an unsilenceable stderr warning on every invocation
for installs that opted out of telemetry entirely.
Host split
- isLoopbackHost rejected 0.0.0.0, so the documented
HYPERFRAMES_PREVIEW_HOST LAN mode silently lost CLI→Studio identity
stitching and split one user across two PostHog persons. Identity is
now allowed when the operator explicitly opted into LAN binding.
- Corrected the comment claiming the guard refuses spoofed Hosts: a
non-browser client sets Host freely. It is a browser DNS-rebinding
mitigation, not access control, and now says so.
Semantics and test hygiene
- percentage:100 did not mean everyone — exclude and no_unit_id sat above
the fast path, so the registry's "delete the entry at 100" step was an
unstaged flip for CI and seedless installs.
- CLI cohort adoption returned before evaluateCanary, dropping Studio's
own webdriver exclusion.
- overdueCanaries() was asserted against wall-clock time, so the whole
core suite would go red on 2026-09-15 for every unrelated PR; and `>`
against midnight made a canary overdue ON its sunset date.
- Statistical assertions ran on unseeded randomUUID() populations tight
enough to fail ~1 run in 200. Seeded.
Also: broke a config -> policy -> transport -> config import cycle by
moving POSTHOG_API_KEY to a leaf module.
Tests: 2347 CLI (bundle absent), 3153 Studio, 1450 core. Fault injection
covers the latch, seed authority, LAN identity, webdriver exclusion and
the anonymous-bucket fix. Two pre-existing tests asserted behaviour these
findings identify as wrong (shouldTrack memoization, 100%-excludes-CI)
and were rewritten with the reasoning stated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5e2a9432f1
commit
3f69a2c635
@@ -44,6 +44,17 @@ vi.mock("@hyperframes/core/canary-registry", async () => {
|
||||
owner: "t",
|
||||
sunsetAfter: "2099-01-01",
|
||||
},
|
||||
{
|
||||
// A PARTIAL percentage. `exclude` and the unit-id check only apply
|
||||
// below 100 — at 100 the guard is about to be deleted, so everyone
|
||||
// must already be on it — so exclusion behaviour cannot be expressed
|
||||
// against test-alpha.
|
||||
name: "test-gamma",
|
||||
percentage: 50,
|
||||
description: "partial rollout",
|
||||
owner: "t",
|
||||
sunsetAfter: "2099-01-01",
|
||||
},
|
||||
{
|
||||
name: "test-beta",
|
||||
percentage: 0,
|
||||
@@ -61,6 +72,13 @@ vi.mock("@hyperframes/core/canary-registry", async () => {
|
||||
owner: "t",
|
||||
sunsetAfter: "2099-01-01",
|
||||
},
|
||||
{
|
||||
name: "test-gamma",
|
||||
percentage: 50,
|
||||
description: "",
|
||||
owner: "t",
|
||||
sunsetAfter: "2099-01-01",
|
||||
},
|
||||
{
|
||||
name: "test-beta",
|
||||
percentage: 0,
|
||||
@@ -84,6 +102,7 @@ beforeEach(() => {
|
||||
policyState.runtimeOverride = null;
|
||||
delete process.env.HF_CANARY_TEST_ALPHA;
|
||||
delete process.env.HF_CANARY_TEST_BETA;
|
||||
delete process.env.HF_CANARY_TEST_GAMMA;
|
||||
});
|
||||
|
||||
describe("telemetry opt-out is canary opt-out", () => {
|
||||
@@ -121,6 +140,7 @@ describe("telemetry opt-out is canary opt-out", () => {
|
||||
configState.telemetryEnabled = false;
|
||||
expect(canaryEventProperties()).toEqual({
|
||||
"$feature/canary-test-alpha": "false",
|
||||
"$feature/canary-test-gamma": "false",
|
||||
"$feature/canary-test-beta": "false",
|
||||
});
|
||||
});
|
||||
@@ -130,16 +150,18 @@ describe("bucketing unit", () => {
|
||||
it("buckets on the bucketSeed when present — the unit that survives config wipes", async () => {
|
||||
const { evaluateCanary } = await import("@hyperframes/core/canary");
|
||||
configState.bucketSeed = "5f1c9d2e-0000-4000-8000-aaaaaaaaaaaa";
|
||||
const viaBinding = resolveCanary("test-alpha").bucket;
|
||||
const viaBinding = resolveCanary("test-gamma").bucket;
|
||||
// 50, not 100: at 100 evaluateCanary short-circuits before bucketing and
|
||||
// reports no bucket at all, which would make this comparison vacuous.
|
||||
const bySeed = evaluateCanary({
|
||||
feature: "test-alpha",
|
||||
feature: "test-gamma",
|
||||
unitId: configState.bucketSeed,
|
||||
percentage: 100,
|
||||
percentage: 50,
|
||||
}).bucket;
|
||||
const byId = evaluateCanary({
|
||||
feature: "test-alpha",
|
||||
feature: "test-gamma",
|
||||
unitId: configState.anonymousId,
|
||||
percentage: 100,
|
||||
percentage: 50,
|
||||
}).bucket;
|
||||
expect(viaBinding).toBe(bySeed);
|
||||
// Only meaningful if the two units actually bucket differently.
|
||||
@@ -148,13 +170,15 @@ describe("bucketing unit", () => {
|
||||
|
||||
it("falls back to the anonymousId when no seed exists (failed legacy backfill)", async () => {
|
||||
const { evaluateCanary } = await import("@hyperframes/core/canary");
|
||||
const viaBinding = resolveCanary("test-alpha").bucket;
|
||||
const viaBinding = resolveCanary("test-gamma").bucket;
|
||||
const byId = evaluateCanary({
|
||||
feature: "test-alpha",
|
||||
feature: "test-gamma",
|
||||
unitId: configState.anonymousId,
|
||||
percentage: 100,
|
||||
percentage: 50,
|
||||
}).bucket;
|
||||
expect(viaBinding).toBe(byId);
|
||||
// Both undefined would satisfy toBe — assert a bucket was actually computed.
|
||||
expect(viaBinding).toEqual(expect.any(Number));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,16 +202,16 @@ describe("CLI canary binding", () => {
|
||||
|
||||
it("excludes CI from percentage enrolment, but an override still reaches it", () => {
|
||||
systemState.is_ci = true;
|
||||
expect(resolveCanary("test-alpha")).toMatchObject({ enabled: false, reason: "excluded" });
|
||||
expect(resolveCanary("test-gamma")).toMatchObject({ enabled: false, reason: "excluded" });
|
||||
|
||||
__resetCanaryCacheForTests();
|
||||
process.env.HF_CANARY_TEST_ALPHA = "on";
|
||||
expect(resolveCanary("test-alpha")).toMatchObject({ enabled: true, reason: "forced_on" });
|
||||
process.env.HF_CANARY_TEST_GAMMA = "on";
|
||||
expect(resolveCanary("test-gamma")).toMatchObject({ enabled: true, reason: "forced_on" });
|
||||
});
|
||||
|
||||
it("fails closed when the install has no anonymousId", () => {
|
||||
configState.anonymousId = "";
|
||||
expect(resolveCanary("test-alpha")).toMatchObject({ enabled: false, reason: "no_unit_id" });
|
||||
expect(resolveCanary("test-gamma")).toMatchObject({ enabled: false, reason: "no_unit_id" });
|
||||
});
|
||||
|
||||
it("memoizes so a decision cannot change mid-process", () => {
|
||||
@@ -202,6 +226,7 @@ describe("CLI canary binding", () => {
|
||||
it("emits PostHog flag-shaped properties for every registered canary", () => {
|
||||
expect(canaryEventProperties()).toEqual({
|
||||
"$feature/canary-test-alpha": "true",
|
||||
"$feature/canary-test-gamma": expect.stringMatching(/^(true|false)$/),
|
||||
"$feature/canary-test-beta": "false",
|
||||
});
|
||||
|
||||
|
||||
@@ -58,9 +58,18 @@ function telemetryActive(): boolean {
|
||||
*/
|
||||
const decisions = new Map<string, CanaryDecision>();
|
||||
|
||||
// The memo is scoped to a telemetry posture, not to the process. Within one
|
||||
// render nothing may change (a render that starts enrolled must finish
|
||||
// enrolled), but `hyperframes preview` is a long-lived server that re-serves
|
||||
// decisions on every page load — and it used to keep serving pre-opt-out
|
||||
// decisions for hours after `hyperframes telemetry disable`, which is exactly
|
||||
// what the docs promise cannot happen.
|
||||
let decisionsTelemetryPosture: boolean | undefined;
|
||||
|
||||
/** Test-only: drop memoized decisions so cases don't leak into each other. */
|
||||
export function __resetCanaryCacheForTests(): void {
|
||||
decisions.clear();
|
||||
decisionsTelemetryPosture = undefined;
|
||||
}
|
||||
|
||||
/** The uncached decision. Split out so `resolveCanary` is purely the memo. */
|
||||
@@ -105,6 +114,11 @@ function decideCanary(name: string): CanaryDecision {
|
||||
* rollout control, and a typo in one must never take down a render.
|
||||
*/
|
||||
export function resolveCanary(name: string): CanaryDecision {
|
||||
const posture = telemetryActive();
|
||||
if (decisionsTelemetryPosture !== posture) {
|
||||
decisions.clear();
|
||||
decisionsTelemetryPosture = posture;
|
||||
}
|
||||
const cached = decisions.get(name);
|
||||
if (cached) return cached;
|
||||
|
||||
|
||||
@@ -36,6 +36,14 @@ vi.mock("node:fs", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// The backfill warning is suppressed under a telemetry runtime override (a
|
||||
// dev build counts as one, which vitest is), so the posture is controlled
|
||||
// explicitly rather than inherited from the test environment.
|
||||
const policyState = { runtimeOverride: null as string | null };
|
||||
vi.mock("./policy.js", () => ({
|
||||
telemetryRuntimeOverride: () => policyState.runtimeOverride,
|
||||
}));
|
||||
|
||||
// Derived here rather than exported from config.ts: the pre-move path is
|
||||
// frozen history, so pinning the literal is the point — an export would just
|
||||
// let a rename pass silently, and it has no non-test consumer.
|
||||
@@ -446,6 +454,7 @@ describe("seed backfill write failure is surfaced", () => {
|
||||
});
|
||||
|
||||
it("warns once when the backfilled seed cannot be persisted", async () => {
|
||||
policyState.runtimeOverride = null;
|
||||
// A config predating bucketSeed, on an unwritable home directory.
|
||||
const seeded = { telemetryEnabled: true, anonymousId: "id", telemetryNoticeShown: true };
|
||||
fsState.files.set(CONFIG_PATH, JSON.stringify(seeded));
|
||||
@@ -553,3 +562,119 @@ describe("the breaker latch is authoritative from install-state", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("install-state authority — negative latch and seed", () => {
|
||||
let readConfig: typeof import("./config.js").readConfig;
|
||||
let readConfigFresh: typeof import("./config.js").readConfigFresh;
|
||||
let CONFIG_PATH: typeof import("./config.js").CONFIG_PATH;
|
||||
let STATE_PATH: typeof import("./config.js").STATE_PATH;
|
||||
|
||||
beforeEach(async () => {
|
||||
fsState.files.clear();
|
||||
vi.resetModules();
|
||||
({ readConfig, readConfigFresh, CONFIG_PATH, STATE_PATH } = await import("./config.js"));
|
||||
});
|
||||
|
||||
// Only `true` is monotonic across processes. Caching `false` froze a stale
|
||||
// negative in a long-lived process (the preview server), so a breaker
|
||||
// tripped by another process was never picked up.
|
||||
it("re-reads a negative latch: false -> external trip -> stale config -> fresh read is true", () => {
|
||||
fsState.files.set(STATE_PATH, JSON.stringify({ markerAt: "2026-07-28T00:00:00.000Z" }));
|
||||
fsState.files.set(
|
||||
CONFIG_PATH,
|
||||
JSON.stringify({ telemetryEnabled: true, anonymousId: "id", bucketSeed: "seed" }),
|
||||
);
|
||||
expect(readConfig().deParallelRouterTrialFired).toBeUndefined();
|
||||
|
||||
// Another process trips the breaker and mirrors it...
|
||||
fsState.files.set(
|
||||
STATE_PATH,
|
||||
JSON.stringify({ markerAt: "2026-07-28T00:00:00.000Z", deParallelRouterTrialFired: true }),
|
||||
);
|
||||
// ...and a stale writer rewrites config.json without the flag.
|
||||
fsState.files.set(
|
||||
CONFIG_PATH,
|
||||
JSON.stringify({ telemetryEnabled: true, anonymousId: "id", bucketSeed: "seed" }),
|
||||
);
|
||||
|
||||
expect(readConfigFresh().deParallelRouterTrialFired).toBe(true);
|
||||
});
|
||||
|
||||
// The latch got read-side authority; the seed did not, so the two stores
|
||||
// could hold different seeds indefinitely and a re-mint flipped every cohort.
|
||||
it("prefers the install-state seed over a restored config.json seed", () => {
|
||||
fsState.files.set(
|
||||
STATE_PATH,
|
||||
JSON.stringify({ markerAt: "2026-07-28T00:00:00.000Z", bucketSeed: "seed-B" }),
|
||||
);
|
||||
fsState.files.set(
|
||||
CONFIG_PATH,
|
||||
JSON.stringify({ telemetryEnabled: true, anonymousId: "id", bucketSeed: "seed-A" }),
|
||||
);
|
||||
expect(readConfig().bucketSeed).toBe("seed-B");
|
||||
});
|
||||
|
||||
// A corrupt file OUTSIDE ~/.hyperframes survived the documented reset and
|
||||
// reported a predecessor forever.
|
||||
it("deletes an unreadable pre-move state file instead of reporting it forever", () => {
|
||||
fsState.files.set(LEGACY_STATE_PATH, "{truncated");
|
||||
const config = readConfig();
|
||||
expect(config.stateFileCorrupt).toBe(true);
|
||||
expect(fsState.files.has(LEGACY_STATE_PATH), "legacy copy removed").toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("an unwritable config dir must not re-roll the seed", () => {
|
||||
let readConfig: typeof import("./config.js").readConfig;
|
||||
let readConfigFresh: typeof import("./config.js").readConfigFresh;
|
||||
|
||||
beforeEach(async () => {
|
||||
fsState.files.clear();
|
||||
policyState.runtimeOverride = null;
|
||||
vi.resetModules();
|
||||
({ readConfig, readConfigFresh } = await import("./config.js"));
|
||||
});
|
||||
|
||||
// No config.json AND an unwritable dir: cachedConfig was never set, so the
|
||||
// next read re-minted and the seed re-rolled — on every command, forever.
|
||||
it("keeps one seed for the process when the initial write fails", async () => {
|
||||
const fs = await import("node:fs");
|
||||
vi.mocked(fs.writeFileSync).mockImplementation(() => {
|
||||
throw new Error("EACCES: permission denied");
|
||||
});
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const first = readConfig();
|
||||
const second = readConfig();
|
||||
expect(first.bucketSeed).toBeTruthy();
|
||||
expect(second.bucketSeed).toBe(first.bucketSeed);
|
||||
expect(second.anonymousId).toBe(first.anonymousId);
|
||||
// And the user is told, rather than churning silently.
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
|
||||
warn.mockRestore();
|
||||
vi.mocked(fs.writeFileSync).mockImplementation((path, content) => {
|
||||
fsState.files.set(String(path), String(content));
|
||||
});
|
||||
});
|
||||
|
||||
it("stays silent for an install that opted out of telemetry", async () => {
|
||||
policyState.runtimeOverride = "HYPERFRAMES_NO_TELEMETRY";
|
||||
const fs = await import("node:fs");
|
||||
vi.mocked(fs.writeFileSync).mockImplementation(() => {
|
||||
throw new Error("EACCES: permission denied");
|
||||
});
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
readConfigFresh();
|
||||
// The only consequence is unstable canary cohorts, and an opted-out
|
||||
// install is never enrolled in one — so this line was pure noise in CI
|
||||
// logs, on every invocation, unsilenceable.
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
|
||||
warn.mockRestore();
|
||||
vi.mocked(fs.writeFileSync).mockImplementation((path, content) => {
|
||||
fsState.files.set(String(path), String(content));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
import { telemetryRuntimeOverride } from "./policy.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config directory: ~/.hyperframes/
|
||||
@@ -124,6 +125,12 @@ let seedBackfillWarned = false;
|
||||
function warnSeedBackfillFailed(error: string | undefined): void {
|
||||
if (seedBackfillWarned) return;
|
||||
seedBackfillWarned = true;
|
||||
// Suppressed when the user has opted out of telemetry: the only consequence
|
||||
// of the failed write is unstable CANARY cohorts, and an opted-out install
|
||||
// is not enrolled in any. Printing anyway put an unsilenceable line into
|
||||
// CI render logs on every invocation for a user who wants no telemetry at
|
||||
// all. `hyperframes doctor` still surfaces it on demand.
|
||||
if (telemetryRuntimeOverride() !== null) return;
|
||||
console.warn(
|
||||
`[hyperframes] Could not persist telemetry config${error ? `: ${error}` : ""}. ` +
|
||||
"Canary cohort assignment will not be stable across runs.",
|
||||
@@ -147,9 +154,12 @@ function backfillBucketSeed(config: HyperframesConfig): void {
|
||||
if (!write.ok) warnSeedBackfillFailed(write.error);
|
||||
}
|
||||
|
||||
// The latch is monotonic — once tripped it never untrips — so one read per
|
||||
// process is enough, and readConfig is hot (every command, every render).
|
||||
let latchFromStateFile: boolean | undefined;
|
||||
// ONLY the positive is cached. The latch is monotonic across processes in one
|
||||
// direction: once some process trips it, it stays tripped. A cached `false`
|
||||
// is not monotonic — another process can trip the breaker while this one is
|
||||
// alive, and a long-lived process (the preview/studio server) would then hold
|
||||
// a stale negative for hours. So `true` short-circuits and `false` re-reads.
|
||||
let latchedFiredSeen = false;
|
||||
|
||||
/**
|
||||
* Is the breaker latched according to install-state?
|
||||
@@ -163,11 +173,22 @@ let latchFromStateFile: boolean | undefined;
|
||||
* to prefer the safety fact.
|
||||
*/
|
||||
function installStateLatchedFired(): boolean {
|
||||
if (latchFromStateFile === undefined) {
|
||||
const state = readInstallState();
|
||||
latchFromStateFile = isInstallState(state) && state.deParallelRouterTrialFired === true;
|
||||
}
|
||||
return latchFromStateFile;
|
||||
if (latchedFiredSeen) return true;
|
||||
const state = readInstallState();
|
||||
latchedFiredSeen = isInstallState(state) && state.deParallelRouterTrialFired === true;
|
||||
return latchedFiredSeen;
|
||||
}
|
||||
|
||||
// Same shape as the latch memo, and same reason for existing: readConfig is
|
||||
// hot. A seed never changes once recorded, so the positive is cacheable.
|
||||
let seedFromStateFile: string | undefined;
|
||||
|
||||
/** The seed install-state has recorded for this machine, if any. */
|
||||
function installStateSeed(): string | undefined {
|
||||
if (seedFromStateFile !== undefined) return seedFromStateFile;
|
||||
const state = readInstallState();
|
||||
if (isInstallState(state) && state.bucketSeed !== undefined) seedFromStateFile = state.bucketSeed;
|
||||
return seedFromStateFile;
|
||||
}
|
||||
|
||||
/** Narrow the parse result to a usable record. */
|
||||
@@ -192,6 +213,12 @@ function readInstallState(): InstallState | InstallStateMiss {
|
||||
}
|
||||
const legacy = parseInstallState(LEGACY_STATE_FILE);
|
||||
if (!isInstallState(legacy)) {
|
||||
// Unreadable legacy copy: nothing to migrate, so drop it here too. It
|
||||
// used to survive, and since it lives OUTSIDE ~/.hyperframes it then
|
||||
// reported predecessorFound/stateFileCorrupt forever on a machine the
|
||||
// user had already reset with `rm -rf ~/.hyperframes` — poisoning the one
|
||||
// metric this file exists to produce.
|
||||
if (legacy === "corrupt") removeLegacyStateFile();
|
||||
// Corruption at EITHER location still means this machine had an install.
|
||||
return current === "corrupt" || legacy === "corrupt" ? "corrupt" : "absent";
|
||||
}
|
||||
@@ -281,7 +308,8 @@ function applyInstallState(config: HyperframesConfig, wantFired: boolean): void
|
||||
if (next !== null) writeInstallState(next);
|
||||
stateMarkerSynced = true;
|
||||
stateFiredSynced = wantFired || state?.deParallelRouterTrialFired === true;
|
||||
if (wantFired) latchFromStateFile = true;
|
||||
if (wantFired) latchedFiredSeen = true;
|
||||
if (next?.bucketSeed !== undefined) seedFromStateFile = next.bucketSeed;
|
||||
}
|
||||
|
||||
function syncInstallState(config: HyperframesConfig): boolean {
|
||||
@@ -296,6 +324,23 @@ function syncInstallState(config: HyperframesConfig): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a fresh config, persist it, and cache it EVEN IF the write failed.
|
||||
*
|
||||
* Without the unconditional cache the next readConfig in the same process
|
||||
* re-minted, re-rolling bucketSeed along with the id — and across processes an
|
||||
* unwritable ~/.hyperframes (read-only mount, root-owned after a sudo run,
|
||||
* full disk) meant a fresh cohort on every single command, which is exactly
|
||||
* the unbounded cumulative exposure the seed exists to prevent.
|
||||
*/
|
||||
function mintAndCacheConfig(): HyperframesConfig {
|
||||
const config = mintConfig();
|
||||
const write = writeConfigWithResult(config);
|
||||
if (!write.ok) warnSeedBackfillFailed(write.error);
|
||||
cachedConfig = { ...config };
|
||||
return { ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a brand-new config for an install with no (readable) config file,
|
||||
* consulting the install-state file for what a previous install on this
|
||||
@@ -501,55 +546,83 @@ function parseRecentRenders(value: unknown): RecentRenderRecord[] | undefined {
|
||||
* Read the config file, creating it with defaults if it doesn't exist.
|
||||
* Returns a mutable copy — call `writeConfig()` to persist changes.
|
||||
*/
|
||||
/**
|
||||
* Materialize a parsed config object, applying defaults and the explicit
|
||||
* type guards. Split out of readConfig purely for size — that function is
|
||||
* otherwise one long object literal plus four control-flow branches.
|
||||
*/
|
||||
/** Fields that are pure passthrough — no default, no validation. */
|
||||
function passthroughFields(parsed: Partial<HyperframesConfig>): Partial<HyperframesConfig> {
|
||||
return {
|
||||
lastUpdateCheck: parsed.lastUpdateCheck,
|
||||
latestVersion: parsed.latestVersion,
|
||||
lastStalePinNoticeAt: parsed.lastStalePinNoticeAt,
|
||||
pendingUpdate: parsed.pendingUpdate,
|
||||
completedUpdate: parsed.completedUpdate,
|
||||
lastSkillsCheck: parsed.lastSkillsCheck,
|
||||
skillsUpdateAvailable: parsed.skillsUpdateAvailable,
|
||||
skillsOutdatedCount: parsed.skillsOutdatedCount,
|
||||
skillsMissingCount: parsed.skillsMissingCount,
|
||||
skillsRemovedCount: parsed.skillsRemovedCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields that need an explicit type guard or a cross-store merge, split from
|
||||
* the plain defaults so neither block is complex on its own.
|
||||
*/
|
||||
function guardedFields(parsed: Partial<HyperframesConfig>): Partial<HyperframesConfig> {
|
||||
return {
|
||||
// Explicit `=== true`/typeof-number checks rather than a truthy/nullish
|
||||
// read — a hand-edited or corrupted config could plausibly carry a
|
||||
// non-boolean/non-number JSON value (e.g. the STRING "false", which is
|
||||
// truthy in JS) for these two fields specifically, since they're read
|
||||
// with a bare truthy check at the call site (review finding).
|
||||
// `|| installStateLatchedFired()` — a latch recorded in install-state
|
||||
// wins over an untripped config.json, never the reverse.
|
||||
deParallelRouterTrialFired:
|
||||
parsed.deParallelRouterTrialFired === true || installStateLatchedFired() ? true : undefined,
|
||||
deParallelRouterTrialRenderCount:
|
||||
typeof parsed.deParallelRouterTrialRenderCount === "number"
|
||||
? parsed.deParallelRouterTrialRenderCount
|
||||
: undefined,
|
||||
predecessorFound:
|
||||
typeof parsed.predecessorFound === "boolean" ? parsed.predecessorFound : undefined,
|
||||
stateFileCorrupt: parsed.stateFileCorrupt === true ? true : undefined,
|
||||
// Install-state wins, exactly as it does for the latch. Without this the
|
||||
// two stores could hold different seeds indefinitely: nextInstallState
|
||||
// is write-once (state's seed always survives), but the read took
|
||||
// config.json's blindly — so restoring or syncing only config.json left
|
||||
// the install bucketing on A while install-state kept B, and the next
|
||||
// re-mint silently flipped every cohort at once.
|
||||
bucketSeed: installStateSeed() ?? parseNonEmptyString(parsed.bucketSeed),
|
||||
};
|
||||
}
|
||||
|
||||
function materializeConfig(parsed: Partial<HyperframesConfig>): HyperframesConfig {
|
||||
return {
|
||||
...passthroughFields(parsed),
|
||||
telemetryEnabled: parsed.telemetryEnabled ?? DEFAULT_CONFIG.telemetryEnabled,
|
||||
anonymousId: parsed.anonymousId || randomUUID(),
|
||||
telemetryNoticeShown: parsed.telemetryNoticeShown ?? DEFAULT_CONFIG.telemetryNoticeShown,
|
||||
commandCount: parsed.commandCount ?? DEFAULT_CONFIG.commandCount,
|
||||
renderSuccessCount: parsed.renderSuccessCount ?? DEFAULT_CONFIG.renderSuccessCount,
|
||||
lastFeedbackPromptAt: parsed.lastFeedbackPromptAt ?? DEFAULT_CONFIG.lastFeedbackPromptAt,
|
||||
...guardedFields(parsed),
|
||||
recentRenders: parseRecentRenders(parsed.recentRenders),
|
||||
};
|
||||
}
|
||||
|
||||
export function readConfig(): HyperframesConfig {
|
||||
if (cachedConfig) return { ...cachedConfig };
|
||||
|
||||
if (!existsSync(CONFIG_FILE)) {
|
||||
const config = mintConfig();
|
||||
writeConfig(config);
|
||||
return config;
|
||||
}
|
||||
if (!existsSync(CONFIG_FILE)) return mintAndCacheConfig();
|
||||
|
||||
try {
|
||||
const raw = readFileSync(CONFIG_FILE, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Partial<HyperframesConfig>;
|
||||
|
||||
const config: HyperframesConfig = {
|
||||
telemetryEnabled: parsed.telemetryEnabled ?? DEFAULT_CONFIG.telemetryEnabled,
|
||||
anonymousId: parsed.anonymousId || randomUUID(),
|
||||
telemetryNoticeShown: parsed.telemetryNoticeShown ?? DEFAULT_CONFIG.telemetryNoticeShown,
|
||||
commandCount: parsed.commandCount ?? DEFAULT_CONFIG.commandCount,
|
||||
renderSuccessCount: parsed.renderSuccessCount ?? DEFAULT_CONFIG.renderSuccessCount,
|
||||
lastFeedbackPromptAt: parsed.lastFeedbackPromptAt ?? DEFAULT_CONFIG.lastFeedbackPromptAt,
|
||||
lastUpdateCheck: parsed.lastUpdateCheck,
|
||||
latestVersion: parsed.latestVersion,
|
||||
lastStalePinNoticeAt: parsed.lastStalePinNoticeAt,
|
||||
pendingUpdate: parsed.pendingUpdate,
|
||||
completedUpdate: parsed.completedUpdate,
|
||||
lastSkillsCheck: parsed.lastSkillsCheck,
|
||||
skillsUpdateAvailable: parsed.skillsUpdateAvailable,
|
||||
skillsOutdatedCount: parsed.skillsOutdatedCount,
|
||||
skillsMissingCount: parsed.skillsMissingCount,
|
||||
skillsRemovedCount: parsed.skillsRemovedCount,
|
||||
// Explicit `=== true`/typeof-number checks rather than a truthy/nullish
|
||||
// read — a hand-edited or corrupted config could plausibly carry a
|
||||
// non-boolean/non-number JSON value (e.g. the STRING "false", which is
|
||||
// truthy in JS) for these two fields specifically, since they're read
|
||||
// with a bare truthy check at the call site (review finding).
|
||||
// `|| installStateLatchedFired()` — a latch recorded in install-state
|
||||
// wins over an untripped config.json, never the reverse.
|
||||
deParallelRouterTrialFired:
|
||||
parsed.deParallelRouterTrialFired === true || installStateLatchedFired() ? true : undefined,
|
||||
deParallelRouterTrialRenderCount:
|
||||
typeof parsed.deParallelRouterTrialRenderCount === "number"
|
||||
? parsed.deParallelRouterTrialRenderCount
|
||||
: undefined,
|
||||
predecessorFound:
|
||||
typeof parsed.predecessorFound === "boolean" ? parsed.predecessorFound : undefined,
|
||||
stateFileCorrupt: parsed.stateFileCorrupt === true ? true : undefined,
|
||||
bucketSeed: parseNonEmptyString(parsed.bucketSeed),
|
||||
recentRenders: parseRecentRenders(parsed.recentRenders),
|
||||
};
|
||||
const config = materializeConfig(parsed);
|
||||
|
||||
// One-time backfill for configs predating the bucket seed: prefer the
|
||||
// recorded seed if a previous install already wrote one, else mint.
|
||||
|
||||
@@ -5,7 +5,9 @@ async function loadPolicy(options?: { devMode?: boolean; apiKey?: string }) {
|
||||
vi.doMock("../utils/env.js", () => ({
|
||||
isDevMode: () => options?.devMode ?? false,
|
||||
}));
|
||||
vi.doMock("./transport.js", () => ({
|
||||
// The key moved to a leaf module to break a config -> policy -> transport
|
||||
// -> config import cycle; policy.ts reads it from there now.
|
||||
vi.doMock("./posthogKey.js", () => ({
|
||||
POSTHOG_API_KEY: options?.apiKey ?? "phc_test",
|
||||
}));
|
||||
return import("./policy.js");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isDevMode } from "../utils/env.js";
|
||||
import { POSTHOG_API_KEY } from "./transport.js";
|
||||
import { POSTHOG_API_KEY } from "./posthogKey.js";
|
||||
|
||||
export type TelemetryStatusSource =
|
||||
| "config"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* The PostHog write-only ingest key, as a LEAF module.
|
||||
*
|
||||
* It lives here rather than in transport.ts because `policy.ts` needs it (an
|
||||
* unconfigured key is itself a telemetry opt-out) and importing transport for
|
||||
* one constant created `config.ts -> policy.ts -> transport.ts -> config.ts`.
|
||||
* A cycle through the telemetry config is the kind that bites at module-init
|
||||
* time, so the constant moved instead of the dependency being tolerated.
|
||||
*/
|
||||
export const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
|
||||
@@ -1,10 +1,11 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { POSTHOG_API_KEY } from "./posthogKey.js";
|
||||
import { readConfig } from "./config.js";
|
||||
|
||||
// This is a public project API key — safe to embed in client-side code.
|
||||
// It only allows writing events, not reading data.
|
||||
export const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
|
||||
|
||||
const POSTHOG_HOST = "https://us.i.posthog.com";
|
||||
const FLUSH_TIMEOUT_MS = 5_000;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user