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:
swaitsandClaude Opus 5.5 committed 2026-10-20 11:14:21 -06:00
1 parent 5bc0633cdd
commit 019d8d76a3
29 files changed
+6443 -136

No files matched your search

+101 -6
View File
@@ -1,6 +1,6 @@
//! The admin socket's messages (`run/admin.sock`, plan 3.1, 3.3, 3.9): how
//! the command line asks the books server to list, create and adopt books,
//! and to make, list and revoke agents' tokens.
//! the command line asks the books server to list, create, adopt and
//! restore books, and to make, list and revoke agents' tokens.
//!
//! ```text
//! GOAL: the command line makes and adopts books without ever touching
@@ -16,9 +16,10 @@
//! that says to restart.
//! 3. No answer names the data root or a path in it, nor echoes a path
//! the caller gave (which could be the root under another name).
//! 4. Adopting is two steps: without `confirmed` the answer is a
//! preview, and nothing is written; `confirmed: true` adopts, and
//! only books still at the head the preview read (`expect_head`).
//! 4. Adopting and restoring are two steps: without `confirmed` the
//! answer is a preview, and nothing is written; `confirmed: true`
//! adopts (restores), and only books (a backup) still at the head the
//! preview read (`expect_head`).
//! 5. Nothing in a settings file is dropped silently: entries the books
//! don't know are refused unless `skip_unknown`, and its notes and
//! comments are in the preview.
@@ -97,6 +98,24 @@ pub enum Request {
#[serde(default)]
skip_unknown: bool,
},
/// Restore a backup (plan 3.7): as new books, or replacing books
/// (which are moved into `legacy/`, kept).
Restore {
/// The books' id.
id: String,
/// The backup: a git repository's address, or its folder
/// (`<book>.git` in the backup folder).
source: String,
/// Replace the books called `id`.
#[serde(default)]
replace: bool,
/// Restore (true), or only preview it.
#[serde(default)]
confirmed: bool,
/// The head the preview read: restored only if still so.
#[serde(default)]
expect_head: Option<(u64, String)>,
},
/// Make a token for an agent (plan 3.3).
TokenCreate {
/// The books it opens.
@@ -237,6 +256,44 @@ pub struct Adopted {
pub problem: Option<String>,
}
/// What a restore would bring in.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RestorePreview {
/// The backup's head, proven.
pub head: (u64, String),
/// Stored files in it, each re-hashed.
pub blobs: u64,
/// TIN vault records in it.
pub vault_files: u64,
/// Whether books of that id exist now.
pub exists: bool,
/// The id of the books it holds, as they were backed up.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<String>,
}
/// What a restore did.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Restored {
/// The books' id.
pub id: String,
/// Their head: the backup's.
pub head: (u64, String),
/// Stored files the books name, re-hashed through the store.
pub blobs_verified: u64,
/// TIN vault records restored.
pub vault_files: u64,
/// The `legacy/` folder the replaced books were moved to.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub replaced: Option<String>,
/// The id of the books it held, as they were backed up.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<String>,
/// Why the restored books didn't open, if they didn't (they are kept).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub problem: Option<String>,
}
/// The server's answer.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
@@ -255,6 +312,10 @@ pub enum Response {
Preview(AdoptPreview),
/// Books adopted and open.
Adopted(Adopted),
/// What a restore would bring in.
RestorePreview(RestorePreview),
/// A backup restored, and open.
Restored(Restored),
/// A token made: its text, shown this once (INVARIANT 6).
TokenCreated {
/// The token.
@@ -307,7 +368,7 @@ pub fn call_within(
// A list changes nothing: its only question is whether it answered.
let look = match request {
Request::List | Request::TokenList => None,
Request::Create { .. } | Request::Adopt { .. } => {
Request::Create { .. } | Request::Adopt { .. } | Request::Restore { .. } => {
Some("`wanderbooks books list` shows the books there are")
}
Request::TokenCreate { .. } | Request::TokenRevoke { .. } => {
@@ -417,6 +478,24 @@ mod tests {
}
}
#[test]
fn a_restore_request_is_a_preview_unless_confirmed() {
let r: Envelope = serde_json::from_value(
json!({"proto": 1, "op": "restore", "id": "acme", "source": "/backups/acme.git"}),
)
.unwrap();
assert_eq!(
r.request,
Request::Restore {
id: "acme".into(),
source: "/backups/acme.git".into(),
replace: false,
confirmed: false,
expect_head: None,
}
);
}
#[test]
fn unknown_operations_and_fields_are_refused() {
for bad in [
@@ -455,6 +534,22 @@ mod tests {
text: TokenText("wb_00".into()),
},
Response::Tokens(vec![]),
Response::RestorePreview(RestorePreview {
head: (3, "ab".into()),
blobs: 2,
vault_files: 1,
exists: true,
origin: Some("acme".into()),
}),
Response::Restored(Restored {
id: "acme".into(),
head: (3, "ab".into()),
blobs_verified: 2,
vault_files: 1,
replaced: Some("acme-20261019-120000".into()),
origin: Some("acme".into()),
problem: None,
}),
] {
let text = serde_json::to_string(&r).unwrap();
assert_eq!(serde_json::from_str::<Response>(&text).unwrap(), r);