diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/SystemTools.js b/starpilot/system/the_galaxy/assets/mobile/js/views/SystemTools.js index e260addf2c..68330e958c 100644 --- a/starpilot/system/the_galaxy/assets/mobile/js/views/SystemTools.js +++ b/starpilot/system/the_galaxy/assets/mobile/js/views/SystemTools.js @@ -309,8 +309,11 @@ export const SystemTools = { try { const selected = this.versionCommits.find(item => item.sha === commit) const version = commit === "latest" ? "Latest" : `${versionTitle(selected, branch === "StarPilot")}\nCommit: ${commit}` - const policy = commit === "latest" ? "Automatic updates will remain off after installation. You can enable them in settings." : "Automatic updates will be paused for this earlier version." - if (!(await GalaxyConfirm({title: "Install selected version?", message: `Branch: ${branch}\nVersion: ${version}\n\n${policy}\n\nThis replaces the current software. Settings and statistics are kept, and local code changes are backed up. Older versions may remove this picker; an SSH recovery copy is saved on the device.\n\nYour device will reboot when installation finishes.`, confirmLabel: "Install & Reboot", danger: true}))) return + const policy = commit === "latest" ? "This uses the normal branch updater, including any required OS update during startup. Your automatic-update setting is unchanged." : "Automatic updates will be paused for this earlier version." + const recovery = commit === "latest" + ? "This replaces the current software. Settings and statistics are kept. Local code changes may be overwritten; standard Rollback saves the previous committed version." + : "This replaces the current software. Settings and statistics are kept, and local code changes are backed up. Older versions may remove this picker; an SSH recovery copy is saved on the device." + if (!(await GalaxyConfirm({title: "Install selected version?", message: `Branch: ${branch}\nVersion: ${version}\n\n${policy}\n\n${recovery}\n\nYour device will reboot when installation finishes.`, confirmLabel: "Install & Reboot", danger: true}))) return // Refresh driving/updater state after the user has reviewed the target. await this.loadFastStatus({throwOnError: true}) if (this.branchSwitchBlocked || this.targetBranch !== branch || generation !== this.versionGeneration) { diff --git a/starpilot/system/the_galaxy/tests/test_branch_selector.mjs b/starpilot/system/the_galaxy/tests/test_branch_selector.mjs index 5436a30ce6..a42108b522 100644 --- a/starpilot/system/the_galaxy/tests/test_branch_selector.mjs +++ b/starpilot/system/the_galaxy/tests/test_branch_selector.mjs @@ -139,7 +139,7 @@ test('return to Latest uses pinned branch and confirms without changing automati instance.fastStatus = {versionPin: {branch: 'feature/test', commit: SHA, installedAt: '2026-09-10'}} await instance.returnToLatest() assert.deepEqual(calls, [{branch: 'feature/test', commit: 'latest'}]) - assert.ok(confirmations[0].message.includes('remain off')) + assert.ok(confirmations[0].message.includes('automatic-update setting is unchanged')) }) test('API preserves branch encoding, pagination head, abort signal and confirmed install body', async () => { const calls = [] @@ -265,3 +265,18 @@ test('changing branch during a multi-page release search cancels remaining pages instance.selectTargetBranch('Dom');finish();await pending assert.equal(requests,2);assert.equal(instance.versionCommits.length,0);assert.equal(instance.versionLoading,false) }) + +test('Latest explains normal OS handling and local-edit behavior; historical confirmation keeps recovery details', async () => { + const {instance, confirmations} = fixture() + instance.selectTargetBranch('Dom') + await instance.installSelectedVersion() + assert.match(confirmations[0].message, /normal branch updater/) + assert.match(confirmations[0].message, /required OS update/) + assert.match(confirmations[0].message, /Local code changes may be overwritten/) + assert.doesNotMatch(confirmations[0].message, /code changes are backed up/) + await instance.onVersionModeSelect({target:{value:'earlier'}}) + instance.selectedCommit=SHA + await instance.installSelectedVersion() + assert.match(confirmations[1].message, /updates will be paused/) + assert.match(confirmations[1].message, /code changes are backed up/) +}) diff --git a/starpilot/system/the_galaxy/tests/test_version_legacy_updates.py b/starpilot/system/the_galaxy/tests/test_version_legacy_updates.py new file mode 100644 index 0000000000..32a723f195 --- /dev/null +++ b/starpilot/system/the_galaxy/tests/test_version_legacy_updates.py @@ -0,0 +1,121 @@ +"""Real local Git update/rollback regressions. No device, OS flashing or reboot.""" +import ast +from datetime import datetime, timezone +import json +from pathlib import Path +import subprocess +import threading +import time +from types import SimpleNamespace +from unittest.mock import Mock + +from flask import Flask, jsonify, request +import pytest +from test_version_routes import server +from test_version_install_rehearsal import rehearsal, git, load_module, write + +MODULE_DIR = Path(__file__).resolve().parents[1] + +def unpack(state): + return (getattr(state, name) for name in ('root','repo','origin','old','latest','data','installer','history')) + +def test_latest_uses_existing_branch_worker_without_historical_restrictions(server): + _, ns = server + ns['_branch_switch_worker'] = Mock() + ns['version_history'].resolve_version.side_effect = AssertionError('Latest must not require history API') + ns['_version_install_worker']('StarPilot', 'latest') + ns['_branch_switch_worker'].assert_called_once_with('StarPilot') + ns['version_install'].install.assert_not_called() + ns['_set_fast_update_error_state'].assert_not_called() + +def legacy_namespace(repo, data, installer): + def run(repo, args, **_): + return subprocess.run(['git','-C',str(repo),*args], capture_output=True, text=True) + def stdout(repo, args, **_): + return git(repo, *args) + def config(repo, key): + return run(repo, ['config','--get',key]).stdout.strip() + def fetch(repo, args, **_): + result=run(repo,args) + return result.returncode, result.stderr + params=SimpleNamespace(get_bool=lambda key:(data/'params/d'/key).read_bytes()==b'1', + put_bool=lambda key,value:write(data,'params/d/'+key,'1' if value else '0')) + app=Flask(__name__) + ns=dict(app=app,request=request,jsonify=jsonify,params=params,time=time,datetime=datetime,timezone=timezone, + threading=SimpleNamespace(Thread=Mock()), _fast_update_lock=threading.Lock(), _fast_update_state={'running':False}, + _FAST_UPDATE_TOTAL_STEPS=5, _FAST_BRANCH_SWITCH_FETCH_TIMEOUT_S=20, _FAST_ROLLBACK_FETCH_TIMEOUT_S=20, + _ROLLBACK_REF='refs/starpilot/rollback', _ROLLBACK_BRANCH_CONFIG_KEY='starpilot.rollbackbranch', + _ROLLBACK_RECORDED_AT_CONFIG_KEY='starpilot.rollbackrecordedat', + _get_openpilot_root=lambda:repo, _run_git=run, _git_stdout=stdout, + _git_config_get=config, _git_config_set=lambda repo,key,value:git(repo,'config',key,value), + _git_config_unset=lambda repo,key:run(repo,['config','--unset',key]), + _git_update_ref=lambda repo,ref,sha:git(repo,'update-ref',ref,sha), + _git_delete_ref=lambda repo,ref:git(repo,'update-ref','-d',ref), + _git_has_commit=lambda repo,sha:run(repo,['cat-file','-e',sha+'^{commit}']).returncode==0, + _build_shallow_fetch_args=lambda branch:['fetch','--depth=1','origin',branch], + _build_shallow_fetch_commit_args=lambda sha:['fetch','--depth=1','origin',sha], + _run_git_with_progress=fetch, _clear_generated_build_state=Mock(), _run_submodule_update_if_needed=Mock(), + _set_fast_update_state=Mock(), _set_fast_update_progress=Mock(), _set_fast_update_error_state=Mock(), + _finish_update_and_reboot=Mock(), version_install=SimpleNamespace(clear_pin=lambda:installer.clear_pin(data))) + names={'_is_valid_git_branch_name','_save_rollback_target','_load_rollback_target','_clear_rollback_target', + '_branch_switch_worker','_rollback_worker','run_update_rollback'} + for node in ast.walk(ast.parse((MODULE_DIR/'the_galaxy.py').read_text())): + if isinstance(node,ast.FunctionDef) and node.name in names: + exec(compile(ast.Module(body=[node],type_ignores=[]),'the_galaxy.py','exec'),ns) + return ns, app.test_client() + +def test_latest_changed_os_still_uses_standard_update_and_rolls_back(rehearsal): + root, repo, origin, old, latest, data, installer, history = unpack(rehearsal) + seed=root/'seed' + git(seed,'checkout','-b','StarPilot') + write(seed,'launch_env.sh','export AGNOS_VERSION="99.0"\n') + write(seed,'system/hardware/tici/agnos.json','[{"name":"system","hash":"new-os"}]\n') + git(seed,'add','.');git(seed,'commit','-m','Different OS requirement') + target=git(seed,'rev-parse','HEAD') + git(seed,'push',str(origin),'StarPilot') + write(data,'starpilot/version_selection.json',json.dumps({'branch':'Dom','commit':latest})) + ns, client=legacy_namespace(repo,data,installer) + ns['_branch_switch_worker']('StarPilot') + ns['_set_fast_update_error_state'].assert_not_called() + assert git(repo,'rev-parse','HEAD')==target + assert '99.0' in (repo/'launch_env.sh').read_text() + assert not (data/'starpilot/version_selection.json').exists() + assert (data/'params/d/AutomaticUpdates').read_bytes()==b'1' + assert ns['_load_rollback_target'](repo)['rollbackCommit']==latest + ns['_finish_update_and_reboot'].assert_called_once() + assert client.post('/api/update/rollback').status_code==202 + assert (data/'params/d/AutomaticUpdates').read_bytes()==b'0' + ns['_rollback_worker']() + ns['_set_fast_update_error_state'].assert_not_called() + assert git(repo,'rev-parse','HEAD')==latest + assert git(repo,'branch','--show-current')=='Dom' + assert ns['_load_rollback_target'](repo)['rollbackAvailable'] is False + assert (data/'params/d/ExampleSetting').read_text()=='preserve me' + +def test_historical_install_records_previous_commit_for_existing_rollback(rehearsal): + root, repo, origin, old, latest, data, installer, history = unpack(rehearsal) + ns, client=legacy_namespace(repo,data,installer) + git(repo,'fetch','--depth=1','origin',old) + installer.install(repo,{'branch':'Dom','commit':old,'pinned':True},data_root=data, + check_parked=lambda:installer.require_parked(data),progress=lambda *args:None) + ns['_save_rollback_target'](repo,'Dom',latest) + assert ns['_load_rollback_target'](repo)['rollbackAvailable'] is True + assert client.post('/api/update/rollback').status_code==202 + ns['_rollback_worker']() + ns['_set_fast_update_error_state'].assert_not_called() + assert git(repo,'rev-parse','HEAD')==latest + assert not (data/'starpilot/version_selection.json').exists() + assert (data/'params/d/AutomaticUpdates').read_bytes()==b'0' + +def test_rollback_remains_blocked_while_driving_or_updating(rehearsal): + root, repo, origin, old, latest, data, installer, history = unpack(rehearsal) + git(repo,'fetch','--depth=1','origin',old) + ns,client=legacy_namespace(repo,data,installer) + ns['_save_rollback_target'](repo,'Dom',old) + write(data,'params/d/IsOnroad','1') + assert client.post('/api/update/rollback').status_code==409 + write(data,'params/d/IsOnroad','0') + ns['_fast_update_state']['running']=True + assert client.post('/api/update/rollback').status_code==409 + ns['threading'].Thread.assert_not_called() + assert git(repo,'rev-parse','HEAD')==latest diff --git a/starpilot/system/the_galaxy/tests/test_version_routes.py b/starpilot/system/the_galaxy/tests/test_version_routes.py index 83e2001728..e18becc356 100644 --- a/starpilot/system/the_galaxy/tests/test_version_routes.py +++ b/starpilot/system/the_galaxy/tests/test_version_routes.py @@ -123,3 +123,10 @@ def test_history_has_no_artificial_page_depth_cutoff(server): reply=client.get('/api/update/versions?branch=Dom&page=1201&head='+'b'*40) assert reply.status_code==200 ns['version_history'].list_versions.assert_called_once_with('/repo','Dom',page=1201,head='b'*40) + +def test_historical_worker_records_pre_install_branch_and_commit_for_rollback(server): + _, ns = server + ns['_git_stdout'].side_effect=['a'*40, 'feature/test', 'b'*40] + ns['_version_install_worker']('Dom','a'*40) + ns['_save_rollback_target'].assert_called_once_with('/repo','feature/test','b'*40) + ns['_set_fast_update_error_state'].assert_not_called() diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index 9ef6385d5e..04d5c4e4de 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -3021,6 +3021,12 @@ def _version_install_worker(branch, selection): installed = None try: version_install.require_parked() + if selection == "latest": + # Preserve the existing branch updater, including boot-time AGNOS handling. + # Historical compatibility and GitHub-history restrictions apply only to + # exact revisions, never to an ordinary update to the latest branch head. + _branch_switch_worker(branch) + return _set_fast_update_progress(1, "Resolving selected version", 10.0, branch) target = version_history.resolve_version(repo_path, branch, selection) version_install.require_parked() @@ -3167,6 +3173,7 @@ def _branch_switch_worker(target_branch): _set_fast_update_progress(3, "Switching branch", 100.0, f"Now on '{target_branch}'.") _run_submodule_update_if_needed(repo_path, step=4) + version_install.clear_pin() _finish_update_and_reboot( f"Switched to '{target_branch}'. Device is rebooting now. Please wait for reconnection." ) @@ -3236,6 +3243,7 @@ def _rollback_worker(): _set_fast_update_progress(3, "Applying rollback target", 100.0, f"Now on {target_branch} @ {short_commit}.") _run_submodule_update_if_needed(repo_path, step=4) + version_install.clear_pin() try: _clear_rollback_target(repo_path) except Exception as exception: diff --git a/starpilot/system/the_galaxy/version_install.py b/starpilot/system/the_galaxy/version_install.py index 234a015c67..f7d3b73c0b 100644 --- a/starpilot/system/the_galaxy/version_install.py +++ b/starpilot/system/the_galaxy/version_install.py @@ -83,6 +83,11 @@ def validate_target(target): raise InstallError('An exact validated commit and branch are required') +def clear_pin(data_root=Path('/data')): + """Normal branch updates and rollback supersede a historical selection.""" + (Path(data_root) / 'starpilot/version_selection.json').unlink(missing_ok=True) + + def read_pin(repo, data_root=Path('/data')): try: value = json.loads((Path(data_root) / 'starpilot/version_selection.json').read_text())