Files
ARCADIA-Studio/crates/oa-desktop/src/recovery.rs
T
Codex e2ecfea69b
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Checkpoint source 7 workflows and in-progress diagram authoring redesign
Preserve current core, source, REST, desktop, recovery, documentation and validation work, including AGENTS.md and the development summary. This is a work-in-progress checkpoint: the new compact palette has outstanding compilation errors and native acceptance; earlier passing gates retain their documented scope.
2026-09-10 10:06:55 -07:00

549 lines
20 KiB
Rust

//! 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;
mod source5;
#[cfg(test)]
mod source5_tests;
#[cfg(test)]
mod source7_tests;
#[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>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "architecture_draft"
)]
architecture: Option<crate::app::architecture_authoring::Saved>,
selected: Id,
}
pub struct Restored {
pub architecture: Option<crate::app::architecture_authoring::Saved>,
pub workflow: Option<crate::workflow::Workflow>,
pub document: Document,
pub workspace: Workspace,
pub property: Option<Draft>,
pub behavior: crate::behavior::State,
pub selected: Id,
}
fn architecture_draft<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<crate::app::architecture_authoring::Saved>, D::Error> {
// Omission is the legacy shape. An explicit new null field is not erased.
crate::app::architecture_authoring::Saved::deserialize(deserializer).map(Some)
}
#[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,
architecture: 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 with_architecture(
mut self,
state: Option<&crate::app::architecture_authoring::State>,
) -> Self {
self.architecture = state.map(|s| s.saved.clone());
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())?;
let legacy_base = source5::upgrade_binding(&self.accepted, &document)?;
if legacy_base.is_some() && self.architecture.is_some() {
return Err(
"Architecture authoring recovery requires its original current-source baseline."
.into(),
);
}
if let Some(saved) = &self.architecture {
crate::app::architecture_authoring::validate_saved(document.project(), saved)?;
}
if legacy_base.is_some() {
if let Some(property) = &self.property {
validate_pre7_intent(&serde_json::to_value(property).map_err(|e| e.to_string())?)?;
}
for value in [&self.behavior, &self.workflow].into_iter().flatten() {
validate_pre7_intent(value)?;
}
}
let mut saved_workspace = self.workspace;
if legacy_base.is_some() {
saved_workspace.upgrade_clean_buffers(&self.accepted, document.bundle());
}
let workspace = Workspace::from_recovery(saved_workspace)?;
let property = self
.property
.map(|s| Draft::from_recovery(document.project(), s))
.transpose()?;
let workflow = self
.workflow
.map(|value| match legacy_base.as_deref() {
Some(base) => crate::workflow::Workflow::from_recovery_after_source5_upgrade(
document.project(),
base,
value,
),
None => 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 {
architecture: self.architecture,
workflow,
document,
workspace,
property,
behavior,
selected: self.selected,
})
}
}
/// Private draft payloads use closed typed records, not arbitrary JSON documents.
/// Reject new structural intent before serde defaults can erase its presence.
/// Only known structural tag/address positions are checked. Authored source,
/// names, expressions and raw input strings are never scanned.
pub(crate) fn validate_pre7_intent(value: &serde_json::Value) -> Result<(), String> {
use serde_json::Value;
const CONTEXT: [&str; 4] = [
"ContextOwner",
"ContextSource",
"ContextTarget",
"ContextPurpose",
];
match value {
Value::Object(fields) => {
let new_field = fields.keys().any(|key| {
matches!(
key.as_str(),
"operation"
| "disabled_operation"
| "contextual"
| "operation_omissions"
| "operation_omission"
| "ContextualLink"
)
});
let new_tag = fields.get("kind").and_then(Value::as_str) == Some("ContextualLink")
|| fields.get("tool").and_then(Value::as_str) == Some("Contextual");
// Constructor caches retain inactive raw branches. Their addresses,
// and input-map keys, are structural paths rather than user prose.
let new_path = fields
.get("inputs")
.and_then(Value::as_object)
.is_some_and(|inputs| inputs.keys().any(|key| source7_draft_path(key)))
|| fields
.get("constructors")
.and_then(Value::as_array)
.is_some_and(|rows| {
rows.iter().any(|row| {
row.get(0)
.and_then(Value::as_str)
.is_some_and(source7_draft_path)
|| row.get(3).and_then(Value::as_object).is_some_and(|inputs| {
inputs.keys().any(|key| source7_draft_path(key))
})
})
});
let new_facet = fields
.get("Occurrence")
.is_some_and(|v| v.as_str() == Some("Operation") || v.get("Operation").is_some())
|| fields.get("Relation").is_some_and(|v| {
v.as_str()
.is_some_and(|s| s == "ContextualLink" || CONTEXT.contains(&s))
|| CONTEXT.iter().any(|key| v.get(*key).is_some())
});
if new_field || new_tag || new_path || new_facet {
return Err("Legacy checkpoint contains source-7 draft fields; it has been retained without interpreting new intent.".into());
}
for child in fields.values() {
validate_pre7_intent(child)?;
}
}
Value::Array(children) => {
for child in children {
validate_pre7_intent(child)?;
}
}
_ => {}
}
Ok(())
}
fn source7_draft_path(path: &str) -> bool {
path.ends_with("/ContextualLink")
|| path.contains("/ContextualLink/")
|| path.ends_with("/Exchange/operation")
|| path.contains("/Exchange/operation/")
}
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());
}
}