Compare commits

...
Author SHA1 Message Date
Nick 1791f7655b fix(tui): textual events import in correct block
CI Pipeline / lint (push) Successful in 24s
CI Pipeline / unit-tests (push) Successful in 31s
CI Pipeline / canary (push) Failing after 28s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 22s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / required-checks (push) Skipped
2026-09-06 20:01:45 +01:00
Nick ae8d644438 feat(tui): port update-aware footer + working refresh to the release lineage
CI Pipeline / lint (push) Failing after 17s
CI Pipeline / unit-tests (push) Successful in 31s
CI Pipeline / canary (push) Failing after 29s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 22s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
The update-aware footer (UpdateChecker, red update render, full on_click
update flow) existed only on the old pinned lineage. After refresh updated
pinned-live to gitea/master, the new TUI booted WITHOUT the feature — dead
refresh button again. Ported: UpdateChecker 5-min active checks, red render,
on_click(events.Click) update flow (backup branch, journal preservation,
untracked-file handling, guarded checkout), UI-thread-safe _chat_or_notify,
_run_startup_tasks split. Removed dead on_static_clicked. Headless verified:
click -> checkout gitea/master -> spawn new TUI -> exit.
2026-09-06 20:00:41 +01:00
+193 -8
View File
@@ -16,6 +16,9 @@ Usage:
"""
import json
import threading
import subprocess
import re
import logging
import os
import sys
@@ -101,6 +104,7 @@ try:
except ImportError:
BACKEND_AVAILABLE = False
from textual import events
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.widgets import Button, Header, Input, ProgressBar, RichLog, Static
@@ -981,6 +985,76 @@ def _resolve_model_name(setting: str, mode: str = "chat") -> str:
return "qwen2.5:3b" if setting == "local" else setting
def _semver_tuple(version: str):
"""'0.54.0' -> (0, 54, 0); unparseable -> () so comparisons fail closed."""
try:
return tuple(int(part) for part in str(version).strip().split("."))
except (TypeError, ValueError, AttributeError):
return ()
class UpdateChecker:
"""Active update detection for the live deploy (footer + refresh).
Compares the running ``rune.__version__`` against gitea/master (the
release authority) via the repo's own git remote — the credential lives
in the remote URL, nothing is hardcoded here. Degrade-not-crash: any
failure yields "unknown", never a false update flag.
"""
REPO = "/mnt/workdrive/rune"
CHECK_INTERVAL = 300 # seconds between background checks
def __init__(self):
self.update_available = False
self.latest_version = None
self.last_error = None
self._lock = threading.Lock()
self._checking = False
def _git(self, *args: str, timeout: int = 45):
return subprocess.run(
["git", "-C", self.REPO, *args],
capture_output=True, text=True, timeout=timeout,
)
def check(self) -> bool:
"""Fetch gitea/master and compare versions. True when a NEWER
version exists remotely. Thread-safe; single-flight."""
with self._lock:
if self._checking:
return self.update_available
self._checking = True
try:
fetch = self._git("fetch", "gitea", "master", timeout=60)
if fetch.returncode != 0:
raise RuntimeError(f"fetch failed: {fetch.stderr.strip()[:160]}")
show = self._git("show", "gitea/master:src/rune/__init__.py", timeout=20)
if show.returncode != 0:
raise RuntimeError(f"read remote version failed: {show.stderr.strip()[:160]}")
match = re.search(r'__version__\s*=\s*"([0-9.]+)"', show.stdout)
if not match:
raise RuntimeError("remote __version__ unparseable")
self.latest_version = match.group(1)
import rune as _rune_pkg
local = _rune_pkg.__version__
remote_t, local_t = _semver_tuple(self.latest_version), _semver_tuple(local)
self.update_available = bool(remote_t and local_t and remote_t > local_t)
self.last_error = None
except Exception as exc:
self.last_error = str(exc)[:200]
finally:
self._checking = False
return self.update_available
_update_checker = UpdateChecker()
class VersionLabel(Static):
"""Footer semver display — derived from the canonical __version__."""
@@ -1584,6 +1658,17 @@ class RuneApp(App):
refresh_btn = self.query_one("#refresh-btn", Static)
refresh_btn.tooltip = "Click to restart Rune with latest code"
version_label = self.query_one("#version-label", VersionLabel)
version_label.tooltip = "Running version. Checks gitea/master every 5 minutes."
def _update_check_worker(initial: bool = False):
try:
_update_checker.check()
self.call_from_thread(version_label.refresh)
except Exception:
pass
self.run_worker(_update_check_worker, thread=True, exclusive=False)
self.set_interval(UpdateChecker.CHECK_INTERVAL, _update_check_worker)
# Welcome messages
self.task_channel.add_message(
"system",
@@ -1717,14 +1802,6 @@ class RuneApp(App):
"system", f"[dim]⚠ Autonomy loop failed: {e}[/dim]"
)
def on_static_clicked(self, event: Static.Clicked) -> None:
"""Handle refresh button click."""
if event.widget.id == "refresh-btn":
import subprocess
self.exit()
subprocess.Popen(["rune"])
# Run independent startup tasks in parallel
# Note: These tasks return results; messages are added on the main thread
# to avoid Textual widget mutation from parallel threads.
@@ -3989,6 +4066,114 @@ class RuneApp(App):
except Exception:
pass
def on_click(self, event: events.Click) -> None:
"""Handle refresh button click (Textual 8.x: Static posts Click, not
Static.Clicked — the old on_static_clicked handler NEVER fired)."""
if getattr(event.widget, "id", None) != "refresh-btn":
return
if getattr(self, "_updating", False):
self._chat_or_notify("Update already in progress…")
return
self._updating = True
self._chat_or_notify("Checking for updates…")
def _do_update():
backup_branch = None
try:
fetch = _update_checker._git("fetch", "gitea", "master", timeout=60)
if fetch.returncode != 0:
self._chat_or_notify(f"Update failed: fetch error ({fetch.stderr.strip()[:120]})")
return
if not _update_checker.check():
import rune as _rune_pkg
self._chat_or_notify(
f"Already up to date (v{_rune_pkg.__version__})"
if not _update_checker.last_error
else f"Update check failed: {_update_checker.last_error}"
)
return
target = _update_checker.latest_version
# Safety: snapshot the current pinned state before moving it
backup_branch = f"backup/pinned-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}"
_update_checker._git("branch", backup_branch, timeout=20)
# Preserve runtime state across the update: the journal is
# tracked and locally modified — copy it out, force-checkout
# gitea/master (local commits are superseded by the release),
# then restore journal continuity.
import shutil
journal = os.path.join(UpdateChecker.REPO, "memory", "journal", "events.jsonl")
stamp = datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')
journal_backup = journal + f".pre-update-{stamp}"
if os.path.exists(journal):
shutil.copy2(journal, journal_backup)
# Untracked local files (NIGHT_LOG.md, state/, …) block a
# checkout when they exist in the target tree. Move them
# aside, checkout, then restore anything the release doesn't
# own — nothing is lost.
aside = os.path.join(UpdateChecker.REPO, f".pre-update-untracked-{stamp}")
os.makedirs(aside, exist_ok=True)
st = _update_checker._git("status", "--porcelain", timeout=20)
moved = []
for line in (st.stdout or "").splitlines():
if not line.startswith("??"):
continue
name = line[3:].strip().rstrip("/")
src = os.path.join(UpdateChecker.REPO, name)
if os.path.exists(src):
dst = os.path.join(aside, os.path.basename(name) or name.replace("/", "_"))
shutil.move(src, dst)
moved.append((src, dst))
co = _update_checker._git("checkout", "-f", "-B", "pinned-live", "gitea/master", timeout=120)
for src, dst in moved:
if not os.path.exists(src):
shutil.move(dst, src)
if co.returncode != 0:
self._chat_or_notify(
f"Update failed: checkout error ({co.stderr.strip()[:120]}). "
f"Local state preserved on {backup_branch}."
)
return
if os.path.exists(journal_backup):
shutil.copy2(journal_backup, journal)
self._chat_or_notify(f"Updated to v{target}. Restarting…")
import time as _time
_time.sleep(1.0)
# Restart: spawn the new instance FIRST (detached), then exit
# — if we exit first, a slow shell can kill the whole chain.
subprocess.Popen(
["bash", "-c", "cd /mnt/workdrive/rune/src && exec /home/nick/.venvs/memory/bin/python3 -m rune"],
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
)
self.exit()
except Exception as exc:
self._chat_or_notify(f"Update failed: {str(exc)[:160]}")
finally:
self._updating = False
self.run_worker(_do_update, thread=True, exclusive=False)
def _chat_or_notify(self, message: str) -> None:
"""Surface update-flow messages without crashing. ALWAYS marshals to
the UI thread — workers must never mount widgets directly (a worker
thread has no event loop; mounting from it raises)."""
def _post():
try:
self.notify(message, timeout=6)
except Exception:
try:
self.chat_channel.add_message("system", f"[dim]{message}[/dim]")
except Exception:
pass
try:
self.call_from_thread(_post)
except Exception:
# No running app loop (e.g. headless test) — best effort, no raise
pass
def action_focus_task(self) -> None:
"""Focus the task channel input."""
self.mode = "task"