hermes-agent/apps/desktop/electron/bundled-runtime.ts

217 lines
7.1 KiB
TypeScript

// bundled-runtime.ts: pure helpers for the embedded desktop runtime.
// An Embedded artifact carries the whole agent runtime in its resources
// and ALWAYS spawns the backend from there — there is no decision contest
// against checkouts. This module only answers: does a complete payload
// exist (resolvePayload), where is its interpreter (findEmbeddedPython),
// and what update channel applies (resolveChannel).
//
// Design: .hermes/plans/2026-08-07_183000-two-axis-install-model.md.
//
// All functions in this file are pure, and the callers inject the
// dependencies. Thus vitest covers the whole decision surface. The impure
// executors live in main.ts and bootstrap-runner.
import fs from 'node:fs'
import path from 'node:path'
// ─── payload discovery ──────────────────────────────────────────────────────
export interface PayloadInfo {
dir: string
tag: string | null
}
/**
* Resolve the agent-payload directory that ships in the resources of the
* packaged app. Returns null for external builds (a stub manifest with
* external:true), for dev runs (no resourcesPath), for unreadable or
* old-schema manifests, and for payloads with a missing item directory.
* Item presence is a build-time invariant (staging fails the build on an
* incomplete payload), so a missing directory here means a damaged or
* truncated artifact — the caller reports it, it does not fall back.
*/
export function resolvePayload(
resourcesPath: string | null | undefined,
readFile: (p: string) => string = p => fs.readFileSync(p, 'utf8'),
dirExists: (p: string) => boolean = p => {
try {
return fs.statSync(p).isDirectory()
} catch {
return false
}
}
): PayloadInfo | null {
if (!resourcesPath) {
return null
}
const dir = path.join(resourcesPath, 'agent-payload')
let parsed
try {
parsed = JSON.parse(readFile(path.join(dir, 'manifest.json')))
} catch {
return null
}
if (!parsed || typeof parsed !== 'object' || parsed.external === true) {
return null
}
if (parsed.schemaVersion !== PAYLOAD_SCHEMA_VERSION) {
return null
}
if (!EMBEDDED_RUNTIME_ITEMS.every(item => dirExists(path.join(dir, item)))) {
return null
}
return {
dir,
tag: typeof parsed.tag === 'string' ? parsed.tag : null
}
}
// The manifest schema this build understands. Staging writes the same
// number (stage-agent-payloads.mjs); the app and its payload travel in the
// same artifact, so a mismatch means a damaged or foreign artifact.
export const PAYLOAD_SCHEMA_VERSION = 3
// The runtime items inside a complete embedded payload — all of them. uv
// never installs the runtime (site-packages ships prebuilt), but runtime
// lazy installs for plugins are a mandatory feature, and uv is what
// installs them into the writable overlay. A payload without uv is an
// incomplete artifact, not a degraded one.
export const EMBEDDED_RUNTIME_ITEMS = ['repo', 'uv', 'python', 'site-packages', 'node'] as const
/**
* Locate the payload CPython binary. The install directory is
* patch-versioned (python/cpython-3.11.15-<triple>/...), so this scans
* rather than hardcoding, and it verifies the binary exists.
*/
export function findEmbeddedPython(
payloadDir: string,
platform: NodeJS.Platform = process.platform,
fsImpl: Pick<typeof fs, 'readdirSync' | 'existsSync'> = fs
): string | null {
const pythonRoot = path.join(payloadDir, 'python')
let entries: string[]
try {
entries = fsImpl.readdirSync(pythonRoot)
} catch {
return null
}
// Prefer the patch-versioned real directory over the minor alias so the
// resolved path is stable across launches (the alias is a symlink).
for (const entry of entries.filter((name) => name.startsWith('cpython-')).sort().reverse()) {
const candidate =
platform === 'win32'
? path.join(pythonRoot, entry, 'python.exe')
: path.join(pythonRoot, entry, 'bin', 'python3')
if (fsImpl.existsSync(candidate)) {
return candidate
}
}
return null
}
// ─── update channel ─────────────────────────────────────────────────────────
/**
* The update channel of a source checkout, read from config.yaml text
* (`update.channel`). The CLI owns this key; Electron only mirrors it for
* the version pill. Anything but an explicit `stable` means `main` — the
* default channel. Embedded artifacts never call this: their updates are
* release-fed by construction.
*
* The parser is deliberately narrow: find the top-level `update:` block,
* then the first `channel:` inside it. config.yaml is machine-written
* (`hermes config set update.channel ...`), so this shape is stable.
*/
export function updateChannelFromConfig(configText: string | null | undefined): 'stable' | 'main' {
if (!configText) {
return 'main'
}
let inUpdateBlock = false
for (const raw of configText.split('\n')) {
const line = raw.replace(/\s+$/, '')
if (/^update:\s*$/.test(line)) {
inUpdateBlock = true
continue
}
if (inUpdateBlock) {
// The block ends at the next top-level key (no leading whitespace).
if (/^\S/.test(line)) {
break
}
const match = line.match(/^\s+channel:\s*["']?(stable|main)["']?\s*(#.*)?$/)
if (match) {
return match[1] as 'stable' | 'main'
}
}
}
return 'main'
}
/**
* Pick the newest final release tag (vX.Y.Z, no prerelease suffix) from
* `git ls-remote --tags` output. Numeric ordering, so v0.10.0 > v0.9.0.
* Returns null when the output has no final release tag.
*
* A peeled entry (`refs/tags/v1.2.3^{}`) resolves the commit that an
* annotated tag points at. It wins over the unpeeled line of the same tag.
*/
export function latestReleaseFromLsRemote(output: string): { tag: string; sha: string } | null {
const versions = new Map<string, { key: [number, number, number]; sha: string; peeled: boolean }>()
for (const line of output.split('\n')) {
// The major component is capped at three digits: the historical CalVer
// tags (v2026.7.20) would win every numeric sort. This mirrors
// _RELEASE_TAG_RE in hermes_cli/update_cmd.py and _SEMVER_TAG_RE in
// scripts/write_install_stamp.py.
const m = line.match(/^([0-9a-f]{40})\trefs\/tags\/(v(?:0|[1-9]\d{0,2})\.\d+\.\d+)(\^\{\})?$/)
if (!m) {
continue
}
const [, sha, tag, peel] = m
const existing = versions.get(tag)
if (!existing || (peel && !existing.peeled)) {
const [major, minor, patch] = tag.slice(1).split('.').map(Number)
versions.set(tag, { key: [major, minor, patch], sha, peeled: Boolean(peel) })
}
}
let best: { tag: string; sha: string; key: [number, number, number] } | null = null
for (const [tag, { key, sha }] of versions) {
const newer =
!best ||
key[0] > best.key[0] ||
(key[0] === best.key[0] && (key[1] > best.key[1] || (key[1] === best.key[1] && key[2] > best.key[2])))
if (newer) {
best = { tag, sha, key }
}
}
return best ? { tag: best.tag, sha: best.sha } : null
}