1use 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
199pub 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
229pub 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
274pub 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
300pub 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
309pub 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
326pub 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
364pub 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
410pub 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}