Integrate release recovery, semantic review, coverage and Windows packaging
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
//! Private crash checkpoints, separate from model files and hosted destinations.
|
||||
use crate::{
|
||||
document::Document,
|
||||
forms::{Draft, RecoveryDraft},
|
||||
source_workspace::{LIMITS, RecoveryWorkspace, Workspace},
|
||||
};
|
||||
use oa_core::{Id, source_project::SourceBundle};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fs,
|
||||
io::{Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
const MAX_BYTES: u64 = 256 * 1024 * 1024;
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Snapshot {
|
||||
version: u32,
|
||||
pub id: Id,
|
||||
pub name: String,
|
||||
pub origin: Option<PathBuf>,
|
||||
pub modified: u64,
|
||||
accepted: SourceBundle,
|
||||
workspace: RecoveryWorkspace,
|
||||
property: Option<RecoveryDraft>,
|
||||
behavior: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
workflow: Option<serde_json::Value>,
|
||||
selected: Id,
|
||||
}
|
||||
pub struct Restored {
|
||||
pub workflow: Option<crate::workflow::Workflow>,
|
||||
pub document: Document,
|
||||
pub workspace: Workspace,
|
||||
pub property: Option<Draft>,
|
||||
pub behavior: crate::behavior::State,
|
||||
pub selected: Id,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Entry {
|
||||
pub id: Id,
|
||||
pub name: String,
|
||||
pub origin: Option<PathBuf>,
|
||||
pub modified: u64,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
pub directory: Option<PathBuf>,
|
||||
pub entries: Vec<Entry>,
|
||||
pub warning: Option<String>,
|
||||
pub writing: bool,
|
||||
pub active: Option<Id>,
|
||||
pub restored: Option<Id>,
|
||||
}
|
||||
impl State {
|
||||
pub fn load_default() -> Self {
|
||||
let Some(directory) = crate::home::data_directory().map(|p| p.join("recovery")) else {
|
||||
return Self {
|
||||
warning: Some(
|
||||
"Local recovery is unavailable: no application data directory.".into(),
|
||||
),
|
||||
..Default::default()
|
||||
};
|
||||
};
|
||||
let mut state = Self {
|
||||
directory: Some(directory),
|
||||
..Default::default()
|
||||
};
|
||||
state.refresh();
|
||||
state
|
||||
}
|
||||
pub fn refresh(&mut self) {
|
||||
let Some(directory) = &self.directory else {
|
||||
return;
|
||||
};
|
||||
match list(directory) {
|
||||
Ok((entries, warning)) => {
|
||||
self.entries = entries;
|
||||
self.warning = warning;
|
||||
}
|
||||
Err(error) => self.warning = Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Snapshot {
|
||||
pub fn capture(
|
||||
id: Id,
|
||||
document: &Document,
|
||||
workspace: &Workspace,
|
||||
property: Option<&Draft>,
|
||||
behavior: &crate::behavior::State,
|
||||
origin: Option<PathBuf>,
|
||||
selected: Id,
|
||||
) -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
version: 1,
|
||||
id,
|
||||
name: document.project().name.clone(),
|
||||
origin,
|
||||
modified: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
accepted: document.bundle().clone(),
|
||||
workspace: workspace.recovery_snapshot(),
|
||||
property: property.filter(|d| d.dirty()).map(Draft::recovery_snapshot),
|
||||
behavior: behavior.recovery_snapshot()?,
|
||||
workflow: None,
|
||||
selected,
|
||||
})
|
||||
}
|
||||
pub fn with_workflow(
|
||||
mut self,
|
||||
workflow: Option<&crate::workflow::Workflow>,
|
||||
) -> Result<Self, String> {
|
||||
self.workflow = workflow
|
||||
.map(crate::workflow::Workflow::recovery_snapshot)
|
||||
.transpose()?;
|
||||
Ok(self)
|
||||
}
|
||||
pub fn restore(self) -> Result<Restored, String> {
|
||||
if self.version != 1 {
|
||||
return Err("This recovery checkpoint uses an unsupported version.".into());
|
||||
}
|
||||
let mut document =
|
||||
Document::from_bundle(self.accepted.clone(), LIMITS).map_err(|e| e.to_string())?;
|
||||
if document.bundle() != &self.accepted {
|
||||
return Err("Checkpoint accepted source requires migration or identity insertion; it cannot be recovered exactly.".into());
|
||||
}
|
||||
let workspace = Workspace::from_recovery(self.workspace)?;
|
||||
let property = self
|
||||
.property
|
||||
.map(|s| Draft::from_recovery(document.project(), s))
|
||||
.transpose()?;
|
||||
let workflow = self
|
||||
.workflow
|
||||
.map(|value| crate::workflow::Workflow::from_recovery(document.project(), value))
|
||||
.transpose()?;
|
||||
let mut behavior = crate::behavior::State::default();
|
||||
if let Some(saved) = self.behavior {
|
||||
behavior.restore_recovery(saved)?;
|
||||
}
|
||||
document.mark_recovered();
|
||||
Ok(Restored {
|
||||
workflow,
|
||||
document,
|
||||
workspace,
|
||||
property,
|
||||
behavior,
|
||||
selected: self.selected,
|
||||
})
|
||||
}
|
||||
}
|
||||
fn path(directory: &Path, id: Id) -> PathBuf {
|
||||
directory.join(format!("{id}.json"))
|
||||
}
|
||||
pub fn write(directory: &Path, snapshot: &Snapshot) -> Result<(), String> {
|
||||
// Rejected source remains editable, but must not replace the last checkpoint
|
||||
// with buffers that the recovery reader cannot restore.
|
||||
snapshot.workspace.validate()?;
|
||||
fs::create_dir_all(directory)
|
||||
.map_err(|e| format!("Cannot create local recovery directory: {e}"))?;
|
||||
let bytes = serde_json::to_vec(snapshot).map_err(|e| e.to_string())?;
|
||||
if bytes.len() as u64 > MAX_BYTES {
|
||||
return Err(
|
||||
"Checkpoint exceeds the 256 MiB recovery limit; save your work explicitly.".into(),
|
||||
);
|
||||
}
|
||||
let mut temp = tempfile::NamedTempFile::new_in(directory).map_err(|e| e.to_string())?;
|
||||
temp.write_all(&bytes)
|
||||
.and_then(|()| temp.as_file().sync_all())
|
||||
.map_err(|e| e.to_string())?;
|
||||
temp.persist(path(directory, snapshot.id))
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
pub fn read(directory: &Path, id: Id) -> Result<Snapshot, String> {
|
||||
let file =
|
||||
fs::File::open(path(directory, id)).map_err(|e| format!("Cannot read checkpoint: {e}"))?;
|
||||
if file.metadata().map_err(|e| e.to_string())?.len() > MAX_BYTES {
|
||||
return Err("Checkpoint exceeds the recovery size limit.".into());
|
||||
}
|
||||
let mut bytes = vec![];
|
||||
file.take(MAX_BYTES + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if bytes.len() as u64 > MAX_BYTES {
|
||||
return Err("Checkpoint exceeds the recovery size limit.".into());
|
||||
}
|
||||
let snapshot: Snapshot =
|
||||
serde_json::from_slice(&bytes).map_err(|e| format!("Checkpoint is damaged: {e}"))?;
|
||||
if snapshot.version != 1 || snapshot.id != id {
|
||||
return Err("Checkpoint identity or version does not match.".into());
|
||||
}
|
||||
Ok(snapshot)
|
||||
}
|
||||
pub fn remove(directory: &Path, id: Id) -> Result<(), String> {
|
||||
match fs::remove_file(path(directory, id)) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(format!("Cannot retire checkpoint: {e}")),
|
||||
}
|
||||
}
|
||||
fn list(directory: &Path) -> Result<(Vec<Entry>, Option<String>), String> {
|
||||
let files = match fs::read_dir(directory) {
|
||||
Ok(files) => files,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((vec![], None)),
|
||||
Err(e) => return Err(format!("Cannot list recovery checkpoints: {e}")),
|
||||
};
|
||||
let mut entries = vec![];
|
||||
let mut rejected = 0;
|
||||
for file in files {
|
||||
let file = file.map_err(|e| e.to_string())?;
|
||||
let file_path = file.path();
|
||||
if file_path.extension().and_then(|s| s.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = file_path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.and_then(|s| Id::parse_str(s).ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
match read(directory, id) {
|
||||
Ok(s) => entries.push(Entry {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
origin: s.origin,
|
||||
modified: s.modified,
|
||||
}),
|
||||
Err(_) => rejected += 1,
|
||||
}
|
||||
}
|
||||
entries.sort_by_key(|e| std::cmp::Reverse(e.modified));
|
||||
Ok((
|
||||
entries,
|
||||
(rejected > 0).then(|| {
|
||||
format!(
|
||||
"{rejected} damaged or incompatible checkpoint(s) were retained in {}.",
|
||||
directory.display()
|
||||
)
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use oa_core::{Command, Project};
|
||||
fn snapshot() -> Snapshot {
|
||||
let mut document = Document::from_project(Project::demo(), LIMITS).unwrap();
|
||||
document
|
||||
.execute(Command::Rename(
|
||||
document.project().id,
|
||||
"Recovered engineering".into(),
|
||||
))
|
||||
.unwrap();
|
||||
let workspace = Workspace::new(document.bundle(), true);
|
||||
Snapshot::capture(
|
||||
Id::new_v4(),
|
||||
&document,
|
||||
&workspace,
|
||||
None,
|
||||
&Default::default(),
|
||||
Some(PathBuf::from("original.arcadia")),
|
||||
document.project().id,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn exact_source_and_unfinished_invalid_buffers_restore_without_model_overwrite() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut s = snapshot();
|
||||
let mut workspace = Workspace::new(&s.accepted, true);
|
||||
let active = workspace.active.clone();
|
||||
let original = workspace.text().to_owned();
|
||||
workspace.edit(
|
||||
format!("{original}\nunfinished {{"),
|
||||
workspace.files[&active].cursor,
|
||||
);
|
||||
s.workspace = workspace.recovery_snapshot();
|
||||
let id = s.id;
|
||||
let accepted = s.accepted.clone();
|
||||
write(directory.path(), &s).unwrap();
|
||||
let restored = read(directory.path(), id).unwrap().restore().unwrap();
|
||||
assert_eq!(restored.document.bundle(), &accepted);
|
||||
assert!(restored.document.dirty());
|
||||
assert!(restored.workspace.dirty());
|
||||
assert!(restored.workspace.text().ends_with("unfinished {"));
|
||||
assert!(!restored.document.can_undo());
|
||||
assert!(!directory.path().join("original.arcadia").exists());
|
||||
}
|
||||
#[test]
|
||||
fn multiple_sessions_replace_only_their_own_checkpoint_and_corruption_is_retained() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let a = snapshot();
|
||||
let b = snapshot();
|
||||
write(directory.path(), &a).unwrap();
|
||||
write(directory.path(), &b).unwrap();
|
||||
write(directory.path(), &a).unwrap();
|
||||
assert_eq!(list(directory.path()).unwrap().0.len(), 2);
|
||||
fs::write(path(directory.path(), a.id), b"broken").unwrap();
|
||||
let (entries, warning) = list(directory.path()).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(warning.is_some());
|
||||
assert!(path(directory.path(), a.id).exists());
|
||||
remove(directory.path(), b.id).unwrap();
|
||||
assert!(path(directory.path(), a.id).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excess_retained_buffers_cannot_replace_the_last_recoverable_checkpoint() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut s = snapshot();
|
||||
write(directory.path(), &s).unwrap();
|
||||
let before = fs::read(path(directory.path(), s.id)).unwrap();
|
||||
// Source discovery retains detached buffers even when the accepted
|
||||
// project itself remains within the file limit.
|
||||
let mut workspace = Workspace::new(&s.accepted, true);
|
||||
let detached = SourceBundle {
|
||||
entry: "detached-0.arcadia".into(),
|
||||
files: (0..LIMITS.max_files)
|
||||
.map(|i| (format!("detached-{i}.arcadia"), "unfinished {".into()))
|
||||
.collect(),
|
||||
};
|
||||
workspace.discovered(&detached, workspace.reachable.clone());
|
||||
assert!(workspace.files.len() > LIMITS.max_files);
|
||||
s.workspace = workspace.recovery_snapshot();
|
||||
let error = write(directory.path(), &s).unwrap_err();
|
||||
assert!(error.contains("file count"));
|
||||
assert_eq!(fs::read(path(directory.path(), s.id)).unwrap(), before);
|
||||
let restored = read(directory.path(), s.id).unwrap().restore().unwrap();
|
||||
assert_eq!(restored.document.project().name, "Recovered engineering");
|
||||
}
|
||||
#[test]
|
||||
fn raw_invalid_property_number_survives_and_stays_rejected_until_repaired() {
|
||||
let mut s = snapshot();
|
||||
let document = Document::from_bundle(s.accepted.clone(), LIMITS).unwrap();
|
||||
let node = document
|
||||
.project()
|
||||
.diagrams
|
||||
.values()
|
||||
.flat_map(|d| d.nodes.keys())
|
||||
.next()
|
||||
.copied()
|
||||
.unwrap();
|
||||
let mut draft = Draft::from_project(document.project(), node).unwrap();
|
||||
draft.input("/position/x", "-".into());
|
||||
s.property = Some(draft.recovery_snapshot());
|
||||
let restored = s.restore().unwrap();
|
||||
let property = restored.property.unwrap();
|
||||
assert_eq!(property.inputs["/position/x"].raw, "-");
|
||||
assert!(property.materialize().is_err());
|
||||
assert!(property.dirty());
|
||||
}
|
||||
#[test]
|
||||
fn invalid_open_retains_distinct_accepted_model_and_repair_workspace() {
|
||||
let mut s = snapshot();
|
||||
let invalid = SourceBundle {
|
||||
entry: "broken.arcadia".into(),
|
||||
files: [("broken.arcadia".into(), "unparsed draft".into())].into(),
|
||||
};
|
||||
s.workspace = Workspace::new(&invalid, false).recovery_snapshot();
|
||||
let restored = s.restore().unwrap();
|
||||
assert_eq!(restored.document.project().name, "Recovered engineering");
|
||||
assert!(!restored.workspace.applied);
|
||||
assert!(restored.workspace.dirty());
|
||||
assert_eq!(restored.workspace.text(), "unparsed draft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_includes_remain_reachable_repair_targets_without_fabricated_buffers() {
|
||||
let mut s = snapshot();
|
||||
let mut workspace = Workspace::new(&s.accepted, true);
|
||||
workspace
|
||||
.reachable
|
||||
.insert("missing/activities.arcadia".into());
|
||||
s.workspace = workspace.recovery_snapshot();
|
||||
let restored = s.restore().unwrap();
|
||||
assert!(
|
||||
restored
|
||||
.workspace
|
||||
.reachable
|
||||
.contains("missing/activities.arcadia")
|
||||
);
|
||||
assert!(
|
||||
!restored
|
||||
.workspace
|
||||
.files
|
||||
.contains_key("missing/activities.arcadia")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn altered_accepted_identity_and_unrecognized_raw_property_fields_reject() {
|
||||
let s = snapshot();
|
||||
let document = Document::from_bundle(s.accepted.clone(), LIMITS).unwrap();
|
||||
let draft = Draft::from_project(document.project(), document.project().id).unwrap();
|
||||
let mut raw = serde_json::to_value(draft.recovery_snapshot()).unwrap();
|
||||
raw["inputs"]["/unknown"] = serde_json::json!("must survive");
|
||||
let mut altered = s.clone();
|
||||
altered.property = Some(serde_json::from_value(raw).unwrap());
|
||||
assert!(altered.restore().err().unwrap().contains("raw buffers"));
|
||||
let mut altered = s;
|
||||
for text in altered.accepted.files.values_mut() {
|
||||
*text = text.replace(&format!("id \"{}\";", document.project().id), "");
|
||||
}
|
||||
// Identity-free accepted source is not silently upgraded during recovery.
|
||||
// Use a fully valid new declaration to force compiler-assigned identity.
|
||||
let entry = altered.accepted.entry.clone();
|
||||
let text = altered.accepted.files.get_mut(&entry).unwrap();
|
||||
let end = text.rfind('}').unwrap();
|
||||
text.insert_str(end, "\n oa_activity unassigned_identity \"Draft\" {}\n");
|
||||
assert!(altered.restore().is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user