Integrate release recovery, semantic review, coverage and Windows packaging

This commit is contained in:
Codex
2026-09-06 16:31:55 -07:00
parent c091f80e6e
commit 2b2d2bc18b
174 changed files with 27635 additions and 127 deletions
+419
View File
@@ -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());
}
}