P2 slice 7: backups and restore (D-30, D-31)
The server backs each book up itself: a per-book git archive, pushed fast-forward only to a git remote and to a folder, when the books open, after a quiet spell, before they close and on request; every commit, push and drill is a record in ops/backup.jsonl. Drills clone each target and verify the chain, the blobs, the vault and the head, weekly and after a failure. `wanderbooks books restore` previews, then restores beside or (with --replace) over, never moving books something holds. The archive refuses anything but a plain bare repository and pins git against the owner's configuration. books.backup and books.back_up_now for the agent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
1 parent
5bc0633cdd
commit
019d8d76a3
29 files changed
+6443
-136
No files matched your search
@@ -0,0 +1,127 @@
|
||||
//! `books.backup` and `books.back_up_now` (plan 3.7, D-30): the books'
|
||||
//! backups as they stand, and one made now.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: the agent can tell the human whether the books are backed up,
|
||||
//! and back them up now when asked.
|
||||
//! SOURCE: plan 3.7 ("books.backup {status|run} reports it": two tools,
|
||||
//! one read-only), D-30.
|
||||
//! INVARIANTS:
|
||||
//! 1. Only books served from the data root are backed up: over stdio
|
||||
//! both tools are hidden, and refuse.
|
||||
//! 2. A status reads the backup record only; a run makes and pushes a
|
||||
//! backup (backup.rs) and answers the status after it.
|
||||
//! ```
|
||||
|
||||
use super::*;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
pub struct BackupParams {}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
pub struct BackUpNowParams {
|
||||
/// The backup folder's disk is new (or another): begin these books'
|
||||
/// backup there again. Only when the human says so: a folder that
|
||||
/// should hold one is otherwise a disk not connected.
|
||||
#[serde(default)]
|
||||
pub new_disk: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct TargetOut {
|
||||
/// "repository" (the private repository) or "folder".
|
||||
pub target: String,
|
||||
/// Where it is.
|
||||
pub at: String,
|
||||
/// When it last took a backup (UTC), if ever.
|
||||
pub last_ok_at: Option<String>,
|
||||
/// The journal seq it holds, if any.
|
||||
pub through_seq: Option<u64>,
|
||||
/// More than a day without the books' latest: tell the human.
|
||||
pub stale: bool,
|
||||
/// Why its last backup failed, if it did.
|
||||
pub last_error: Option<String>,
|
||||
/// When its backup was last proven restorable (UTC), if ever: each
|
||||
/// is cloned and checked weekly.
|
||||
pub last_drill_at: Option<String>,
|
||||
/// Why that check failed, if it did: tell the human.
|
||||
pub drill_error: Option<String>,
|
||||
/// Not backed up there: these books were restored as a copy of others.
|
||||
pub paused: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct BackupOut {
|
||||
/// The books' journal seq now.
|
||||
pub head_seq: u64,
|
||||
/// Each backup the settings name.
|
||||
pub targets: Vec<TargetOut>,
|
||||
/// What to do, when none is set up.
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
impl From<crate::backup::Status> for BackupOut {
|
||||
fn from(s: crate::backup::Status) -> Self {
|
||||
BackupOut {
|
||||
head_seq: s.head_seq,
|
||||
note: s.note,
|
||||
targets: s
|
||||
.targets
|
||||
.into_iter()
|
||||
.map(|t| TargetOut {
|
||||
target: t.target,
|
||||
at: t.at,
|
||||
last_ok_at: t.last_ok_at,
|
||||
through_seq: t.through_seq,
|
||||
stale: t.stale,
|
||||
last_error: t.last_error,
|
||||
last_drill_at: t.last_drill_at,
|
||||
drill_error: t.drill_error,
|
||||
paused: t.paused,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BooksServer {
|
||||
fn backups(&self) -> Result<&std::sync::Arc<crate::backup::Backups>, String> {
|
||||
self.backups.as_ref().ok_or_else(|| {
|
||||
"these books aren't backed up by this server: only books kept in the books' own private folder are".to_string()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_router(router = backup_router, vis = "pub(super)")]
|
||||
impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.backup",
|
||||
title = "Backups",
|
||||
description = "Whether the books are backed up: each backup target the settings name (backup.git_remote, a private repository; backup.folder, a folder on another disk), when it last took the books, through which journal seq, and why it failed if it did. stale: it lacks the latest, and the first try to back that up was more than a day ago: tell the human. The books back themselves up a little after each change, when they open and before they close; books.back_up_now makes one now.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn backup(&self, _params: Parameters<BackupParams>) -> Result<Json<BackupOut>, String> {
|
||||
Ok(Json(self.backups()?.status().await.into()))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.back_up_now",
|
||||
title = "Back up now",
|
||||
description = "Back the books up now to every target the settings name, and answer how each went (as books.backup). new_disk: true only when the human says the backup folder's disk is new: the books' backup is then begun there again. Needs books:admin.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
idempotent_hint = true,
|
||||
open_world_hint = true
|
||||
)
|
||||
)]
|
||||
async fn back_up_now(
|
||||
&self,
|
||||
params: Parameters<BackUpNowParams>,
|
||||
) -> Result<Json<BackupOut>, String> {
|
||||
let b = self.backups()?;
|
||||
Ok(Json(b.run_with("asked", params.0.new_disk).await?.into()))
|
||||
}
|
||||
}
|
||||
@@ -803,6 +803,24 @@ impl BooksServer {
|
||||
"An open item is due within 30 days, or overdue: raise it with the human.",
|
||||
));
|
||||
}
|
||||
// The books' backups (backup.rs): none set up, or one behind or
|
||||
// failing.
|
||||
if let Some(b) = &self.backups {
|
||||
let st = b.standing(store);
|
||||
if st.note.is_some() {
|
||||
next_steps.push(step(
|
||||
"books.backup",
|
||||
None,
|
||||
"No backups are set up: ask the human for a private repository and a folder on another disk, then set backup.git_remote and backup.folder (books.set_setting).",
|
||||
));
|
||||
} else if st.needs_attention() {
|
||||
next_steps.push(step(
|
||||
"books.backup",
|
||||
None,
|
||||
"A backup is behind or failing: books.backup says which and why; tell the human.",
|
||||
));
|
||||
}
|
||||
}
|
||||
match focus {
|
||||
"close" => next_steps.push(step(
|
||||
"books.conventions",
|
||||
|
||||
Reference in new issue
Block a user