Files
ARCADIA-Studio/harness/release-recovery-design.md

11 KiB
Raw Permalink Blame History

Recoverable local checkpoints — bounded design, 2026-09-06

Read-only investigation of current data-contract-integration baseline c091f80. Root owns implementation. No native recovery acceptance is claimed. This supersedes the initial behavior review's obsolete static-canvas baseline: current state, scenario and process direct editing is already integrated; process/scenario branches need no cherry-pick, and state branch differences are patch-equivalent (316dc55→9118218 and a8f451e→c5276b1).

Checkpoint the accepted source bundle and the unapplied source workspace separately in user-local application state. A checkpoint is a recoverable local copy, never a model save and never authorization to overwrite a prior local/hosted destination. Startup lists recoverable sessions; Restore is explicit and creates a detached unsaved document. Original path is display-only provenance. Save after restore uses Save As. A failed restore retains both the current application and the original recovery file.

Candidates considered: (1) periodically save the actual model files; rejected because invalid drafts cannot compile, external edits can be overwritten and this conflates recovery with Save. (2) persist the entire App; rejected because it includes tokens, stale async context, caches and modal state. (3) serialize a small explicit recovery DTO with source/workspace payload; selected because the canonical compiler can reconstitute the model while preserving exact comments/includes and invalid raw text.

Checkpoint content

  • Closed format_version, random session/checkpoint ID, monotonically increasing generation, timestamp, product/source schema metadata, human title and optional original local path label.
  • Exact Document::bundle() accepted entry and file map. Do not format Project back to text: that loses lexical/source ownership history. No serialized compiled index is trusted on restore.
  • Workspace entry, active, applied, reachable, and every loaded file buffer's text and baseline. This includes dirty detached files. Workspace::bundle() alone is insufficient: it loses the baseline used by dirty/Revert and cannot distinguish a new invalid draft from a clean loaded file. Optional cursors may be encoded as line/column integers and clamped on restore. Undo/redo stacks need not be persisted for this bounded slice, but say so in the recovery UI/help.
  • Unsaved state must survive: Document::from_bundle calls the restored bundle saved. Use an explicit recovered-unsaved flag at App level (cleared only by successful local source save), or a dedicated Document restore constructor that also restores its saved source baseline. Prefer the flag if it avoids duplicating a second potentially large accepted bundle. Merely setting path=None does not make App::dirty() true.
  • Do not include hosted State/session/tokens/URLs with credentials, reqwest clients, network receipts, DiskProject write expectations, pending save-journal rollback handles, compiled caches, queued commands, interaction gestures or model-menu state. Never serialize App wholesale.

Important invalid-open case: open_source_project keeps the previously accepted graphical Document when an invalid new file opens, and replaces Workspace with applied=false. The bundles may describe different projects. Preserve both independently; do not attempt to compile draft text to replace the accepted document during checkpoint capture or restoration. State clearly that graphics show the last accepted model while the recovered source needs repair.

Property/behavior/semantic-review drafts are separate mutable state. Accepted+per-file source coverage must not be described as recovery of every unsaved edit. For full release recovery, add explicit versioned DTOs for these drafts, preserving raw invalid fields and avoiding command replay. Until then, indicate their exclusion in the checkpoint UI and keep their existing navigation/close guards. A source-only checkpoint made while such a form is pending must not call Apply automatically.

Capture and write integration

App::subscription can emit a short periodic RecoveryTick. Handle recovery ticks/completions before ordinary busy/modal gates in App::update, like the existing passive scroll observation: saving or a modal must not lose edits already in memory. A dirty generation is driven by accepted document revision and source workspace edits, not just document revision (unapplied text does not change the latter). Capture an immutable DTO on the UI thread, then bounded serialization/filesystem work on spawn_blocking; no sleep/blocking fs on UI thread.

If using a wrapper after dispatch, App::update has many early returns; wrapping only the tail misses SourceEdit, local/hosted loads, source include changes and prepared semantic Apply paths. A periodic snapshot comparison plus a coalesced single writer is simpler to reason about. Avoid cloning all 64MiB bundles for every mouse-move event. One in-flight write per session; if newer edits arrive, schedule the newest snapshot after completion. Completion includes session ID/generation and cannot clear/acknowledge another document's checkpoint.

Persist before cleanly leaving a recoverable session, or retain the already durable checkpoint. reset rebuilds the whole App; preserve the recovery coordinator and start a fresh session identity. An explicit Discard should retire only that session's checkpoint, but coordinate with in-flight writes so an old completion cannot resurrect it. A cancelled Open or Save must not retire recovery. A successful JSON export must not clear recovery: current Save deliberately leaves source dirty. A successful source save retires only the captured generation if no newer edits/drafts remain. Hosted Save likewise cannot clear a checkpoint that contains unapplied local drafts.

Windows directory: LocalAppData/ArcadiaStudio/recovery. Linux: XDG_STATE_HOME/arcadia-studio/recovery, falling back to HOME/.local/state/arcadia-studio/recovery. Resolve a stable absolute app-owned root; no fallback to repository or model directory. Use per-session immutable generation files or two generation slots plus a safe manifest. Write a unique temp file, flush/sync, then publish only the complete file. Keep the last valid generation until the new generation is durable. Handle Windows rename-over-existing behavior explicitly; do not delete the only durable snapshot before replacement. Startup ignores temp files and falls back to a previous valid generation after a truncated newest file.

The payload paths are virtual SourceBundle paths, not filesystem extraction targets. Apply existing relative/source path validation; cap checkpoint file bytes before parsing, number of files, per-file and total text+baseline bytes, and nesting before allocation. Normal draft bytes may be invalid Arcadia but still must obey resource limits. Reject absolute/traversing virtual paths, missing entry/active buffers and invalid reachable keys without deleting evidence. Unknown checkpoint versions stay listed with a clear incompatible-version message. Storage failure is nonmodal, reports that recent work is not protected, and retries without dropping model/drafts; never claim saved success from a queued write.

Restore integration order

  1. Read/validate DTO off thread and compile the accepted bundle through Document::from_bundle(..., LIMITS); reject altered/incompatible accepted source without changing App. Require exact accepted source equality after compilation, or expose an explicit migration step; UUID generation during silent restore is unacceptable.
  2. Construct Workspace from persisted text+baseline through a narrow restore API in source_workspace.rs. Preserve applied=false, detached buffers and dirty flags; never Workspace::new(draft_bundle,true).
  3. On explicit user Restore, pass existing dirty-navigation handling. Build replacement state only after preparation succeeds. Preserve current window size, guide/Home state and recovery coordinator as reset already does for selected persistent UI state.
  4. Install prepared Document, then recovered Workspace; clear local path, DiskProject, pending save recovery and hosted document binding; assign new canvas_document/epoch and source_version. Reset gestures/forms/caches and selection to valid identities. Do not call sync_source() after installing drafts, because Workspace::accept replaces reachable dirty buffers.
  5. Call show_source_file(true), refresh accepted model diagnostics/index separately, and request draft lint without Apply. Select Text when unapplied/dirty draft files exist; retain explicit recovery status and unsaved marker. Keep original checkpoint until recovered work is saved or explicitly discarded.

Falsification tests

  1. Accepted nested source with comments plus two invalid unsaved file edits: checkpoint/crash/restore yields identical accepted Project and accepted bytes, exact raw drafts and correct dirty/Revert baselines. No file in the original model directory changes.
  2. Dirty detached file after include removal: restore keeps it discoverable and dirty; applying reachable text retains detached draft and save guard.
  3. Invalid Open from hosted A into broken local B: recovered accepted graphics remain A, draft workspace is B with applied=false, path/session/token/ETag absent; repair and Save cannot overwrite hosted A or local B without explicit destination selection.
  4. Rapid edits during asynchronous write: old completion never marks newer generation protected; restart recovers latest completed generation. Change project while a write is in flight; no session collision. Discard while writing cannot recreate a discarded snapshot.
  5. Restore invalid text in active included file with stale cursor: buffer text is exact, cursor clamps, lint reports the right path, Apply rejection preserves draft and accepted model.
  6. Save cancelled/failed, JSON export, stale local-save receipt and stale hosted response all retain needed recovery. Successful source save with no later edits retires only its session. New edits after saved snapshot remain recoverable.
  7. Truncated JSON, unsupported version, oversized bytes/map, invalid path, missing entry, invalid accepted source and read-only recovery directory each fail visibly without changing current document or deleting the last valid checkpoint. Two app instances use independent session files.
  8. Explicit Restore while another model is dirty goes through Save/Discard/Cancel; Cancel leaves both models/checkpoints untouched. Source restore does not inadvertently apply property/behavior drafts.
  9. Real Windows/Linux native trial: type invalid included source, wait for confirmed checkpoint, terminate process abruptly, relaunch, inspect recovery choice, Restore, fix, Save As, reopen and compare exact model/source. Repeat at 1100×700 and inspect failure/retry UI. Unit success is not a substitute for this crash/reopen workflow.

Relevant current seams

document.rs::{bundle,snapshot,dirty,mark_saved,from_bundle}; source_workspace.rs::{Buffer,Workspace,accept,discovered,dirty,detached_dirty}; app.rs::{update,subscription,reset,sync_source,show_source_file,open_source_project,save_to,proceed} and Message::{SourceEdit,SourceUndo,IncludesLoaded,Loaded,Saved,Discard}. Existing pending_recovery/save-journal rollback concerns interrupted explicit model writes; autosave recovery must remain a separate mechanism and separate UI wording.