Files
Triple-C/app/src-tauri/src/storage/projects_store.rs
T

608 lines
24 KiB
Rust
Raw Normal View History

use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use crate::models::marketplace::{MarketplaceInstall, MarketplaceItemRef};
use crate::models::Project;
/// The sticky marker for `projects.json`: `projects.json.corrupt`, beside it.
///
/// Derived from the file rather than from `dirs::data_dir()` so the marker
/// always lands in the directory the store is actually using — and so the
/// writer can be tested against a temp directory.
fn corrupt_marker_for(file_path: &Path) -> PathBuf {
file_path.with_extension("json.corrupt")
}
/// Keep the bytes of an unparseable `projects.json`, and record that it
/// happened.
///
/// **The existing `.bak` is never overwritten.** A second corruption used to
/// clobber the first, and the first is the valuable one: it was taken before
/// the app rewrote the file with whatever it had in memory, so it is the only
/// copy that can still hold the full project list. Later ones are copies of an
/// already-degraded file and get a timestamped name.
fn record_corrupt_load(file_path: &Path, now: &chrono::DateTime<chrono::Utc>) {
let first = file_path.with_extension("json.bak");
let backup = if first.exists() {
file_path.with_extension(format!("json.corrupt-{}.bak", now.format("%Y%m%d-%H%M%S")))
} else {
first
};
if !backup.exists() {
if let Err(e) = fs::copy(file_path, &backup) {
log::error!("Failed to back up corrupted projects.json: {}", e);
} else {
log::error!(
"A copy of the unreadable projects.json was kept at {}",
backup.display()
);
}
}
// Sticky, and written even though nothing in the app reads it back on this
// branch: the Disk panel's `project_store_trust` was the reader and went to
// `hold/disk-and-dragout`. The marker stays because it is the only durable
// record that a project list was lost — the in-memory symptom does not
// survive the next save — and because re-deriving *when* it happened is
// impossible after the fact.
let marker = corrupt_marker_for(file_path);
if marker.exists() {
// The *first* corruption is the one that dates the loss.
return;
}
if let Err(e) = fs::write(&marker, now.to_rfc3339()) {
log::error!(
"Could not record the corrupt projects.json load at {}: {} — nothing will be able to \
tell later that the project list was incomplete",
marker.display(),
e
);
}
}
pub struct ProjectsStore {
projects: Mutex<Vec<Project>>,
file_path: PathBuf,
}
impl ProjectsStore {
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or_else(|| "Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string())?
.join("triple-c");
fs::create_dir_all(&data_dir).ok();
let file_path = data_dir.join("projects.json");
let (projects, needs_save) = if file_path.exists() {
match fs::read_to_string(&file_path) {
Ok(data) => {
// First try to parse as Vec<Value> to run migration
match serde_json::from_str::<Vec<serde_json::Value>>(&data) {
Ok(raw_values) => {
let mut migrated = false;
let migrated_values: Vec<serde_json::Value> = raw_values
.into_iter()
.map(|v| {
let has_path = v.as_object().map_or(false, |o| o.contains_key("path") && !o.contains_key("paths"));
if has_path {
migrated = true;
}
crate::models::Project::migrate_from_value(v)
})
.collect();
// Now deserialize the migrated values
let json_str = serde_json::to_string(&migrated_values).unwrap_or_default();
match serde_json::from_str::<Vec<crate::models::Project>>(&json_str) {
Ok(parsed) => (parsed, migrated),
Err(e) => {
log::error!("Failed to parse migrated projects.json: {}. Starting with empty list.", e);
record_corrupt_load(&file_path, &chrono::Utc::now());
(Vec::new(), false)
}
}
}
Err(e) => {
log::error!("Failed to parse projects.json: {}. Starting with empty list.", e);
record_corrupt_load(&file_path, &chrono::Utc::now());
(Vec::new(), false)
}
}
}
Err(e) => {
log::error!("Failed to read projects.json: {}", e);
(Vec::new(), false)
}
}
} else {
(Vec::new(), false)
};
// Reconcile stale transient statuses: on a cold app start no Docker
// operations can be in flight, so Starting/Stopping are always stale.
// Running/Error are left as-is and reconciled against Docker later
// via the reconcile_project_statuses command.
let mut projects = projects;
let mut needs_save = needs_save;
for p in projects.iter_mut() {
match p.status {
crate::models::ProjectStatus::Starting | crate::models::ProjectStatus::Stopping => {
log::warn!(
"Reconciling stale '{}' status for project '{}' ({}) → Stopped",
serde_json::to_string(&p.status).unwrap_or_default().trim_matches('"'),
p.name,
p.id
);
p.status = crate::models::ProjectStatus::Stopped;
p.updated_at = chrono::Utc::now().to_rfc3339();
needs_save = true;
}
_ => {}
}
}
let store = Self {
projects: Mutex::new(projects),
file_path,
};
// Persist migrated/reconciled format back to disk
if needs_save {
log::info!("Saving reconciled/migrated projects.json to disk");
let projects = store.lock();
if let Err(e) = store.save(&projects) {
log::error!("Failed to save projects: {}", e);
}
}
Ok(store)
}
fn lock(&self) -> std::sync::MutexGuard<'_, Vec<Project>> {
self.projects.lock().unwrap_or_else(|e| e.into_inner())
}
fn save(&self, projects: &[Project]) -> Result<(), String> {
let data = serde_json::to_string_pretty(projects)
.map_err(|e| format!("Failed to serialize projects: {}", e))?;
// Atomic write: write to temp file, then rename
let tmp_path = self.file_path.with_extension("json.tmp");
fs::write(&tmp_path, data)
.map_err(|e| format!("Failed to write temp projects file: {}", e))?;
fs::rename(&tmp_path, &self.file_path)
.map_err(|e| format!("Failed to rename projects file: {}", e))?;
Ok(())
}
pub fn list(&self) -> Vec<Project> {
self.lock().clone()
}
pub fn get(&self, id: &str) -> Option<Project> {
self.lock().iter().find(|p| p.id == id).cloned()
}
pub fn add(&self, project: Project) -> Result<Project, String> {
let mut projects = self.lock();
let cloned = project.clone();
projects.push(project);
self.save(&projects)?;
Ok(cloned)
}
pub fn update(&self, updated: Project) -> Result<Project, String> {
let mut projects = self.lock();
if let Some(p) = projects.iter_mut().find(|p| p.id == updated.id) {
*p = updated.clone();
self.save(&projects)?;
Ok(updated)
} else {
Err(format!("Project {} not found", updated.id))
}
}
/// Replace a project with `updated`, after `restore` has copied onto it
/// the fields the store owns from the record *as stored under the lock*.
/// `update_project` restores from a copy it read earlier, so an install
/// or a status change landing in between would otherwise be written over
/// (re-review round 2).
pub fn update_restoring(
&self,
mut updated: Project,
restore: impl FnOnce(&mut Project, &Project),
) -> Result<Project, String> {
let mut projects = self.lock();
let p = projects
.iter_mut()
.find(|p| p.id == updated.id)
.ok_or_else(|| format!("Project {} not found", updated.id))?;
restore(&mut updated, p);
*p = updated.clone();
self.save(&projects)?;
Ok(updated)
}
pub fn remove(&self, id: &str) -> Result<(), String> {
let mut projects = self.lock();
let initial_len = projects.len();
projects.retain(|p| p.id != id);
if projects.len() == initial_len {
return Err(format!("Project {} not found", id));
}
self.save(&projects)?;
Ok(())
}
pub fn update_status(&self, id: &str, status: crate::models::ProjectStatus) -> Result<(), String> {
let mut projects = self.lock();
if let Some(p) = projects.iter_mut().find(|p| p.id == id) {
p.status = status;
p.updated_at = chrono::Utc::now().to_rfc3339();
self.save(&projects)?;
Ok(())
} else {
Err(format!("Project {} not found", id))
}
}
/// Granular setter for the auth bridge opt-in, so toggling it can't clobber
/// concurrent edits to the rest of the project record.
pub fn set_auth_bridge_enabled(&self, project_id: &str, enabled: bool) -> Result<(), String> {
let mut projects = self.lock();
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
p.auth_bridge_enabled = enabled;
p.updated_at = chrono::Utc::now().to_rfc3339();
self.save(&projects)?;
Ok(())
} else {
Err(format!("Project {} not found", project_id))
}
}
/// Granular setter for the browser view's opt-in, for the same reason
/// [`Self::set_auth_bridge_enabled`] has one: the pane toggles this while
/// the Config tab may be holding an older copy of the whole record.
pub fn set_browser_view_enabled(&self, project_id: &str, enabled: bool) -> Result<(), String> {
let mut projects = self.lock();
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
p.browser_view_enabled = enabled;
p.updated_at = chrono::Utc::now().to_rfc3339();
self.save(&projects)?;
Ok(())
} else {
Err(format!("Project {} not found", project_id))
}
}
/// Read-modify-write of one project's marketplace installs and opt-outs
/// under the store's lock, touching nothing else (PR review #2): the
/// marketplace commands must not write back a whole record read before a
/// start changed its status or container id. When `f` fails nothing is
/// saved. Returns `f`'s value and the saved project.
pub fn update_marketplace_fields<T>(
&self,
project_id: &str,
f: impl FnOnce(
&mut Vec<MarketplaceInstall>,
&mut Vec<MarketplaceItemRef>,
) -> Result<T, String>,
) -> Result<(T, Project), String> {
let mut projects = self.lock();
let p = projects
.iter_mut()
.find(|p| p.id == project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let mut installs = p.marketplace_installs.clone();
let mut disabled = p.marketplace_disabled.clone();
let out = f(&mut installs, &mut disabled)?;
p.marketplace_installs = installs;
p.marketplace_disabled = disabled;
p.updated_at = chrono::Utc::now().to_rfc3339();
let saved = p.clone();
self.save(&projects)?;
Ok((out, saved))
}
/// [`Self::update_marketplace_fields`] over every project at once, in one
/// save. Projects `f` leaves as they were are not touched at all.
pub fn update_all_marketplace_fields(
&self,
mut f: impl FnMut(&mut Vec<MarketplaceInstall>, &mut Vec<MarketplaceItemRef>),
) -> Result<(), String> {
let mut projects = self.lock();
let mut changed = false;
for p in projects.iter_mut() {
let mut installs = p.marketplace_installs.clone();
let mut disabled = p.marketplace_disabled.clone();
f(&mut installs, &mut disabled);
if installs != p.marketplace_installs || disabled != p.marketplace_disabled {
p.marketplace_installs = installs;
p.marketplace_disabled = disabled;
p.updated_at = chrono::Utc::now().to_rfc3339();
changed = true;
}
}
if changed {
self.save(&projects)?;
}
Ok(())
}
pub fn set_container_id(&self, project_id: &str, container_id: Option<String>) -> Result<(), String> {
let mut projects = self.lock();
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
p.container_id = container_id;
p.updated_at = chrono::Utc::now().to_rfc3339();
self.save(&projects)?;
Ok(())
} else {
Err(format!("Project {} not found", project_id))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"triple-c-store-{}-{}",
tag,
uuid::Uuid::new_v4().simple()
));
fs::create_dir_all(&dir).expect("temp dir");
dir
}
#[test]
fn a_corrupt_load_leaves_a_marker_the_next_write_cannot_erase() {
// H-3, the whole chain in one test. `ProjectsStore::new()` swallows an
// unparseable file into an empty list *without rewriting it*, and the
// first `save()` after that — as little as `update_status()` — writes
// `[{one project}]` over it. Everything the old guard keyed on ("the
// list is empty and the file exists") is gone at that point, while
// every *other* project's volumes are still on the daemon claimed by
// nobody.
let dir = temp_dir("corrupt");
let file = dir.join("projects.json");
fs::write(&file, "{ this is not a project list").unwrap();
let now = chrono::Utc::now();
record_corrupt_load(&file, &now);
let marker = corrupt_marker_for(&file);
assert!(marker.exists(), "the corrupt load must be recorded on disk");
assert_eq!(fs::read_to_string(&marker).unwrap(), now.to_rfc3339());
assert!(
dir.join("projects.json.bak").exists(),
"the unreadable bytes must be kept"
);
// The write that used to erase the evidence. The marker is a separate
// file, so it does not care.
fs::write(&file, r#"[{"id":"the-one-project-started-since"}]"#).unwrap();
assert!(marker.exists());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_second_corruption_keeps_the_first_copy_and_the_first_date() {
// The `.bak` used to be a fixed name, so a second corruption clobbered
// the first — and the first is the only copy taken before the app
// rewrote the file with whatever it had in memory, i.e. the only one
// that can still hold the full project list.
let dir = temp_dir("second");
let file = dir.join("projects.json");
fs::write(&file, "original bytes").unwrap();
let first = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
record_corrupt_load(&file, &first);
fs::write(&file, "degraded bytes").unwrap();
let second = chrono::DateTime::parse_from_rfc3339("2026-06-01T00:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
record_corrupt_load(&file, &second);
assert_eq!(
fs::read_to_string(dir.join("projects.json.bak")).unwrap(),
"original bytes",
"the first copy must survive the second corruption"
);
assert_eq!(
fs::read_to_string(dir.join("projects.json.corrupt-20260601-000000.bak")).unwrap(),
"degraded bytes"
);
// And the marker still dates the loss from the first failure, which is
// when the project list actually stopped being complete.
assert_eq!(
fs::read_to_string(corrupt_marker_for(&file)).unwrap(),
first.to_rfc3339()
);
fs::remove_dir_all(&dir).ok();
}
/// A store over a temp file. `new()` insists on `dirs::data_dir()`, which
/// is the real user's; the fields are right here, so the granular setters
/// can be exercised against a directory the test owns.
fn store_over(dir: &Path, projects: Vec<Project>) -> ProjectsStore {
ProjectsStore {
projects: Mutex::new(projects),
file_path: dir.join("projects.json"),
}
}
#[test]
fn the_browser_view_flag_is_written_to_disk_and_read_back() {
// The point of the whole exercise: before this the flag lived in a
// `HashSet` in `BrowserViewManager` and an app restart forgot it.
let dir = temp_dir("browser-view");
let project = Project::new("demo".to_string(), Vec::new());
let id = project.id.clone();
let store = store_over(&dir, vec![project]);
assert!(!store.get(&id).unwrap().browser_view_enabled);
store.set_browser_view_enabled(&id, true).unwrap();
assert!(store.get(&id).unwrap().browser_view_enabled);
// Durable, not merely in memory — this is what a restart reads.
let on_disk: Vec<Project> =
serde_json::from_str(&fs::read_to_string(dir.join("projects.json")).unwrap()).unwrap();
assert!(on_disk[0].browser_view_enabled);
store.set_browser_view_enabled(&id, false).unwrap();
assert!(!store.get(&id).unwrap().browser_view_enabled);
assert!(store.set_browser_view_enabled("no-such-project", true).is_err());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_granular_toggle_leaves_every_other_field_alone() {
// Why these setters exist at all: the Config tab can be holding an
// older copy of the whole record while the pane flips one flag.
let dir = temp_dir("granular");
let mut project = Project::new("demo".to_string(), Vec::new());
project.claude_instructions = Some("keep me".to_string());
let id = project.id.clone();
let store = store_over(&dir, vec![project]);
store.set_browser_view_enabled(&id, true).unwrap();
store.set_auth_bridge_enabled(&id, false).unwrap();
let saved = store.get(&id).unwrap();
assert_eq!(saved.claude_instructions.as_deref(), Some("keep me"));
assert!(saved.browser_view_enabled);
assert!(!saved.auth_bridge_enabled);
fs::remove_dir_all(&dir).ok();
}
fn market_install(key: &str) -> crate::models::marketplace::MarketplaceInstall {
crate::models::marketplace::MarketplaceInstall {
marketplace_id: "m1".into(),
kind: crate::models::marketplace::ItemKind::Agent,
key: key.into(),
commit: "a".repeat(40),
}
}
#[test]
fn marketplace_edits_keep_a_concurrent_status_and_container_change() {
// PR review #2: a marketplace install/uninstall used to write back a
// whole record read before a start flipped status and container_id,
// leaving the project stuck at Starting with no container.
let dir = temp_dir("marketplace-fields");
let project = Project::new("demo".to_string(), Vec::new());
let id = project.id.clone();
let store = store_over(&dir, vec![project]);
// The start flow moves on while a marketplace command is running.
store.set_container_id(&id, Some("cid-1".into())).unwrap();
store.update_status(&id, crate::models::ProjectStatus::Starting).unwrap();
let (added, saved) = store
.update_marketplace_fields(&id, |installs, disabled| {
installs.push(market_install("a"));
disabled.push(market_install("g").item_ref());
Ok(installs.len())
})
.unwrap();
assert_eq!(added, 1);
assert_eq!(saved.container_id.as_deref(), Some("cid-1"));
assert_eq!(saved.status, crate::models::ProjectStatus::Starting);
let on_disk: Vec<Project> =
serde_json::from_str(&fs::read_to_string(dir.join("projects.json")).unwrap()).unwrap();
assert_eq!(on_disk[0].container_id.as_deref(), Some("cid-1"));
assert_eq!(on_disk[0].marketplace_installs, vec![market_install("a")]);
// A refusal inside the closure writes nothing.
let before = fs::read_to_string(dir.join("projects.json")).unwrap();
let err = store
.update_marketplace_fields(&id, |installs, _| {
installs.clear();
Err::<(), _>("not installed".to_string())
})
.unwrap_err();
assert_eq!(err, "not installed");
assert_eq!(store.get(&id).unwrap().marketplace_installs.len(), 1);
assert_eq!(fs::read_to_string(dir.join("projects.json")).unwrap(), before);
assert!(store.update_marketplace_fields("nope", |_, _| Ok(())).is_err());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_project_save_keeps_a_marketplace_install_made_after_it_read_the_record() {
// Re-review round 2: `update_project` read the stored record, then
// wrote the whole payload back later. An install landing in between
// was lost. The restore now runs against the record under the lock.
let dir = temp_dir("save-restore");
let project = Project::new("demo".to_string(), Vec::new());
let id = project.id.clone();
let store = store_over(&dir, vec![project]);
let mut payload = store.get(&id).unwrap(); // the Config tab's copy
payload.name = "renamed".to_string();
store
.update_marketplace_fields(&id, |installs, _| {
installs.push(market_install("late"));
Ok(())
})
.unwrap();
let saved = store
.update_restoring(payload, |incoming, stored| {
incoming.marketplace_installs = stored.marketplace_installs.clone();
incoming.marketplace_disabled = stored.marketplace_disabled.clone();
})
.unwrap();
assert_eq!(saved.name, "renamed");
assert_eq!(saved.marketplace_installs, vec![market_install("late")]);
assert_eq!(store.get(&id).unwrap().marketplace_installs, vec![market_install("late")]);
let mut ghost = Project::new("ghost".to_string(), Vec::new());
ghost.id = "nope".into();
assert!(store.update_restoring(ghost, |_, _| {}).is_err());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn marketplace_edits_across_all_projects_touch_only_those_fields() {
let dir = temp_dir("marketplace-all");
let mut a = Project::new("a".to_string(), Vec::new());
a.marketplace_installs = vec![market_install("x")];
let b = Project::new("b".to_string(), Vec::new());
let (a_id, b_id) = (a.id.clone(), b.id.clone());
let store = store_over(&dir, vec![a, b]);
store.set_container_id(&b_id, Some("cid-b".into())).unwrap();
store.update_status(&a_id, crate::models::ProjectStatus::Running).unwrap();
let b_updated_at = store.get(&b_id).unwrap().updated_at;
store
.update_all_marketplace_fields(|installs, _| {
installs.retain(|i| i.marketplace_id != "m1")
})
.unwrap();
let a = store.get(&a_id).unwrap();
assert!(a.marketplace_installs.is_empty());
assert_eq!(a.status, crate::models::ProjectStatus::Running);
let b = store.get(&b_id).unwrap();
assert_eq!(b.container_id.as_deref(), Some("cid-b"));
assert_eq!(b.updated_at, b_updated_at, "an untouched project is not rewritten");
fs::remove_dir_all(&dir).ok();
}
}