Status LEDs, image model selection, backup restore, provider test, monthly versions
- Sidebar: three status LEDs (server, models, MCP) with an overview popover (/api/status) - Images: preview stage, purpose-based model choice and reference images (Nano Banana Pro edit refines the chosen preview) - Settings: restore a stored or uploaded backup (staged, applied on restart) - Provider form: test connection; saving or reloading re-checks the status at once - opencode Zen/Go: send x-opencode-session with a stable id per conversation - Releases are named vYYYYMM-NNNNN; scripts/release.sh numbers, tags, builds and publishes - Fix: backups made in the same second no longer overwrite each other Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
49d404eec7
commit
1f69d63b09
35 files changed
+1156
-88
No files matched your search
+63
-1
@@ -56,6 +56,56 @@ pub async fn create_zip(app: &App) -> Result<Vec<u8>> {
|
||||
.await?
|
||||
}
|
||||
|
||||
/// Directory a restore is staged in; applied at the next start (see `apply_pending_restore`).
|
||||
fn pending_dir(data_dir: &std::path::Path) -> PathBuf {
|
||||
data_dir.join("restore-pending")
|
||||
}
|
||||
|
||||
/// Read config.toml and codecraft.db from a backup zip and check both.
|
||||
fn unpack(bytes: &[u8]) -> Result<(String, Vec<u8>)> {
|
||||
use std::io::Read;
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).context("not a zip file")?;
|
||||
let mut config = String::new();
|
||||
z.by_name("config.toml").context("config.toml missing in the backup")?.read_to_string(&mut config)?;
|
||||
let mut db = Vec::new();
|
||||
z.by_name("codecraft.db").context("codecraft.db missing in the backup")?.read_to_end(&mut db)?;
|
||||
if !db.starts_with(b"SQLite format 3\0") {
|
||||
bail!("codecraft.db in the backup is not an SQLite database");
|
||||
}
|
||||
crate::config::Config::parse(&config).context("config.toml in the backup is invalid")?;
|
||||
Ok((config, db))
|
||||
}
|
||||
|
||||
/// Check a backup, back up the current state, and stage the backup for the next start.
|
||||
pub async fn stage_restore(app: &App, bytes: Vec<u8>) -> Result<()> {
|
||||
let (config, db) = tokio::task::spawn_blocking(move || unpack(&bytes)).await??;
|
||||
// Safety net: the current state as a normal backup file.
|
||||
run_named(app, "-before-restore").await.context("could not back up the current state first")?;
|
||||
let dir = pending_dir(&app.config().data_dir());
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
tokio::fs::create_dir_all(&dir).await?;
|
||||
tokio::fs::write(dir.join("codecraft.db"), db).await?;
|
||||
tokio::fs::write(dir.join("config.toml"), config).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Called at startup before the database is opened: move a staged restore into place.
|
||||
pub fn apply_pending_restore(data_dir: &std::path::Path, config_path: &std::path::Path) -> Result<bool> {
|
||||
let dir = pending_dir(data_dir);
|
||||
let (db, cfg) = (dir.join("codecraft.db"), dir.join("config.toml"));
|
||||
if !db.is_file() || !cfg.is_file() {
|
||||
return Ok(false);
|
||||
}
|
||||
let target = data_dir.join("codecraft.db");
|
||||
for ext in ["db-wal", "db-shm"] {
|
||||
let _ = std::fs::remove_file(target.with_extension(ext));
|
||||
}
|
||||
std::fs::rename(&db, &target)?;
|
||||
std::fs::copy(&cfg, config_path)?;
|
||||
std::fs::remove_dir_all(&dir)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn backup_dir(app: &App) -> PathBuf {
|
||||
let cfg = app.config();
|
||||
if cfg.backup.dir.is_empty() { cfg.data_dir().join("backups") } else { PathBuf::from(expand_env(&cfg.backup.dir)) }
|
||||
@@ -89,12 +139,24 @@ pub fn list(app: &App) -> Vec<BackupFile> {
|
||||
|
||||
/// Create a backup now: local file (+ pruning) and S3 upload if configured.
|
||||
pub async fn run(app: &App) -> Result<LastRun> {
|
||||
run_named(app, "").await
|
||||
}
|
||||
|
||||
/// Like `run`, with a suffix in the file name (e.g. "-before-restore").
|
||||
pub async fn run_named(app: &App, suffix: &str) -> Result<LastRun> {
|
||||
let _guard = app.backup.running.lock().await;
|
||||
let cfg = app.config().backup.clone();
|
||||
let dir = backup_dir(app);
|
||||
tokio::fs::create_dir_all(&dir).await.with_context(|| format!("create {}", dir.display()))?;
|
||||
let bytes = create_zip(app).await?;
|
||||
let name = format!("codecraft-backup-{}.zip", Local::now().format("%Y%m%d-%H%M%S"));
|
||||
// Names have second resolution: never overwrite a backup made in the same second.
|
||||
let stamp = Local::now().format("%Y%m%d-%H%M%S").to_string();
|
||||
let mut name = format!("codecraft-backup-{stamp}{suffix}.zip");
|
||||
let mut n = 1;
|
||||
while dir.join(&name).exists() {
|
||||
n += 1;
|
||||
name = format!("codecraft-backup-{stamp}{suffix}-{n}.zip");
|
||||
}
|
||||
let path = dir.join(&name);
|
||||
tokio::fs::write(&path, &bytes).await.with_context(|| format!("write {}", path.display()))?;
|
||||
// Restrictive permissions: the zip contains the config with API keys.
|
||||
|
||||
Reference in new issue
Block a user