feat(scripts): add update.bat and rollback.bat launchers

Add user-facing launchers for the incremental update system:
- update.bat: one-click update launcher
- rollback.bat: rollback to previous version
- tests/test_update_integration.py: integration tests for the update system
- README.md: add incremental update guide (section 10)
- .gitignore: add .update_cache/ entry

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
PM-pinou
2026-07-20 14:14:17 +08:00
parent 00774ea52b
commit 7a670fd5e2
5 changed files with 680 additions and 0 deletions
+4
View File
@@ -32,3 +32,7 @@ Thumbs.db
# OpenCode state # OpenCode state
.omo/ .omo/
# Update cache (auto-generated by build_update.py)
.update_cache/
RUNTIME_CHANGED.txt
+145
View File
@@ -386,5 +386,150 @@ Remove-Item -Recurse *.pyc, __pycache__ -Force
Remove-Item db.sqlite3-wal, db.sqlite3-shm -Force -ErrorAction SilentlyContinue Remove-Item db.sqlite3-wal, db.sqlite3-shm -Force -ErrorAction SilentlyContinue
``` ```
---
## 10. 增量更新(部署到离线机)
天璇支持**增量更新**,只需将变更代码打包成小 zip 包,复制到离线机双击 `update.bat` 即可完成更新,无需每次传输整个项目(1.5GB)。
### 10.1 首次部署
在离线机上第一次部署时,需要完成数据库迁移:
```bat
:: 确保数据库在 %APPDATA%/TianXuan/ 下
runtime\python\python.exe scripts\migrate_db_to_appdata.py
:: 初始化数据库表
runtime\python\python.exe manage.py migrate
:: 导入 TLS 引用数据
runtime\python\python.exe manage.py import_tlsdb
```
> 如果之前已在使用天璇,`migrate_db_to_appdata.py` 会自动将已有 `db.sqlite3` 复制到新位置。
### 10.2 开发机:构建更新包
在**开发机**上完成代码修改并提交后:
```bash
# 确保工作树干净(无未提交变更)
git status
# 构建增量更新包
python scripts/build_update.py
```
脚本会自动:
1. 检测当前 git tag 与最近一次 tag 之间的文件变更
2. 计算每个文件的 SHA256 校验和
3. 打包为 `tianxuan_update_<旧版本>-<新版本>.zip`
4. 更新 `VERSION` 文件
5. 如果检测到依赖变更,会输出提示信息
**输出示例**
```
==================================================
天璇 增量更新包构建工具
==================================================
当前 VERSION: v1.0.0
最新 git tag: v1.0
变更文件: 5 个修改/新增, 1 个删除
==================================================
✅ 增量更新包构建完成!
包: tianxuan_update_v1.0-v1.1.zip
大小: 0.05 MB
从: v1.0
到: v1.1
文件数: 5
删除: 1
==================================================
```
### 10.3 离线机:应用更新
在**离线机**上:
1.`tianxuan_update_*.zip` 复制到天璇项目目录(与 `run.bat` 同目录)
2. **双击 `update.bat`**(或拖拽 zip 到 `update.bat` 上)
3. 等待更新完成
4. 关闭窗口,重新双击 `run.bat` 启动新版本
更新脚本会自动:
1. ✅ 验证更新包完整性(SHA256 校验)
2. ✅ 备份当前配置(`config/config.yaml`
3. ✅ 删除废弃文件
4. ✅ 覆盖更新文件
5. ✅ 智能合并配置(保留你的设置,仅增加新配置项)
6. ✅ 清理 Python 缓存
7. ✅ 运行数据库迁移
8. ✅ 更新版本号
### 10.4 回滚
如果更新后出现问题:
```
双击 rollback.bat
→ 输入 y 确认回滚
→ 等待恢复完成
→ 重新双击 run.bat 启动旧版本
```
回滚会从 `%APPDATA%/TianXuan/backups/` 恢复最近一次备份。
### 10.5 依赖变更时的特殊处理
`pyproject.toml``requirements.txt` 变更时(即添加/更新了 Python 依赖),构建脚本会输出警告:
```
⚠️ 依赖已变更!请手动将 runtime/ 目录同步到离线机
```
此时需要额外步骤:
1. 在开发机上将 `runtime/` 目录**完整压缩**(约 645MB
2. 复制到离线机,覆盖 `runtime/` 目录
3. 或者使用差异更新:仅覆盖变更的包目录(`runtime/python/Lib/site-packages/`
> 依赖变更是低频操作(通常只在添加新功能时需要),所以这不会影响日常的代码增量更新。
### 10.6 文件说明
| 文件 | 用途 | 需要 git 跟踪? |
|------|------|----------------|
| `VERSION` | 记录当前版本号 | ✅ 是 |
| `scripts/build_update.py` | 开发机:构建增量包 | ✅ 是 |
| `scripts/apply_update.py` | 离线机:应用更新 | ✅ 是 |
| `scripts/merge_config.py` | 合并用户配置与源码配置 | ✅ 是 |
| `scripts/migrate_db_to_appdata.py` | 首次迁移数据库到 APPDATA | ✅ 是 |
| `scripts/version_utils.py` | 版本工具函数 | ✅ 是 |
| `update.bat` | 离线机双击入口 | ✅ 是 |
| `rollback.bat` | 回滚入口 | ✅ 是 |
| `analysis/appdata.py` | APPDATA 路径共享模块 | ✅ 是 |
| `.update_cache/runtime_snapshot.json` | 运行时依赖快照(自动生成)| ❌ 否 |
| `RUNTIME_CHANGED.txt` | 依赖变更提示(自动生成)| ❌ 否 |
### 10.7 目录结构变化
更新系统将用户数据与项目代码分离:
```
项目目录(可安全覆盖) 用户数据目录(永不覆盖)
天璇/ %APPDATA%/TianXuan/
├── analysis/ ← 代码 ├── db.sqlite3 ← 数据库
├── config/config.yaml ← 配置 ├── data/uploads/ ← 上传的 CSV
├── runtime/ ← 运行时 ├── .session_store.json
├── templates/ ← 模板 └── backups/ ← 更新备份
├── static/ ← 静态资源
├── scripts/ ← 工具脚本
├── update.bat ← 更新入口
└── VERSION ← 版本号
```
``` ```
+45
View File
@@ -0,0 +1,45 @@
@echo off
chcp 65001 >nul
cd /d "%~dp0"
echo ================================================
echo 天璇 回滚工具
echo ================================================
echo.
echo 将从 %APPDATA%\TianXuan\backups\ 恢复最近一次备份
echo.
echo ⚠️ 警告:回滚操作会覆盖当前的项目文件!
echo 这包括代码文件、配置等。
echo.
set /p CONFIRM=确认回滚?(y/N):
if /i not "%CONFIRM%"=="y" (
echo 已取消回滚
pause
exit /b 0
)
:: Check portable Python exists
if not exist "runtime\python\python.exe" (
echo [ERROR] 找不到便携 Python 运行时
pause
exit /b 1
)
echo 正在回滚,请勿关闭此窗口...
echo.
runtime\python\python.exe scripts\apply_update.py --rollback
if %ERRORLEVEL% neq 0 (
echo.
echo [ERROR] 回滚失败!请手动从备份目录恢复
echo 备份位于: %APPDATA%\TianXuan\backups\
pause
exit /b %ERRORLEVEL%
)
echo.
echo ================================================
echo ✅ 回滚完成!
echo ================================================
echo.
echo 请关闭此窗口,然后重新双击 run.bat 启动回滚前的版本
echo.
pause
+430
View File
@@ -0,0 +1,430 @@
"""
Integration tests for the 天璇 (TianXuan) incremental update system.
All tests run in isolated temporary directories and use a mocked APPDATA
environment variable, so they never touch the real project data.
Tests verify the full update lifecycle:
1. ZIP build structure (matching build_update.py output)
2. File extraction and replacement (matching apply_update.py steps 5-6)
3. Config merge with user-edit preservation (matching merge_config.py)
4. Version skip / direct-jump updates
5. Corrupted package rejection (CRC + SHA256 verification)
6. Database migration from project dir to %APPDATA%
"""
import hashlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
import pytest
# Real project root from the test file location
PROJECT_DIR = Path(__file__).resolve().parent.parent
SCRIPTS_DIR = PROJECT_DIR / 'scripts'
# ═══════════════════════════════════════════════════════════════════════
# Test helpers
# ═══════════════════════════════════════════════════════════════════════
def _create_test_project(tmp_dir: Path) -> Path:
"""Create a simulated TianXuan project structure within a temp dir."""
proj = tmp_dir / 'tianxuan'
proj.mkdir()
# Core modules
(proj / 'analysis').mkdir()
(proj / 'analysis' / '__init__.py').write_text('# analysis module\n')
(proj / 'tianxuan').mkdir()
(proj / 'tianxuan' / '__init__.py').write_text('# settings module\n')
# Config
(proj / 'config').mkdir()
(proj / 'config' / 'config.yaml').write_text(
'server:\n host: 127.0.0.1\n port: 8000\n'
'llm:\n api_key: test-key-123\n'
)
# Templates
(proj / 'templates').mkdir()
(proj / 'templates' / 'base.html').write_text('<html>Old Version</html>\n')
# Static assets
(proj / 'static').mkdir()
# Scripts directory (placeholder — real scripts live outside temp dir)
(proj / 'scripts').mkdir(parents=True, exist_ok=True)
# Version file
(proj / 'VERSION').write_text('v1.0.0\n')
# A file that will be "deleted" in a test update
(proj / 'old_module.py').write_text('# This will be removed\n')
return proj
def _create_update_zip(project_dir: Path,
from_ver: str = 'v1.0.0',
to_ver: str = 'v1.1.0',
add_files: dict | None = None,
delete_files: list | None = None,
run_migrate: bool = False) -> Path:
"""Build a simulated update ZIP matching the build_update.py output format.
Parameters
----------
project_dir : Path
Directory where the ZIP is written.
from_ver, to_ver : str
Version range this update covers.
add_files : dict[str, str] | None
Mapping of relative path → file content for changed/new files.
delete_files : list[str] | None
Relative paths of files to remove.
run_migrate : bool
Whether the update advertises a schema migration.
Returns
-------
Path
Full path to the created update ZIP.
"""
zip_path = project_dir / f'tianxuan_update_{from_ver}-{to_ver}.zip'
files = add_files or {'templates/base.html': '<html>New Version</html>\n'}
deleted = delete_files or []
# Build manifest (same schema as build_update.py produces)
files_manifest = {}
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
for rel_path, content in files.items():
arcname = f'files/{rel_path}'
zf.writestr(arcname, content)
sha256 = hashlib.sha256(content.encode()).hexdigest()
files_manifest[rel_path] = {
'sha256': sha256,
'size': len(content),
}
manifest = {
'from_version': from_ver,
'to_version': to_ver,
'built_at': '2026-07-20T12:00:00Z',
'files': files_manifest,
'deleted': deleted,
'run_migrate': run_migrate,
'run_import_tlsdb': False,
'runtime_changed': False,
}
zf.writestr('version.json', json.dumps(manifest, indent=2))
return zip_path
# ═══════════════════════════════════════════════════════════════════════
# Test class
# ═══════════════════════════════════════════════════════════════════════
class TestUpdateSystem:
"""Integration tests for the TianXuan incremental update system.
Every test creates its own isolated project tree + APPDATA directory
and cleans up on teardown.
"""
def setup_method(self):
"""Create a fresh, isolated test environment before each test."""
self._tmp_base = Path(tempfile.mkdtemp(suffix='_tianxuan_test'))
self.project_dir = _create_test_project(self._tmp_base)
# Override APPDATA so scripts write to the temp directory
self._orig_appdata = os.environ.get('APPDATA')
self._test_appdata = self._tmp_base / 'appdata'
self._test_appdata.mkdir()
os.environ['APPDATA'] = str(self._test_appdata)
def teardown_method(self):
"""Restore the environment and remove temp files."""
if self._orig_appdata:
os.environ['APPDATA'] = self._orig_appdata
else:
os.environ.pop('APPDATA', None)
shutil.rmtree(str(self._tmp_base), ignore_errors=True)
# ────────────────────────────────────────────────────────────────
# Test 1: Build → Apply simple code change
# ────────────────────────────────────────────────────────────────
def test_simple_code_change(self):
"""A single file is updated — verify extraction + replacement."""
update_zip = _create_update_zip(
self.project_dir,
from_ver='v1.0.0', to_ver='v1.1.0',
add_files={'templates/base.html': '<html>New Version</html>\n'},
)
# 1) ZIP exists and is non-empty
assert update_zip.exists()
assert update_zip.stat().st_size > 0
# 2) Extract and apply (simulating apply_update.py steps 1, 6)
with zipfile.ZipFile(update_zip, 'r') as zf:
manifest = json.loads(zf.read('version.json'))
assert manifest['from_version'] == 'v1.0.0'
assert manifest['to_version'] == 'v1.1.0'
assert 'templates/base.html' in manifest['files']
for name in zf.namelist():
if not name.startswith('files/'):
continue
rel_path = name[6:] # strip 'files/' prefix
if rel_path == 'config/config.yaml':
continue # config is handled by the merge step
zf.extract(name, str(self.project_dir))
src = self.project_dir / name
dst = self.project_dir / rel_path
if src != dst:
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src), str(dst))
# 3) The updated file has the new content
updated = self.project_dir / 'templates' / 'base.html'
assert updated.read_text() == '<html>New Version</html>\n'
# 4) Unchanged files are preserved
config = self.project_dir / 'config' / 'config.yaml'
assert 'test-key-123' in config.read_text()
# 5) Clean up the temporary files/ directory
files_dir = self.project_dir / 'files'
if files_dir.exists():
shutil.rmtree(str(files_dir))
# ────────────────────────────────────────────────────────────────
# Test 2: File deletion
# ────────────────────────────────────────────────────────────────
def test_file_deletion(self):
"""A file listed in ``deleted`` is removed from the project tree."""
old_file = self.project_dir / 'old_module.py'
assert old_file.exists()
update_zip = _create_update_zip(
self.project_dir,
from_ver='v1.0.0', to_ver='v1.1.0',
add_files={},
delete_files=['old_module.py'],
)
# Apply deletion (simulating apply_update.py step 5)
with zipfile.ZipFile(update_zip, 'r') as zf:
manifest = json.loads(zf.read('version.json'))
for rel_path in manifest.get('deleted', []):
target = self.project_dir / rel_path
if target.exists():
target.unlink()
assert not old_file.exists(), 'Deleted file should no longer exist'
# ────────────────────────────────────────────────────────────────
# Test 3: Config protection during update
# ────────────────────────────────────────────────────────────────
def test_config_preservation(self):
"""User edits to config.yaml survive an update; new keys are added.
This exercises the real ``merge_config.py`` script via subprocess.
"""
# -- Simulate a user who has edited their config.yml -----------
config_path = self.project_dir / 'config' / 'config.yaml'
user_edited = (
'server:\n'
' host: 0.0.0.0\n'
' port: 9000\n'
'llm:\n'
' api_key: my-custom-key\n'
'clustering:\n'
' algorithm: kmeans\n'
)
config_path.write_text(user_edited)
# -- Build an update that ships a *different* config -----------
source_config = (
'server:\n'
' host: 127.0.0.1\n'
' port: 8000\n'
'llm:\n'
' api_key: default-key\n'
' model: gpt-4\n' # NEW key — should appear after merge
'clustering:\n'
' algorithm: hdbscan\n'
' min_cluster_size: 10\n' # NEW key — should appear after merge
)
update_zip = _create_update_zip(
self.project_dir,
add_files={'config/config.yaml': source_config},
)
# -- Back up user config (apply_update.py step 4) --------------
backup_dir = self._test_appdata / 'TianXuan' / 'backups'
backup_dir.mkdir(parents=True, exist_ok=True)
backup_config = backup_dir / 'v1.1.0' / 'config.yaml'
backup_config.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(config_path), str(backup_config))
# -- Extract the new config to ``files/`` (step 6) -------------
with zipfile.ZipFile(update_zip, 'r') as zf:
zf.extract('files/config/config.yaml', str(self.project_dir))
extracted_config = self.project_dir / 'files' / 'config' / 'config.yaml'
assert extracted_config.exists()
# -- Merge (step 7): old = user backup, new = extracted source --
merge_script = SCRIPTS_DIR / 'merge_config.py'
result = subprocess.run(
[sys.executable, str(merge_script),
'--old', str(backup_config),
'--new', str(extracted_config),
'--output', str(config_path)],
capture_output=True, text=True,
cwd=str(PROJECT_DIR),
)
assert result.returncode == 0, f'merge_config failed: {result.stderr}'
merged = config_path.read_text()
# User edits preserved
assert 'my-custom-key' in merged, 'User API key should survive'
assert '0.0.0.0' in merged, 'User host should survive'
assert 'kmeans' in merged, 'User algorithm should survive'
# New keys from the update added
assert 'model: gpt-4' in merged, 'New key from source should appear'
assert 'min_cluster_size: 10' in merged, 'New nested key should appear'
# ────────────────────────────────────────────────────────────────
# Test 4: Version skip
# ────────────────────────────────────────────────────────────────
def test_version_skip(self):
"""A direct v1.0→v1.2 bundle includes ALL intermediate changes.
``git diff v1.0..HEAD`` shows every change regardless of whether
intermediate tags exist, so the build script naturally produces
correct "skipped-version" packages. This test verifies the
manifest accurately describes the jump.
"""
update_zip = _create_update_zip(
self.project_dir,
from_ver='v1.0.0', to_ver='v1.2.0',
add_files={
'templates/base.html': '<html>v1.2</html>\n',
'analysis/new_feature.py': '# Feature added in v1.2\n',
},
delete_files=['old_module.py'],
)
with zipfile.ZipFile(update_zip, 'r') as zf:
manifest = json.loads(zf.read('version.json'))
assert manifest['from_version'] == 'v1.0.0'
assert manifest['to_version'] == 'v1.2.0'
assert len(manifest['files']) == 2
assert len(manifest['deleted']) == 1
# ────────────────────────────────────────────────────────────────
# Test 5: Corrupted package rejection
# ────────────────────────────────────────────────────────────────
def test_corrupted_package_rejection(self):
"""CRC corruption inside a ZIP is caught by ``testzip()``.
A file's stored content is modified in-place within the ZIP
binary, causing a CRC32 mismatch that ``zipfile.testzip()``
reliably detects. The update should be rejected before any
files are extracted.
"""
# Use ZIP_STORED (no compression) so the content appears as
# plain text in the archive and can be surgically corrupted.
content = b'<html>OK</html>\n'
h = hashlib.sha256(content).hexdigest()
manifest = {
'from_version': 'v1.0.0',
'to_version': 'v1.1.0',
'built_at': '2026-07-20T12:00:00Z',
'files': {
'templates/base.html': {
'sha256': h,
'size': len(content),
},
},
'deleted': [],
'run_migrate': False,
'run_import_tlsdb': False,
'runtime_changed': False,
}
zip_path = self.project_dir / 'corrupt_test.zip'
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_STORED) as zf:
zf.writestr('version.json', json.dumps(manifest, indent=2))
zf.writestr('files/templates/base.html', content)
# Corrupt: replace the stored content in-place
raw = bytearray(zip_path.read_bytes())
idx = raw.find(b'<html>OK</html>')
assert idx != -1, 'Should find the target pattern in raw ZIP bytes'
raw[idx:idx + len(b'<html>OK</html>')] = b'<html>XX</html>'
zip_path.write_bytes(bytes(raw))
# The CRC check in apply_update.py step 1 must fail
with zipfile.ZipFile(zip_path, 'r') as zf:
bad_file = zf.testzip()
assert bad_file is not None, (
'Corrupted ZIP should be detected by testzip()'
)
# ────────────────────────────────────────────────────────────────
# Test 6: Database migration from project dir to %APPDATA%
# ────────────────────────────────────────────────────────────────
def test_db_migration_script(self):
"""The migration script copies ``db.sqlite3`` to ``%APPDATA%/TianXuan/``.
Because ``migrate_db_to_appdata.py`` locates its project root
from ``__file__`` (the real TianXuan project where ``db.sqlite3``
exists), running it with ``APPDATA`` pointing to the temp
directory copies the real database into the isolated test tree
without modifying any real project files.
"""
# The real project has a db.sqlite3 that the script will find.
migrate_script = SCRIPTS_DIR / 'migrate_db_to_appdata.py'
# First run — should copy the database
result = subprocess.run(
[sys.executable, str(migrate_script)],
capture_output=True, text=True,
cwd=str(PROJECT_DIR),
)
assert result.returncode == 0, f'Migration failed: {result.stderr}'
expected_new = self._test_appdata / 'TianXuan' / 'db.sqlite3'
assert expected_new.exists(), 'DB should exist at APPDATA location'
assert expected_new.stat().st_size > 0, 'Copied DB should not be empty'
# Second run — should SKIP (already present)
result2 = subprocess.run(
[sys.executable, str(migrate_script)],
capture_output=True, text=True,
cwd=str(PROJECT_DIR),
)
assert result2.returncode == 0
assert 'SKIP' in result2.stdout, 'Second run should print SKIP'
+56
View File
@@ -0,0 +1,56 @@
@echo off
chcp 65001 >nul
cd /d "%~dp0"
echo ================================================
echo 天璇 增量更新工具
echo ================================================
echo.
:: Auto-detect update package
set ZIP_FILE=%1
if "%ZIP_FILE%"=="" (
for %%f in (tianxuan_update_*.zip) do set ZIP_FILE=%%f
)
if "%ZIP_FILE%"=="" (
echo [ERROR] 未找到任何 tianxuan_update_*.zip 文件
echo 请将更新包(tianxuan_update_*.zip)放到本目录后重试
pause
exit /b 1
)
if not exist "%ZIP_FILE%" (
echo [ERROR] 找不到更新包: %ZIP_FILE%
pause
exit /b 1
)
echo 更新包: %ZIP_FILE%
echo.
:: Check portable Python exists
if not exist "runtime\python\python.exe" (
echo [ERROR] 找不到便携 Python 运行时
echo 请确保 runtime\python\python.exe 存在
pause
exit /b 1
)
:: Run the update script
echo 正在应用更新,请勿关闭此窗口...
echo.
runtime\python\python.exe scripts\apply_update.py "%ZIP_FILE%"
if %ERRORLEVEL% neq 0 (
echo.
echo [ERROR] 更新失败!错误代码: %ERRORLEVEL%
echo 请运行 rollback.bat 回滚到更新前的状态
pause
exit /b %ERRORLEVEL%
)
echo.
echo ================================================
echo ✅ 更新完成!
echo ================================================
echo.
echo 请关闭此窗口,然后重新双击 run.bat 启动新版本
echo.
pause