@@ -11,6 +11,128 @@ import { normalizeErrorMessage } from "../utils/errorMessage.js";
1111const CONFIG_DIR = join ( homedir ( ) , ".hyperframes" ) ;
1212const CONFIG_FILE = join ( CONFIG_DIR , "config.json" ) ;
1313
14+ // ---------------------------------------------------------------------------
15+ // Install-state file: ~/.local/state/hyperframes/install-state.json
16+ //
17+ // A second, deliberately separate location from CONFIG_DIR, so it survives
18+ // the most common identity reset — deleting or reinstalling ~/.hyperframes.
19+ // It exists to carry exactly two facts across that reset, and nothing else:
20+ //
21+ // 1. `markerAt` — "a hyperframes install existed on this machine". Written
22+ // unconditionally, so the fraction of fresh installs that find it is a
23+ // direct measurement of recoverable id churn (config wiped, machine
24+ // persisted) vs unrecoverable (fresh machine/container/new user).
25+ // 2. `deParallelRouterTrialFired` — the DE parallel-router circuit
26+ // breaker's tripped state. Without this, a config wipe re-enrols the
27+ // install into an experimental path that already FAILED on this exact
28+ // machine; the breaker's whole point is that a real failure turns the
29+ // trial off for good.
30+ //
31+ // It intentionally holds NO identity: no anonymousId, no counters, nothing
32+ // that could link the old install to the new one. A user who wipes their
33+ // config gets a fresh id unconditionally — this file only stops the wipe
34+ // from also discarding a safety fact about the machine.
35+ // ---------------------------------------------------------------------------
36+
37+ const STATE_DIR = join ( homedir ( ) , ".local" , "state" , "hyperframes" ) ;
38+ const STATE_FILE = join ( STATE_DIR , "install-state.json" ) ;
39+
40+ interface InstallState {
41+ /** ISO timestamp of when the marker was first written. */
42+ markerAt : string ;
43+ /** Rolled-over circuit-breaker state — see HyperframesConfig's field. */
44+ deParallelRouterTrialFired ?: boolean ;
45+ }
46+
47+ /** Read the install-state file; any parse/shape failure reads as absent. */
48+ function readInstallState ( ) : InstallState | null {
49+ try {
50+ if ( ! existsSync ( STATE_FILE ) ) return null ;
51+ const parsed = JSON . parse ( readFileSync ( STATE_FILE , "utf-8" ) ) as Partial < InstallState > ;
52+ if ( typeof parsed . markerAt !== "string" ) return null ;
53+ return {
54+ markerAt : parsed . markerAt ,
55+ deParallelRouterTrialFired : parsed . deParallelRouterTrialFired === true ? true : undefined ,
56+ } ;
57+ } catch {
58+ return null ;
59+ }
60+ }
61+
62+ // Sync bookkeeping, so the existsSync+read doesn't run on every writeConfig:
63+ // `stateMarkerSynced` = the marker is known present; `stateFiredSynced` = the
64+ // state file is known to already carry fired=true.
65+ let stateMarkerSynced = false ;
66+ let stateFiredSynced = false ;
67+
68+ /** Test-only: reset the sync memo (module state leaks across vitest cases). */
69+ export function __resetInstallStateSyncForTests ( ) : void {
70+ stateMarkerSynced = false ;
71+ stateFiredSynced = false ;
72+ }
73+
74+ /**
75+ * Atomic for the same reason writeConfig is: a torn read must never exist,
76+ * since a corrupted state file silently reads as absent.
77+ */
78+ function writeInstallState ( next : InstallState ) : void {
79+ mkdirSync ( STATE_DIR , { recursive : true , mode : 0o700 } ) ;
80+ const tmpFile = `${ STATE_FILE } .${ process . pid } .tmp` ;
81+ writeFileSync ( tmpFile , JSON . stringify ( next , null , 2 ) + "\n" , { mode : 0o600 } ) ;
82+ renameSync ( tmpFile , STATE_FILE ) ;
83+ }
84+
85+ /**
86+ * Bring the install-state file up to date with this config write: ensure the
87+ * marker exists, and mirror a tripped breaker. Called from `writeConfig` so
88+ * no breaker write site can forget it. Never throws — same contract as the
89+ * rest of this file, telemetry must not break the CLI.
90+ */
91+ /** What the state file should say after this config write; null = already correct. */
92+ function nextInstallState ( state : InstallState | null , wantFired : boolean ) : InstallState | null {
93+ const hadFired = state ?. deParallelRouterTrialFired === true ;
94+ if ( state !== null && ( hadFired || ! wantFired ) ) return null ;
95+ // Every path reaching here has hadFired === false (state is either null, or
96+ // the guard above already returned when hadFired was true) — the field is
97+ // simply wantFired, not a merge of the two (review nit, two independent
98+ // reviewers).
99+ return {
100+ markerAt : state ?. markerAt ?? new Date ( ) . toISOString ( ) ,
101+ deParallelRouterTrialFired : wantFired || undefined ,
102+ } ;
103+ }
104+
105+ function syncInstallState ( config : HyperframesConfig ) : void {
106+ const wantFired = config . deParallelRouterTrialFired === true ;
107+ if ( stateMarkerSynced && ( stateFiredSynced || ! wantFired ) ) return ;
108+ try {
109+ const state = readInstallState ( ) ;
110+ const next = nextInstallState ( state , wantFired ) ;
111+ if ( next !== null ) writeInstallState ( next ) ;
112+ stateMarkerSynced = true ;
113+ stateFiredSynced = wantFired || state ?. deParallelRouterTrialFired === true ;
114+ } catch {
115+ // Leave the memo unset so a later write retries.
116+ }
117+ }
118+
119+ /**
120+ * Build a brand-new config for an install with no (readable) config file,
121+ * consulting the install-state file for what a previous install on this
122+ * machine left behind.
123+ */
124+ function mintConfig ( ) : HyperframesConfig {
125+ const state = readInstallState ( ) ;
126+ return {
127+ ...DEFAULT_CONFIG ,
128+ anonymousId : randomUUID ( ) ,
129+ predecessorFound : state !== null ,
130+ // The rollover itself: a breaker tripped by a previous install on this
131+ // machine stays tripped for the new one.
132+ deParallelRouterTrialFired : state ?. deParallelRouterTrialFired === true ? true : undefined ,
133+ } ;
134+ }
135+
14136export interface HyperframesConfig {
15137 /** Whether anonymous telemetry is enabled (default: true in production) */
16138 telemetryEnabled : boolean ;
@@ -86,6 +208,14 @@ export interface HyperframesConfig {
86208 * `DE_PARALLEL_ROUTER_TRIAL_MAX_RENDERS` in `render.ts`.
87209 */
88210 deParallelRouterTrialRenderCount ?: number ;
211+ /**
212+ * Whether a previous install's state marker existed on this machine when
213+ * this config was minted. Attached to telemetry so the fraction of fresh
214+ * installs that are RECOVERABLE churn (config wiped, machine persisted) is
215+ * measurable directly. `undefined` on configs minted before this field
216+ * existed — a different fact from `false` (minted fresh, no predecessor).
217+ */
218+ predecessorFound ?: boolean ;
89219 /**
90220 * Ring of the last few local renders (newest last). `hyperframes feedback`
91221 * attaches these ids — which are the `render_job_id` /
@@ -140,7 +270,7 @@ export function readConfig(): HyperframesConfig {
140270 if ( cachedConfig ) return { ...cachedConfig } ;
141271
142272 if ( ! existsSync ( CONFIG_FILE ) ) {
143- const config = { ... DEFAULT_CONFIG , anonymousId : randomUUID ( ) } ;
273+ const config = mintConfig ( ) ;
144274 writeConfig ( config ) ;
145275 return config ;
146276 }
@@ -176,6 +306,8 @@ export function readConfig(): HyperframesConfig {
176306 typeof parsed . deParallelRouterTrialRenderCount === "number"
177307 ? parsed . deParallelRouterTrialRenderCount
178308 : undefined ,
309+ predecessorFound :
310+ typeof parsed . predecessorFound === "boolean" ? parsed . predecessorFound : undefined ,
179311 recentRenders : Array . isArray ( parsed . recentRenders )
180312 ? parsed . recentRenders
181313 . filter (
@@ -195,14 +327,10 @@ export function readConfig(): HyperframesConfig {
195327 } catch {
196328 // A missing file is handled above. Any failure here means an existing
197329 // preference could not be read safely (corrupt JSON, permissions, I/O).
198- // Preserve the historical recovery behavior for the rest of the config,
199- // but fail closed for the privacy control: recovery must never silently
200- // turn telemetry back on.
201- const config = {
202- ...DEFAULT_CONFIG ,
203- telemetryEnabled : false ,
204- anonymousId : randomUUID ( ) ,
205- } ;
330+ // Recover through the same mint path as a missing file — so a tripped
331+ // breaker survives config corruption too — but fail closed for the
332+ // privacy control: recovery must never silently turn telemetry back on.
333+ const config = { ...mintConfig ( ) , telemetryEnabled : false } ;
206334 writeConfig ( config ) ;
207335 return config ;
208336 }
@@ -254,6 +382,9 @@ export function writeConfigWithResult(config: HyperframesConfig): ConfigWriteRes
254382 writeFileSync ( tmpFile , JSON . stringify ( config , null , 2 ) + "\n" , { mode : 0o600 } ) ;
255383 renameSync ( tmpFile , CONFIG_FILE ) ;
256384 cachedConfig = { ...config } ;
385+ // Mirror into the install-state file (marker + tripped breaker) so no
386+ // breaker write site has to remember to do it.
387+ syncInstallState ( config ) ;
257388 return { ok : true } ;
258389 } catch ( error ) {
259390 // Non-fatal — telemetry should never break the CLI
@@ -273,3 +404,6 @@ export function incrementCommandCount(): number {
273404
274405/** Expose the config directory path for the telemetry command output */
275406export const CONFIG_PATH = CONFIG_FILE ;
407+
408+ /** Expose the install-state path for the telemetry command output. */
409+ export const STATE_PATH = STATE_FILE ;
0 commit comments