diff --git a/CHANGELOG.md b/CHANGELOG.md index 32de55bc..080d8764 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ This fork restructured the post-0.6.4 tree around a five-crate Rust workspace. The changes below are the user-visible summary of that campaign; behavior-preserving moves are not listed individually. +### Added + +- `vfs adopt --db --base [--pin ]`: + first-class installation of a transferred pack artifact as a local run + session, replacing the hand-written "externally materialized run + sessions" receiver contract. Adopt integrity-checks the artifact, + migrates supported older artifact schemas to the current version, + verifies the receiving base checkout's `HEAD` against the artifact's + recorded seed pin (or a required `--pin` for artifacts without recorded + provenance), and publishes the session atomically so a partial or + corrupt session is never observable. `vfs version --json` reports the + capability as `features.adopt`. +- `vfs seed` records its resolved pin in `fs_session_metadata` as + `seed_pin`, giving packed artifacts the base provenance `vfs adopt` + verifies on the receiving machine. + ### Removed - Deleted SDKs: the Go, Python, and TypeScript SDKs and their CI workflows; diff --git a/crates/vfs-cli/src/cmd/adopt.rs b/crates/vfs-cli/src/cmd/adopt.rs new file mode 100644 index 00000000..83eb683d --- /dev/null +++ b/crates/vfs-cli/src/cmd/adopt.rs @@ -0,0 +1,602 @@ +//! First-class installation of externally transferred run sessions. + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use vfs_core::{schema, Vfs, VfsOptions}; + +use super::pack::SessionStillRunning; +use super::safety::{build_local_database, remove_sqlite_sidecars_after_checkpoint}; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdoptManifest { + manifest_version: u32, + session_id: String, + base_path: PathBuf, + base_pin: String, + generation: u64, + schema_version: String, + seeded_paths: Vec, + vfs_version: String, +} + +/// Install a transferred session artifact and print its machine-readable manifest. +pub async fn handle_adopt_command( + stdout: &mut impl Write, + session_id: String, + db: PathBuf, + base: PathBuf, + pin: Option, + _json: bool, +) -> Result<()> { + let home = dirs::home_dir().context("Failed to get home directory")?; + adopt_session(stdout, &home, session_id, db, base, pin).await +} + +async fn adopt_session( + stdout: &mut impl Write, + home: &Path, + session_id: String, + db: PathBuf, + base: PathBuf, + pin: Option, +) -> Result<()> { + if !VfsOptions::validate_agent_id(&session_id) { + bail!("invalid session ID: {session_id}"); + } + let source_db = std::path::absolute(&db).context("Failed to resolve --db path")?; + if !source_db.is_file() { + bail!("session artifact not found: {}", source_db.display()); + } + let base_path = std::path::absolute(&base).context("Failed to resolve --base path")?; + if !base_path.is_dir() { + bail!( + "base checkout does not exist or is not a directory: {}", + base_path.display() + ); + } + super::seed::ensure_git_repository(&base_path)?; + + let session_dir = home.join(".vfs").join("run").join(&session_id); + let db_path = session_dir.join("delta.db"); + if db_path.is_file() { + bail!("session already exists: {}", session_dir.display()); + } + let mut cleanup = SessionDirCleanup::new(&session_dir); + fs::create_dir_all(&session_dir).context("Failed to create run session directory")?; + let _session_lock = + super::session_lock::SessionLock::try_exclusive(&session_dir).map_err(|error| { + if error.kind() == std::io::ErrorKind::WouldBlock { + anyhow::Error::new(SessionStillRunning) + } else { + anyhow::Error::new(error).context("Failed to lock session for adoption") + } + })?; + super::pack::recover_interrupted_publication(&db_path)?; + if db_path.is_file() { + bail!("session already exists: {}", session_dir.display()); + } + super::pack::ensure_session_inactive(&session_dir)?; + + let staging = + StagedArtifact::new(session_dir.join(format!(".delta.db.adopt-{}.tmp", Uuid::new_v4()))); + super::pack::copy_database_family(&source_db, staging.path()) + .context("Failed to stage the session artifact")?; + verify_artifact_integrity(staging.path()).await?; + let artifact_version = migrate_artifact_to_current(staging.path()).await?; + + let vfs = Vfs::open(VfsOptions::with_path(staging.path().to_string_lossy())) + .await + .context("Failed to open the staged session artifact")?; + let metadata = vfs.session_metadata().await?; + let recorded_pin = vfs.seed_pin().await?; + vfs.fs + .finalize() + .await + .context("Failed to finalize the staged session artifact")?; + drop(vfs); + remove_sqlite_sidecars_after_checkpoint(staging.path())?; + + let base_pin = resolve_expected_pin(&base_path, recorded_pin, pin)?; + let head = super::seed::resolve_commit(&base_path, "HEAD") + .context("base checkout has no valid HEAD")?; + if head != base_pin { + bail!( + "base checkout {} is at {head}, but the session requires pin {base_pin}; \ + check out the pin before adopting", + base_path.display() + ); + } + + let base_path_file = session_dir.join("base_path"); + fs::write(&base_path_file, base_path.to_string_lossy().as_bytes()) + .context("Failed to publish session base path")?; + super::pack::sync_file_and_parent(&base_path_file)?; + fs::rename(staging.path(), &db_path).with_context(|| { + format!( + "Failed to install adopted session database {}", + db_path.display() + ) + })?; + super::pack::sync_file_and_parent(&db_path)?; + cleanup.disarm(); + + let manifest = AdoptManifest { + manifest_version: 1, + session_id, + base_path, + base_pin, + generation: metadata.generation, + schema_version: artifact_version.to_string(), + seeded_paths: metadata.seeded_paths, + vfs_version: super::version::VERSION.to_string(), + }; + serde_json::to_writer(&mut *stdout, &manifest)?; + writeln!(stdout)?; + Ok(()) +} + +/// Removes a session directory this adopt created if installation fails, so +/// a failed adopt never leaves a half-materialized session behind. +struct SessionDirCleanup { + session_dir: PathBuf, + armed: bool, +} + +impl SessionDirCleanup { + fn new(session_dir: &Path) -> Self { + Self { + session_dir: session_dir.to_path_buf(), + armed: !session_dir.exists(), + } + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for SessionDirCleanup { + fn drop(&mut self) { + if self.armed { + let _ = fs::remove_dir_all(&self.session_dir); + } + } +} + +struct StagedArtifact { + path: PathBuf, +} + +impl StagedArtifact { + fn new(path: PathBuf) -> Self { + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for StagedArtifact { + fn drop(&mut self) { + super::pack::remove_database_family(&self.path); + } +} + +async fn verify_artifact_integrity(staging: &Path) -> Result<()> { + let db = build_local_database(staging, None) + .await + .context("Failed to open the session artifact")?; + let conn = db + .connect() + .context("Failed to connect to the session artifact")?; + let mut rows = conn + .query("PRAGMA integrity_check", ()) + .await + .context("Failed to check session artifact integrity")?; + let mut results = Vec::new(); + while let Some(row) = rows + .next() + .await + .context("Failed to read session artifact integrity results")? + { + results.push(row.get::(0)?); + } + if results != ["ok".to_string()] { + bail!("session artifact failed integrity check: {results:?}"); + } + Ok(()) +} + +/// Lands any supported artifact schema at [`schema::CURRENT`] so the adopted +/// session opens under `vfs run` without a separate migrate step, mirroring +/// the staging migration `vfs pack` performs before it ships an artifact. +async fn migrate_artifact_to_current(staging: &Path) -> Result { + let db = build_local_database(staging, None) + .await + .context("Failed to open the session artifact for migration")?; + let conn = db + .connect() + .context("Failed to connect to the session artifact for migration")?; + if let Err(error) = schema::detect_schema_version(&conn).await { + if let vfs_core::error::Error::SchemaVersionMismatch { found, expected } = &error { + bail!( + "session artifact requires schema {found}, newer than the newest supported \ + artifact version {expected} (vfs {}); upgrade vfs to adopt it", + super::version::VERSION + ); + } + return Err(anyhow::Error::new(error).context("Failed to detect artifact schema version")); + } + schema::ensure_current(&conn) + .await + .context("Failed to migrate the session artifact to the current schema")?; + Ok(schema::CURRENT) +} + +fn resolve_expected_pin( + base_path: &Path, + recorded_pin: Option, + requested_pin: Option, +) -> Result { + let requested = requested_pin + .map(|requested| { + super::seed::resolve_commit(base_path, &requested) + .with_context(|| format!("invalid adopt pin: {requested}")) + }) + .transpose()?; + match (recorded_pin, requested) { + (Some(recorded), Some(requested)) if recorded != requested => bail!( + "requested pin {requested} does not match the artifact's recorded seed pin {recorded}" + ), + (Some(recorded), _) => Ok(recorded), + (None, Some(requested)) => Ok(requested), + (None, None) => bail!( + "session artifact does not record a seed pin; pass --pin to verify the \ + base checkout" + ), + } +} + +#[cfg(test)] +mod tests { + use std::process::Command; + + use tempfile::{tempdir, TempDir}; + use turso::Builder; + use vfs_core::OverlayFS; + + use super::*; + + struct AdoptFixture { + _root: TempDir, + home: PathBuf, + sender_repo: PathBuf, + receiver_base: PathBuf, + pin: String, + head: String, + } + + impl AdoptFixture { + fn new() -> Result { + let root = tempdir()?; + let home = root.path().join("home"); + let sender_repo = root.path().join("sender"); + let receiver_base = root.path().join("receiver"); + fs::create_dir_all(&home)?; + fs::create_dir_all(&sender_repo)?; + git(&sender_repo, &["init", "-b", "main"])?; + git(&sender_repo, &["config", "user.name", "Vfs Adopt Test"])?; + git(&sender_repo, &["config", "user.email", "adopt@example.com"])?; + fs::write(sender_repo.join("tracked.txt"), "pin content\n")?; + git(&sender_repo, &["add", "."])?; + git(&sender_repo, &["commit", "-m", "pin"])?; + let pin = git_text(&sender_repo, &["rev-parse", "HEAD"])?; + fs::write(sender_repo.join("tracked.txt"), "post-pin content\n")?; + git(&sender_repo, &["commit", "-am", "post pin"])?; + let head = git_text(&sender_repo, &["rev-parse", "HEAD"])?; + + git( + root.path(), + &[ + "clone", + "--quiet", + sender_repo.to_str().unwrap(), + receiver_base.to_str().unwrap(), + ], + )?; + git(&receiver_base, &["checkout", "--quiet", &pin])?; + + Ok(Self { + _root: root, + home, + sender_repo, + receiver_base, + pin, + head, + }) + } + + async fn create_artifact(&self, name: &str, pin: Option<&str>) -> Result { + let scratch = self._root.path().join(format!("{name}-scratch.db")); + let artifact = self._root.path().join(format!("{name}.db")); + let vfs = Vfs::open(VfsOptions::with_path(scratch.to_string_lossy())).await?; + let conn = vfs.get_connection().await?; + OverlayFS::init_schema(&conn, self.sender_repo.to_string_lossy().as_ref()).await?; + drop(conn); + if let Some(pin) = pin { + vfs.record_seed_state(&["tracked.txt".to_string()], &[], pin) + .await?; + } + vfs.compact_local_database_into(&artifact).await?; + vfs.fs.finalize().await?; + Ok(artifact) + } + + fn session_dir(&self, session_id: &str) -> PathBuf { + self.home.join(".vfs").join("run").join(session_id) + } + } + + async fn adopt( + fixture: &AdoptFixture, + session_id: &str, + artifact: &Path, + pin: Option<&str>, + ) -> Result { + let mut stdout = Vec::new(); + adopt_session( + &mut stdout, + &fixture.home, + session_id.to_string(), + artifact.to_path_buf(), + fixture.receiver_base.clone(), + pin.map(str::to_string), + ) + .await?; + Ok(serde_json::from_slice(&stdout)?) + } + + #[tokio::test] + async fn adopt_installs_a_transferred_session() -> Result<()> { + let fixture = AdoptFixture::new()?; + let artifact = fixture.create_artifact("happy", Some(&fixture.pin)).await?; + + let manifest = adopt(&fixture, "adopt-happy", &artifact, None).await?; + assert_eq!(manifest.manifest_version, 1); + assert_eq!(manifest.session_id, "adopt-happy"); + assert_eq!(manifest.base_path, fixture.receiver_base); + assert_eq!(manifest.base_pin, fixture.pin); + assert_eq!(manifest.generation, 0); + assert_eq!(manifest.schema_version, schema::CURRENT.as_str()); + assert_eq!(manifest.seeded_paths, vec!["tracked.txt".to_string()]); + assert_eq!(manifest.vfs_version, super::super::version::VERSION); + + let session_dir = fixture.session_dir("adopt-happy"); + assert_eq!( + fs::read_to_string(session_dir.join("base_path"))?, + fixture.receiver_base.to_string_lossy() + ); + let installed_db = session_dir.join("delta.db"); + assert!(installed_db.is_file()); + assert_eq!(fs::read(&installed_db)?, fs::read(&artifact)?); + + let installed = Vfs::open(VfsOptions::with_path(installed_db.to_string_lossy())).await?; + assert_eq!( + installed.session_metadata().await?.seeded_paths, + vec!["tracked.txt".to_string()] + ); + assert_eq!( + installed.seed_pin().await?.as_deref(), + Some(fixture.pin.as_str()) + ); + Ok(()) + } + + #[tokio::test] + async fn adopt_refuses_an_existing_session() -> Result<()> { + let fixture = AdoptFixture::new()?; + let artifact = fixture + .create_artifact("exists", Some(&fixture.pin)) + .await?; + let session_dir = fixture.session_dir("adopt-exists"); + fs::create_dir_all(&session_dir)?; + fs::write(session_dir.join("delta.db"), b"existing session")?; + + let error = adopt(&fixture, "adopt-exists", &artifact, None) + .await + .expect_err("existing session must be refused"); + assert!(error.to_string().contains("session already exists")); + assert_eq!(fs::read(session_dir.join("delta.db"))?, b"existing session"); + Ok(()) + } + + #[tokio::test] + async fn adopt_refuses_a_pin_mismatch_without_partial_state() -> Result<()> { + let fixture = AdoptFixture::new()?; + let artifact = fixture + .create_artifact("mismatch", Some(&fixture.head)) + .await?; + + let error = adopt(&fixture, "adopt-mismatch", &artifact, None) + .await + .expect_err("pin mismatch must be refused"); + assert!(error.to_string().contains("requires pin")); + assert!(error.to_string().contains(&fixture.head)); + assert!(!fixture.session_dir("adopt-mismatch").exists()); + Ok(()) + } + + #[tokio::test] + async fn adopt_refuses_a_requested_pin_conflicting_with_provenance() -> Result<()> { + let fixture = AdoptFixture::new()?; + let artifact = fixture + .create_artifact("conflict", Some(&fixture.pin)) + .await?; + + let error = adopt(&fixture, "adopt-conflict", &artifact, Some(&fixture.head)) + .await + .expect_err("conflicting pins must be refused"); + assert!(error + .to_string() + .contains("does not match the artifact's recorded seed pin")); + assert!(!fixture.session_dir("adopt-conflict").exists()); + Ok(()) + } + + #[tokio::test] + async fn adopt_requires_a_pin_for_artifacts_without_provenance() -> Result<()> { + let fixture = AdoptFixture::new()?; + let artifact = fixture.create_artifact("no-pin", None).await?; + + let error = adopt(&fixture, "adopt-no-pin", &artifact, None) + .await + .expect_err("missing provenance without --pin must be refused"); + assert!(error.to_string().contains("pass --pin")); + assert!(!fixture.session_dir("adopt-no-pin").exists()); + + let manifest = adopt(&fixture, "adopt-no-pin", &artifact, Some(&fixture.pin)).await?; + assert_eq!(manifest.base_pin, fixture.pin); + assert!(manifest.seeded_paths.is_empty()); + Ok(()) + } + + #[tokio::test] + async fn adopt_refuses_a_corrupt_artifact_without_partial_state() -> Result<()> { + let fixture = AdoptFixture::new()?; + let artifact = fixture._root.path().join("corrupt.db"); + fs::write(&artifact, b"this is not a sqlite database")?; + + let error = adopt(&fixture, "adopt-corrupt", &artifact, Some(&fixture.pin)) + .await + .expect_err("corrupt artifact must be refused"); + assert!(format!("{error:#}").contains("session artifact")); + assert!(!fixture.session_dir("adopt-corrupt").exists()); + Ok(()) + } + + #[tokio::test] + async fn adopt_refuses_an_artifact_newer_than_supported() -> Result<()> { + let fixture = AdoptFixture::new()?; + let artifact = fixture.create_artifact("newer", Some(&fixture.pin)).await?; + let newer_user_version = schema::CURRENT.user_version() + 1; + let db = Builder::new_local(artifact.to_str().unwrap()) + .build() + .await?; + let conn = db.connect()?; + conn.execute(&format!("PRAGMA user_version = {newer_user_version}"), ()) + .await?; + drop(conn); + drop(db); + + let error = adopt(&fixture, "adopt-newer", &artifact, None) + .await + .expect_err("newer artifact must be refused"); + let message = error.to_string(); + assert!(message.contains(&format!("user_version {newer_user_version}"))); + assert!(message.contains(schema::CURRENT.as_str())); + assert!(message.contains("upgrade vfs")); + assert!(!fixture.session_dir("adopt-newer").exists()); + Ok(()) + } + + #[tokio::test] + async fn adopt_migrates_an_older_supported_artifact() -> Result<()> { + let fixture = AdoptFixture::new()?; + let artifact = fixture._root.path().join("older.db"); + let db = Builder::new_local(artifact.to_str().unwrap()) + .build() + .await?; + let conn = db.connect()?; + conn.execute( + "CREATE TABLE fs_inode ( + ino INTEGER PRIMARY KEY AUTOINCREMENT, + mode INTEGER NOT NULL, + uid INTEGER NOT NULL DEFAULT 0, + gid INTEGER NOT NULL DEFAULT 0, + size INTEGER NOT NULL DEFAULT 0, + atime INTEGER NOT NULL, + mtime INTEGER NOT NULL, + ctime INTEGER NOT NULL + )", + (), + ) + .await?; + conn.execute( + "CREATE TABLE fs_config (key TEXT PRIMARY KEY, value TEXT NOT NULL)", + (), + ) + .await?; + drop(conn); + drop(db); + + let manifest = adopt(&fixture, "adopt-older", &artifact, Some(&fixture.pin)).await?; + assert_eq!(manifest.schema_version, schema::CURRENT.as_str()); + assert_eq!(manifest.generation, 0); + + let installed_db = fixture.session_dir("adopt-older").join("delta.db"); + Vfs::open(VfsOptions::with_path(installed_db.to_string_lossy())) + .await + .context("adopted session must open at the current schema")?; + Ok(()) + } + + #[tokio::test] + async fn adopt_refuses_a_locked_session_as_still_running() -> Result<()> { + let fixture = AdoptFixture::new()?; + let artifact = fixture + .create_artifact("locked", Some(&fixture.pin)) + .await?; + let session_dir = fixture.session_dir("adopt-locked"); + fs::create_dir_all(&session_dir)?; + let _live_lock = super::super::session_lock::SessionLock::try_shared(&session_dir)?; + + let error = adopt(&fixture, "adopt-locked", &artifact, None) + .await + .expect_err("locked session must be refused"); + assert!(error.downcast_ref::().is_some()); + Ok(()) + } + + fn git(repo: &Path, args: &[&str]) -> Result<()> { + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .env("GIT_AUTHOR_NAME", "Vfs Adopt Test") + .env("GIT_AUTHOR_EMAIL", "adopt@example.com") + .env("GIT_COMMITTER_NAME", "Vfs Adopt Test") + .env("GIT_COMMITTER_EMAIL", "adopt@example.com") + .output()?; + if !output.status.success() { + bail!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(()) + } + + fn git_text(repo: &Path, args: &[&str]) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .output()?; + if !output.status.success() { + bail!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(String::from_utf8(output.stdout)?.trim().to_string()) + } +} diff --git a/crates/vfs-cli/src/cmd/mod.rs b/crates/vfs-cli/src/cmd/mod.rs index c5600525..51cb03dd 100644 --- a/crates/vfs-cli/src/cmd/mod.rs +++ b/crates/vfs-cli/src/cmd/mod.rs @@ -1,3 +1,4 @@ +pub mod adopt; pub mod completions; pub mod fs; pub mod init; diff --git a/crates/vfs-cli/src/cmd/pack.rs b/crates/vfs-cli/src/cmd/pack.rs index 30111484..b26b0237 100644 --- a/crates/vfs-cli/src/cmd/pack.rs +++ b/crates/vfs-cli/src/cmd/pack.rs @@ -275,7 +275,7 @@ async fn read_metadata_value(conn: &turso::Connection, key: &str) -> Result Result<()> { +pub(crate) fn ensure_session_inactive(session_dir: &Path) -> Result<()> { let mountpoint = session_dir.join("mnt"); if vfs_mount::is_mountpoint(&mountpoint) || super::ps::procs_dir_has_live_processes(&session_dir.join("procs")) diff --git a/crates/vfs-cli/src/cmd/seed.rs b/crates/vfs-cli/src/cmd/seed.rs index af47a792..94b44f08 100644 --- a/crates/vfs-cli/src/cmd/seed.rs +++ b/crates/vfs-cli/src/cmd/seed.rs @@ -153,7 +153,7 @@ pub(crate) async fn seed_session( .iter() .map(|path| format!("/{path}")) .collect::>(); - vfs.record_seed_state(&seeded_paths, &whiteout_db_paths) + vfs.record_seed_state(&seeded_paths, &whiteout_db_paths, &plan.pin) .await .context("Failed to record seed metadata")?; vfs.fs @@ -354,7 +354,7 @@ fn build_seed_plan(base_path: &Path, requested_pin: &str) -> Result { }) } -fn ensure_git_repository(base_path: &Path) -> Result<()> { +pub(crate) fn ensure_git_repository(base_path: &Path) -> Result<()> { let inside = git_capture_text(base_path, &["rev-parse", "--is-inside-work-tree"]) .context("session base is not a git repository")?; if inside.trim() != "true" { @@ -363,7 +363,7 @@ fn ensure_git_repository(base_path: &Path) -> Result<()> { Ok(()) } -fn resolve_commit(base_path: &Path, revision: &str) -> Result { +pub(crate) fn resolve_commit(base_path: &Path, revision: &str) -> Result { Ok(git_capture_text( base_path, &["rev-parse", "--verify", &format!("{revision}^{{commit}}")], @@ -1049,6 +1049,7 @@ mod tests { vfs.session_metadata().await?.seeded_paths, summary.seeded_paths ); + assert_eq!(vfs.seed_pin().await?.as_deref(), Some(fixture.pin.as_str())); let overlay = OverlayFS::new(Arc::new(HostFS::new(&pristine)?), vfs.fs); overlay.load().await?; diff --git a/crates/vfs-cli/src/cmd/version.rs b/crates/vfs-cli/src/cmd/version.rs index 9d47305e..9db64fc5 100644 --- a/crates/vfs-cli/src/cmd/version.rs +++ b/crates/vfs-cli/src/cmd/version.rs @@ -20,6 +20,7 @@ struct VersionFeatures { uid_squash_run: bool, pack: bool, seed: bool, + adopt: bool, } /// Print the build version and supported handoff capabilities. @@ -32,6 +33,7 @@ pub fn handle_version_command(stdout: &mut impl Write, json: bool) -> Result<()> uid_squash_run: cfg!(target_os = "linux"), pack: true, seed: true, + adopt: true, }, }; @@ -59,5 +61,6 @@ mod tests { assert_eq!(value["features"]["uidSquashRun"], cfg!(target_os = "linux")); assert_eq!(value["features"]["pack"], true); assert_eq!(value["features"]["seed"], true); + assert_eq!(value["features"]["adopt"], true); } } diff --git a/crates/vfs-cli/src/main.rs b/crates/vfs-cli/src/main.rs index 9bdb54ac..2b3d5523 100644 --- a/crates/vfs-cli/src/main.rs +++ b/crates/vfs-cli/src/main.rs @@ -372,6 +372,23 @@ fn dispatch(args: Args) -> anyhow::Result<()> { json, )) } + Command::Adopt { + session_id, + db, + base, + pin, + json, + } => { + let rt = get_runtime(); + rt.block_on(cmd::adopt::handle_adopt_command( + &mut std::io::stdout(), + session_id, + db, + base, + pin, + json, + )) + } Command::Status { session_id, json, diff --git a/crates/vfs-cli/src/opts.rs b/crates/vfs-cli/src/opts.rs index d41e350f..14684bf0 100644 --- a/crates/vfs-cli/src/opts.rs +++ b/crates/vfs-cli/src/opts.rs @@ -478,6 +478,36 @@ pub enum Command { #[arg(long)] json: bool, }, + /// Install an externally transferred run session. + /// + /// Verifies a packed session database against the receiver's base git + /// checkout (the checkout's HEAD must equal the artifact's recorded seed + /// pin), migrates supported older artifact schemas to the current + /// version, and atomically publishes ~/.vfs/run/. After + /// adopt, `vfs run --session ` resumes the transferred + /// session. + Adopt { + /// Run session identifier + #[arg(value_name = "SESSION_ID")] + session_id: String, + + /// Packed session database produced by `vfs pack` + #[arg(long, value_name = "PATH")] + db: PathBuf, + + /// The receiver's base git checkout for the session + #[arg(long, value_name = "PATH", add = ArgValueCompleter::new(PathCompleter::dir()))] + base: PathBuf, + + /// Git commit the base checkout must be at (required only when the + /// artifact does not record a seed pin) + #[arg(long, value_name = "COMMIT")] + pin: Option, + + /// Emit machine-readable JSON (adopt output is always JSON) + #[arg(long)] + json: bool, + }, /// Show run session state for daemon preflight Status { /// Run session identifier @@ -891,6 +921,40 @@ mod tests { } } + #[test] + fn adopt_options_parse() { + let args = Args::try_parse_from([ + "vfs", + "adopt", + "session-1", + "--db", + "/tmp/packed.db", + "--base", + "/tmp/checkout", + "--pin", + "abc123", + "--json", + ]) + .unwrap(); + + match args.command { + Command::Adopt { + session_id, + db, + base, + pin, + json, + } => { + assert_eq!(session_id, "session-1"); + assert_eq!(db, PathBuf::from("/tmp/packed.db")); + assert_eq!(base, PathBuf::from("/tmp/checkout")); + assert_eq!(pin.as_deref(), Some("abc123")); + assert!(json); + } + other => panic!("expected adopt command, got {other:?}"), + } + } + #[test] fn version_json_option_parses() { let args = Args::try_parse_from(["vfs", "version", "--json"]).unwrap(); diff --git a/crates/vfs-core/src/session.rs b/crates/vfs-core/src/session.rs index 01af2575..a3a998c4 100644 --- a/crates/vfs-core/src/session.rs +++ b/crates/vfs-core/src/session.rs @@ -11,6 +11,7 @@ use crate::Vfs; const GENERATION_KEY: &str = "generation"; const SEEDED_PATHS_KEY: &str = "seeded_paths"; +const SEED_PIN_KEY: &str = "seed_pin"; /// Persistent handoff metadata stored inside a session database. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -82,15 +83,22 @@ impl Vfs { write_metadata_value(&conn, SEEDED_PATHS_KEY, value).await } - /// Atomically persist seed whiteouts and the path manifest. + /// Read the git commit recorded as the session's seed pin, if any. + pub async fn seed_pin(&self) -> Result> { + let conn = self.pool.get_connection().await?; + read_metadata_value(&conn, SEED_PIN_KEY).await + } + + /// Atomically persist seed whiteouts, the path manifest, and the pin. /// - /// Content import commits before this call; publishing the whiteouts and - /// manifest together prevents a completed seed from exposing only half of - /// its deletion state. + /// Content import commits before this call; publishing the whiteouts, + /// manifest, and pin together prevents a completed seed from exposing only + /// half of its deletion state. pub async fn record_seed_state( &self, seeded_paths: &[String], whiteout_paths: &[String], + pin: &str, ) -> Result<()> { let conn = self.pool.get_connection().await?; let txn = Transaction::new_unchecked(&conn, TransactionBehavior::Immediate).await?; @@ -107,6 +115,7 @@ impl Vfs { ) .await?; } + write_metadata_value(&conn, SEED_PIN_KEY, pin.to_string()).await?; let value = serde_json::to_string(seeded_paths)?; write_metadata_value(&conn, SEEDED_PATHS_KEY, value).await } @@ -285,12 +294,13 @@ mod tests { } ); let seeded_paths = vec!["src/main.rs".to_string(), "deleted.txt".to_string()]; - vfs.record_seed_state(&seeded_paths, &["/deleted.txt".to_string()]) + vfs.record_seed_state(&seeded_paths, &["/deleted.txt".to_string()], "pin-sha") .await?; assert_eq!( vfs.session_metadata().await?.seeded_paths, seeded_paths.clone() ); + assert_eq!(vfs.seed_pin().await?.as_deref(), Some("pin-sha")); assert_eq!( vfs.get_whiteouts().await?, std::collections::HashSet::from(["/deleted.txt".to_string()]) diff --git a/docs/MANUAL.md b/docs/MANUAL.md index 3ab42933..e84eacde 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -432,6 +432,27 @@ vfs seed [OPTIONS] - `--pin ` — Git commit used as the pristine portable base - `--json` — Emit machine-readable JSON (seed output is always JSON) +### vfs adopt + +Install an externally transferred run session. + +Verifies a packed session database against the receiver's base git checkout (the checkout's HEAD must equal the artifact's recorded seed pin), migrates supported older artifact schemas to the current version, and atomically publishes ~/.vfs/run/. After adopt, `vfs run --session ` resumes the transferred session. + +``` +vfs adopt [OPTIONS] +``` + +**Arguments:** + +- `` — Run session identifier + +**Options:** + +- `--db ` — Packed session database produced by `vfs pack` +- `--base ` — The receiver's base git checkout for the session +- `--pin ` — Git commit the base checkout must be at (required only when the artifact does not record a seed pin) +- `--json` — Emit machine-readable JSON (adopt output is always JSON) + ### vfs status Show run session state for daemon preflight @@ -603,6 +624,9 @@ copies the sender's index bytes, preserving staged versus unstaged state; Git revalidates cached stat fields when the delta is materialized over a pristine checkout at the pin. +Seed records the resolved pin inside the session database as the session's +base provenance; `vfs adopt` verifies the receiving base checkout against it. + Seed is a birth-time operation. A second call fails with `session already seeded`, and a live mount or wrapped process fails with the same exit-code-3 teardown gate used by `vfs pack`. @@ -639,31 +663,58 @@ The packed transfer artifact is only `delta.db`; `procs/`, `mnt/`, `.session.lock`, `base_path`, and other session-directory files are never included in that artifact. -### Externally materialized run sessions +### Adopting run sessions + +`vfs adopt --db --base ` installs a transferred +pack artifact as a local run session. Adopt owns the receiver side of a +handoff end to end: + +1. It refuses a session ID whose `~/.vfs/run//delta.db` already + exists, and takes the same exclusive `.session.lock` used by `run`, `seed`, + and `pack`, so a live owner fails with exit code `3`. +2. It stages a private copy of the artifact, checks SQLite integrity, and + migrates supported older artifact schemas to the current version — the + same staging migration `vfs pack` performs before it ships an artifact, so + an adopted session always opens under `vfs run` without a separate + `vfs migrate` step. An artifact written by a newer vfs is refused with an + error naming both versions. +3. It verifies base provenance: `--base` must be a git checkout whose `HEAD` + equals the seed pin recorded in the artifact by `vfs seed`. For artifacts + without recorded provenance, `--pin ` is required and verified the + same way; a `--pin` that contradicts recorded provenance is refused. A + base checkout at any other commit is refused before anything is published. +4. Publication writes `base_path` and then atomically renames the staged + database to `delta.db`. That rename is the commit point: on any earlier + failure a session directory adopt created is removed again, so a partial + or corrupt session is never observable. + +Adopt prints a one-line JSON manifest: -The receiver contract for an externally materialized session is exactly: - -```text -~/.vfs/run// -├── delta.db -└── base_path +```json +{ + "manifestVersion": 1, + "sessionId": "", + "basePath": "/abs/receiver/checkout", + "basePin": "", + "generation": 1, + "schemaVersion": "0.6", + "seededPaths": ["src/lib.rs"], + "vfsVersion": "0.6.4" +} ``` -- `delta.db` is the packed database produced by `vfs pack`. -- `base_path` is a UTF-8 text file containing the absolute path of the - receiver's existing base checkout. The path must name a directory. -- The receiver does not create `mnt/`, `procs/`, `.session.lock`, SQLite - sidecars, or any other state. `vfs run --session ` creates or - recovers those runtime artifacts lazily. -- A later invocation may start from any host working directory; the persisted - `base_path` remains the session base. - -After writing those two files, resume the session with: +After adopt, resume the session with: ```bash vfs run --session -- ``` +The installed layout — `delta.db` plus a `base_path` text file naming the +receiver checkout, with `mnt/`, `procs/`, `.session.lock`, and +`runtime-status.json` created lazily by `vfs run` — is an internal detail +owned by adopt; sessions must be installed through `vfs adopt`, not by +writing those files by hand. + ### Run session status `vfs status --json` emits one JSON object: @@ -698,9 +749,9 @@ these reserved statuses before the wrapped command begins: |--------|---------| | `1` | General CLI failure not assigned a more specific status | | `2` | Command-line usage or argument parsing failure | -| `3` | The requested session is genuinely live; `run`, `pack`, or `seed` cannot take exclusive ownership | +| `3` | The requested session is genuinely live; `run`, `pack`, `seed`, or `adopt` cannot take exclusive ownership | | `4` | `vfs run` could not create, recover, or install the session mount/sandbox | -| `5` | The requested run session ID or externally materialized session directory is missing, malformed, or invalid | +| `5` | The requested run session ID or adopted session directory is missing, malformed, or invalid | | `126` | The wrapped command was found but could not be executed | | `127` | The wrapped command was not found | diff --git a/docs/SPEC.md b/docs/SPEC.md index c8fd0576..82cf9706 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -882,6 +882,7 @@ CREATE TABLE fs_session_metadata ( |-----|----------|-------------| | `generation` | Base-10 unsigned integer text | Monotonic counter incremented by each successful `vfs pack` | | `seeded_paths` | JSON string array | Paths materialized by the future seed operation; defaults to `[]` | +| `seed_pin` | Full git commit hash text | Base provenance recorded by `vfs seed`; `vfs adopt` verifies the receiving base checkout's `HEAD` against it | `vfs pack` updates metadata only in its private database copy. The new generation becomes authoritative when the compacted copy is atomically