Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user