Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Step 5: btrfs Workspace Management — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Master image import, workspace creation via btrfs snapshots, list/inspect/delete — all behind a filesystem-agnostic WorkspaceBackend trait, with REST endpoints and CLI commands.

Architecture: A new WorkspaceBackend trait abstracts all filesystem operations (import image, create snapshot, delete, list, inspect). BtrfsBackend implements this trait using libbtrfsutil for btrfs subvolume operations. The database tracks master images and workspaces via new master_images and workspaces tables. nexusd gains REST routes for images and workspaces. nexusctl gains image and ws subcommands. Each workspace subvolume contains a raw ext4 image file created by mke2fs -d, enabling Firecracker block device exposure while preserving btrfs CoW at the host layer.

Tech Stack (additions to existing):

  • libbtrfsutil 0.7 — Rust bindings to libbtrfsutil for subvolume create, snapshot, delete, and inspection via ioctls. Requires libbtrfsutil and libclang installed on the host at build time.

XDG Directory Layout:

$XDG_DATA_HOME/nexus/           (~/.local/share/nexus/)
  ├── workspaces/
  │   ├── @base-agent/           ← read-only master image (btrfs subvolume)
  │   ├── @work-code-1/          ← CoW snapshot of @base-agent
  │   └── @portal-openclaw/      ← CoW snapshot of portal master
  └── images/
      └── vmlinux               ← kernel (future step)

See data model for the full master_images and workspaces table definitions. See drives architecture for the btrfs-backed storage design. See CLI design for image and workspace command grammar.


Task 1: Add libbtrfsutil Dependency to nexus-lib

Files:

  • Modify: nexus/nexus-lib/Cargo.toml

Step 1: Add libbtrfsutil dependency

# nexus/nexus-lib/Cargo.toml
[dependencies]
# ... existing deps ...
libbtrfsutil = "0.7"

Step 2: Verify build

Run: cd ~/Work/WorkFort/nexus && mise run check

Expected: Compiles with no errors. (Requires libbtrfsutil and libclang installed on the host.)

Step 3: Commit

cd ~/Work/WorkFort/nexus
git add nexus-lib/Cargo.toml Cargo.lock
git commit -m "chore(nexus-lib): add libbtrfsutil dependency for btrfs workspace operations"

Task 2: Add master_images and workspaces Tables to Schema

Files:

  • Modify: nexus/nexus-lib/src/store/schema.rs
  • Modify: nexus/nexus-lib/src/store/sqlite.rs
  • Modify: nexus/nexusd/tests/daemon.rs

Step 1: Update SCHEMA_VERSION and SCHEMA_SQL

Bump the schema version from 2 to 3 and append the master_images and workspaces tables.

#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/store/schema.rs

/// Schema version — increment when the schema changes.
/// Pre-alpha migration strategy: if the stored version doesn't match,
/// delete the DB and recreate.
pub const SCHEMA_VERSION: u32 = 3;

/// Database schema. Executed as a single batch on first start.
/// Domain tables are added by later steps — each step bumps SCHEMA_VERSION
/// and appends its tables here. Pre-alpha migration (delete + recreate)
/// means all tables are always created from this single constant.
pub const SCHEMA_SQL: &str = r#"
-- Schema version tracking
CREATE TABLE schema_meta (
    key TEXT PRIMARY KEY,
    value TEXT NOT NULL
);

-- Application settings (key-value store)
CREATE TABLE settings (
    key TEXT PRIMARY KEY,
    value TEXT NOT NULL,
    type TEXT NOT NULL CHECK(type IN ('string', 'int', 'bool', 'json'))
);

-- VMs: Firecracker microVM instances
CREATE TABLE vms (
    id TEXT PRIMARY KEY,
    name TEXT UNIQUE NOT NULL,
    role TEXT NOT NULL CHECK(role IN ('portal', 'work', 'service')),
    state TEXT NOT NULL CHECK(state IN ('created', 'running', 'stopped', 'crashed', 'failed')),
    cid INTEGER NOT NULL UNIQUE,
    vcpu_count INTEGER NOT NULL DEFAULT 1,
    mem_size_mib INTEGER NOT NULL DEFAULT 128,
    config_json TEXT,
    pid INTEGER,
    socket_path TEXT,
    uds_path TEXT,
    console_log_path TEXT,
    created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
    updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
    started_at INTEGER,
    stopped_at INTEGER
);

CREATE INDEX idx_vms_role ON vms(role);
CREATE INDEX idx_vms_state ON vms(state);

-- Master images: read-only btrfs subvolumes
CREATE TABLE master_images (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL UNIQUE,
    subvolume_path TEXT NOT NULL UNIQUE,
    size_bytes INTEGER,
    created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);

-- Workspaces: btrfs subvolume snapshots
CREATE TABLE workspaces (
    id TEXT PRIMARY KEY,
    name TEXT UNIQUE,
    vm_id TEXT,
    subvolume_path TEXT NOT NULL UNIQUE,
    master_image_id TEXT,
    parent_workspace_id TEXT,
    size_bytes INTEGER,
    is_root_device INTEGER NOT NULL DEFAULT 0 CHECK(is_root_device IN (0, 1)),
    is_read_only INTEGER NOT NULL DEFAULT 0 CHECK(is_read_only IN (0, 1)),
    attached_at INTEGER,
    detached_at INTEGER,
    created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
    FOREIGN KEY (vm_id) REFERENCES vms(id) ON DELETE SET NULL,
    FOREIGN KEY (master_image_id) REFERENCES master_images(id) ON DELETE RESTRICT,
    FOREIGN KEY (parent_workspace_id) REFERENCES workspaces(id) ON DELETE SET NULL
);

CREATE INDEX idx_workspaces_vm_id ON workspaces(vm_id);
CREATE INDEX idx_workspaces_base ON workspaces(master_image_id);
"#;
}

Step 2: Update table count expectations in existing tests

In nexus/nexus-lib/src/store/sqlite.rs, update the following tests:

  • init_creates_all_tables: change assert_eq!(status.table_count, 3, ...) to assert_eq!(status.table_count, 5, ...) (schema_meta, settings, vms, master_images, workspaces)
  • init_is_idempotent: change assert_eq!(status.table_count, 3) to assert_eq!(status.table_count, 5)
  • schema_mismatch_triggers_recreate: change assert_eq!(status.table_count, 3, ...) to assert_eq!(status.table_count, 5, ...)

In nexus/nexusd/tests/daemon.rs, update:

  • daemon_starts_serves_health_and_stops: change assert_eq!(body["database"]["table_count"], 3) to assert_eq!(body["database"]["table_count"], 5)

Step 3: Run workspace tests

Run: cd ~/Work/WorkFort/nexus && mise run test

Expected: All tests PASS.

Step 4: Commit

cd ~/Work/WorkFort/nexus
git add nexus-lib/src/store/schema.rs nexus-lib/src/store/sqlite.rs nexusd/tests/daemon.rs
git commit -m "feat(nexus-lib): add master_images and workspaces tables to schema (v3)"

Task 3: Master Image and Workspace Domain Types

Files:

  • Create: nexus/nexus-lib/src/workspace.rs
  • Modify: nexus/nexus-lib/src/lib.rs

Step 1: Create the workspace module with domain types and tests

#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/workspace.rs

use serde::{Deserialize, Serialize};

/// A master image: a read-only btrfs subvolume registered in the database.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MasterImage {
    pub id: String,
    pub name: String,
    pub subvolume_path: String,
    pub size_bytes: Option<i64>,
    pub created_at: i64,
}

/// Parameters for importing a master image.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportImageParams {
    /// Human-readable name for the image
    pub name: String,
    /// Path to the directory to import as a btrfs subvolume
    pub source_path: String,
}

/// A workspace: a btrfs subvolume snapshot, optionally attached to a VM.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Workspace {
    pub id: String,
    pub name: Option<String>,
    pub vm_id: Option<String>,
    pub subvolume_path: String,
    pub master_image_id: Option<String>,
    pub parent_workspace_id: Option<String>,
    pub size_bytes: Option<i64>,
    pub is_root_device: bool,
    pub is_read_only: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub attached_at: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub detached_at: Option<i64>,
    pub created_at: i64,
}

/// Parameters for creating a workspace from a master image.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateWorkspaceParams {
    /// Workspace name (optional, auto-generated if omitted)
    pub name: Option<String>,
    /// Name of the master image to snapshot from
    pub base: String,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn master_image_serializes() {
        let img = MasterImage {
            id: "img-1".to_string(),
            name: "base-agent".to_string(),
            subvolume_path: "/data/workspaces/@base-agent".to_string(),
            size_bytes: Some(1024 * 1024),
            created_at: 1000,
        };
        let json = serde_json::to_string(&img).unwrap();
        assert!(json.contains("base-agent"));
        assert!(json.contains("1048576"));
    }

    #[test]
    fn workspace_serializes_without_none_fields() {
        let ws = Workspace {
            id: "ws-1".to_string(),
            name: Some("my-ws".to_string()),
            vm_id: None,
            subvolume_path: "/data/workspaces/@my-ws".to_string(),
            master_image_id: Some("img-1".to_string()),
            parent_workspace_id: None,
            size_bytes: None,
            is_root_device: false,
            is_read_only: false,
            attached_at: None,
            detached_at: None,
            created_at: 2000,
        };
        let json = serde_json::to_string(&ws).unwrap();
        assert!(json.contains("my-ws"));
        assert!(!json.contains("attached_at"));
        assert!(!json.contains("detached_at"));
    }

    #[test]
    fn import_params_deserializes() {
        let json = r#"{"name": "base", "source_path": "/tmp/rootfs"}"#;
        let params: ImportImageParams = serde_json::from_str(json).unwrap();
        assert_eq!(params.name, "base");
        assert_eq!(params.source_path, "/tmp/rootfs");
    }

    #[test]
    fn create_workspace_params_deserializes_with_name() {
        let json = r#"{"name": "my-ws", "base": "base-agent"}"#;
        let params: CreateWorkspaceParams = serde_json::from_str(json).unwrap();
        assert_eq!(params.name, Some("my-ws".to_string()));
        assert_eq!(params.base, "base-agent");
    }

    #[test]
    fn create_workspace_params_deserializes_without_name() {
        let json = r#"{"base": "base-agent"}"#;
        let params: CreateWorkspaceParams = serde_json::from_str(json).unwrap();
        assert!(params.name.is_none());
        assert_eq!(params.base, "base-agent");
    }
}
}

Step 2: Export the module from lib.rs

Add pub mod workspace; to nexus/nexus-lib/src/lib.rs:

#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/lib.rs
pub mod client;
pub mod config;
pub mod store;
pub mod vm;
pub mod workspace;

#[cfg(feature = "test-support")]
pub mod test_support;
}

Step 3: Run tests to verify they pass

Run: cd ~/Work/WorkFort/nexus && cargo test -p nexus-lib workspace::tests

Expected: All 5 tests PASS.

Step 4: Commit

cd ~/Work/WorkFort/nexus
git add nexus-lib/src/workspace.rs nexus-lib/src/lib.rs
git commit -m "feat(nexus-lib): add MasterImage and Workspace domain types"

Task 4: WorkspaceBackend Trait

Files:

  • Create: nexus/nexus-lib/src/backend/mod.rs
  • Create: nexus/nexus-lib/src/backend/traits.rs
  • Modify: nexus/nexus-lib/src/lib.rs

This trait abstracts all filesystem operations so btrfs can be swapped for another backend (e.g., plain directory copy, ZFS, OverlayFS) in the future. Similar to how StateStore abstracts over SQLite.

Step 1: Create the backend trait

#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/backend/traits.rs

use std::path::{Path, PathBuf};

/// Errors from workspace backend operations.
#[derive(Debug)]
pub enum BackendError {
    /// The source path does not exist or is inaccessible
    NotFound(String),
    /// A subvolume/image with this name already exists
    AlreadyExists(String),
    /// Filesystem operation failed
    Io(String),
    /// The backend does not support this operation
    Unsupported(String),
}

impl std::fmt::Display for BackendError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            BackendError::NotFound(e) => write!(f, "not found: {e}"),
            BackendError::AlreadyExists(e) => write!(f, "already exists: {e}"),
            BackendError::Io(e) => write!(f, "I/O error: {e}"),
            BackendError::Unsupported(e) => write!(f, "unsupported: {e}"),
        }
    }
}

impl std::error::Error for BackendError {}

/// Information about a subvolume/snapshot returned by the backend.
#[derive(Debug, Clone)]
pub struct SubvolumeInfo {
    pub path: PathBuf,
    pub read_only: bool,
    /// Size in bytes, if available
    pub size_bytes: Option<u64>,
}

/// Filesystem-agnostic trait for workspace storage operations.
///
/// The btrfs implementation uses subvolumes and CoW snapshots.
/// Other implementations could use directory copies, ZFS, OverlayFS, etc.
///
/// This trait is `Send + Sync` so it can be stored in `AppState` and
/// shared across async tasks.
pub trait WorkspaceBackend: Send + Sync {
    /// Import a directory as a new master image subvolume.
    ///
    /// 1. Creates a new subvolume at `dest` (under the workspaces root)
    /// 2. Copies contents from `source` into the subvolume
    /// 3. Marks the subvolume as read-only
    ///
    /// Returns the path to the created subvolume.
    fn import_image(&self, source: &Path, dest: &Path) -> Result<SubvolumeInfo, BackendError>;

    /// Create a snapshot of an existing subvolume.
    ///
    /// For btrfs: `btrfs subvolume snapshot source dest`
    /// The snapshot is writable by default.
    fn create_snapshot(&self, source: &Path, dest: &Path) -> Result<SubvolumeInfo, BackendError>;

    /// Delete a subvolume/snapshot.
    fn delete_subvolume(&self, path: &Path) -> Result<(), BackendError>;

    /// Check if a path is a valid subvolume managed by this backend.
    fn is_subvolume(&self, path: &Path) -> Result<bool, BackendError>;

    /// Get information about a subvolume.
    fn subvolume_info(&self, path: &Path) -> Result<SubvolumeInfo, BackendError>;

    /// Set whether a subvolume is read-only.
    fn set_read_only(&self, path: &Path, read_only: bool) -> Result<(), BackendError>;
}
}

Step 2: Create the backend module

#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/backend/mod.rs

pub mod traits;
}

Step 3: Export from lib.rs

Update nexus/nexus-lib/src/lib.rs:

#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/lib.rs
pub mod backend;
pub mod client;
pub mod config;
pub mod store;
pub mod vm;
pub mod workspace;

#[cfg(feature = "test-support")]
pub mod test_support;
}

Step 4: Verify build

Run: cd ~/Work/WorkFort/nexus && mise run check

Expected: Compiles with no errors.

Step 5: Commit

cd ~/Work/WorkFort/nexus
git add nexus-lib/src/backend/mod.rs nexus-lib/src/backend/traits.rs nexus-lib/src/lib.rs
git commit -m "feat(nexus-lib): add WorkspaceBackend trait for filesystem-agnostic storage operations"

Task 5: BtrfsBackend Implementation

Files:

  • Create: nexus/nexus-lib/src/backend/btrfs.rs
  • Modify: nexus/nexus-lib/src/backend/mod.rs

Step 1: Implement BtrfsBackend

#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/backend/btrfs.rs

use crate::backend::traits::{BackendError, SubvolumeInfo, WorkspaceBackend};
use std::path::{Path, PathBuf};

/// btrfs-backed implementation of WorkspaceBackend.
///
/// Uses libbtrfsutil for subvolume operations. Common operations
/// (create, snapshot) work unprivileged — no CAP_SYS_ADMIN required.
pub struct BtrfsBackend {
    /// Root directory for all workspace subvolumes.
    /// Typically $XDG_DATA_HOME/nexus/workspaces/
    workspaces_root: PathBuf,
}

impl BtrfsBackend {
    pub fn new(workspaces_root: PathBuf) -> Result<Self, BackendError> {
        // Ensure the workspaces root directory exists
        std::fs::create_dir_all(&workspaces_root).map_err(|e| {
            BackendError::Io(format!(
                "cannot create workspaces directory {}: {e}",
                workspaces_root.display()
            ))
        })?;

        Ok(BtrfsBackend { workspaces_root })
    }

    pub fn workspaces_root(&self) -> &Path {
        &self.workspaces_root
    }
}

impl WorkspaceBackend for BtrfsBackend {
    fn import_image(&self, source: &Path, dest: &Path) -> Result<SubvolumeInfo, BackendError> {
        // Validate source exists
        if !source.exists() {
            return Err(BackendError::NotFound(format!(
                "source path does not exist: {}",
                source.display()
            )));
        }

        // Check dest doesn't already exist
        if dest.exists() {
            return Err(BackendError::AlreadyExists(format!(
                "destination already exists: {}",
                dest.display()
            )));
        }

        // Create a subvolume at dest
        libbtrfsutil::create_subvolume(dest).map_err(|e| {
            BackendError::Io(format!(
                "cannot create subvolume at {}: {e}",
                dest.display()
            ))
        })?;

        // Copy contents from source into the new subvolume
        copy_dir_contents(source, dest).map_err(|e| {
            // Clean up the subvolume on failure
            let _ = libbtrfsutil::delete_subvolume(dest);
            BackendError::Io(format!(
                "cannot copy contents from {} to {}: {e}",
                source.display(),
                dest.display()
            ))
        })?;

        // Mark as read-only
        libbtrfsutil::set_subvolume_read_only(dest, true).map_err(|e| {
            BackendError::Io(format!(
                "cannot set read-only on {}: {e}",
                dest.display()
            ))
        })?;

        Ok(SubvolumeInfo {
            path: dest.to_path_buf(),
            read_only: true,
            size_bytes: dir_size(dest).ok(),
        })
    }

    fn create_snapshot(&self, source: &Path, dest: &Path) -> Result<SubvolumeInfo, BackendError> {
        // Validate source is a subvolume
        if !libbtrfsutil::is_subvolume(source).unwrap_or(false) {
            return Err(BackendError::NotFound(format!(
                "source is not a btrfs subvolume: {}",
                source.display()
            )));
        }

        // Check dest doesn't already exist
        if dest.exists() {
            return Err(BackendError::AlreadyExists(format!(
                "destination already exists: {}",
                dest.display()
            )));
        }

        // Create a writable snapshot
        libbtrfsutil::CreateSnapshotOptions::new()
            .create(source, dest)
            .map_err(|e| {
                BackendError::Io(format!(
                    "cannot create snapshot from {} to {}: {e}",
                    source.display(),
                    dest.display()
                ))
            })?;

        Ok(SubvolumeInfo {
            path: dest.to_path_buf(),
            read_only: false,
            size_bytes: dir_size(dest).ok(),
        })
    }

    fn delete_subvolume(&self, path: &Path) -> Result<(), BackendError> {
        if !path.exists() {
            return Err(BackendError::NotFound(format!(
                "subvolume does not exist: {}",
                path.display()
            )));
        }

        // If read-only, make writable first (required for deletion)
        if libbtrfsutil::subvolume_read_only(path).unwrap_or(false) {
            libbtrfsutil::set_subvolume_read_only(path, false).map_err(|e| {
                BackendError::Io(format!(
                    "cannot unset read-only on {}: {e}",
                    path.display()
                ))
            })?;
        }

        libbtrfsutil::delete_subvolume(path).map_err(|e| {
            BackendError::Io(format!(
                "cannot delete subvolume {}: {e}",
                path.display()
            ))
        })
    }

    fn is_subvolume(&self, path: &Path) -> Result<bool, BackendError> {
        Ok(libbtrfsutil::is_subvolume(path).unwrap_or(false))
    }

    fn subvolume_info(&self, path: &Path) -> Result<SubvolumeInfo, BackendError> {
        if !path.exists() {
            return Err(BackendError::NotFound(format!(
                "path does not exist: {}",
                path.display()
            )));
        }

        let read_only = libbtrfsutil::subvolume_read_only(path).map_err(|e| {
            BackendError::Io(format!(
                "cannot query subvolume {}: {e}",
                path.display()
            ))
        })?;

        Ok(SubvolumeInfo {
            path: path.to_path_buf(),
            read_only,
            size_bytes: dir_size(path).ok(),
        })
    }

    fn set_read_only(&self, path: &Path, read_only: bool) -> Result<(), BackendError> {
        libbtrfsutil::set_subvolume_read_only(path, read_only).map_err(|e| {
            BackendError::Io(format!(
                "cannot set read-only={read_only} on {}: {e}",
                path.display()
            ))
        })
    }
}

/// Recursively copy directory contents from src to dest.
/// dest must already exist.
fn copy_dir_contents(src: &Path, dest: &Path) -> std::io::Result<()> {
    for entry in std::fs::read_dir(src)? {
        let entry = entry?;
        let src_path = entry.path();
        let dest_path = dest.join(entry.file_name());

        if entry.file_type()?.is_dir() {
            std::fs::create_dir_all(&dest_path)?;
            copy_dir_contents(&src_path, &dest_path)?;
        } else {
            std::fs::copy(&src_path, &dest_path)?;
        }
    }
    Ok(())
}

/// Estimate directory size by summing file sizes.
fn dir_size(path: &Path) -> std::io::Result<u64> {
    let mut total = 0u64;
    if path.is_file() {
        return Ok(std::fs::metadata(path)?.len());
    }
    for entry in std::fs::read_dir(path)? {
        let entry = entry?;
        let meta = entry.metadata()?;
        if meta.is_file() {
            total += meta.len();
        } else if meta.is_dir() {
            total += dir_size(&entry.path())?;
        }
    }
    Ok(total)
}
}

Step 2: Export from mod.rs

#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/backend/mod.rs

pub mod btrfs;
pub mod traits;
}

Step 3: Verify build

Run: cd ~/Work/WorkFort/nexus && mise run check

Expected: Compiles with no errors.

Step 4: Commit

cd ~/Work/WorkFort/nexus
git add nexus-lib/src/backend/btrfs.rs nexus-lib/src/backend/mod.rs
git commit -m "feat(nexus-lib): implement BtrfsBackend using libbtrfsutil"

Task 6: Image and Workspace Store Methods

Files:

  • Modify: nexus/nexus-lib/src/store/traits.rs
  • Modify: nexus/nexus-lib/src/store/sqlite.rs
  • Modify: nexus/nexusd/src/api.rs (mock stores)

Step 1: Extend the StateStore trait

Add these imports and methods to nexus/nexus-lib/src/store/traits.rs:

#![allow(unused)]
fn main() {
// Add to imports at top of traits.rs
use crate::workspace::{ImportImageParams, MasterImage, Workspace};

// Add these methods to the StateStore trait, after the existing VM methods:

    // --- Master Image methods ---

    /// Register a master image in the database.
    fn create_image(&self, params: &ImportImageParams, subvolume_path: &str) -> Result<MasterImage, StoreError>;

    /// List all master images.
    fn list_images(&self) -> Result<Vec<MasterImage>, StoreError>;

    /// Get a master image by name or ID.
    fn get_image(&self, name_or_id: &str) -> Result<Option<MasterImage>, StoreError>;

    /// Delete a master image by name or ID.
    /// Returns true if deleted, false if not found.
    /// Fails with Conflict if workspaces reference this image.
    fn delete_image(&self, name_or_id: &str) -> Result<bool, StoreError>;

    // --- Workspace methods ---

    /// Register a workspace in the database.
    fn create_workspace(
        &self,
        name: Option<&str>,
        subvolume_path: &str,
        master_image_id: &str,
    ) -> Result<Workspace, StoreError>;

    /// List all workspaces, optionally filtered by master image name.
    fn list_workspaces(&self, base: Option<&str>) -> Result<Vec<Workspace>, StoreError>;

    /// Get a workspace by name or ID.
    fn get_workspace(&self, name_or_id: &str) -> Result<Option<Workspace>, StoreError>;

    /// Delete a workspace by name or ID.
    /// Returns true if deleted, false if not found.
    /// Fails with Conflict if workspace is attached to a VM.
    fn delete_workspace(&self, name_or_id: &str) -> Result<bool, StoreError>;
}

Step 2: Write the tests in sqlite.rs

Add these tests to the tests module in nexus/nexus-lib/src/store/sqlite.rs:

#![allow(unused)]
fn main() {
    use crate::workspace::ImportImageParams;

    #[test]
    fn create_image_and_get_by_name() {
        let dir = tempfile::tempdir().unwrap();
        let db_path = dir.path().join("test.db");
        let store = SqliteStore::open_and_init(&db_path).unwrap();

        let params = ImportImageParams {
            name: "base-agent".to_string(),
            source_path: "/tmp/rootfs".to_string(),
        };
        let img = store.create_image(&params, "/data/workspaces/@base-agent").unwrap();

        assert!(!img.id.is_empty());
        assert_eq!(img.name, "base-agent");
        assert_eq!(img.subvolume_path, "/data/workspaces/@base-agent");

        let found = store.get_image("base-agent").unwrap().unwrap();
        assert_eq!(found.id, img.id);
    }

    #[test]
    fn create_image_duplicate_name_fails() {
        let dir = tempfile::tempdir().unwrap();
        let db_path = dir.path().join("test.db");
        let store = SqliteStore::open_and_init(&db_path).unwrap();

        let params = ImportImageParams {
            name: "dup-img".to_string(),
            source_path: "/tmp/a".to_string(),
        };
        store.create_image(&params, "/data/a").unwrap();
        let result = store.create_image(&params, "/data/b");
        assert!(result.is_err());
    }

    #[test]
    fn list_images_returns_all() {
        let dir = tempfile::tempdir().unwrap();
        let db_path = dir.path().join("test.db");
        let store = SqliteStore::open_and_init(&db_path).unwrap();

        store.create_image(
            &ImportImageParams { name: "img-a".to_string(), source_path: "/a".to_string() },
            "/data/a",
        ).unwrap();
        store.create_image(
            &ImportImageParams { name: "img-b".to_string(), source_path: "/b".to_string() },
            "/data/b",
        ).unwrap();

        let imgs = store.list_images().unwrap();
        assert_eq!(imgs.len(), 2);
    }

    #[test]
    fn delete_image_removes_record() {
        let dir = tempfile::tempdir().unwrap();
        let db_path = dir.path().join("test.db");
        let store = SqliteStore::open_and_init(&db_path).unwrap();

        store.create_image(
            &ImportImageParams { name: "del-me".to_string(), source_path: "/a".to_string() },
            "/data/del-me",
        ).unwrap();

        let deleted = store.delete_image("del-me").unwrap();
        assert!(deleted);
        assert!(store.get_image("del-me").unwrap().is_none());
    }

    #[test]
    fn delete_image_not_found_returns_false() {
        let dir = tempfile::tempdir().unwrap();
        let db_path = dir.path().join("test.db");
        let store = SqliteStore::open_and_init(&db_path).unwrap();

        assert!(!store.delete_image("ghost").unwrap());
    }

    #[test]
    fn delete_image_with_workspaces_fails() {
        let dir = tempfile::tempdir().unwrap();
        let db_path = dir.path().join("test.db");
        let store = SqliteStore::open_and_init(&db_path).unwrap();

        let img = store.create_image(
            &ImportImageParams { name: "base".to_string(), source_path: "/a".to_string() },
            "/data/base",
        ).unwrap();

        store.create_workspace(Some("ws-1"), "/data/ws-1", &img.id).unwrap();

        let result = store.delete_image("base");
        assert!(result.is_err());
    }

    #[test]
    fn create_workspace_and_get_by_name() {
        let dir = tempfile::tempdir().unwrap();
        let db_path = dir.path().join("test.db");
        let store = SqliteStore::open_and_init(&db_path).unwrap();

        let img = store.create_image(
            &ImportImageParams { name: "base".to_string(), source_path: "/a".to_string() },
            "/data/base",
        ).unwrap();

        let ws = store.create_workspace(Some("my-ws"), "/data/my-ws", &img.id).unwrap();

        assert!(!ws.id.is_empty());
        assert_eq!(ws.name, Some("my-ws".to_string()));
        assert_eq!(ws.master_image_id, Some(img.id.clone()));

        let found = store.get_workspace("my-ws").unwrap().unwrap();
        assert_eq!(found.id, ws.id);
    }

    #[test]
    fn create_workspace_auto_generates_name_when_none() {
        let dir = tempfile::tempdir().unwrap();
        let db_path = dir.path().join("test.db");
        let store = SqliteStore::open_and_init(&db_path).unwrap();

        let img = store.create_image(
            &ImportImageParams { name: "base".to_string(), source_path: "/a".to_string() },
            "/data/base",
        ).unwrap();

        let ws = store.create_workspace(None, "/data/anon-ws", &img.id).unwrap();
        assert!(ws.name.is_none());
    }

    #[test]
    fn list_workspaces_returns_all() {
        let dir = tempfile::tempdir().unwrap();
        let db_path = dir.path().join("test.db");
        let store = SqliteStore::open_and_init(&db_path).unwrap();

        let img = store.create_image(
            &ImportImageParams { name: "base".to_string(), source_path: "/a".to_string() },
            "/data/base",
        ).unwrap();

        store.create_workspace(Some("ws-a"), "/data/ws-a", &img.id).unwrap();
        store.create_workspace(Some("ws-b"), "/data/ws-b", &img.id).unwrap();

        let wss = store.list_workspaces(None).unwrap();
        assert_eq!(wss.len(), 2);
    }

    #[test]
    fn list_workspaces_filter_by_base() {
        let dir = tempfile::tempdir().unwrap();
        let db_path = dir.path().join("test.db");
        let store = SqliteStore::open_and_init(&db_path).unwrap();

        let img_a = store.create_image(
            &ImportImageParams { name: "base-a".to_string(), source_path: "/a".to_string() },
            "/data/base-a",
        ).unwrap();
        let img_b = store.create_image(
            &ImportImageParams { name: "base-b".to_string(), source_path: "/b".to_string() },
            "/data/base-b",
        ).unwrap();

        store.create_workspace(Some("ws-a"), "/data/ws-a", &img_a.id).unwrap();
        store.create_workspace(Some("ws-b"), "/data/ws-b", &img_b.id).unwrap();

        let wss = store.list_workspaces(Some("base-a")).unwrap();
        assert_eq!(wss.len(), 1);
        assert_eq!(wss[0].name, Some("ws-a".to_string()));
    }

    #[test]
    fn delete_workspace_removes_record() {
        let dir = tempfile::tempdir().unwrap();
        let db_path = dir.path().join("test.db");
        let store = SqliteStore::open_and_init(&db_path).unwrap();

        let img = store.create_image(
            &ImportImageParams { name: "base".to_string(), source_path: "/a".to_string() },
            "/data/base",
        ).unwrap();

        store.create_workspace(Some("del-ws"), "/data/del-ws", &img.id).unwrap();

        let deleted = store.delete_workspace("del-ws").unwrap();
        assert!(deleted);
        assert!(store.get_workspace("del-ws").unwrap().is_none());
    }

    #[test]
    fn delete_workspace_not_found_returns_false() {
        let dir = tempfile::tempdir().unwrap();
        let db_path = dir.path().join("test.db");
        let store = SqliteStore::open_and_init(&db_path).unwrap();

        assert!(!store.delete_workspace("ghost").unwrap());
    }
}

Step 3: Run tests to verify they fail

Run: cd ~/Work/WorkFort/nexus && cargo test -p nexus-lib store::sqlite::tests::create_image

Expected: FAIL — create_image does not exist on SqliteStore yet.

Step 4: Implement the store methods

Add these imports at the top of nexus/nexus-lib/src/store/sqlite.rs:

#![allow(unused)]
fn main() {
use crate::workspace::{ImportImageParams, MasterImage, Workspace};
}

Add the image and workspace CRUD implementation to impl StateStore for SqliteStore:

#![allow(unused)]
fn main() {
    fn create_image(&self, params: &ImportImageParams, subvolume_path: &str) -> Result<MasterImage, StoreError> {
        let conn = self.conn.lock().unwrap();
        let id = Uuid::new_v4().to_string();

        conn.execute(
            "INSERT INTO master_images (id, name, subvolume_path) VALUES (?1, ?2, ?3)",
            rusqlite::params![id, params.name, subvolume_path],
        )
        .map_err(|e| {
            if let rusqlite::Error::SqliteFailure(err, _) = &e {
                if err.code == rusqlite::ErrorCode::ConstraintViolation {
                    return StoreError::Conflict(format!("image name '{}' already exists", params.name));
                }
            }
            StoreError::Query(format!("cannot insert image: {e}"))
        })?;

        drop(conn);
        self.get_image(&id)?
            .ok_or_else(|| StoreError::Query("image not found after insert".to_string()))
    }

    fn list_images(&self) -> Result<Vec<MasterImage>, StoreError> {
        let conn = self.conn.lock().unwrap();
        let mut stmt = conn
            .prepare("SELECT id, name, subvolume_path, size_bytes, created_at FROM master_images ORDER BY created_at DESC")
            .map_err(|e| StoreError::Query(format!("cannot prepare image list query: {e}")))?;

        let images = stmt
            .query_map([], |row| Ok(row_to_image(row)))
            .map_err(|e| StoreError::Query(format!("cannot list images: {e}")))?
            .collect::<Result<Vec<_>, _>>()
            .map_err(|e| StoreError::Query(format!("cannot read image row: {e}")))?;

        Ok(images)
    }

    fn get_image(&self, name_or_id: &str) -> Result<Option<MasterImage>, StoreError> {
        let conn = self.conn.lock().unwrap();
        let mut stmt = conn
            .prepare("SELECT id, name, subvolume_path, size_bytes, created_at FROM master_images WHERE id = ?1 OR name = ?1")
            .map_err(|e| StoreError::Query(format!("cannot prepare image get query: {e}")))?;

        let mut rows = stmt
            .query_map([name_or_id], |row| Ok(row_to_image(row)))
            .map_err(|e| StoreError::Query(format!("cannot get image: {e}")))?;

        match rows.next() {
            Some(Ok(img)) => Ok(Some(img)),
            Some(Err(e)) => Err(StoreError::Query(format!("cannot read image row: {e}"))),
            None => Ok(None),
        }
    }

    fn delete_image(&self, name_or_id: &str) -> Result<bool, StoreError> {
        let image = match self.get_image(name_or_id)? {
            Some(img) => img,
            None => return Ok(false),
        };

        // Check for workspaces referencing this image
        let conn = self.conn.lock().unwrap();
        let ws_count: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM workspaces WHERE master_image_id = ?1",
                [&image.id],
                |row| row.get(0),
            )
            .map_err(|e| StoreError::Query(format!("cannot check workspace references: {e}")))?;

        if ws_count > 0 {
            return Err(StoreError::Conflict(format!(
                "cannot delete image '{}': {} workspace(s) reference it, delete them first",
                image.name, ws_count
            )));
        }

        let deleted = conn
            .execute(
                "DELETE FROM master_images WHERE id = ?1",
                [&image.id],
            )
            .map_err(|e| StoreError::Query(format!("cannot delete image: {e}")))?;

        Ok(deleted > 0)
    }

    fn create_workspace(
        &self,
        name: Option<&str>,
        subvolume_path: &str,
        master_image_id: &str,
    ) -> Result<Workspace, StoreError> {
        let conn = self.conn.lock().unwrap();
        let id = Uuid::new_v4().to_string();

        conn.execute(
            "INSERT INTO workspaces (id, name, subvolume_path, master_image_id) VALUES (?1, ?2, ?3, ?4)",
            rusqlite::params![id, name, subvolume_path, master_image_id],
        )
        .map_err(|e| {
            if let rusqlite::Error::SqliteFailure(err, _) = &e {
                if err.code == rusqlite::ErrorCode::ConstraintViolation {
                    return StoreError::Conflict(format!(
                        "workspace name '{}' already exists",
                        name.unwrap_or("(unnamed)")
                    ));
                }
            }
            StoreError::Query(format!("cannot insert workspace: {e}"))
        })?;

        drop(conn);
        self.get_workspace(&id)?
            .ok_or_else(|| StoreError::Query("workspace not found after insert".to_string()))
    }

    fn list_workspaces(&self, base: Option<&str>) -> Result<Vec<Workspace>, StoreError> {
        let conn = self.conn.lock().unwrap();

        let (sql, params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = match base {
            Some(base_name) => {
                (
                    "SELECT w.id, w.name, w.vm_id, w.subvolume_path, w.master_image_id, \
                     w.parent_workspace_id, w.size_bytes, w.is_root_device, w.is_read_only, \
                     w.attached_at, w.detached_at, w.created_at \
                     FROM workspaces w \
                     JOIN master_images m ON w.master_image_id = m.id \
                     WHERE m.name = ? \
                     ORDER BY w.created_at DESC".to_string(),
                    vec![Box::new(base_name.to_string()) as Box<dyn rusqlite::types::ToSql>],
                )
            }
            None => {
                (
                    "SELECT id, name, vm_id, subvolume_path, master_image_id, \
                     parent_workspace_id, size_bytes, is_root_device, is_read_only, \
                     attached_at, detached_at, created_at \
                     FROM workspaces ORDER BY created_at DESC".to_string(),
                    vec![],
                )
            }
        };

        let mut stmt = conn.prepare(&sql)
            .map_err(|e| StoreError::Query(format!("cannot prepare workspace list query: {e}")))?;

        let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
        let workspaces = stmt
            .query_map(param_refs.as_slice(), |row| Ok(row_to_workspace(row)))
            .map_err(|e| StoreError::Query(format!("cannot list workspaces: {e}")))?
            .collect::<Result<Vec<_>, _>>()
            .map_err(|e| StoreError::Query(format!("cannot read workspace row: {e}")))?;

        Ok(workspaces)
    }

    fn get_workspace(&self, name_or_id: &str) -> Result<Option<Workspace>, StoreError> {
        let conn = self.conn.lock().unwrap();
        let mut stmt = conn
            .prepare(
                "SELECT id, name, vm_id, subvolume_path, master_image_id, \
                 parent_workspace_id, size_bytes, is_root_device, is_read_only, \
                 attached_at, detached_at, created_at \
                 FROM workspaces WHERE id = ?1 OR name = ?1",
            )
            .map_err(|e| StoreError::Query(format!("cannot prepare workspace get query: {e}")))?;

        let mut rows = stmt
            .query_map([name_or_id], |row| Ok(row_to_workspace(row)))
            .map_err(|e| StoreError::Query(format!("cannot get workspace: {e}")))?;

        match rows.next() {
            Some(Ok(ws)) => Ok(Some(ws)),
            Some(Err(e)) => Err(StoreError::Query(format!("cannot read workspace row: {e}"))),
            None => Ok(None),
        }
    }

    fn delete_workspace(&self, name_or_id: &str) -> Result<bool, StoreError> {
        let ws = match self.get_workspace(name_or_id)? {
            Some(ws) => ws,
            None => return Ok(false),
        };

        // Cannot delete workspace attached to a VM
        if ws.vm_id.is_some() {
            return Err(StoreError::Conflict(format!(
                "cannot delete workspace '{}': attached to VM, detach it first",
                ws.name.as_deref().unwrap_or(&ws.id)
            )));
        }

        let conn = self.conn.lock().unwrap();
        let deleted = conn
            .execute("DELETE FROM workspaces WHERE id = ?1", [&ws.id])
            .map_err(|e| StoreError::Query(format!("cannot delete workspace: {e}")))?;

        Ok(deleted > 0)
    }
}

Add the row mapping helper functions (outside the impl block):

#![allow(unused)]
fn main() {
fn row_to_image(row: &rusqlite::Row) -> MasterImage {
    MasterImage {
        id: row.get(0).unwrap(),
        name: row.get(1).unwrap(),
        subvolume_path: row.get(2).unwrap(),
        size_bytes: row.get(3).unwrap(),
        created_at: row.get(4).unwrap(),
    }
}

fn row_to_workspace(row: &rusqlite::Row) -> Workspace {
    Workspace {
        id: row.get(0).unwrap(),
        name: row.get(1).unwrap(),
        vm_id: row.get(2).unwrap(),
        subvolume_path: row.get(3).unwrap(),
        master_image_id: row.get(4).unwrap(),
        parent_workspace_id: row.get(5).unwrap(),
        size_bytes: row.get(6).unwrap(),
        is_root_device: row.get::<_, i32>(7).unwrap() != 0,
        is_read_only: row.get::<_, i32>(8).unwrap() != 0,
        attached_at: row.get(9).unwrap(),
        detached_at: row.get(10).unwrap(),
        created_at: row.get(11).unwrap(),
    }
}
}

Step 5: Update MockStore and FailingStore in nexusd/src/api.rs

Add stub implementations of the new trait methods to MockStore and FailingStore:

#![allow(unused)]
fn main() {
    fn create_image(&self, _params: &ImportImageParams, _subvolume_path: &str) -> Result<MasterImage, StoreError> {
        unimplemented!()
    }
    fn list_images(&self) -> Result<Vec<MasterImage>, StoreError> {
        unimplemented!()
    }
    fn get_image(&self, _name_or_id: &str) -> Result<Option<MasterImage>, StoreError> {
        unimplemented!()
    }
    fn delete_image(&self, _name_or_id: &str) -> Result<bool, StoreError> {
        unimplemented!()
    }
    fn create_workspace(&self, _name: Option<&str>, _subvolume_path: &str, _master_image_id: &str) -> Result<Workspace, StoreError> {
        unimplemented!()
    }
    fn list_workspaces(&self, _base: Option<&str>) -> Result<Vec<Workspace>, StoreError> {
        unimplemented!()
    }
    fn get_workspace(&self, _name_or_id: &str) -> Result<Option<Workspace>, StoreError> {
        unimplemented!()
    }
    fn delete_workspace(&self, _name_or_id: &str) -> Result<bool, StoreError> {
        unimplemented!()
    }
}

Add the import to the test module:

#![allow(unused)]
fn main() {
    use nexus_lib::workspace::{ImportImageParams, MasterImage, Workspace};
}

Step 6: Run tests to verify they pass

Run: cd ~/Work/WorkFort/nexus && cargo test -p nexus-lib store::sqlite::tests

Expected: All tests PASS (existing VM tests + new image/workspace tests).

Step 7: Run full workspace tests

Run: cd ~/Work/WorkFort/nexus && mise run test

Expected: All tests PASS.

Step 8: Commit

cd ~/Work/WorkFort/nexus
git add nexus-lib/src/store/traits.rs nexus-lib/src/store/sqlite.rs nexusd/src/api.rs
git commit -m "feat(nexus-lib): implement image and workspace CRUD in SqliteStore"

Task 7: Workspace Service — Orchestrates Backend + Store

Files:

  • Create: nexus/nexus-lib/src/workspace_service.rs
  • Modify: nexus/nexus-lib/src/lib.rs

The workspace service coordinates between the WorkspaceBackend (filesystem) and StateStore (database). It is the single point of orchestration: import an image = create subvolume + register in DB. Create workspace = snapshot subvolume + register in DB. Delete = remove from DB + delete subvolume.

Step 1: Create the workspace service

#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/workspace_service.rs

use crate::backend::traits::{BackendError, WorkspaceBackend};
use crate::store::traits::{StateStore, StoreError};
use crate::workspace::{ImportImageParams, MasterImage, Workspace};
use std::path::PathBuf;
use uuid::Uuid;

/// Errors from workspace service operations.
#[derive(Debug)]
pub enum WorkspaceServiceError {
    Store(StoreError),
    Backend(BackendError),
    /// A referenced entity (e.g., master image) was not found.
    NotFound(String),
}

impl std::fmt::Display for WorkspaceServiceError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            WorkspaceServiceError::Store(e) => write!(f, "{e}"),
            WorkspaceServiceError::Backend(e) => write!(f, "{e}"),
            WorkspaceServiceError::NotFound(e) => write!(f, "not found: {e}"),
        }
    }
}

impl std::error::Error for WorkspaceServiceError {}

impl From<StoreError> for WorkspaceServiceError {
    fn from(e: StoreError) -> Self {
        WorkspaceServiceError::Store(e)
    }
}

impl From<BackendError> for WorkspaceServiceError {
    fn from(e: BackendError) -> Self {
        WorkspaceServiceError::Backend(e)
    }
}

/// Orchestrates workspace operations between the filesystem backend and the database.
pub struct WorkspaceService<'a> {
    store: &'a dyn StateStore,
    backend: &'a dyn WorkspaceBackend,
    workspaces_root: PathBuf,
}

impl<'a> WorkspaceService<'a> {
    pub fn new(
        store: &'a dyn StateStore,
        backend: &'a dyn WorkspaceBackend,
        workspaces_root: PathBuf,
    ) -> Self {
        WorkspaceService {
            store,
            backend,
            workspaces_root,
        }
    }

    /// Import a directory as a master image.
    ///
    /// 1. Creates a btrfs subvolume from the source directory
    /// 2. Marks it read-only
    /// 3. Registers in the database
    pub fn import_image(&self, params: &ImportImageParams) -> Result<MasterImage, WorkspaceServiceError> {
        let source = PathBuf::from(&params.source_path);
        let dest = self.workspaces_root.join(format!("@{}", params.name));

        // Create the subvolume on disk (copies source, marks read-only)
        let info = self.backend.import_image(&source, &dest)?;

        // Register in database; roll back subvolume on failure
        let image = match self.store.create_image(params, &dest.to_string_lossy()) {
            Ok(img) => img,
            Err(e) => {
                let _ = self.backend.delete_subvolume(&dest);
                return Err(e.into());
            }
        };

        Ok(image)
    }

    /// Create a workspace by snapshotting a master image.
    ///
    /// 1. Looks up the master image by name
    /// 2. Creates a btrfs snapshot
    /// 3. Registers in the database
    pub fn create_workspace(
        &self,
        base_name: &str,
        ws_name: Option<&str>,
    ) -> Result<Workspace, WorkspaceServiceError> {
        // Look up the master image
        let image = self.store.get_image(base_name)?
            .ok_or_else(|| WorkspaceServiceError::NotFound(
                format!("master image '{}' not found", base_name)
            ))?;

        let source = PathBuf::from(&image.subvolume_path);
        let snap_name = match ws_name {
            Some(name) => name.to_string(),
            None => format!("{}-{}", base_name, &Uuid::new_v4().to_string()[..8]),
        };
        let dest = self.workspaces_root.join(format!("@{}", snap_name));

        // Create the snapshot on disk
        self.backend.create_snapshot(&source, &dest)?;

        // Register in database; roll back snapshot on failure
        let workspace = match self.store.create_workspace(
            ws_name,
            &dest.to_string_lossy(),
            &image.id,
        ) {
            Ok(ws) => ws,
            Err(e) => {
                let _ = self.backend.delete_subvolume(&dest);
                return Err(e.into());
            }
        };

        Ok(workspace)
    }

    /// Delete a workspace: remove from DB, then delete the subvolume.
    pub fn delete_workspace(&self, name_or_id: &str) -> Result<bool, WorkspaceServiceError> {
        let ws = match self.store.get_workspace(name_or_id)? {
            Some(ws) => ws,
            None => return Ok(false),
        };

        // Delete from DB first (validates constraints like attached-to-VM)
        self.store.delete_workspace(&ws.id)?;

        // Delete from filesystem
        let path = PathBuf::from(&ws.subvolume_path);
        if path.exists() {
            self.backend.delete_subvolume(&path)?;
        }

        Ok(true)
    }

    /// Delete a master image: ensure no workspaces reference it, then remove.
    pub fn delete_image(&self, name_or_id: &str) -> Result<bool, WorkspaceServiceError> {
        let image = match self.store.get_image(name_or_id)? {
            Some(img) => img,
            None => return Ok(false),
        };

        // Delete from DB first (validates constraints like workspace references)
        self.store.delete_image(&image.id)?;

        // Delete from filesystem
        let path = PathBuf::from(&image.subvolume_path);
        if path.exists() {
            self.backend.delete_subvolume(&path)?;
        }

        Ok(true)
    }

    /// List all master images (delegates to store).
    pub fn list_images(&self) -> Result<Vec<MasterImage>, WorkspaceServiceError> {
        Ok(self.store.list_images()?)
    }

    /// Get a master image by name or ID (delegates to store).
    pub fn get_image(&self, name_or_id: &str) -> Result<Option<MasterImage>, WorkspaceServiceError> {
        Ok(self.store.get_image(name_or_id)?)
    }

    /// List all workspaces, optionally filtered (delegates to store).
    pub fn list_workspaces(&self, base: Option<&str>) -> Result<Vec<Workspace>, WorkspaceServiceError> {
        Ok(self.store.list_workspaces(base)?)
    }

    /// Get a workspace by name or ID (delegates to store).
    pub fn get_workspace(&self, name_or_id: &str) -> Result<Option<Workspace>, WorkspaceServiceError> {
        Ok(self.store.get_workspace(name_or_id)?)
    }
}
}

Step 2: Export from lib.rs

#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/lib.rs
pub mod backend;
pub mod client;
pub mod config;
pub mod store;
pub mod vm;
pub mod workspace;
pub mod workspace_service;

#[cfg(feature = "test-support")]
pub mod test_support;
}

Step 3: Verify build

Run: cd ~/Work/WorkFort/nexus && mise run check

Expected: Compiles with no errors.

Step 4: Commit

cd ~/Work/WorkFort/nexus
git add nexus-lib/src/workspace_service.rs nexus-lib/src/lib.rs
git commit -m "feat(nexus-lib): add WorkspaceService orchestrating backend + store"

Task 8: Add Workspaces Root to Config

Files:

  • Modify: nexus/nexus-lib/src/config.rs

Step 1: Add workspaces_root to the daemon Config

Add a storage section to the config:

#![allow(unused)]
fn main() {
// In nexus/nexus-lib/src/config.rs

#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct Config {
    pub api: ApiConfig,
    pub storage: StorageConfig,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct StorageConfig {
    pub workspaces: String,
}

impl Default for StorageConfig {
    fn default() -> Self {
        let data_dir = dirs::data_dir()
            .expect("cannot determine XDG_DATA_HOME")
            .join("nexus")
            .join("workspaces");
        StorageConfig {
            workspaces: data_dir.to_string_lossy().to_string(),
        }
    }
}
}

Add a helper:

#![allow(unused)]
fn main() {
/// Returns the default workspaces path: $XDG_DATA_HOME/nexus/workspaces
pub fn default_workspaces_path() -> PathBuf {
    let data_dir = dirs::data_dir()
        .expect("cannot determine XDG_DATA_HOME")
        .join("nexus");
    data_dir.join("workspaces")
}
}

Step 2: Add tests

#![allow(unused)]
fn main() {
    #[test]
    fn storage_config_defaults() {
        let config = Config::default();
        assert!(config.storage.workspaces.contains("nexus/workspaces"));
    }

    #[test]
    fn config_with_storage_section_deserializes() {
        let yaml = r#"
api:
  listen: "127.0.0.1:8080"
storage:
  workspaces: "/mnt/btrfs/nexus/workspaces"
"#;
        let config: Config = serde_norway::from_str(yaml).unwrap();
        assert_eq!(config.storage.workspaces, "/mnt/btrfs/nexus/workspaces");
    }
}

Step 3: Run tests

Run: cd ~/Work/WorkFort/nexus && cargo test -p nexus-lib config::tests

Expected: All tests PASS.

Step 4: Commit

cd ~/Work/WorkFort/nexus
git add nexus-lib/src/config.rs
git commit -m "feat(nexus-lib): add storage.workspaces config with XDG default"

Task 9: REST API Endpoints for Images and Workspaces

Files:

  • Modify: nexus/nexusd/src/api.rs
  • Modify: nexus/nexusd/src/main.rs

Step 1: Update AppState to include WorkspaceBackend

Update nexus/nexusd/src/api.rs to add the workspace backend and workspaces root to AppState:

#![allow(unused)]
fn main() {
// nexus/nexusd/src/api.rs — update AppState
use nexus_lib::backend::traits::WorkspaceBackend;
use nexus_lib::workspace::{ImportImageParams, CreateWorkspaceParams, MasterImage, Workspace};
use nexus_lib::workspace_service::{WorkspaceService, WorkspaceServiceError};
use std::path::PathBuf;

pub struct AppState {
    pub store: Box<dyn StateStore + Send + Sync>,
    pub backend: Box<dyn WorkspaceBackend>,
    pub workspaces_root: PathBuf,
}
}

Step 2: Add the image and workspace handlers

#![allow(unused)]
fn main() {
async fn import_image(
    State(state): State<Arc<AppState>>,
    Json(params): Json<ImportImageParams>,
) -> (StatusCode, Json<serde_json::Value>) {
    let svc = WorkspaceService::new(
        state.store.as_ref(),
        state.backend.as_ref(),
        state.workspaces_root.clone(),
    );
    match svc.import_image(&params) {
        Ok(img) => (StatusCode::CREATED, Json(serde_json::to_value(img).unwrap())),
        Err(WorkspaceServiceError::Store(StoreError::Conflict(msg))) => (
            StatusCode::CONFLICT,
            Json(serde_json::json!({"error": msg})),
        ),
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({"error": e.to_string()})),
        ),
    }
}

async fn list_images(
    State(state): State<Arc<AppState>>,
) -> (StatusCode, Json<serde_json::Value>) {
    let svc = WorkspaceService::new(
        state.store.as_ref(),
        state.backend.as_ref(),
        state.workspaces_root.clone(),
    );
    match svc.list_images() {
        Ok(imgs) => (StatusCode::OK, Json(serde_json::to_value(imgs).unwrap())),
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({"error": e.to_string()})),
        ),
    }
}

async fn get_image(
    State(state): State<Arc<AppState>>,
    Path(name_or_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
    let svc = WorkspaceService::new(
        state.store.as_ref(),
        state.backend.as_ref(),
        state.workspaces_root.clone(),
    );
    match svc.get_image(&name_or_id) {
        Ok(Some(img)) => (StatusCode::OK, Json(serde_json::to_value(img).unwrap())),
        Ok(None) => (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({"error": format!("image '{}' not found", name_or_id)})),
        ),
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({"error": e.to_string()})),
        ),
    }
}

async fn delete_image_handler(
    State(state): State<Arc<AppState>>,
    Path(name_or_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
    let svc = WorkspaceService::new(
        state.store.as_ref(),
        state.backend.as_ref(),
        state.workspaces_root.clone(),
    );
    match svc.delete_image(&name_or_id) {
        Ok(true) => (StatusCode::NO_CONTENT, Json(serde_json::json!(null))),
        Ok(false) => (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({"error": format!("image '{}' not found", name_or_id)})),
        ),
        Err(WorkspaceServiceError::Store(StoreError::Conflict(msg))) => (
            StatusCode::CONFLICT,
            Json(serde_json::json!({"error": msg})),
        ),
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({"error": e.to_string()})),
        ),
    }
}

async fn create_workspace_handler(
    State(state): State<Arc<AppState>>,
    Json(params): Json<CreateWorkspaceParams>,
) -> (StatusCode, Json<serde_json::Value>) {
    let svc = WorkspaceService::new(
        state.store.as_ref(),
        state.backend.as_ref(),
        state.workspaces_root.clone(),
    );
    match svc.create_workspace(&params.base, params.name.as_deref()) {
        Ok(ws) => (StatusCode::CREATED, Json(serde_json::to_value(ws).unwrap())),
        Err(WorkspaceServiceError::NotFound(msg)) => (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({"error": msg})),
        ),
        Err(WorkspaceServiceError::Store(StoreError::Conflict(msg))) => (
            StatusCode::CONFLICT,
            Json(serde_json::json!({"error": msg})),
        ),
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({"error": e.to_string()})),
        ),
    }
}

async fn list_workspaces(
    State(state): State<Arc<AppState>>,
    axum::extract::Query(query): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> (StatusCode, Json<serde_json::Value>) {
    let base = query.get("base").map(|s| s.as_str());
    let svc = WorkspaceService::new(
        state.store.as_ref(),
        state.backend.as_ref(),
        state.workspaces_root.clone(),
    );
    match svc.list_workspaces(base) {
        Ok(wss) => (StatusCode::OK, Json(serde_json::to_value(wss).unwrap())),
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({"error": e.to_string()})),
        ),
    }
}

async fn get_workspace(
    State(state): State<Arc<AppState>>,
    Path(name_or_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
    let svc = WorkspaceService::new(
        state.store.as_ref(),
        state.backend.as_ref(),
        state.workspaces_root.clone(),
    );
    match svc.get_workspace(&name_or_id) {
        Ok(Some(ws)) => (StatusCode::OK, Json(serde_json::to_value(ws).unwrap())),
        Ok(None) => (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({"error": format!("workspace '{}' not found", name_or_id)})),
        ),
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({"error": e.to_string()})),
        ),
    }
}

async fn delete_workspace_handler(
    State(state): State<Arc<AppState>>,
    Path(name_or_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
    let svc = WorkspaceService::new(
        state.store.as_ref(),
        state.backend.as_ref(),
        state.workspaces_root.clone(),
    );
    match svc.delete_workspace(&name_or_id) {
        Ok(true) => (StatusCode::NO_CONTENT, Json(serde_json::json!(null))),
        Ok(false) => (
            StatusCode::NOT_FOUND,
            Json(serde_json::json!({"error": format!("workspace '{}' not found", name_or_id)})),
        ),
        Err(WorkspaceServiceError::Store(StoreError::Conflict(msg))) => (
            StatusCode::CONFLICT,
            Json(serde_json::json!({"error": msg})),
        ),
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({"error": e.to_string()})),
        ),
    }
}
}

Step 3: Update the router

#![allow(unused)]
fn main() {
pub fn router(state: Arc<AppState>) -> Router {
    Router::new()
        .route("/v1/health", get(health))
        .route("/v1/vms", post(create_vm).get(list_vms))
        .route("/v1/vms/{name_or_id}", get(get_vm).delete(delete_vm))
        .route("/v1/images", post(import_image).get(list_images))
        .route("/v1/images/{name_or_id}", get(get_image).delete(delete_image_handler))
        .route("/v1/workspaces", post(create_workspace_handler).get(list_workspaces))
        .route("/v1/workspaces/{name_or_id}", get(get_workspace).delete(delete_workspace_handler))
        .with_state(state)
}
}

Step 4: Update nexusd main.rs

Update nexus/nexusd/src/main.rs to initialize the BtrfsBackend and pass it to AppState:

#![allow(unused)]
fn main() {
// nexus/nexusd/src/main.rs — add imports
use nexus_lib::backend::btrfs::BtrfsBackend;

// In main(), after initializing the store:
    let workspaces_root = std::path::PathBuf::from(&config.storage.workspaces);

    let backend = match BtrfsBackend::new(workspaces_root.clone()) {
        Ok(backend) => {
            info!(workspaces = %workspaces_root.display(), "workspace backend initialized");
            backend
        }
        Err(e) => {
            error!(error = %e, "failed to initialize workspace backend");
            std::process::exit(1);
        }
    };

    let state = Arc::new(api::AppState {
        store: Box::new(store),
        backend: Box::new(backend),
        workspaces_root,
    });
}

Step 5: Update test helper test_state() in api.rs tests

Since tests don’t run on a btrfs filesystem, create a mock WorkspaceBackend for API tests:

#![allow(unused)]
fn main() {
    use nexus_lib::backend::traits::{BackendError, SubvolumeInfo, WorkspaceBackend};
    use std::path::PathBuf;

    /// A no-op backend for API unit tests (no real btrfs needed).
    struct MockBackend;

    impl WorkspaceBackend for MockBackend {
        fn import_image(&self, _source: &std::path::Path, dest: &std::path::Path) -> Result<SubvolumeInfo, BackendError> {
            Ok(SubvolumeInfo { path: dest.to_path_buf(), read_only: true, size_bytes: None })
        }
        fn create_snapshot(&self, _source: &std::path::Path, dest: &std::path::Path) -> Result<SubvolumeInfo, BackendError> {
            Ok(SubvolumeInfo { path: dest.to_path_buf(), read_only: false, size_bytes: None })
        }
        fn delete_subvolume(&self, _path: &std::path::Path) -> Result<(), BackendError> { Ok(()) }
        fn is_subvolume(&self, _path: &std::path::Path) -> Result<bool, BackendError> { Ok(true) }
        fn subvolume_info(&self, path: &std::path::Path) -> Result<SubvolumeInfo, BackendError> {
            Ok(SubvolumeInfo { path: path.to_path_buf(), read_only: false, size_bytes: None })
        }
        fn set_read_only(&self, _path: &std::path::Path, _read_only: bool) -> Result<(), BackendError> { Ok(()) }
    }

    fn test_state() -> Arc<AppState> {
        let dir = tempfile::tempdir().unwrap();
        let db_path = dir.path().join("test.db");
        let ws_root = dir.path().join("workspaces");
        std::fs::create_dir_all(&ws_root).unwrap();
        let store = SqliteStore::open_and_init(&db_path).unwrap();
        // Leak the tempdir so it lives long enough
        std::mem::forget(dir);
        Arc::new(AppState {
            store: Box::new(store),
            backend: Box::new(MockBackend),
            workspaces_root: ws_root,
        })
    }
}

Step 6: Verify build

Run: cd ~/Work/WorkFort/nexus && mise run check

Expected: Compiles with no errors.

Step 7: Run existing tests

Run: cd ~/Work/WorkFort/nexus && mise run test:unit

Expected: All unit tests PASS.

Step 8: Commit

cd ~/Work/WorkFort/nexus
git add nexusd/src/api.rs nexusd/src/main.rs
git commit -m "feat(nexusd): add REST API endpoints for images and workspaces"

TODO (nice to have): Add API-level tests for image and workspace endpoints mirroring the existing VM API tests in api.rs. Use the MockBackend from Step 5 to exercise POST /v1/images -> 201, GET /v1/images -> 200, GET /v1/images/:name -> 200/404, DELETE /v1/images/:name -> 204/404/409, and the corresponding workspace endpoints. Also consider wrapping blocking btrfs calls in tokio::task::spawn_blocking in production to avoid blocking the async runtime.


Task 10: Add Image and Workspace Methods to NexusClient

Files:

  • Modify: nexus/nexus-lib/src/client.rs

Step 1: Add image and workspace methods to NexusClient

#![allow(unused)]
fn main() {
    // --- Image methods ---

    pub async fn import_image(&self, params: &crate::workspace::ImportImageParams) -> Result<crate::workspace::MasterImage, ClientError> {
        let url = format!("{}/v1/images", self.base_url);
        let resp = self.http.post(&url).json(params).send().await.map_err(|e| {
            if e.is_connect() || e.is_timeout() { ClientError::Connect(e.to_string()) }
            else { ClientError::Api(e.to_string()) }
        })?;

        let status = resp.status();
        if status == reqwest::StatusCode::CONFLICT {
            let body: serde_json::Value = resp.json().await.map_err(|e| ClientError::Api(e.to_string()))?;
            return Err(ClientError::Api(body["error"].as_str().unwrap_or("conflict").to_string()));
        }
        if !status.is_success() {
            let body = resp.text().await.unwrap_or_default();
            return Err(ClientError::Api(format!("unexpected status {status}: {body}")));
        }

        resp.json().await.map_err(|e| ClientError::Api(e.to_string()))
    }

    pub async fn list_images(&self) -> Result<Vec<crate::workspace::MasterImage>, ClientError> {
        let url = format!("{}/v1/images", self.base_url);
        let resp = self.http.get(&url).send().await.map_err(|e| {
            if e.is_connect() || e.is_timeout() { ClientError::Connect(e.to_string()) }
            else { ClientError::Api(e.to_string()) }
        })?;

        if !resp.status().is_success() {
            return Err(ClientError::Api(format!("unexpected status: {}", resp.status())));
        }

        resp.json().await.map_err(|e| ClientError::Api(e.to_string()))
    }

    pub async fn get_image(&self, name_or_id: &str) -> Result<Option<crate::workspace::MasterImage>, ClientError> {
        let url = format!("{}/v1/images/{name_or_id}", self.base_url);
        let resp = self.http.get(&url).send().await.map_err(|e| {
            if e.is_connect() || e.is_timeout() { ClientError::Connect(e.to_string()) }
            else { ClientError::Api(e.to_string()) }
        })?;

        if resp.status() == reqwest::StatusCode::NOT_FOUND { return Ok(None); }
        if !resp.status().is_success() {
            return Err(ClientError::Api(format!("unexpected status: {}", resp.status())));
        }

        resp.json().await.map(Some).map_err(|e| ClientError::Api(e.to_string()))
    }

    pub async fn delete_image(&self, name_or_id: &str) -> Result<bool, ClientError> {
        let url = format!("{}/v1/images/{name_or_id}", self.base_url);
        let resp = self.http.delete(&url).send().await.map_err(|e| {
            if e.is_connect() || e.is_timeout() { ClientError::Connect(e.to_string()) }
            else { ClientError::Api(e.to_string()) }
        })?;

        match resp.status().as_u16() {
            204 => Ok(true),
            404 => Ok(false),
            409 => {
                let body: serde_json::Value = resp.json().await.map_err(|e| ClientError::Api(e.to_string()))?;
                Err(ClientError::Api(body["error"].as_str().unwrap_or("conflict").to_string()))
            }
            other => Err(ClientError::Api(format!("unexpected status: {other}"))),
        }
    }

    // --- Workspace methods ---

    pub async fn create_workspace(&self, params: &crate::workspace::CreateWorkspaceParams) -> Result<crate::workspace::Workspace, ClientError> {
        let url = format!("{}/v1/workspaces", self.base_url);
        let resp = self.http.post(&url).json(params).send().await.map_err(|e| {
            if e.is_connect() || e.is_timeout() { ClientError::Connect(e.to_string()) }
            else { ClientError::Api(e.to_string()) }
        })?;

        let status = resp.status();
        if status == reqwest::StatusCode::CONFLICT {
            let body: serde_json::Value = resp.json().await.map_err(|e| ClientError::Api(e.to_string()))?;
            return Err(ClientError::Api(body["error"].as_str().unwrap_or("conflict").to_string()));
        }
        if !status.is_success() {
            let body = resp.text().await.unwrap_or_default();
            return Err(ClientError::Api(format!("unexpected status {status}: {body}")));
        }

        resp.json().await.map_err(|e| ClientError::Api(e.to_string()))
    }

    pub async fn list_workspaces(&self, base: Option<&str>) -> Result<Vec<crate::workspace::Workspace>, ClientError> {
        let mut url = format!("{}/v1/workspaces", self.base_url);
        if let Some(b) = base {
            url.push_str(&format!("?base={b}"));
        }
        let resp = self.http.get(&url).send().await.map_err(|e| {
            if e.is_connect() || e.is_timeout() { ClientError::Connect(e.to_string()) }
            else { ClientError::Api(e.to_string()) }
        })?;

        if !resp.status().is_success() {
            return Err(ClientError::Api(format!("unexpected status: {}", resp.status())));
        }

        resp.json().await.map_err(|e| ClientError::Api(e.to_string()))
    }

    pub async fn get_workspace(&self, name_or_id: &str) -> Result<Option<crate::workspace::Workspace>, ClientError> {
        let url = format!("{}/v1/workspaces/{name_or_id}", self.base_url);
        let resp = self.http.get(&url).send().await.map_err(|e| {
            if e.is_connect() || e.is_timeout() { ClientError::Connect(e.to_string()) }
            else { ClientError::Api(e.to_string()) }
        })?;

        if resp.status() == reqwest::StatusCode::NOT_FOUND { return Ok(None); }
        if !resp.status().is_success() {
            return Err(ClientError::Api(format!("unexpected status: {}", resp.status())));
        }

        resp.json().await.map(Some).map_err(|e| ClientError::Api(e.to_string()))
    }

    pub async fn delete_workspace(&self, name_or_id: &str) -> Result<bool, ClientError> {
        let url = format!("{}/v1/workspaces/{name_or_id}", self.base_url);
        let resp = self.http.delete(&url).send().await.map_err(|e| {
            if e.is_connect() || e.is_timeout() { ClientError::Connect(e.to_string()) }
            else { ClientError::Api(e.to_string()) }
        })?;

        match resp.status().as_u16() {
            204 => Ok(true),
            404 => Ok(false),
            409 => {
                let body: serde_json::Value = resp.json().await.map_err(|e| ClientError::Api(e.to_string()))?;
                Err(ClientError::Api(body["error"].as_str().unwrap_or("conflict").to_string()))
            }
            other => Err(ClientError::Api(format!("unexpected status: {other}"))),
        }
    }
}

Step 2: Run tests

Run: cd ~/Work/WorkFort/nexus && cargo test -p nexus-lib client::tests

Expected: All tests PASS.

Step 3: Commit

cd ~/Work/WorkFort/nexus
git add nexus-lib/src/client.rs
git commit -m "feat(nexus-lib): add image and workspace methods to NexusClient"

Task 11: nexusctl image and ws Subcommands

Files:

  • Modify: nexus/nexusctl/src/main.rs

Step 1: Add the Image and Ws subcommands to the Commands enum

#![allow(unused)]
fn main() {
#[derive(Subcommand)]
enum Commands {
    /// Show daemon status
    Status,
    /// Print version information
    Version,
    /// Manage virtual machines
    Vm {
        #[command(subcommand)]
        action: VmAction,
    },
    /// Manage master images
    Image {
        #[command(subcommand)]
        action: ImageAction,
    },
    /// Manage workspaces (alias: workspace)
    #[command(alias = "workspace")]
    Ws {
        #[command(subcommand)]
        action: WsAction,
    },
}

#[derive(Subcommand)]
enum ImageAction {
    /// List all master images
    List,
    /// Import a directory as a master image
    Import {
        /// Path to directory to import
        path: String,
        /// Name for the image
        #[arg(long)]
        name: String,
    },
    /// Show image details
    Inspect {
        /// Image name or ID
        name: String,
    },
    /// Delete a master image
    Delete {
        /// Image name or ID
        name: String,
        /// Skip confirmation
        #[arg(short, long)]
        yes: bool,
    },
}

#[derive(Subcommand)]
enum WsAction {
    /// List all workspaces
    List {
        /// Filter by base image name
        #[arg(long)]
        base: Option<String>,
    },
    /// Create a workspace from a master image
    Create {
        /// Workspace name
        #[arg(long)]
        name: Option<String>,
        /// Base image name
        #[arg(long)]
        base: String,
    },
    /// Show workspace details
    Inspect {
        /// Workspace name or ID
        name: String,
    },
    /// Delete a workspace
    Delete {
        /// Workspace name or ID
        name: String,
        /// Skip confirmation
        #[arg(short, long)]
        yes: bool,
    },
}
}

Step 2: Add imports and dispatch in main()

#![allow(unused)]
fn main() {
use nexus_lib::workspace::{CreateWorkspaceParams, ImportImageParams};
}

Update the match in main():

#![allow(unused)]
fn main() {
    match cli.command {
        Commands::Status => cmd_status(&daemon_addr).await,
        Commands::Version => cmd_version(&daemon_addr).await,
        Commands::Vm { action } => cmd_vm(&daemon_addr, action).await,
        Commands::Image { action } => cmd_image(&daemon_addr, action).await,
        Commands::Ws { action } => cmd_ws(&daemon_addr, action).await,
    }
}

Step 3: Implement the image command handlers

#![allow(unused)]
fn main() {
async fn cmd_image(daemon_addr: &str, action: ImageAction) -> ExitCode {
    let client = NexusClient::new(daemon_addr);

    match action {
        ImageAction::List => {
            match client.list_images().await {
                Ok(imgs) => {
                    if imgs.is_empty() {
                        println!("No images found.");
                        return ExitCode::SUCCESS;
                    }
                    println!("{:<20} {:<50}", "NAME", "PATH");
                    for img in &imgs {
                        println!("{:<20} {:<50}", img.name, img.subvolume_path);
                    }
                    ExitCode::SUCCESS
                }
                Err(e) if e.is_connect() => {
                    print_connect_error(daemon_addr);
                    ExitCode::from(EXIT_DAEMON_UNREACHABLE)
                }
                Err(e) => {
                    eprintln!("Error: {e}");
                    ExitCode::from(EXIT_GENERAL_ERROR)
                }
            }
        }
        ImageAction::Import { path, name } => {
            let params = ImportImageParams {
                name: name.clone(),
                source_path: path.clone(),
            };
            match client.import_image(&params).await {
                Ok(img) => {
                    println!("Imported image \"{}\"", img.name);
                    println!("  Path: {}", img.subvolume_path);
                    println!("\n  Create a workspace: nexusctl ws create --base {} --name my-ws", img.name);
                    ExitCode::SUCCESS
                }
                Err(e) if e.is_connect() => {
                    print_connect_error(daemon_addr);
                    ExitCode::from(EXIT_DAEMON_UNREACHABLE)
                }
                Err(e) => {
                    eprintln!("Error: cannot import image \"{name}\"\n  {e}");
                    ExitCode::from(EXIT_GENERAL_ERROR)
                }
            }
        }
        ImageAction::Inspect { name } => {
            match client.get_image(&name).await {
                Ok(Some(img)) => {
                    println!("Name:       {}", img.name);
                    println!("ID:         {}", img.id);
                    println!("Path:       {}", img.subvolume_path);
                    if let Some(size) = img.size_bytes {
                        println!("Size:       {} bytes", size);
                    }
                    println!("Created:    {}", format_timestamp(img.created_at));
                    ExitCode::SUCCESS
                }
                Ok(None) => {
                    eprintln!("Error: image \"{}\" not found", name);
                    ExitCode::from(EXIT_NOT_FOUND)
                }
                Err(e) if e.is_connect() => {
                    print_connect_error(daemon_addr);
                    ExitCode::from(EXIT_DAEMON_UNREACHABLE)
                }
                Err(e) => {
                    eprintln!("Error: {e}");
                    ExitCode::from(EXIT_GENERAL_ERROR)
                }
            }
        }
        ImageAction::Delete { name, yes } => {
            if !yes {
                eprintln!(
                    "Error: refusing to delete image without confirmation\n  \
                     Run with --yes to skip confirmation: nexusctl image delete {} --yes",
                    name
                );
                return ExitCode::from(EXIT_GENERAL_ERROR);
            }
            match client.delete_image(&name).await {
                Ok(true) => {
                    println!("Deleted image \"{}\"", name);
                    ExitCode::SUCCESS
                }
                Ok(false) => {
                    eprintln!("Error: image \"{}\" not found", name);
                    ExitCode::from(EXIT_NOT_FOUND)
                }
                Err(e) if e.is_connect() => {
                    print_connect_error(daemon_addr);
                    ExitCode::from(EXIT_DAEMON_UNREACHABLE)
                }
                Err(e) => {
                    eprintln!("Error: cannot delete image \"{}\"\n  {e}", name);
                    ExitCode::from(EXIT_CONFLICT)
                }
            }
        }
    }
}
}

Step 4: Implement the workspace command handlers

#![allow(unused)]
fn main() {
async fn cmd_ws(daemon_addr: &str, action: WsAction) -> ExitCode {
    let client = NexusClient::new(daemon_addr);

    match action {
        WsAction::List { base } => {
            match client.list_workspaces(base.as_deref()).await {
                Ok(wss) => {
                    if wss.is_empty() {
                        println!("No workspaces found.");
                        return ExitCode::SUCCESS;
                    }
                    println!("{:<20} {:<50} {:<10}", "NAME", "PATH", "READ-ONLY");
                    for ws in &wss {
                        let name = ws.name.as_deref().unwrap_or("(unnamed)");
                        let ro = if ws.is_read_only { "yes" } else { "no" };
                        println!("{:<20} {:<50} {:<10}", name, ws.subvolume_path, ro);
                    }
                    ExitCode::SUCCESS
                }
                Err(e) if e.is_connect() => {
                    print_connect_error(daemon_addr);
                    ExitCode::from(EXIT_DAEMON_UNREACHABLE)
                }
                Err(e) => {
                    eprintln!("Error: {e}");
                    ExitCode::from(EXIT_GENERAL_ERROR)
                }
            }
        }
        WsAction::Create { name, base } => {
            let params = CreateWorkspaceParams {
                name: name.clone(),
                base: base.clone(),
            };
            match client.create_workspace(&params).await {
                Ok(ws) => {
                    let ws_name = ws.name.as_deref().unwrap_or(&ws.id);
                    println!("Created workspace \"{}\" from base \"{}\"", ws_name, base);
                    println!("  Path: {}", ws.subvolume_path);
                    println!("\n  Inspect it: nexusctl ws inspect {}", ws_name);
                    ExitCode::SUCCESS
                }
                Err(e) if e.is_connect() => {
                    print_connect_error(daemon_addr);
                    ExitCode::from(EXIT_DAEMON_UNREACHABLE)
                }
                Err(e) => {
                    eprintln!("Error: cannot create workspace\n  {e}");
                    ExitCode::from(EXIT_GENERAL_ERROR)
                }
            }
        }
        WsAction::Inspect { name } => {
            match client.get_workspace(&name).await {
                Ok(Some(ws)) => {
                    let ws_name = ws.name.as_deref().unwrap_or("(unnamed)");
                    println!("Name:       {}", ws_name);
                    println!("ID:         {}", ws.id);
                    println!("Path:       {}", ws.subvolume_path);
                    if let Some(ref img_id) = ws.master_image_id {
                        println!("Base Image: {}", img_id);
                    }
                    if let Some(ref vm_id) = ws.vm_id {
                        println!("Attached:   VM {}", vm_id);
                    } else {
                        println!("Attached:   (none)");
                    }
                    println!("Read-Only:  {}", if ws.is_read_only { "yes" } else { "no" });
                    println!("Root Dev:   {}", if ws.is_root_device { "yes" } else { "no" });
                    if let Some(size) = ws.size_bytes {
                        println!("Size:       {} bytes", size);
                    }
                    println!("Created:    {}", format_timestamp(ws.created_at));
                    ExitCode::SUCCESS
                }
                Ok(None) => {
                    eprintln!("Error: workspace \"{}\" not found", name);
                    ExitCode::from(EXIT_NOT_FOUND)
                }
                Err(e) if e.is_connect() => {
                    print_connect_error(daemon_addr);
                    ExitCode::from(EXIT_DAEMON_UNREACHABLE)
                }
                Err(e) => {
                    eprintln!("Error: {e}");
                    ExitCode::from(EXIT_GENERAL_ERROR)
                }
            }
        }
        WsAction::Delete { name, yes } => {
            if !yes {
                eprintln!(
                    "Error: refusing to delete workspace without confirmation\n  \
                     Run with --yes to skip confirmation: nexusctl ws delete {} --yes",
                    name
                );
                return ExitCode::from(EXIT_GENERAL_ERROR);
            }
            match client.delete_workspace(&name).await {
                Ok(true) => {
                    println!("Deleted workspace \"{}\"", name);
                    ExitCode::SUCCESS
                }
                Ok(false) => {
                    eprintln!("Error: workspace \"{}\" not found", name);
                    ExitCode::from(EXIT_NOT_FOUND)
                }
                Err(e) if e.is_connect() => {
                    print_connect_error(daemon_addr);
                    ExitCode::from(EXIT_DAEMON_UNREACHABLE)
                }
                Err(e) => {
                    eprintln!("Error: cannot delete workspace \"{}\"\n  {e}", name);
                    ExitCode::from(EXIT_CONFLICT)
                }
            }
        }
    }
}
}

Step 5: Verify build

Run: cd ~/Work/WorkFort/nexus && mise run build

Expected: Compiles with no errors.

Step 6: Verify help output

Run: cd ~/Work/WorkFort/nexus && cargo run -p nexusctl -- image --help

Expected output showing list, import, inspect, delete subcommands.

Run: cd ~/Work/WorkFort/nexus && cargo run -p nexusctl -- ws --help

Expected output showing list, create, inspect, delete subcommands.

Step 7: Commit

cd ~/Work/WorkFort/nexus
git add nexusctl/src/main.rs
git commit -m "feat(nexusctl): add image and ws subcommands for workspace management"

Task 12: Update TestDaemon for Workspace Backend

Files:

  • Modify: nexus/nexus-lib/src/test_support.rs

The TestDaemon currently does not configure a workspaces root for the daemon. Integration tests that exercise workspace endpoints need the daemon to have a temp workspaces directory. Since the daemon creates BtrfsBackend::new(workspaces_root) on startup (which just calls create_dir_all), and integration tests run on the real filesystem, the TestDaemon just needs a temp directory passed via config.

Step 1: Update TestDaemon config to include storage section

#![allow(unused)]
fn main() {
// In test_support.rs, update the config_yaml line in start_with_binary:
        let config_yaml = format!(
            "api:\n  listen: \"{addr}\"\nstorage:\n  workspaces: \"{}\"",
            tmp_dir.path().join("workspaces").display()
        );
}

Step 2: Run tests

Run: cd ~/Work/WorkFort/nexus && mise run test

Expected: All existing tests PASS.

Step 3: Commit

cd ~/Work/WorkFort/nexus
git add nexus-lib/src/test_support.rs
git commit -m "test(nexus-lib): configure workspaces root in TestDaemon"

Task 13: Workspace-Wide Verification

TODO (nice to have): Add an automated image_workspace_crud_lifecycle integration test in nexusd/tests/daemon.rs (similar to the existing vm_crud_lifecycle test). This requires a btrfs filesystem in the test environment. If not available in CI, gate the test behind a #[cfg] or #[ignore] attribute.

Step 1: Full build

Run: cd ~/Work/WorkFort/nexus && mise run build

Expected: Compiles with no errors and no warnings.

Step 2: Full test suite

Run: cd ~/Work/WorkFort/nexus && mise run test

Expected: All tests pass.

Step 3: Clippy

Run: cd ~/Work/WorkFort/nexus && mise run clippy

Expected: No warnings.

Step 4: End-to-end smoke test (requires btrfs filesystem)

Note: This smoke test requires a btrfs-formatted filesystem at the workspaces root path. If the host filesystem is not btrfs, configure storage.workspaces in the nexus config to point to a btrfs mount point.

Terminal 1:

cd ~/Work/WorkFort/nexus && mise run run

Terminal 2:

cd ~/Work/WorkFort/nexus

# Create a test directory to import as an image
mkdir -p /tmp/test-rootfs
echo "hello from rootfs" > /tmp/test-rootfs/README

# Import image
mise run run:nexusctl -- --daemon 127.0.0.1:9600 image import /tmp/test-rootfs --name base
# Expected: Imported image "base"

# List images
mise run run:nexusctl -- --daemon 127.0.0.1:9600 image list
# Expected: Table with "base" image

# Inspect image
mise run run:nexusctl -- --daemon 127.0.0.1:9600 image inspect base
# Expected: Full detail (name, ID, path, created timestamp)

# Create workspace from image
mise run run:nexusctl -- --daemon 127.0.0.1:9600 ws create --base base --name my-ws
# Expected: Created workspace "my-ws" from base "base"

# List workspaces
mise run run:nexusctl -- --daemon 127.0.0.1:9600 ws list
# Expected: Table with "my-ws"

# Inspect workspace
mise run run:nexusctl -- --daemon 127.0.0.1:9600 ws inspect my-ws
# Expected: Full detail (name, ID, path, base image, attached state)

# Verify with btrfs
btrfs subvolume list ~/.local/share/nexus/workspaces/
# Expected: Shows @base (read-only) and @my-ws (read-write)

# Delete workspace
mise run run:nexusctl -- --daemon 127.0.0.1:9600 ws delete my-ws --yes
# Expected: Deleted workspace "my-ws"

# Try deleting image with no workspaces
mise run run:nexusctl -- --daemon 127.0.0.1:9600 image delete base --yes
# Expected: Deleted image "base"

# Verify with btrfs
btrfs subvolume list ~/.local/share/nexus/workspaces/
# Expected: Empty (no subvolumes)

Kill the daemon (Ctrl-C in terminal 1).

Step 5: Verify schema migration

sqlite3 ~/.local/state/nexus/nexus.db ".tables"
# Expected: master_images  schema_meta  settings  vms  workspaces

sqlite3 ~/.local/state/nexus/nexus.db "SELECT value FROM schema_meta WHERE key='version'"
# Expected: 3

Step 6: Commit (if any final adjustments were needed)

cd ~/Work/WorkFort/nexus
git add nexus/
git commit -m "chore: final adjustments from step 5 verification"

Verification Checklist

  • mise run build succeeds with no warnings
  • mise run test – all tests pass
  • mise run clippy – no warnings
  • WorkspaceBackend trait is filesystem-agnostic (no btrfs-specific types in the trait interface)
  • BtrfsBackend implements WorkspaceBackend using libbtrfsutil
  • Schema version is 3, master_images and workspaces tables exist
  • POST /v1/images with ImportImageParams creates subvolume + registers in DB
  • GET /v1/images returns JSON array of all images
  • GET /v1/images/:name returns image detail
  • DELETE /v1/images/:name returns 204, refuses if workspaces exist (409)
  • POST /v1/workspaces with CreateWorkspaceParams creates btrfs snapshot + registers in DB
  • GET /v1/workspaces returns JSON array of all workspaces
  • GET /v1/workspaces?base=<name> filters by base image
  • GET /v1/workspaces/:name returns workspace detail
  • DELETE /v1/workspaces/:name returns 204, refuses if attached to VM (409)
  • nexusctl image import /path --name base imports and registers an image
  • nexusctl image list renders a table
  • nexusctl image inspect base shows full detail
  • nexusctl image delete base --yes deletes the image
  • nexusctl ws create --base base --name my-ws creates a workspace from image
  • nexusctl ws list renders a table with NAME, PATH, READ-ONLY columns
  • nexusctl ws inspect my-ws shows full detail
  • nexusctl ws delete my-ws --yes deletes the workspace
  • nexusctl workspace list works as alias for nexusctl ws list
  • btrfs subvolumes are created and visible via btrfs subvolume list
  • Master images are marked read-only after import
  • Workspace snapshots are writable by default
  • Pre-alpha schema migration (delete + recreate) works with version bump from 2 to 3