Merge pull request #10613 from spencerbull/hermes-desktop-in-app-updates
Enable Hermes in-app updates and guard removal
(cherry picked from commit a703092631)
(cherry picked from commit 2438293d775d679ce99364cdbced108596c5b2bc)
This commit is contained in:
1 parent
866d994dcf
commit
e919a64014
6 files changed
+706
-10
No files matched your search
@@ -11,6 +11,14 @@ mock_bin="$test_tmp/bin"
|
||||
test_home="$test_tmp/home"
|
||||
mkdir -p "$mock_bin"
|
||||
|
||||
# Keep package-path checks scoped to the fixture, even with a live app open.
|
||||
python3 - "$ROOT/bin/omarchy-remove-ai-hermes" "$test_tmp" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
source, scratch = map(Path, sys.argv[1:])
|
||||
(scratch / 'remover').write_text(source.read_text().replace('/opt/hermes-desktop', str(scratch / 'package')))
|
||||
PY
|
||||
|
||||
cat >"$mock_bin/omarchy-pkg-drop" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\0' "$@" >>"$OMARCHY_TEST_DROP_LOG"
|
||||
@@ -32,6 +40,14 @@ SH
|
||||
cat >"$mock_bin/gum" <<'SH'
|
||||
#!/bin/bash
|
||||
printf '%s\0' "$@" >>"$OMARCHY_TEST_GUM_LOG"
|
||||
if [[ -n ${OMARCHY_TEST_PROMPT_GATE:-} ]]; then
|
||||
touch "$OMARCHY_TEST_PROMPT_GATE.started"
|
||||
for (( attempt=0; attempt<500; attempt++ )); do
|
||||
[[ ! -e $OMARCHY_TEST_PROMPT_GATE.continue ]] || exit 0
|
||||
sleep 0.01
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
exit "${OMARCHY_TEST_GUM_STATUS:-1}"
|
||||
SH
|
||||
cat >"$mock_bin/systemctl" <<'SH'
|
||||
@@ -70,7 +86,7 @@ remove() {
|
||||
OMARCHY_TEST_SYSTEMCTL_LOG="$test_tmp/systemctl-log" \
|
||||
OMARCHY_TEST_GUM_LOG="$test_tmp/gum-log" \
|
||||
HOME="$test_home" PATH="$mock_bin:$PATH" \
|
||||
bash "$ROOT/bin/omarchy-remove-ai-hermes" </dev/null >/dev/null 2>&1
|
||||
bash "$test_tmp/remover" </dev/null >"$test_tmp/output" 2>&1
|
||||
}
|
||||
|
||||
# script(1) puts the remover on a pty, which is the only way -t 0 answers true
|
||||
@@ -85,7 +101,7 @@ remove_tty() {
|
||||
OMARCHY_TEST_GUM_LOG="$test_tmp/gum-log" \
|
||||
OMARCHY_TEST_GUM_STATUS="${OMARCHY_TEST_GUM_STATUS:-1}" \
|
||||
HOME="$test_home" PATH="$mock_bin:$PATH" \
|
||||
script -qec "bash '$ROOT/bin/omarchy-remove-ai-hermes'" /dev/null >/dev/null 2>&1
|
||||
script -qec "bash '$test_tmp/remover'" /dev/null >"$test_tmp/output" 2>&1
|
||||
}
|
||||
|
||||
# The app brings its own uv and its own node; both are runtime, not data.
|
||||
@@ -229,3 +245,140 @@ OMARCHY_TEST_INSTALLER_STATUS=1 remove && fail "a failed CLI teardown surfaces i
|
||||
[[ ! -d $test_home/.hermes/hermes-agent ]] ||
|
||||
fail "a failed CLI teardown does not stop the runtime removal"
|
||||
pass "a failed CLI teardown is reported after the runtime is handled"
|
||||
|
||||
# Real SQLite writers exercise the kernel's live/deleted file descriptors.
|
||||
# Package, service and confirmation commands remain confined to the mocks.
|
||||
python3 - "$test_tmp" <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pty
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
scratch = Path(sys.argv[1])
|
||||
writer_code = '''import os, sqlite3, sys
|
||||
c = sqlite3.connect(os.environ['TEST_DB'])
|
||||
c.execute('pragma journal_mode=wal')
|
||||
c.execute('create table fixture(value)')
|
||||
c.execute("insert into fixture values ('keep')")
|
||||
c.commit()
|
||||
print('ready', flush=True)
|
||||
sys.stdin.readline()
|
||||
c.close()
|
||||
'''
|
||||
|
||||
def setup(name):
|
||||
home = scratch / name
|
||||
runtime = home / '.hermes/hermes-agent'
|
||||
runtime.mkdir(parents=True)
|
||||
(runtime / '.hermes-bootstrap-complete').touch()
|
||||
(home / '.config/Hermes').mkdir(parents=True)
|
||||
env = {**os.environ, 'HOME': str(home), 'PATH': f"{scratch / 'bin'}:/usr/bin:/bin",
|
||||
'OMARCHY_TEST_GUM_STATUS': '0'}
|
||||
for key in ('DROP', 'INSTALLER', 'SYSTEMCTL', 'GUM'):
|
||||
log = home / (key + '.log')
|
||||
log.touch()
|
||||
env['OMARCHY_TEST_' + key + '_LOG'] = str(log)
|
||||
return home, runtime, env
|
||||
|
||||
def writer(db):
|
||||
child = subprocess.Popen([sys.executable, '-u', '-c', writer_code],
|
||||
env={**os.environ, 'TEST_DB': str(db)},
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
|
||||
assert child.stdout.readline().strip() == 'ready'
|
||||
return child
|
||||
|
||||
def stop(child):
|
||||
if child.poll() is None:
|
||||
child.stdin.write('\n')
|
||||
child.stdin.flush()
|
||||
child.wait(timeout=5)
|
||||
|
||||
def remove(env):
|
||||
master, slave = pty.openpty()
|
||||
try:
|
||||
return subprocess.run(['bash', str(scratch / 'remover')], env=env,
|
||||
stdin=slave, capture_output=True, text=True, timeout=10)
|
||||
finally:
|
||||
os.close(master)
|
||||
os.close(slave)
|
||||
|
||||
def blocked(result, home, runtime, child):
|
||||
assert result.returncode != 0 and str(child.pid) in result.stderr, result
|
||||
assert 'Close Hermes' in result.stderr, result.stderr
|
||||
assert (runtime / '.hermes-bootstrap-complete').exists()
|
||||
assert all((home / (name + '.log')).stat().st_size == 0
|
||||
for name in ('DROP', 'INSTALLER', 'SYSTEMCTL', 'GUM'))
|
||||
assert child.poll() is None, 'remover must not kill sessions'
|
||||
|
||||
for deleted in (False, True):
|
||||
home, runtime, env = setup('deleted-writer' if deleted else 'live-writer')
|
||||
db = home / '.hermes/state.db'
|
||||
child = writer(db)
|
||||
try:
|
||||
if deleted:
|
||||
for suffix in ('', '-wal', '-shm'):
|
||||
Path(str(db) + suffix).unlink()
|
||||
db.write_bytes(b'new database generation')
|
||||
blocked(remove(env), home, runtime, child)
|
||||
if deleted:
|
||||
assert db.read_bytes() == b'new database generation'
|
||||
finally:
|
||||
stop(child)
|
||||
assert remove(env).returncode == 0, 'removal succeeds once the writer closes'
|
||||
assert not (home / '.hermes').exists()
|
||||
print('ok - live and deleted SQLite holders block removal before any side effects; closing them allows retry')
|
||||
|
||||
for kind in ('terminal', 'desktop', 'working-directory'):
|
||||
home, runtime, env = setup(kind)
|
||||
executable_name = str(scratch / 'package/Hermes') if kind == 'desktop' else str(runtime / 'hermes')
|
||||
args = ['sleep', '30'] if kind == 'working-directory' else [executable_name, '30']
|
||||
child = subprocess.Popen(args, executable='/usr/bin/sleep',
|
||||
cwd=runtime if kind == 'working-directory' else scratch)
|
||||
try:
|
||||
blocked(remove(env), home, runtime, child)
|
||||
finally:
|
||||
child.terminate()
|
||||
child.wait(timeout=5)
|
||||
print('ok - terminal, packaged desktop and runtime working-directory processes are detected without a database')
|
||||
|
||||
home, runtime, env = setup('unrelated-writer')
|
||||
sibling = home / '.hermes-other'
|
||||
sibling.mkdir()
|
||||
child = writer(sibling / 'state.db')
|
||||
try:
|
||||
assert remove(env).returncode == 0, 'a sibling database does not block Hermes removal'
|
||||
assert child.poll() is None
|
||||
finally:
|
||||
stop(child)
|
||||
print('ok - unrelated database holders are left alone')
|
||||
|
||||
home, runtime, env = setup('prompt-race')
|
||||
gate = home / 'prompt'
|
||||
env['OMARCHY_TEST_PROMPT_GATE'] = str(gate)
|
||||
master, slave = pty.openpty()
|
||||
remover = subprocess.Popen(['bash', str(scratch / 'remover')], env=env, stdin=slave,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
os.close(slave)
|
||||
child = None
|
||||
try:
|
||||
deadline = time.monotonic() + 5
|
||||
while not Path(str(gate) + '.started').exists():
|
||||
assert remover.poll() is None and time.monotonic() < deadline, 'prompt was not reached'
|
||||
time.sleep(0.01)
|
||||
child = writer(home / '.hermes/state.db')
|
||||
Path(str(gate) + '.continue').touch()
|
||||
stdout, stderr = remover.communicate(timeout=10)
|
||||
assert remover.returncode != 0 and str(child.pid) in stderr, (stdout, stderr)
|
||||
assert (home / '.hermes/state.db-wal').exists()
|
||||
assert (home / '.config/Hermes').exists()
|
||||
finally:
|
||||
if child is not None:
|
||||
stop(child)
|
||||
if remover.poll() is None:
|
||||
remover.terminate()
|
||||
remover.wait(timeout=5)
|
||||
os.close(master)
|
||||
print('ok - a writer started during confirmation blocks data deletion')
|
||||
PY
|
||||
Reference in new issue
Block a user