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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 09:31:24 +01:00
1 parent 49d404eec7
commit 1f69d63b09
35 files changed
+1156 -88

No files matched your search

+63 -1
View File
@@ -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.