mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 13:26:40 +00:00
fix(update): reuse the packaged desktop app when no desktop build input changed
hermes update rebuilt and repackaged the Electron app (6-38 min on Windows) on every commit move, because _packaged_desktop_current_for_head requires the app's baked commit to be HEAD before the freshness receipt is consulted. The receipt already content-hashes every desktop build input (sources, lockfile, build scripts, prepared icons/native trees, renderer env, Node, platform, arch) and the output. The baked install stamp is the one input it cannot see. desktop_build_reuse asks whether a build at HEAD would bake a stamp that differs in anything but commit provenance (commit, commitDate, dirty, builtAt), using write-build-stamp.mjs's own resolver, and reuses the packaged app when the receipt is current and it would not. The reused app keeps naming the commit it was built from: the stamp is never rewritten (bundle-swap would relaunch into a "swap" forever), and bundle-skew counts desktop runtime-path commits since it, which identical inputs rule out.
This commit is contained in:
1 parent
5c6660eaf5
commit
7a954418ce
3 files changed
+108
No files matched your search
@@ -0,0 +1,72 @@
|
||||
"""Reuse a packaged desktop app across commits that changed none of its build inputs.
|
||||
|
||||
``hermes update`` used to rebuild and repackage the Electron app (6-38 min on Windows) whenever HEAD
|
||||
moved, because ``_packaged_desktop_current_for_head`` requires the app's baked commit to BE HEAD. The
|
||||
freshness receipt already proves everything else the build reads is byte-identical; the baked
|
||||
install stamp is the one input it cannot see, since only a build rewrites it. This module answers
|
||||
the missing half: would a build at HEAD bake a stamp that differs in anything but commit provenance?
|
||||
|
||||
The reused app keeps naming the commit it was really built from (never rewritten: the running app
|
||||
compares its boot stamp with the one on disk and would relaunch into the "swap" forever). Every
|
||||
reader of that commit tolerates an older one: bundle skew counts only desktop runtime-path commits
|
||||
since it, which identical inputs rule out, and bootstrap repair follows the checkout's own HEAD.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _stamp_drift(project_root: Path, baked: Path, env: dict) -> Optional[list[str]]:
|
||||
"""Fields a build at HEAD would bake differently, or None when that cannot be told."""
|
||||
from pm import installed_package
|
||||
|
||||
installed = installed_package("node")
|
||||
if installed is None or installed.binary is None:
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(installed.binary), str(project_root / "scripts/build/desktop-reuse.mjs"),
|
||||
"--source", str(project_root), "--baked", str(baked)],
|
||||
cwd=project_root, env=env, capture_output=True, text=True, encoding="utf-8",
|
||||
errors="replace", timeout=60, check=True,
|
||||
)
|
||||
drift = json.loads(result.stdout.strip().splitlines()[-1])
|
||||
except (OSError, subprocess.SubprocessError, ValueError, IndexError):
|
||||
return None
|
||||
return drift if isinstance(drift, list) else None
|
||||
|
||||
|
||||
def packaged_desktop_reusable(desktop_dir: Path, project_root: Path, env: dict) -> Optional[str]:
|
||||
"""The commit the packaged app was built from, when it can stand for HEAD unchanged.
|
||||
|
||||
Requires a current freshness receipt for the packaged bundle (inputs and output intact, not
|
||||
torn, native binary present) whose baked stamp is the prepared stamp that receipt covers, and a
|
||||
stamp a build at HEAD would bake that differs only in commit provenance. Anything unreadable
|
||||
means build.
|
||||
"""
|
||||
from hermes_cli.main_desktop import _desktop_build_needed, _packaged_resources_dir
|
||||
|
||||
resources = _packaged_resources_dir(desktop_dir)
|
||||
if resources is None:
|
||||
return None
|
||||
baked_path = resources / "install-stamp.json"
|
||||
try:
|
||||
baked = json.loads(baked_path.read_text(encoding="utf-8-sig"))
|
||||
prepared = json.loads((desktop_dir / "build/install-stamp.json").read_text(encoding="utf-8-sig"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
commit = baked.get("commit") if isinstance(baked, dict) else None
|
||||
if not isinstance(commit, str) or not commit or prepared != baked:
|
||||
return None
|
||||
if _desktop_build_needed(desktop_dir, project_root, source_mode=False):
|
||||
return None
|
||||
if sys.platform == "win32":
|
||||
# The build resolves HEAD and branch with PM's git (build_prepared_desktop); so must this.
|
||||
import pm
|
||||
env = pm.ensure("git", base_env=env).env
|
||||
return commit if _stamp_drift(project_root, baked_path, env) == [] else None
|
||||
@@ -273,12 +273,15 @@ def _rebuild_served_webapp(project_root: Path, publish_stage) -> None:
|
||||
|
||||
|
||||
def _build_desktop_product(project_root: Path, env: dict, publish_stage) -> None:
|
||||
from hermes_cli.desktop_build_reuse import packaged_desktop_reusable
|
||||
from hermes_cli.main_desktop import (
|
||||
_packaged_desktop_current_for_head, _refresh_installed_desktop_apps, build_prepared_desktop)
|
||||
|
||||
desktop_dir = project_root / "apps/desktop"
|
||||
if _packaged_desktop_current_for_head(desktop_dir, project_root):
|
||||
print(" ✓ Desktop app is up to date")
|
||||
elif built_from := packaged_desktop_reusable(desktop_dir, project_root, env):
|
||||
print(f" ✓ Desktop app is up to date: no desktop build input changed since {built_from[:12]}")
|
||||
else:
|
||||
publish_stage("Building the desktop app")
|
||||
# The desktop build mutates checkout-scoped node_modules and
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Can a packaged desktop app built at an earlier commit stand for HEAD?
|
||||
//
|
||||
// The freshness receipt already proves the bundle's inputs are byte-identical
|
||||
// (sources, lockfile, build scripts, prepared icons/native trees, renderer env,
|
||||
// Node/platform/arch). The one input it cannot see is the install stamp a build
|
||||
// at HEAD would bake, because only a build rewrites build/install-stamp.json.
|
||||
// This compares that would-be stamp with the baked one: provenance that moves
|
||||
// with every commit (commit, its date, the dirty flag, the build clock) is
|
||||
// expected to differ; anything else (branch, payload, update mechanism, tag,
|
||||
// channel, version strings) changes the shipped app and means "build".
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { isMain } from './frontend-common.mjs'
|
||||
|
||||
const commitProvenance = new Set(['commit', 'commitDate', 'dirty', 'builtAt'])
|
||||
|
||||
/** Stamp fields a build at HEAD would bake differently from ``baked`` (empty = it may stand). */
|
||||
export async function stampDrift({ source, baked, env = process.env, platform = process.platform }) {
|
||||
const stampModule = join(resolve(source), 'apps/desktop/scripts/write-build-stamp.mjs')
|
||||
const { resolveStamp, buildStampPayload } = await import(pathToFileURL(stampModule).href)
|
||||
const fresh = buildStampPayload(resolveStamp({ env, repoRoot: resolve(source) }), env, platform, null)
|
||||
return [...new Set([...Object.keys(baked), ...Object.keys(fresh)])].sort()
|
||||
.filter(key => !commitProvenance.has(key) && JSON.stringify(baked[key]) !== JSON.stringify(fresh[key]))
|
||||
}
|
||||
|
||||
if (isMain(import.meta.url)) {
|
||||
const { values } = parseArgs({ options: { source: { type: 'string' }, baked: { type: 'string' } } })
|
||||
if (!values.source || !values.baked) throw new Error('--source and --baked are required')
|
||||
const baked = JSON.parse(readFileSync(values.baked, 'utf8'))
|
||||
console.log(JSON.stringify(await stampDrift({ source: values.source, baked })))
|
||||
}
|
||||
Reference in new issue
Block a user