Skip to main content

qualia_client_core/
studio_workspace_wal.rs

1//! Quin-backed audit trail for studio workspace deploys.
2//!
3//! Each manifest save appends a deploy checkpoint Quin plus one placement Quin per
4//! pane. History is recoverable from `{storage}/studio-workspace.wal`.
5
6use qualia_core_db::{q_hash, wal::WriteAheadLog, NQuin};
7
8const OBJECT_HASH_MASK: u64 = 0x0FFF_FFFF_FFFF_FFFF;
9const LAMPORT_SHIFT: u32 = 32;
10const LAMPORT_MASK: u64 = 0x1FFF_FFFF;
11
12pub const STUDIO_WAL_FILE: &str = "studio-workspace.wal";
13pub const REVISION_SNAPSHOT_PREFIX: &str = "studio-workspace-rev-";
14const WORKSPACE_SUBJECT: &str = "studio:workspace";
15const PREDICATE_DEPLOY: &str = "q42:studioDeploy";
16const PREDICATE_PANE: &str = "q42:studioPanePlacement";
17const PREDICATE_UNDO_FRAME: &str = "q42:studioUndoFrame";
18
19pub const UNDO_FRAME_SNAPSHOT_PREFIX: &str = "studio-undo-frame-";
20pub const MAX_UNDO_FRAMES: usize = 32;
21
22#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
23pub struct StudioUndoFrameRecord {
24    pub frame_seq: u64,
25    pub stack_index: u16,
26    pub manifest_hash: u64,
27    pub unix_ts: u32,
28}
29
30#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
31pub struct StudioDeployRecord {
32    pub revision: u64,
33    pub unix_ts: u32,
34    pub pane_count: u16,
35    pub manifest_hash: u64,
36}
37
38#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
39struct WalManifestPage {
40    url_path: String,
41    panes: Vec<WalManifestPane>,
42}
43
44#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
45struct WalManifestPane {
46    component_id: String,
47    x: u16,
48    y: u16,
49    w: u16,
50    h: u16,
51}
52
53#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
54struct WalManifest {
55    pages: Vec<WalManifestPage>,
56}
57
58pub fn studio_wal_path(storage_path: &str) -> std::path::PathBuf {
59    std::path::PathBuf::from(storage_path).join(STUDIO_WAL_FILE)
60}
61
62fn workspace_subject() -> u64 {
63    q_hash(WORKSPACE_SUBJECT)
64}
65
66fn author_context_hash() -> u64 {
67    let profile = crate::user_profile::load_profile();
68    q_hash(&profile.public_did)
69}
70
71fn manifest_content_hash(manifest_json: &str) -> u64 {
72    q_hash(manifest_json) & OBJECT_HASH_MASK
73}
74
75pub fn undo_frame_snapshot_path(storage_path: &str, frame_seq: u64) -> std::path::PathBuf {
76    std::path::PathBuf::from(storage_path)
77        .join(format!("{UNDO_FRAME_SNAPSHOT_PREFIX}{frame_seq}.json"))
78}
79
80pub fn revision_snapshot_path(storage_path: &str, revision: u64) -> std::path::PathBuf {
81    std::path::PathBuf::from(storage_path)
82        .join(format!("{REVISION_SNAPSHOT_PREFIX}{revision}.json"))
83}
84
85pub fn persist_revision_snapshot(
86    storage_path: &str,
87    revision: u64,
88    manifest_json: &str,
89) -> Result<(), String> {
90    let path = revision_snapshot_path(storage_path, revision);
91    if let Some(parent) = path.parent() {
92        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
93    }
94    let tmp = path.with_extension("json.tmp");
95    std::fs::write(&tmp, manifest_json.as_bytes()).map_err(|e| e.to_string())?;
96    std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
97    Ok(())
98}
99
100pub fn unpack_placement(object: u64) -> (u16, u16, u16, u16) {
101    let o = object & OBJECT_HASH_MASK;
102    (
103        (o & 0xFFFF) as u16,
104        ((o >> 16) & 0xFFFF) as u16,
105        ((o >> 32) & 0xFFFF) as u16,
106        ((o >> 48) & 0xFFFF) as u16,
107    )
108}
109
110fn pack_placement(x: u16, y: u16, w: u16, h: u16) -> u64 {
111    ((w as u64) << 48) | ((h as u64) << 32) | ((y as u64) << 16) | (x as u64)
112}
113
114fn count_existing_undo_frames(wal_path: &std::path::Path) -> u64 {
115    let Ok(mut wal) = WriteAheadLog::open(wal_path) else {
116        return 0;
117    };
118    let Ok(quins) = wal.recover() else {
119        return 0;
120    };
121    let undo_pred = q_hash(PREDICATE_UNDO_FRAME);
122    quins.iter().filter(|q| q.predicate == undo_pred).count() as u64
123}
124
125fn build_undo_frame_quin(frame_seq: u64, stack_index: u16, manifest_json: &str) -> NQuin {
126    let subject = workspace_subject();
127    let predicate = q_hash(PREDICATE_UNDO_FRAME);
128    let object = manifest_content_hash(manifest_json);
129    let context = author_context_hash();
130    let metadata = ((frame_seq & LAMPORT_MASK) << LAMPORT_SHIFT) | ((stack_index as u64) & 0xFFFF);
131    let parity = subject ^ predicate ^ object ^ context ^ metadata;
132    NQuin {
133        subject,
134        predicate,
135        object,
136        context,
137        metadata,
138        parity,
139    }
140}
141
142fn build_deploy_quin(revision: u64, manifest_json: &str) -> NQuin {
143    let subject = workspace_subject();
144    let predicate = q_hash(PREDICATE_DEPLOY);
145    let object = manifest_content_hash(manifest_json);
146    let context = author_context_hash();
147    let unix_ts = std::time::SystemTime::now()
148        .duration_since(std::time::UNIX_EPOCH)
149        .unwrap_or_default()
150        .as_secs() as u32;
151    let metadata = ((revision & LAMPORT_MASK) << LAMPORT_SHIFT) | (unix_ts as u64);
152    let parity = subject ^ predicate ^ object ^ context ^ metadata;
153    NQuin {
154        subject,
155        predicate,
156        object,
157        context,
158        metadata,
159        parity,
160    }
161}
162
163fn build_pane_quin(
164    page_path: &str,
165    component_id: &str,
166    x: u16,
167    y: u16,
168    w: u16,
169    h: u16,
170    revision: u64,
171) -> NQuin {
172    let subject = q_hash(component_id);
173    let predicate = q_hash(PREDICATE_PANE);
174    let object = pack_placement(x, y, w, h) & OBJECT_HASH_MASK;
175    let context = q_hash(page_path);
176    let metadata = (revision & LAMPORT_MASK) << LAMPORT_SHIFT;
177    let parity = subject ^ predicate ^ object ^ context ^ metadata;
178    NQuin {
179        subject,
180        predicate,
181        object,
182        context,
183        metadata,
184        parity,
185    }
186}
187
188fn count_existing_deploys(wal_path: &std::path::Path) -> u64 {
189    let Ok(mut wal) = WriteAheadLog::open(wal_path) else {
190        return 0;
191    };
192    let Ok(quins) = wal.recover() else {
193        return 0;
194    };
195    let deploy_pred = q_hash(PREDICATE_DEPLOY);
196    quins.iter().filter(|q| q.predicate == deploy_pred).count() as u64
197}
198
199/// Append deploy + pane placement Quins for a saved workspace manifest.
200pub fn append_workspace_deploy(storage_path: &str, manifest_json: &str) -> Result<u64, String> {
201    let manifest: WalManifest =
202        serde_json::from_str(manifest_json).map_err(|e| format!("manifest parse: {e}"))?;
203    let wal_path = studio_wal_path(storage_path);
204    let revision = count_existing_deploys(&wal_path) + 1;
205
206    let mut wal = WriteAheadLog::open(&wal_path).map_err(|e| format!("wal open: {e}"))?;
207    wal.append_mutation(&build_deploy_quin(revision, manifest_json))
208        .map_err(|e| format!("wal deploy append: {e}"))?;
209
210    for page in &manifest.pages {
211        for pane in &page.panes {
212            let quin = build_pane_quin(
213                &page.url_path,
214                &pane.component_id,
215                pane.x,
216                pane.y,
217                pane.w,
218                pane.h,
219                revision,
220            );
221            wal.append_mutation(&quin)
222                .map_err(|e| format!("wal pane append: {e}"))?;
223        }
224    }
225
226    Ok(revision)
227}
228
229/// Append an undo-stack frame Quin plus on-disk snapshot (bounded to [`MAX_UNDO_FRAMES`]).
230pub fn append_undo_frame(
231    storage_path: &str,
232    stack_index: u16,
233    manifest_json: &str,
234) -> Result<u64, String> {
235    if manifest_json.trim().is_empty() {
236        return Err("empty undo manifest".to_string());
237    }
238    let wal_path = studio_wal_path(storage_path);
239    let frame_seq = count_existing_undo_frames(&wal_path) + 1;
240    let snap_path = undo_frame_snapshot_path(storage_path, frame_seq);
241    if let Some(parent) = snap_path.parent() {
242        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
243    }
244    let tmp = snap_path.with_extension("json.tmp");
245    std::fs::write(&tmp, manifest_json.as_bytes()).map_err(|e| e.to_string())?;
246    std::fs::rename(&tmp, &snap_path).map_err(|e| e.to_string())?;
247
248    let mut wal = WriteAheadLog::open(&wal_path).map_err(|e| format!("wal open: {e}"))?;
249    wal.append_mutation(&build_undo_frame_quin(
250        frame_seq,
251        stack_index,
252        manifest_json,
253    ))
254    .map_err(|e| format!("wal undo append: {e}"))?;
255
256    prune_old_undo_snapshots(storage_path, frame_seq)?;
257    Ok(frame_seq)
258}
259
260fn prune_old_undo_snapshots(storage_path: &str, latest_seq: u64) -> Result<(), String> {
261    if latest_seq <= MAX_UNDO_FRAMES as u64 {
262        return Ok(());
263    }
264    let remove_before = latest_seq - MAX_UNDO_FRAMES as u64;
265    for seq in 1..=remove_before {
266        let path = undo_frame_snapshot_path(storage_path, seq);
267        if path.is_file() {
268            let _ = std::fs::remove_file(path);
269        }
270    }
271    Ok(())
272}
273
274/// List undo-frame checkpoints from the studio WAL (oldest first).
275pub fn list_undo_frames(storage_path: &str) -> Result<Vec<StudioUndoFrameRecord>, String> {
276    let wal_path = studio_wal_path(storage_path);
277    if !wal_path.is_file() {
278        return Ok(Vec::new());
279    }
280    let mut wal = WriteAheadLog::open(&wal_path).map_err(|e| format!("wal open: {e}"))?;
281    let quins = wal.recover().map_err(|e| format!("wal recover: {e}"))?;
282    let undo_pred = q_hash(PREDICATE_UNDO_FRAME);
283    let mut records = Vec::new();
284    for quin in quins {
285        if quin.predicate != undo_pred {
286            continue;
287        }
288        let frame_seq = (quin.metadata >> LAMPORT_SHIFT) & LAMPORT_MASK;
289        let stack_index = (quin.metadata & 0xFFFF) as u16;
290        records.push(StudioUndoFrameRecord {
291            frame_seq,
292            stack_index,
293            manifest_hash: quin.object,
294            unix_ts: 0,
295        });
296    }
297    Ok(records)
298}
299
300/// Load a single undo-frame manifest snapshot by sequence id.
301pub fn load_undo_frame_manifest(storage_path: &str, frame_seq: u64) -> Result<String, String> {
302    let path = undo_frame_snapshot_path(storage_path, frame_seq);
303    if !path.is_file() {
304        return Err(format!("undo frame {frame_seq} snapshot missing"));
305    }
306    std::fs::read_to_string(&path).map_err(|e| e.to_string())
307}
308
309/// Recover the last N undo manifests in chronological order for history hydration.
310pub fn recover_undo_chain_manifests(storage_path: &str) -> Result<Vec<String>, String> {
311    let frames = list_undo_frames(storage_path)?;
312    if frames.is_empty() {
313        return Ok(Vec::new());
314    }
315    let start = frames.len().saturating_sub(MAX_UNDO_FRAMES);
316    let mut manifests = Vec::new();
317    for frame in &frames[start..] {
318        match load_undo_frame_manifest(storage_path, frame.frame_seq) {
319            Ok(body) => manifests.push(body),
320            Err(err) => eprintln!("undo frame {} skip: {err}", frame.frame_seq),
321        }
322    }
323    Ok(manifests)
324}
325
326/// Recover deploy checkpoints from the studio WAL (most recent last).
327pub fn list_deploy_history(storage_path: &str) -> Result<Vec<StudioDeployRecord>, String> {
328    let wal_path = studio_wal_path(storage_path);
329    if !wal_path.is_file() {
330        return Ok(Vec::new());
331    }
332    let mut wal = WriteAheadLog::open(&wal_path).map_err(|e| format!("wal open: {e}"))?;
333    let quins = wal.recover().map_err(|e| format!("wal recover: {e}"))?;
334    let deploy_pred = q_hash(PREDICATE_DEPLOY);
335    let pane_pred = q_hash(PREDICATE_PANE);
336    let mut pane_counts: std::collections::HashMap<u64, u16> = std::collections::HashMap::new();
337    for quin in &quins {
338        if quin.predicate != pane_pred {
339            continue;
340        }
341        let revision = (quin.metadata >> LAMPORT_SHIFT) & LAMPORT_MASK;
342        let entry = pane_counts.entry(revision).or_insert(0);
343        *entry = entry.saturating_add(1);
344    }
345
346    let mut records = Vec::new();
347    for quin in quins {
348        if quin.predicate != deploy_pred {
349            continue;
350        }
351        let revision = (quin.metadata >> LAMPORT_SHIFT) & LAMPORT_MASK;
352        let unix_ts = (quin.metadata & 0xFFFF_FFFF) as u32;
353        let pane_count = pane_counts.get(&revision).copied().unwrap_or(0);
354        records.push(StudioDeployRecord {
355            revision,
356            unix_ts,
357            pane_count,
358            manifest_hash: quin.object,
359        });
360    }
361    Ok(records)
362}
363
364/// Reconstruct a minimal workspace manifest from pane placement Quins at `revision`.
365pub fn reconstruct_manifest_from_pane_quins(
366    storage_path: &str,
367    revision: u64,
368) -> Result<String, String> {
369    let wal_path = studio_wal_path(storage_path);
370    let mut wal = WriteAheadLog::open(&wal_path).map_err(|e| format!("wal open: {e}"))?;
371    let quins = wal.recover().map_err(|e| format!("wal recover: {e}"))?;
372    let pane_pred = q_hash(PREDICATE_PANE);
373
374    let mut pages: std::collections::HashMap<String, Vec<WalManifestPane>> =
375        std::collections::HashMap::new();
376
377    for quin in &quins {
378        if quin.predicate != pane_pred {
379            continue;
380        }
381        let rev = (quin.metadata >> LAMPORT_SHIFT) & LAMPORT_MASK;
382        if rev != revision {
383            continue;
384        }
385        let page_path = format!("wal-page-{}", quin.context);
386        let (x, y, w, h) = unpack_placement(quin.object);
387        let component_id = format!("wal-pane-{}", quin.subject);
388        pages.entry(page_path).or_default().push(WalManifestPane {
389            component_id,
390            x,
391            y,
392            w,
393            h,
394        });
395    }
396
397    if pages.is_empty() {
398        return Err(format!("no pane quins for revision {revision}"));
399    }
400
401    let manifest = WalManifest {
402        pages: pages
403            .into_iter()
404            .map(|(url_path, panes)| WalManifestPage { url_path, panes })
405            .collect(),
406    };
407    serde_json::to_string(&manifest).map_err(|e| format!("manifest encode: {e}"))
408}
409
410/// Load a saved revision snapshot, falling back to pane-quin reconstruction.
411pub fn replay_workspace_manifest(storage_path: &str, revision: u64) -> Result<String, String> {
412    let snap = revision_snapshot_path(storage_path, revision);
413    if snap.is_file() {
414        return std::fs::read_to_string(&snap).map_err(|e| e.to_string());
415    }
416    reconstruct_manifest_from_pane_quins(storage_path, revision)
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422    use std::fs;
423
424    #[test]
425    fn append_and_recover_deploy_history() {
426        let dir = std::env::temp_dir().join(format!(
427            "qualia-studio-wal-{}",
428            std::time::SystemTime::now()
429                .duration_since(std::time::UNIX_EPOCH)
430                .unwrap()
431                .as_nanos()
432        ));
433        fs::create_dir_all(&dir).unwrap();
434        let storage = dir.to_string_lossy().to_string();
435        let manifest = r#"{"pages":[{"url_path":"/","panes":[{"component_id":"n3-logic-studio","x":0,"y":0,"w":40,"h":30}]}]}"#;
436
437        let rev = append_workspace_deploy(&storage, manifest).unwrap();
438        assert_eq!(rev, 1);
439
440        let history = list_deploy_history(&storage).unwrap();
441        assert_eq!(history.len(), 1);
442        assert_eq!(history[0].revision, 1);
443        assert_eq!(history[0].pane_count, 1);
444        assert_eq!(history[0].manifest_hash, manifest_content_hash(manifest));
445
446        let rev2 = append_workspace_deploy(&storage, manifest).unwrap();
447        assert_eq!(rev2, 2);
448        let history2 = list_deploy_history(&storage).unwrap();
449        assert_eq!(history2.len(), 2);
450
451        let _ = fs::remove_dir_all(dir);
452    }
453
454    #[test]
455    fn revision_snapshot_roundtrip() {
456        let dir = std::env::temp_dir().join(format!(
457            "qualia-studio-snap-{}",
458            std::time::SystemTime::now()
459                .duration_since(std::time::UNIX_EPOCH)
460                .unwrap()
461                .as_nanos()
462        ));
463        std::fs::create_dir_all(&dir).unwrap();
464        let storage = dir.to_string_lossy().to_string();
465        let body = r#"{"pages":[{"url_path":"/","panes":[]}]}"#;
466        persist_revision_snapshot(&storage, 3, body).unwrap();
467        let loaded = replay_workspace_manifest(&storage, 3).unwrap();
468        assert_eq!(loaded, body);
469        let _ = std::fs::remove_dir_all(dir);
470    }
471
472    #[test]
473    fn undo_frame_append_and_recover() {
474        let dir = std::env::temp_dir().join(format!(
475            "qualia-studio-undo-{}",
476            std::time::SystemTime::now()
477                .duration_since(std::time::UNIX_EPOCH)
478                .unwrap()
479                .as_nanos()
480        ));
481        fs::create_dir_all(&dir).unwrap();
482        let storage = dir.to_string_lossy().to_string();
483        let m1 = r#"{"pages":[{"url_path":"/","panes":[{"component_id":"a","x":0,"y":0,"w":10,"h":10}]}]}"#;
484        let m2 = r#"{"pages":[{"url_path":"/","panes":[{"component_id":"b","x":1,"y":1,"w":20,"h":20}]}]}"#;
485        let s1 = append_undo_frame(&storage, 0, m1).unwrap();
486        let s2 = append_undo_frame(&storage, 1, m2).unwrap();
487        assert_eq!(s1, 1);
488        assert_eq!(s2, 2);
489        let chain = recover_undo_chain_manifests(&storage).unwrap();
490        assert_eq!(chain.len(), 2);
491        assert_eq!(chain[0], m1);
492        assert_eq!(chain[1], m2);
493        let _ = fs::remove_dir_all(dir);
494    }
495
496    #[test]
497    fn pack_placement_roundtrip_bits() {
498        let packed = pack_placement(4, 8, 32, 16);
499        assert_eq!(packed & 0xFFFF, 4);
500        assert_eq!((packed >> 16) & 0xFFFF, 8);
501        assert_eq!((packed >> 32) & 0xFFFF, 16);
502        assert_eq!((packed >> 48) & 0xFFFF, 32);
503    }
504}