diff --git a/api/config.py b/api/config.py index 6c93ce19..b2f80efc 100644 --- a/api/config.py +++ b/api/config.py @@ -8183,6 +8183,7 @@ _SETTINGS_DEFAULTS = { "show_previous_messaging_sessions": False, # show older Telegram/Discord/etc. reset segments "sync_to_insights": False, # mirror WebUI token usage to state.db for /insights "check_for_updates": True, # check if webui/agent repos are behind upstream + "update_channel": "stable", # stable | experimental — which release stream to track (stable = soaked/promoted; experimental = every batch) "ignore_agent_updates": False, # keep WebUI update notices but suppress Agent update checks "whats_new_summary_enabled": False, # show an LLM-written What's New summary before diff links "tts_enabled": False, @@ -8463,6 +8464,7 @@ _SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - { _SETTINGS_ENUM_VALUES = { "send_key": {"enter", "ctrl+enter", "shift+enter"}, "sidebar_density": {"compact", "detailed"}, + "update_channel": {"stable", "experimental"}, "font_size": {"small", "default", "large", "xlarge"}, "auto_title_refresh_every": {"0", "5", "10", "20"}, "default_message_mode": {"queue", "interrupt", "steer"}, diff --git a/api/routes.py b/api/routes.py index a9a64412..dedc609b 100644 --- a/api/routes.py +++ b/api/routes.py @@ -11628,6 +11628,16 @@ def handle_get(handler, parsed) -> bool: settings["agent_version"] = AGENT_VERSION except Exception: pass + # Channel-scoped display badge — SEPARATE from webui_version (which is + # load-bearing for asset cache-busting / SW cache / skew detection and + # must stay channel-neutral). update_channel_version is display-only. + try: + from api.updates import channel_version_badge, _read_update_channel + channel = _read_update_channel() + settings["update_channel"] = channel + settings["update_channel_version"] = channel_version_badge(channel) + except Exception: + pass return j(handler, settings) if parsed.path == "/api/transcribe/capability": @@ -13072,9 +13082,18 @@ def handle_post(handler, parsed) -> bool: return j(handler, {"disabled": True}) include_agent_updates = not bool(settings.get("ignore_agent_updates")) force = bool(body.get("force", False)) + # Allow the client to pass the channel explicitly in the POST body. This + # avoids a race on channel switch: the Settings dropdown re-checks + # immediately, but its autosave PUT (debounced) may not have landed + # server-side yet, so reading the saved setting here could answer for the + # OLD channel. An explicit body channel (validated against the enum) wins; + # otherwise fall back to the saved setting. (Fable UX gate.) + channel = body.get("channel") if isinstance(body, dict) else None + if channel not in ("stable", "experimental"): + channel = settings.get("update_channel") from api.updates import check_for_updates - return j(handler, check_for_updates(force=force, include_agent=include_agent_updates)) + return j(handler, check_for_updates(force=force, include_agent=include_agent_updates, channel=channel)) if parsed.path == "/api/extensions/toggle": from api.extensions import ExtensionToggleError, set_extension_user_enabled @@ -15107,17 +15126,27 @@ def handle_post(handler, parsed) -> bool: target = body.get("target", "") if target not in ("webui", "agent"): return bad(handler, 'target must be "webui" or "agent"') + # Honor an explicit validated body channel (the client sends the channel + # the banner was offering) so a channel switch whose debounced autosave + # hasn't landed can't make apply read the OLD saved channel (Codex gate). + # Fall back to the saved setting when absent/invalid. + _apply_channel = body.get("channel") if isinstance(body, dict) else None + if _apply_channel not in ("stable", "experimental"): + _apply_channel = None from api.updates import apply_update - return j(handler, apply_update(target)) + return j(handler, apply_update(target, _apply_channel)) if parsed.path == "/api/updates/force": target = body.get("target", "") if target not in ("webui", "agent"): return bad(handler, 'target must be "webui" or "agent"') + _force_channel = body.get("channel") if isinstance(body, dict) else None + if _force_channel not in ("stable", "experimental"): + _force_channel = None from api.updates import apply_force_update - return j(handler, apply_force_update(target)) + return j(handler, apply_force_update(target, _force_channel)) if parsed.path == "/api/updates/clear_lock": # Manual-instruction recovery for the .git/index.lock case. The diff --git a/api/updates.py b/api/updates.py index 51c5aa5e..048925c5 100644 --- a/api/updates.py +++ b/api/updates.py @@ -35,7 +35,7 @@ try: except ImportError: _AGENT_DIR = None -_update_cache = {'webui': None, 'agent': None, 'checked_at': 0, 'include_agent': True} +_update_cache = {'webui': None, 'agent': None, 'checked_at': 0, 'include_agent': True, 'channel': 'stable'} _SUMMARY_CACHE_MAX = 16 _summary_cache: OrderedDict = OrderedDict() _cache_lock = threading.Lock() @@ -327,10 +327,12 @@ def apply_clear_lock(target: str) -> dict: if not inv['well_known_lock_present']: # Lock is gone. Run the normal non-destructive update flow and # annotate the response with what we found for the user's - # records. + # records. Pass the configured channel through — otherwise an + # experimental-channel WebUI lock-recovery retry silently falls back + # to stable (Codex gate: _apply_update_inner defaults to stable). with _cache_lock: _update_cache['checked_at'] = 0 - retry_result = _apply_update_inner(target) + retry_result = _apply_update_inner(target, _read_update_channel()) retry_result = dict(retry_result) retry_result['lock_recovery'] = { 'action': 'no-lock-found', @@ -648,17 +650,102 @@ def _detect_default_branch(path): return 'master' -def _release_tags(path): - """Return release tags newest-first, using the repo's version-sort order.""" - out, ok = _run_git(['tag', '--list', 'v*', '--sort=-v:refname'], path) +# ── Release channels ───────────────────────────────────────────────────────── +# The self-updater tracks ONE of several release channels, selected in Settings +# (``update_channel``). A channel is nothing more than *which glob of tags the +# updater reads* on the single linear master line — no branches, no divergence, +# so every hard-won ff-only guarantee (#2653/#2846/#3140) is preserved. +# +# stable -> 'v*' promoted, soaked releases (the default). Same glob +# the updater has always used — every existing +# v0.51.N tag matches, so legacy installs and the +# full existing test suite keep working unchanged. +# experimental -> 'exp-v*' every release batch, tagged for testers who opt in. +# +# ``exp-v*`` deliberately does NOT match ``v*`` (exp tags start with 'e', not +# 'v'): the two channels never leak into each other's tag list, and a legacy +# install running the historical 'v*' glob never matches an exp tag, so it +# auto-lands on the stable stream with zero action. +DEFAULT_UPDATE_CHANNEL = 'stable' +_CHANNEL_TAG_GLOBS = { + 'stable': 'v*', + 'experimental': 'exp-v*', +} + + +def _normalize_channel(channel) -> str: + """Return a known channel name, defaulting to stable for anything unknown.""" + if isinstance(channel, str) and channel in _CHANNEL_TAG_GLOBS: + return channel + return DEFAULT_UPDATE_CHANNEL + + +def _channel_tag_glob(channel) -> str: + """Return the ``git tag --list`` glob for the given channel.""" + return _CHANNEL_TAG_GLOBS[_normalize_channel(channel)] + + +def _read_update_channel() -> str: + """Read the configured update channel from settings (stable fallback). + + Read lazily at request time — never baked at import — so a channel switch in + Settings takes effect on the next update check without a process restart. + """ + try: + from api.config import load_settings + return _normalize_channel(load_settings().get('update_channel')) + except Exception: + return DEFAULT_UPDATE_CHANNEL + + +def channel_version_badge(channel=None) -> str: + """Return a channel-scoped version string for the Settings display badge ONLY. + + This is DELIBERATELY separate from ``WEBUI_VERSION``. ``WEBUI_VERSION`` is + load-bearing in exact-string-equality systems — asset cache-busting URLs, the + service-worker CACHE_NAME, the models-cache stamp, and the stale-client skew + banner — so it must stay channel-neutral and stable for the process lifetime. + Making it channel-dependent would falsely trip "hard refresh" banners and + spurious cache rebuilds on every channel flip. This helper is read at request + time purely to render ``WebUI: v0.52.47 · Experimental`` in Settings. + + Returns the channel-matched ``git describe`` (e.g. ``v0.52.47`` on stable, + ``exp-v0.52.51`` on experimental), or falls back to ``WEBUI_VERSION`` when no + channel tag is reachable (fresh clone, Docker image without channel tags). + """ + if channel is None: + channel = _read_update_channel() + channel = _normalize_channel(channel) + out, ok = _run_git( + ['describe', '--tags', '--always', '--match', _channel_tag_glob(channel)], + REPO_ROOT, + ) + if ok and out: + return out + _dirty_suffix(REPO_ROOT) + return WEBUI_VERSION + + +def _release_tags(path, channel=DEFAULT_UPDATE_CHANNEL): + """Return the channel's release tags newest-first, in version-sort order.""" + glob = _channel_tag_glob(channel) + out, ok = _run_git(['tag', '--list', glob, '--sort=-v:refname'], path) if not (ok and out): return [] return [line.strip() for line in out.splitlines() if line.strip()] -def _current_release_tag(path): - """Return the latest release tag reachable from HEAD, if one exists.""" - out, ok = _run_git(['describe', '--tags', '--abbrev=0'], path) +def _current_release_tag(path, channel=DEFAULT_UPDATE_CHANNEL): + """Return the latest channel release tag reachable from HEAD, if one exists. + + MUST filter by the channel glob (``--match``): a commit tagged BOTH + ``v0.52.0`` and ``exp-v0.52.0`` describes as ``exp-v0.52.0`` (git prefers the + lexically-later tag), so an unfiltered ``describe`` would make stable-channel + math resolve to the experimental tag and fall through to the branch firehose. + """ + out, ok = _run_git( + ['describe', '--tags', '--abbrev=0', '--match', _channel_tag_glob(channel)], + path, + ) return out if ok and out else None @@ -671,18 +758,25 @@ def _release_gap(tags, current, latest): return 1 -def _head_is_past_latest_tag(path, current_tag): - """Return True when HEAD has moved past the latest reachable release tag. +def _head_is_past_latest_tag(path, current_tag, channel=DEFAULT_UPDATE_CHANNEL): + """Return True when HEAD has moved past the latest reachable channel tag. - `git describe --tags --always` returns the bare tag name (e.g. ``v2026.5.16``) - when HEAD is exactly on the tag, and a ``v2026.5.16-608-g1d22b9c2`` suffix - when HEAD has moved 608 commits past it. Used by both the update check and - the update apply path so they agree on which ref to advance to — see #2653 - (check side) and #2846 (apply side). + `git describe --tags --always --match ` returns the bare tag name + (e.g. ``v2026.5.16``) when HEAD is exactly on the tag, and a + ``v2026.5.16-608-g1d22b9c2`` suffix when HEAD has moved 608 commits past it. + Used by both the update check and the update apply path so they agree on + which ref to advance to — see #2653 (check side) and #2846 (apply side). + + The ``--match`` filter is mandatory: without it, a HEAD sitting on a commit + that carries the other channel's tag would describe against that tag and + give a wrong past/at answer for THIS channel. """ if not current_tag: return False - full_desc, ok = _run_git(['describe', '--tags', '--always'], path) + full_desc, ok = _run_git( + ['describe', '--tags', '--always', '--match', _channel_tag_glob(channel)], + path, + ) return bool(ok and full_desc and full_desc != current_tag) @@ -708,7 +802,7 @@ def _can_fast_forward_to(path, ref): return bool(ok) -def _select_apply_compare_ref(path): +def _select_apply_compare_ref(path, channel=DEFAULT_UPDATE_CHANNEL, target=None): """Return the same remote ref family that the update check reports. The update banner prefers published release tags when they exist. Applying @@ -722,11 +816,24 @@ def _select_apply_compare_ref(path): decision — otherwise we run `git pull --ff-only ` against a checkout that's already past the tag, no-op, restart, and the banner re-appears with the same N commits available. See #2846. + + CHANNEL SEMANTICS (webui only): the stable/experimental channels govern the + WebUI repo. For ``target == 'webui'`` on the ``stable`` channel, stable tags + are a *promoted subset* of master, so a stable install whose HEAD already + contains the latest stable tag but sits behind master's tip must NOT fall + through to the branch comparison (that would advance it to ``origin/master`` + — the full experimental firehose, defeating the channel). We return ``None`` + so the caller reports "no update". Every other case — the experimental + channel, and the AGENT repo (which is a separate project that legitimately + tracks master past its tags) — keeps the historical branch fallthrough + unchanged. This mirrors ``_check_repo_release``. """ - tags = _release_tags(path) + channel = _normalize_channel(channel) + suppress_stable_fallthrough = (channel == 'stable' and target == 'webui') + tags = _release_tags(path, channel) if tags: latest_tag = tags[0] - current_tag = _current_release_tag(path) + current_tag = _current_release_tag(path, channel) behind = _release_gap(tags, current_tag, latest_tag) # Mirror the check side exactly: fall through to the branch comparison # whenever the checkout has already moved past the release tag that the @@ -735,13 +842,18 @@ def _select_apply_compare_ref(path): # have behind > 0 after fetching a newer tag that HEAD already contains # (#3140). In both cases applying the tag would no-op, move backwards, # or fail fast-forward; branch comparison is the truthful update path. + # Short-circuit `or` preserves the original minimal git-call pattern. if ( - behind == 0 and _head_is_past_latest_tag(path, current_tag) - ) or ( - behind > 0 and _head_contains_ref(path, latest_tag) - ) or ( - behind > 0 and not _can_fast_forward_to(path, latest_tag) + (behind == 0 and _head_is_past_latest_tag(path, current_tag, channel)) + or (behind > 0 and _head_contains_ref(path, latest_tag)) + or (behind > 0 and not _can_fast_forward_to(path, latest_tag)) ): + # WebUI stable: "HEAD past/contains the latest stable tag" means + # up-to-date on the promoted subset — NOT a signal to advance to + # master. Return None so the caller reports no update. + if suppress_stable_fallthrough: + return None + # Experimental / agent: preserve the historical branch fallthrough. pass else: return latest_tag @@ -754,14 +866,39 @@ def _select_apply_compare_ref(path): return f'origin/{branch}' -def _check_repo_release(path, name): - """Check if a git repo is behind its latest published release tag.""" - tags = _release_tags(path) +def _channel_up_to_date_info(path, name, channel, current_tag): + """Return an 'up to date' payload for a channel that must NOT branch-compare. + + Used by the stable channel: stable tags are a promoted subset of master, so + when HEAD already contains the latest stable tag we report up-to-date + (behind == 0) rather than falling through to the branch comparison, which + would advance the user onto the experimental firehose. + """ + remote_url, _ = _run_git(['remote', 'get-url', 'origin'], path) + remote_url = _normalize_remote_url(remote_url) + return { + 'name': name, + 'behind': 0, + 'current_sha': current_tag, + 'latest_sha': current_tag, + 'branch': current_tag, + 'repo_url': remote_url, + 'release_based': True, + 'current_version': current_tag, + 'latest_version': current_tag, + 'channel': channel, + } + + +def _check_repo_release(path, name, channel=DEFAULT_UPDATE_CHANNEL): + """Check if a git repo is behind its latest published channel release tag.""" + channel = _normalize_channel(channel) + tags = _release_tags(path, channel) if not tags: return None latest_tag = tags[0] - current_tag = _current_release_tag(path) + current_tag = _current_release_tag(path, channel) behind = _release_gap(tags, current_tag, latest_tag) # If behind == 0 but HEAD has moved past the tag (e.g. the agent repo @@ -770,7 +907,16 @@ def _check_repo_release(path, name): # Fall through to _check_repo_branch so the real commit count is reported # instead. The same predicate is used by _select_apply_compare_ref so the # check and apply sides cannot drift again. See #2653 (check), #2846 (apply). - if behind == 0 and _head_is_past_latest_tag(path, current_tag): + # + # CHANNEL (webui only): for the WebUI repo on stable, stable tags are a + # promoted SUBSET of master, so "HEAD past the latest stable tag" means + # up-to-date on the promoted subset, NOT a signal to branch-compare against + # origin/master (the firehose). Report up-to-date. The AGENT repo and the + # experimental channel keep the historical fall-through. + suppress_stable_fallthrough = (channel == 'stable' and name == 'webui') + if behind == 0 and _head_is_past_latest_tag(path, current_tag, channel): + if suppress_stable_fallthrough: + return _channel_up_to_date_info(path, name, channel, current_tag) return None # Users tracking main can already contain the newest fetched release tag @@ -779,12 +925,16 @@ def _check_repo_release(path, name): # Fall through to the branch check so the banner compares against the # configured upstream instead of advertising a tag that cannot fast-forward. if behind > 0 and _head_contains_ref(path, latest_tag): + if suppress_stable_fallthrough: + return _channel_up_to_date_info(path, name, channel, current_tag) return None # Patch releases can land on a side branch while day-to-day installs track # main past an older tag. A positive tag-name gap then advertises an update # that `git pull --ff-only ` cannot reach. if behind > 0 and not _can_fast_forward_to(path, latest_tag): + if suppress_stable_fallthrough: + return _channel_up_to_date_info(path, name, channel, current_tag) return None remote_url, _ = _run_git(['remote', 'get-url', 'origin'], path) @@ -802,6 +952,7 @@ def _check_repo_release(path, name): 'release_based': True, 'current_version': current_tag, 'latest_version': latest_tag, + 'channel': channel, } @@ -876,7 +1027,7 @@ def _check_repo_branch(path, name, *, fetch=True): } -def _check_repo(path, name): +def _check_repo(path, name, channel=DEFAULT_UPDATE_CHANNEL): """Check if a git repo is behind its latest release. Returns dict or None. The returned dict (when not None) always carries a ``dirty: bool`` reflecting @@ -889,6 +1040,7 @@ def _check_repo(path, name): with ``no_git: True`` and ``behind: None`` so the frontend can distinguish "can't check" from "up to date" (issue #4356). """ + channel = _normalize_channel(channel) if path is None or not (path / '.git').exists(): return { 'name': name, @@ -907,7 +1059,7 @@ def _check_repo(path, name): # See #2756. fetch_out, fetch_ok = _run_git(['fetch', 'origin', '--tags', '--force'], path, timeout=15) if not fetch_ok: - release_info = _check_repo_release(path, name) + release_info = _check_repo_release(path, name, channel) message = 'fetch failed' if fetch_out: message = f'{message}: {_sanitize_git_diagnostic(fetch_out)}' @@ -925,7 +1077,7 @@ def _check_repo(path, name): 'dirty': _is_dirty(path), } - release_info = _check_repo_release(path, name) + release_info = _check_repo_release(path, name, channel) if release_info is not None: release_info = dict(release_info) release_info['dirty'] = _is_dirty(path) @@ -935,6 +1087,7 @@ def _check_repo(path, name): if branch_info is not None: branch_info = dict(branch_info) branch_info['dirty'] = _is_dirty(path) + branch_info['channel'] = channel return branch_info return None @@ -959,11 +1112,20 @@ def _ignored_agent_update_info() -> dict: return {'name': 'agent', 'behind': 0, 'ignored': True} -def cached_update_status(*, include_agent=True): +def cached_update_status(*, include_agent=True, channel=None): """Return cached update status without performing network or git mutations.""" include_agent = bool(include_agent) + if channel is None: + channel = _read_update_channel() + channel = _normalize_channel(channel) with _cache_lock: cached = dict(_update_cache) + # If the cache was populated for a different channel, it is not a valid + # answer for this channel — signal that so callers don't render stale + # cross-channel data as authoritative. + if cached.get('channel') != channel: + cached['channel'] = channel + cached['stale_channel'] = True if cached.get('include_agent') != include_agent: cached['include_agent'] = include_agent if not include_agent: @@ -972,31 +1134,48 @@ def cached_update_status(*, include_agent=True): return cached -def check_for_updates(force=False, *, include_agent=True): +def check_for_updates(force=False, *, include_agent=True, channel=None): """Return cached update status for webui and agent repos.""" global _check_in_progress include_agent = bool(include_agent) + if channel is None: + channel = _read_update_channel() + channel = _normalize_channel(channel) with _cache_lock: + # Cache is only valid when BOTH the channel AND include_agent match — + # a channel switch must not serve the previous channel's answer, and an + # in-progress check for the other channel must not short-circuit this one + # with a stale cross-channel payload (Codex SILENT #5). + cache_matches = ( + _update_cache.get('include_agent') == include_agent + and _update_cache.get('channel') == channel + ) if ( not force - and _update_cache.get('include_agent') == include_agent + and cache_matches and time.time() - _update_cache['checked_at'] < CACHE_TTL ): return dict(_update_cache) - if _check_in_progress: - return dict(_update_cache) # another thread is already checking + if _check_in_progress and cache_matches: + return dict(_update_cache) # another thread is already checking this channel _check_in_progress = True try: # Run checks outside the lock (network I/O) - webui_info = _check_repo(REPO_ROOT, 'webui') - agent_info = _check_repo(_AGENT_DIR, 'agent') if include_agent else _ignored_agent_update_info() + webui_info = _check_repo(REPO_ROOT, 'webui', channel) + # The update channel is a WebUI-only concept. The Agent is a separate + # project that tags plain v* and legitimately tracks master past its + # tags; it must ALWAYS use the default channel regardless of the user's + # WebUI channel selection. (Codex gate: passing 'experimental' here made + # the Agent ignore its v* tags and fall back to origin/master.) + agent_info = _check_repo(_AGENT_DIR, 'agent', DEFAULT_UPDATE_CHANNEL) if include_agent else _ignored_agent_update_info() with _cache_lock: _update_cache['webui'] = webui_info _update_cache['agent'] = agent_info _update_cache['checked_at'] = time.time() _update_cache['include_agent'] = include_agent + _update_cache['channel'] = channel return dict(_update_cache) finally: _check_in_progress = False @@ -1482,7 +1661,7 @@ def _agent_gateway_restart_failure_message(target: str, restart_result: dict) -> ) -def apply_force_update(target: str) -> dict: +def apply_force_update(target: str, channel=None) -> dict: """Force-reset the target repo to the latest remote HEAD. Unlike apply_update() which requires a clean working tree and refuses @@ -1493,7 +1672,17 @@ def apply_force_update(target: str) -> dict: Should only be called when apply_update() has already returned a response with ``conflict: True`` or ``diverged: True`` and the user has confirmed they want to discard local changes. + + CHANNEL SAFETY (rewind guard): ``reset --hard`` is destructive. When the + selected channel resolves to a ref that is an ANCESTOR of HEAD (i.e. the + checkout is already ahead of the channel — e.g. an ex-experimental install + switching back to stable), resetting to it would REWIND code and on-disk + state. We refuse and return a clear message instead of silently downgrading. + A deliberate rollback would be a separate, explicit feature. """ + if channel is None: + channel = _read_update_channel() + channel = _normalize_channel(channel) blocker_snapshot = _restart_blocker_snapshot() if blocker_snapshot.get('restart_blocked'): return _restart_blocked_response(target, blocker_snapshot) @@ -1505,6 +1694,8 @@ def apply_force_update(target: str) -> dict: path = REPO_ROOT elif target == 'agent': path = _AGENT_DIR + # Channel is WebUI-only — the Agent always uses the default channel. + channel = DEFAULT_UPDATE_CHANNEL else: return {'ok': False, 'message': f'Unknown target: {target}'} @@ -1533,8 +1724,39 @@ def apply_force_update(target: str) -> dict: ), } - compare_ref = _select_apply_compare_ref(path) + compare_ref = _select_apply_compare_ref(path, channel, target) + # Stable channel, already up to date on the promoted subset: nothing to + # force to. Do NOT fall back to origin/master (firehose). See + # _select_apply_compare_ref channel semantics. + if compare_ref is None: + return { + 'ok': True, + 'message': f'{target} is already up to date on the {channel} channel.', + 'target': target, + 'up_to_date': True, + 'channel': channel, + } + # Rewind guard (Codex CORE #3): refuse to reset --hard onto a ref that + # is an ANCESTOR of HEAD — that would downgrade the checkout. This is the + # switch-back-to-stable-while-ahead case. A ref that is a descendant of + # HEAD (normal update / opt-in to experimental) fast-forwards fine and is + # allowed. Refs on a divergent line (neither ancestor nor descendant) are + # the legitimate force-update case (conflict/diverged recovery) and are + # also allowed — the guard fires ONLY on a pure-ancestor rewind. + if _head_contains_ref(path, compare_ref) and not _can_fast_forward_to(path, compare_ref): + return { + 'ok': False, + 'message': ( + f'{target} is already ahead of the {channel} channel ' + f'({compare_ref}); refusing to rewind the checkout. ' + 'Switching to a slower channel keeps your current version ' + 'until that channel catches up.' + ), + 'target': target, + 'channel': channel, + 'refused_rewind': True, + } # Discard local modifications and untracked colliders before resetting. # Do not use -x: ignored build/cache artifacts should survive force update. _run_git(['checkout', '.'], path) @@ -1587,8 +1809,11 @@ def apply_force_update(target: str) -> dict: _apply_lock.release() -def apply_update(target): +def apply_update(target, channel=None): """Stash, pull --ff-only, pop for the given target repo.""" + if channel is None: + channel = _read_update_channel() + channel = _normalize_channel(channel) blocker_snapshot = _restart_blocker_snapshot() if blocker_snapshot.get('restart_blocked'): return _restart_blocked_response(target, blocker_snapshot) @@ -1596,7 +1821,7 @@ def apply_update(target): if not _apply_lock.acquire(blocking=False): return {'ok': False, 'message': 'Update already in progress'} try: - return _apply_update_inner(target) + return _apply_update_inner(target, channel) finally: _apply_lock.release() @@ -1636,12 +1861,16 @@ def _restore_stash_after_pull_failure( ) -def _apply_update_inner(target): +def _apply_update_inner(target, channel=DEFAULT_UPDATE_CHANNEL): """Inner implementation of apply_update, called under _apply_lock.""" + channel = _normalize_channel(channel) if target == 'webui': path = REPO_ROOT elif target == 'agent': path = _AGENT_DIR + # Channel is WebUI-only — the Agent always uses the default channel + # regardless of the user's WebUI selection (see check_for_updates). + channel = DEFAULT_UPDATE_CHANNEL else: return {'ok': False, 'message': f'Unknown target: {target}'} @@ -1666,7 +1895,19 @@ def _apply_update_inner(target): ), } - compare_ref = _select_apply_compare_ref(path) + compare_ref = _select_apply_compare_ref(path, channel, target) + # On the stable channel a None ref means HEAD already contains the latest + # promoted stable tag (up-to-date on the promoted subset). Do NOT fall back + # to origin/master — that would advance the user onto the experimental + # firehose. Report success/no-op instead. See _select_apply_compare_ref. + if compare_ref is None: + return { + 'ok': True, + 'message': f'{target} is already up to date on the {channel} channel.', + 'target': target, + 'up_to_date': True, + 'channel': channel, + } # Check for dirty working tree (ignore untracked files — git stash # doesn't include them, so stashing on '??' alone leaves nothing to pop) diff --git a/static/i18n.js b/static/i18n.js index 65dfc441..559df517 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -842,6 +842,10 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Show previous messaging sessions', settings_label_sync_insights: 'Sync to insights', settings_label_check_updates: 'Check for updates', + settings_label_update_channel: 'Update channel', + settings_update_channel_stable: 'Stable (recommended)', + settings_update_channel_experimental: 'Experimental', + settings_desc_update_channel: 'Stable gets soak-tested, proven releases. Experimental gets every release as it lands — faster, but may hit rough edges. Switching back to Stable keeps your current version until Stable catches up. Applies to WebUI updates only.', settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Default assistant name', @@ -2537,6 +2541,10 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Mostra sessioni di messaggistica precedenti', settings_label_sync_insights: 'Sincronizza con insights', settings_label_check_updates: 'Verifica aggiornamenti', + settings_label_update_channel: 'Canale di aggiornamento', + settings_update_channel_stable: 'Stabile (consigliato)', + settings_update_channel_experimental: 'Sperimentale', + settings_desc_update_channel: 'Stabile riceve release consolidate e testate. Sperimentale riceve ogni release appena esce: più veloce, ma può avere imperfezioni. Tornando a Stabile mantieni la versione attuale finché Stabile non la raggiunge. Si applica solo agli aggiornamenti della WebUI.', settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Nome assistente predefinito', @@ -4223,6 +4231,10 @@ const LOCALES = { settings_label_previous_messaging_sessions: '以前のメッセージングセッションを表示', settings_label_sync_insights: 'インサイトに同期', settings_label_check_updates: 'アップデートを確認', + settings_label_update_channel: 'アップデートチャンネル', + settings_update_channel_stable: '安定版(推奨)', + settings_update_channel_experimental: '実験版', + settings_desc_update_channel: '安定版は熟成済みの実績あるリリースを受け取ります。実験版はリリースを出た直後に受け取ります。速いですが不具合が出ることがあります。安定版に戻しても、安定版が追いつくまで現在のバージョンを維持します。 WebUI の更新にのみ適用されます。', settings_label_ignore_agent_updates: 'Agentのアップデートを無視', settings_label_whats_new_summary: "What's New をAIで要約", settings_label_bot_name: 'デフォルトのアシスタント名', @@ -5617,6 +5629,10 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Показывать предыдущие сеансы обмена сообщениями', settings_label_sync_insights: 'Синхронизировать с Insights', settings_label_check_updates: 'Проверять обновления', + settings_label_update_channel: 'Канал обновлений', + settings_update_channel_stable: 'Стабильный (рекомендуется)', + settings_update_channel_experimental: 'Экспериментальный', + settings_desc_update_channel: 'Стабильный канал получает проверенные, обкатанные релизы. Экспериментальный получает каждый релиз сразу — быстрее, но возможны шероховатости. При возврате на Стабильный ваша версия сохраняется, пока Стабильный не догонит её. Применяется только к обновлениям WebUI.', settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Имя помощника по умолчанию', @@ -7230,6 +7246,10 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Mostrar sesiones de mensajería anteriores', settings_label_sync_insights: 'Sincronizar con insights', settings_label_check_updates: 'Buscar actualizaciones', + settings_label_update_channel: 'Canal de actualizaciones', + settings_update_channel_stable: 'Estable (recomendado)', + settings_update_channel_experimental: 'Experimental', + settings_desc_update_channel: 'Estable recibe versiones probadas y maduradas. Experimental recibe cada versión en cuanto sale: más rápido, pero puede tener asperezas. Al volver a Estable conservas tu versión actual hasta que Estable la alcance. Se aplica solo a las actualizaciones de la WebUI.', settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Nombre predeterminado del asistente', @@ -8831,6 +8851,10 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Vorherige Messaging-Sitzungen anzeigen', settings_label_sync_insights: 'Mit Insights synchronisieren', settings_label_check_updates: 'Nach Updates suchen', + settings_label_update_channel: 'Update-Kanal', + settings_update_channel_stable: 'Stabil (empfohlen)', + settings_update_channel_experimental: 'Experimentell', + settings_desc_update_channel: 'Stabil erhält ausgereifte, bewährte Releases. Experimentell erhält jedes Release sofort — schneller, aber mit möglichen Ecken und Kanten. Beim Zurückwechseln zu Stabil behältst du deine aktuelle Version, bis Stabil aufholt. Gilt nur für WebUI-Updates.', settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Standard-Assistentenname', @@ -10497,6 +10521,10 @@ const LOCALES = { settings_label_previous_messaging_sessions: '显示以前的消息会话', settings_label_sync_insights: '同步到 insights', settings_label_check_updates: '检查更新', + settings_label_update_channel: '更新通道', + settings_update_channel_stable: '稳定版(推荐)', + settings_update_channel_experimental: '实验版', + settings_desc_update_channel: '稳定版提供经过充分验证的成熟版本。实验版会在每个版本发布时立即提供——更快,但可能有粗糙之处。切回稳定版会保留当前版本,直到稳定版赶上。 仅适用于 WebUI 更新。', settings_label_ignore_agent_updates: '忽略智能体更新', settings_label_whats_new_summary: "用AI总结新功能", settings_label_bot_name: '默认助手名称', @@ -12404,6 +12432,10 @@ const LOCALES = { settings_label_previous_messaging_sessions: '顯示以前的訊息對話', settings_label_sync_insights: '同步至 insights', settings_label_check_updates: '檢查更新', + settings_label_update_channel: '更新通道', + settings_update_channel_stable: '穩定版(建議)', + settings_update_channel_experimental: '實驗版', + settings_desc_update_channel: '穩定版提供經過充分驗證的成熟版本。實驗版會在每個版本發布時立即提供——更快,但可能有粗糙之處。切回穩定版會保留目前版本,直到穩定版趕上。 僅適用於 WebUI 更新。', settings_label_ignore_agent_updates: '忽略 Agent 更新', settings_label_whats_new_summary: '使用 AI 摘要新版內容', settings_label_bot_name: '助理名稱', @@ -13985,6 +14017,10 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Mostrar sessões de mensagens anteriores', settings_label_sync_insights: 'Sincronizar para insights', settings_label_check_updates: 'Verificar atualizações', + settings_label_update_channel: 'Canal de atualização', + settings_update_channel_stable: 'Estável (recomendado)', + settings_update_channel_experimental: 'Experimental', + settings_desc_update_channel: 'Estável recebe versões testadas e amadurecidas. Experimental recebe cada versão assim que sai — mais rápido, mas pode ter arestas. Ao voltar para Estável você mantém sua versão atual até que o Estável a alcance. Aplica-se apenas a atualizações da WebUI.', settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Nome padrão do assistente', @@ -15570,6 +15606,10 @@ const LOCALES = { settings_label_previous_messaging_sessions: '이전 메시징 세션 표시', settings_label_sync_insights: 'Insights에 동기화', settings_label_check_updates: '업데이트 확인', + settings_label_update_channel: '업데이트 채널', + settings_update_channel_stable: '안정 버전(권장)', + settings_update_channel_experimental: '실험 버전', + settings_desc_update_channel: '안정 채널은 충분히 검증된 릴리스를 받습니다. 실험 채널은 릴리스가 나오는 즉시 받습니다 — 더 빠르지만 거친 부분이 있을 수 있습니다. 안정 채널로 되돌려도 안정 채널이 따라잡을 때까지 현재 버전이 유지됩니다. WebUI 업데이트에만 적용됩니다.', settings_label_ignore_agent_updates: 'Ignore Agent updates', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: '기본 Assistant 이름', @@ -17268,6 +17308,10 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Afficher les sessions de messagerie précédentes', settings_label_sync_insights: 'Synchroniser avec les analyses', settings_label_check_updates: 'Vérifier les mises à jour', + settings_label_update_channel: 'Canal de mise à jour', + settings_update_channel_stable: 'Stable (recommandé)', + settings_update_channel_experimental: 'Expérimental', + settings_desc_update_channel: 'Stable reçoit des versions éprouvées et matures. Expérimental reçoit chaque version dès sa sortie — plus rapide, mais avec de possibles aspérités. En revenant sur Stable, vous conservez votre version actuelle jusqu\'à ce que Stable la rattrape. Ne s\'applique qu\'aux mises à jour de la WebUI.', settings_label_ignore_agent_updates: 'Ignorer les mises à jour de l\'agent', settings_label_whats_new_summary: "Résumer les nouveautés avec l'IA", settings_label_bot_name: 'Nom de l\'assistant par défaut', @@ -19058,6 +19102,10 @@ const LOCALES = { settings_label_bot_name: 'Výchozí název asistenta', settings_label_chat_activity_display_mode: 'Zobrazení aktivity', settings_label_check_updates: 'Kontrolovat aktualizace', + settings_label_update_channel: 'Kanál aktualizací', + settings_update_channel_stable: 'Stabilní (doporučeno)', + settings_update_channel_experimental: 'Experimentální', + settings_desc_update_channel: 'Stabilní kanál dostává ověřená, prověřená vydání. Experimentální dostává každé vydání hned — rychlejší, ale může mít nedodělky. Při přepnutí zpět na Stabilní si ponecháte svou současnou verzi, dokud ji Stabilní nedožene. Platí pouze pro aktualizace WebUI.', settings_label_claude_code_sessions: 'Zobrazit Claude Code relace', settings_label_composer_controls: 'Ovládací prvky zápatí chatu', settings_label_composer_situational_controls: 'Situační ovládací prvky zápatí', @@ -20567,6 +20615,10 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Önceki mesajlaşma oturumlarını göster', settings_label_sync_insights: 'Analizlerle senkronize edin', settings_label_check_updates: 'Güncellemeleri kontrol edin', + settings_label_update_channel: 'Güncelleme kanalı', + settings_update_channel_stable: 'Kararlı (önerilir)', + settings_update_channel_experimental: 'Deneysel', + settings_desc_update_channel: 'Kararlı kanal olgunlaşmış, kanıtlanmış sürümleri alır. Deneysel kanal her sürümü çıktığı anda alır — daha hızlı ama pürüzler olabilir. Kararlı\'ya geri dönmek, Kararlı yetişene kadar mevcut sürümünüzü korur. Yalnızca WebUI güncellemeleri için geçerlidir.', settings_label_whats_new_summary: "Summarize What's New with AI", settings_label_bot_name: 'Varsayılan asistanın adı', settings_label_password: 'Erişim Şifresi', @@ -22267,6 +22319,10 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Pokaż poprzednie sesje komunikatorów', settings_label_sync_insights: 'Synchronizuj z Insights', settings_label_check_updates: 'Sprawdzaj aktualizacje', + settings_label_update_channel: 'Kanał aktualizacji', + settings_update_channel_stable: 'Stabilny (zalecany)', + settings_update_channel_experimental: 'Eksperymentalny', + settings_desc_update_channel: 'Kanał stabilny otrzymuje sprawdzone, dojrzałe wydania. Eksperymentalny otrzymuje każde wydanie od razu — szybciej, ale mogą wystąpić niedoróbki. Powrót na Stabilny zachowuje bieżącą wersję, dopóki Stabilny jej nie dogoni. Dotyczy tylko aktualizacji WebUI.', settings_label_ignore_agent_updates: 'Ignoruj aktualizacje agenta', settings_label_whats_new_summary: 'Podsumuj Co Nowego za pomocą AI', settings_label_bot_name: 'Domyślna nazwa asystenta', @@ -23795,6 +23851,10 @@ const LOCALES = { settings_label_previous_messaging_sessions: 'Hiện các phiên nhắn tin trước đó', settings_label_sync_insights: 'Đồng bộ sang insights', settings_label_check_updates: 'Kiểm tra cập nhật', + settings_label_update_channel: 'Kênh cập nhật', + settings_update_channel_stable: 'Ổn định (khuyến nghị)', + settings_update_channel_experimental: 'Thử nghiệm', + settings_desc_update_channel: 'Kênh Ổn định nhận các bản phát hành đã được kiểm chứng kỹ. Kênh Thử nghiệm nhận mọi bản phát hành ngay khi có — nhanh hơn nhưng có thể còn lỗi. Khi chuyển về Ổn định, bạn giữ phiên bản hiện tại cho đến khi Ổn định bắt kịp. Chỉ áp dụng cho các bản cập nhật WebUI.', settings_label_ignore_agent_updates: 'Bỏ qua cập nhật Agent', settings_label_whats_new_summary: "Tóm tắt What's New bằng AI", settings_label_bot_name: 'Tên assistant mặc định', diff --git a/static/index.html b/static/index.html index 23bd8271..f550228a 100644 --- a/static/index.html +++ b/static/index.html @@ -1398,6 +1398,14 @@
Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.
+
+ + +
Stable gets soak-tested, proven releases. Experimental gets every release as it lands — faster, but may hit rough edges. Switching back to Stable keeps your current version until Stable catches up. Applies to WebUI updates only.
+