Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
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.
549 lines
20 KiB
Rust
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());
|
|
}
|
|
}
|