312 lines
9.8 KiB
TypeScript
312 lines
9.8 KiB
TypeScript
// Pure migration-decision helpers for active-profile.json first-boot seeding.
|
||
// Extracted from main.ts so they're unit-testable without Electron.
|
||
//
|
||
// The helpers here are deliberately side-effect-free except for the orchestrator
|
||
// (`migrateActiveProfileIfMissing`), which only writes the preference file when
|
||
// every fs/path concern is funnelled through the injected `MigrationDeps` bag.
|
||
// Tests inject synthetic fs maps; production wires real fs/path.
|
||
|
||
import type { Dirent } from 'node:fs'
|
||
|
||
// Floor for the size dimension of the hybrid score. A 100-byte file (effectively
|
||
// empty) and a 1 KiB file score the same on the size axis — both are too small to
|
||
// be the user's primary workspace on their own.
|
||
export const PROFILE_SCORE_MIN_SIZE_BYTES = 1024
|
||
|
||
export interface MigrationDeps {
|
||
legacyActivePath: string
|
||
/** Default profile home (`~/.hermes`). Default's state.db and gateway.pid live here. */
|
||
hermesHome: string
|
||
/** Named-profile root (`~/.hermes/profiles`). Does not contain `default`. */
|
||
profilesRoot: string
|
||
existsSync: (path: string) => boolean
|
||
readFileSync: (path: string, encoding: 'utf8') => string
|
||
statSync: (path: string) => { size: number; mtimeMs: number }
|
||
readdirSync: (path: string, options?: { withFileTypes?: boolean }) => Dirent[]
|
||
isHermesProcess: (pid: number) => boolean
|
||
now: () => number
|
||
writeJson: (path: string, payload: MigrationDecision) => void
|
||
isValidProfileName: (name: string) => boolean
|
||
}
|
||
|
||
export interface MigrationDecision {
|
||
profile: string | null
|
||
/** True when chosen from the state.db heuristic (auto-detected), undefined when explicit. */
|
||
_migrated?: boolean
|
||
}
|
||
|
||
/**
|
||
* Production layout: default IS `hermesHome`; named profiles are children of
|
||
* `profilesRoot`. There is no `profiles/default` directory on a normal install.
|
||
*/
|
||
export function profileStateDbPath(name: string, hermesHome: string, profilesRoot: string): string {
|
||
return name === 'default' ? `${hermesHome}/state.db` : `${profilesRoot}/${name}/state.db`
|
||
}
|
||
|
||
export function profileGatewayPidPath(name: string, hermesHome: string, profilesRoot: string): string {
|
||
return name === 'default' ? `${hermesHome}/gateway.pid` : `${profilesRoot}/${name}/gateway.pid`
|
||
}
|
||
|
||
function resolveHermesHome(profilesRoot: string, hermesHome?: string): string {
|
||
if (hermesHome) {
|
||
return hermesHome
|
||
}
|
||
|
||
// Tests that predate hermesHome pass only profilesRoot.
|
||
for (const suffix of ['/profiles', '\\profiles']) {
|
||
if (profilesRoot.endsWith(suffix)) {
|
||
return profilesRoot.slice(0, -suffix.length)
|
||
}
|
||
}
|
||
|
||
return profilesRoot
|
||
}
|
||
|
||
/**
|
||
* Parse the legacy CLI-sticky file. Returns the trimmed name on success, null when
|
||
* missing/unreadable/empty, undefined when present but invalid (so the caller can
|
||
* distinguish "explicitly chose default" from "no legacy file at all"). The
|
||
* `'default'` profile is always rejected here — it's an implicit fallback, never a
|
||
* user-chosen CLI value, and accepting it would suppress the heuristic rung that
|
||
* is the whole point of this migration.
|
||
*/
|
||
export function readLegacyActiveProfile(
|
||
legacyActivePath: string,
|
||
readFile: MigrationDeps['readFileSync'],
|
||
isValid: MigrationDeps['isValidProfileName']
|
||
): string | null | undefined {
|
||
let raw: string
|
||
|
||
try {
|
||
raw = readFile(legacyActivePath, 'utf8')
|
||
} catch {
|
||
return null
|
||
}
|
||
|
||
const name = raw.trim()
|
||
|
||
if (!name) {
|
||
return null
|
||
}
|
||
|
||
if (name === 'default') {
|
||
return undefined
|
||
}
|
||
|
||
return isValid(name) ? name : undefined
|
||
}
|
||
|
||
/**
|
||
* Return the profile names whose gateway.pid file points to a live hermes process.
|
||
* Tolerates missing/malformed pid files and stale-but-recycled PIDs (the latter is
|
||
* the whole reason we check both liveness AND cmdline identity).
|
||
*
|
||
* `hermesHome` is optional so existing call sites that only pass `profilesRoot`
|
||
* still work: it is derived as the parent of `…/profiles`.
|
||
*/
|
||
export function findRunningGatewayProfiles(
|
||
profilesRoot: string,
|
||
allProfiles: string[],
|
||
deps: Pick<MigrationDeps, 'existsSync' | 'readFileSync' | 'isHermesProcess'> & { hermesHome?: string }
|
||
): string[] {
|
||
const hermesHome = resolveHermesHome(profilesRoot, deps.hermesHome)
|
||
const running: string[] = []
|
||
|
||
for (const name of allProfiles) {
|
||
const pidFile = profileGatewayPidPath(name, hermesHome, profilesRoot)
|
||
|
||
if (!deps.existsSync(pidFile)) {
|
||
continue
|
||
}
|
||
|
||
let parsed: { pid?: unknown } | null = null
|
||
|
||
try {
|
||
parsed = JSON.parse(deps.readFileSync(pidFile, 'utf8'))
|
||
} catch {
|
||
continue
|
||
}
|
||
|
||
const pid = Number(parsed?.pid)
|
||
|
||
if (!Number.isInteger(pid) || pid < 1) {
|
||
continue
|
||
}
|
||
|
||
if (deps.isHermesProcess(pid)) {
|
||
running.push(name)
|
||
}
|
||
}
|
||
|
||
return running
|
||
}
|
||
|
||
/**
|
||
* Hybrid recency × size score for a state.db file. Returns null when the file is
|
||
* missing. The formula picks the primary workspace across profiles whose databases
|
||
* have been touched at similar times — a 409 MB DB beats a 28 MB one even with a
|
||
* slightly newer mtime. Floors the recency weight at 0.1 so a profile touched
|
||
* years ago but never deleted still scores > 0 if its DB is large.
|
||
*/
|
||
export function scoreStateDb(dbPath: string, now: number, stat: MigrationDeps['statSync']): number | null {
|
||
let s: { size: number; mtimeMs: number }
|
||
|
||
try {
|
||
s = stat(dbPath)
|
||
} catch {
|
||
return null
|
||
}
|
||
|
||
const daysSinceModified = Math.max(0, (now - s.mtimeMs) / (1000 * 60 * 60 * 24))
|
||
const recencyWeight = Math.max(0.1, 30 - daysSinceModified)
|
||
const sizeWeight = Math.log10(Math.max(PROFILE_SCORE_MIN_SIZE_BYTES, s.size))
|
||
|
||
return recencyWeight * sizeWeight
|
||
}
|
||
|
||
/**
|
||
* Pure decision logic. Returns null when nothing migratable was found.
|
||
* - legacy non-null → always prefer (no _migrated flag, user-chosen)
|
||
* - running.length === 1 → prefer that profile (no flag, gateway-owned)
|
||
* - state.db heuristic → set _migrated=true (auto-detected)
|
||
* - best === 'default' → suppress write (single-profile fallback)
|
||
*/
|
||
export function decideMigration(
|
||
legacyActive: string | null | undefined,
|
||
running: string[],
|
||
candidates: string[],
|
||
deps: MigrationDeps,
|
||
score: (dbPath: string) => number | null
|
||
): MigrationDecision | null {
|
||
if (legacyActive) {
|
||
return { profile: legacyActive }
|
||
}
|
||
|
||
if (running.length === 1) {
|
||
return { profile: running[0] }
|
||
}
|
||
|
||
let best: string | null = null
|
||
let maxScore = -Infinity
|
||
|
||
for (const name of candidates) {
|
||
const s = score(profileStateDbPath(name, deps.hermesHome, deps.profilesRoot))
|
||
|
||
if (s == null) {
|
||
continue
|
||
}
|
||
|
||
if (s > maxScore) {
|
||
maxScore = s
|
||
best = name
|
||
}
|
||
}
|
||
|
||
if (!best || best === 'default') {
|
||
return null
|
||
}
|
||
|
||
return { profile: best, _migrated: true }
|
||
}
|
||
|
||
/**
|
||
* List named profile directory names under `profilesRoot`. A directory named
|
||
* `default` is accepted if present (unusual) but production default is not a
|
||
* child of this folder — see `withDefaultCandidate`.
|
||
*/
|
||
export function listProfileDirs(deps: MigrationDeps): string[] {
|
||
let entries: Dirent[]
|
||
|
||
try {
|
||
entries = deps.readdirSync(deps.profilesRoot, { withFileTypes: true })
|
||
} catch {
|
||
return []
|
||
}
|
||
|
||
return entries
|
||
.filter(e => e.isDirectory() && (e.name === 'default' || deps.isValidProfileName(e.name)))
|
||
.map(e => e.name)
|
||
}
|
||
|
||
/** Default is always a candidate; it is `$HERMES_HOME`, not `$HERMES_HOME/profiles/default`. */
|
||
export function withDefaultCandidate(named: string[]): string[] {
|
||
return ['default', ...named.filter(name => name !== 'default')]
|
||
}
|
||
|
||
/**
|
||
* Read an existing active-profile.json. Returns null when missing/malformed.
|
||
* `_migrated: true` means the first-boot heuristic wrote it (safe to re-score).
|
||
* Absence of that flag is a user/CLI choice and must not be overwritten.
|
||
*/
|
||
export function readExistingPreference(
|
||
desktopProfileConfigPath: string,
|
||
readFile: MigrationDeps['readFileSync']
|
||
): { profile: string | null; migrated: boolean } | null {
|
||
let parsed: unknown
|
||
|
||
try {
|
||
parsed = JSON.parse(readFile(desktopProfileConfigPath, 'utf8'))
|
||
} catch {
|
||
return null
|
||
}
|
||
|
||
if (!parsed || typeof parsed !== 'object') {
|
||
return null
|
||
}
|
||
|
||
const rec = parsed as { profile?: unknown; _migrated?: unknown }
|
||
const raw = typeof rec.profile === 'string' ? rec.profile.trim() : ''
|
||
|
||
return {
|
||
profile: raw || null,
|
||
migrated: rec._migrated === true
|
||
}
|
||
}
|
||
|
||
/**
|
||
* First-boot seed, plus repair of heuristic-owned files (`_migrated: true`).
|
||
* User-selected files (no `_migrated`) are never overwritten. When a repaired
|
||
* heuristic would now pick default, write `{ profile: null }` so Desktop drops
|
||
* `--profile` instead of pinning `default`.
|
||
*/
|
||
export function migrateActiveProfileIfMissing(desktopProfileConfigPath: string, deps: MigrationDeps): boolean {
|
||
const existing = deps.existsSync(desktopProfileConfigPath)
|
||
? readExistingPreference(desktopProfileConfigPath, deps.readFileSync)
|
||
: null
|
||
|
||
if (existing && !existing.migrated) {
|
||
return false
|
||
}
|
||
|
||
const legacyActive = readLegacyActiveProfile(deps.legacyActivePath, deps.readFileSync, deps.isValidProfileName)
|
||
|
||
const allProfiles = withDefaultCandidate(listProfileDirs(deps))
|
||
const running = findRunningGatewayProfiles(deps.profilesRoot, allProfiles, deps)
|
||
const candidates = running.length > 1 ? running : allProfiles
|
||
|
||
const decision = decideMigration(legacyActive, running, candidates, deps, dbPath =>
|
||
scoreStateDb(dbPath, deps.now(), deps.statSync)
|
||
)
|
||
|
||
// Same as the heuristic rung: pinning `default` into active-profile.json
|
||
// launches `hermes --profile default` and is worse than writing nothing
|
||
// (legacy sticky / implicit default). Covers a lone default gateway.pid.
|
||
if (!decision || decision.profile === 'default') {
|
||
if (existing?.migrated) {
|
||
deps.writeJson(desktopProfileConfigPath, { profile: null })
|
||
|
||
return true
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
if (existing?.migrated && existing.profile === decision.profile) {
|
||
return false
|
||
}
|
||
|
||
deps.writeJson(desktopProfileConfigPath, decision)
|
||
|
||
return true
|
||
}
|