Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions crates/bashkit-js/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,60 @@ impl Bash {
})
}

// ========================================================================
// Snapshot / Resume
// ========================================================================

/// Serialize interpreter state (shell variables, VFS contents, counters) to bytes.
///
/// Returns a `Buffer` (Uint8Array) that can be persisted and used with
/// `Bash.fromSnapshot()` to restore the session later.
#[napi]
pub fn snapshot(&self) -> napi::Result<napi::bindgen_prelude::Buffer> {
block_on_with(&self.state, |s| async move {
let bash = s.inner.lock().await;
let bytes = bash
.snapshot()
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
Ok(napi::bindgen_prelude::Buffer::from(bytes))
})
}

/// Restore interpreter state from a snapshot previously created with `snapshot()`.
#[napi]
pub fn restore_snapshot(&self, data: napi::bindgen_prelude::Buffer) -> napi::Result<()> {
block_on_with(&self.state, |s| async move {
let mut bash = s.inner.lock().await;
bash.restore_snapshot(&data)
.map_err(|e| napi::Error::from_reason(e.to_string()))
})
}

/// Create a new Bash instance from a snapshot.
#[napi(factory)]
pub fn from_snapshot(data: napi::bindgen_prelude::Buffer) -> napi::Result<Self> {
let bash =
RustBash::from_snapshot(&data).map_err(|e| napi::Error::from_reason(e.to_string()))?;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| napi::Error::from_reason(format!("Failed to create runtime: {e}")))?;
Ok(Self {
state: Arc::new(SharedState {
inner: Mutex::new(bash),
rt: tokio::sync::Mutex::new(rt),
cancelled: Arc::new(AtomicBool::new(false)),
username: None,
hostname: None,
max_commands: None,
max_loop_iterations: None,
python: false,
external_functions: Vec::new(),
external_handler: None,
}),
})
}

// ========================================================================
// VFS — direct filesystem access
// ========================================================================
Expand Down
46 changes: 46 additions & 0 deletions crates/bashkit-js/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,52 @@ export class Bash {
this.native.reset();
}

// Snapshot / Resume

/**
* Serialize interpreter state (variables, VFS, counters) to a Uint8Array.
*
* The snapshot can be persisted to disk, sent over the network, and later
* used with `Bash.fromSnapshot()` to restore the session.
*
* @example
* ```typescript
* const bash = new Bash();
* await bash.execute("x=42");
* const snapshot = bash.snapshot();
* // persist snapshot...
* const bash2 = Bash.fromSnapshot(snapshot);
* const r = await bash2.execute("echo $x"); // "42\n"
* ```
*/
snapshot(): Uint8Array {
return this.native.snapshot();
}

/**
* Restore interpreter state from a previously captured snapshot.
* Preserves current configuration (limits, builtins) but replaces
* shell state and VFS contents.
*/
restoreSnapshot(data: Uint8Array): void {
this.native.restoreSnapshot(Buffer.from(data));
}

/**
* Create a new Bash instance from a snapshot.
*
* @example
* ```typescript
* const snapshot = existingBash.snapshot();
* const restored = Bash.fromSnapshot(snapshot);
* ```
*/
static fromSnapshot(data: Uint8Array): Bash {
const instance = new Bash();
instance.native = NativeBash.fromSnapshot(Buffer.from(data));
return instance;
}

// VFS — direct filesystem access

/** Read a file from the virtual filesystem as a UTF-8 string. */
Expand Down
9 changes: 9 additions & 0 deletions crates/bashkit/src/fs/memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1612,6 +1612,15 @@ impl FileSystemExt for InMemoryFs {
fn limits(&self) -> FsLimits {
self.limits.clone()
}

fn vfs_snapshot(&self) -> Option<VfsSnapshot> {
Some(self.snapshot())
}

fn vfs_restore(&self, snapshot: &VfsSnapshot) -> bool {
self.restore(snapshot);
true
}
}

#[cfg(test)]
Expand Down
10 changes: 10 additions & 0 deletions crates/bashkit/src/fs/mountable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,16 @@ impl FileSystemExt for MountableFs {
let (fs, resolved) = self.resolve(path);
fs.mkfifo(&resolved, mode).await
}

fn vfs_snapshot(&self) -> Option<super::VfsSnapshot> {
// Delegate to root filesystem
self.root.vfs_snapshot()
}

fn vfs_restore(&self, snapshot: &super::VfsSnapshot) -> bool {
// Delegate to root filesystem
self.root.vfs_restore(snapshot)
}
}

#[cfg(test)]
Expand Down
9 changes: 9 additions & 0 deletions crates/bashkit/src/fs/overlay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,15 @@ impl FileSystemExt for OverlayFs {
fn limits(&self) -> FsLimits {
self.limits.clone()
}

fn vfs_snapshot(&self) -> Option<super::VfsSnapshot> {
Some(self.upper.snapshot())
}

fn vfs_restore(&self, snapshot: &super::VfsSnapshot) -> bool {
self.upper.restore(snapshot);
true
}
}

#[cfg(test)]
Expand Down
17 changes: 17 additions & 0 deletions crates/bashkit/src/fs/traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,23 @@ pub trait FileSystemExt: Send + Sync {
fn limits(&self) -> FsLimits {
FsLimits::unlimited()
}

/// Take a snapshot of the filesystem contents for serialization.
///
/// Returns `None` if this filesystem implementation doesn't support snapshots.
/// The default implementation returns `None`. `InMemoryFs` and filesystems
/// wrapping it (e.g. `MountableFs`, `OverlayFs`) return `Some(snapshot)`.
fn vfs_snapshot(&self) -> Option<super::VfsSnapshot> {
None
}

/// Restore filesystem contents from a snapshot.
///
/// Returns `false` if this filesystem doesn't support restore.
/// The default implementation returns `false`.
fn vfs_restore(&self, _snapshot: &super::VfsSnapshot) -> bool {
false
}
}

/// Async virtual filesystem trait.
Expand Down
11 changes: 11 additions & 0 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1004,6 +1004,17 @@ impl Interpreter {
self.traps = state.traps.clone();
}

/// Get a reference to the current execution counters.
pub fn counters(&self) -> &crate::limits::ExecutionCounters {
&self.counters
}

/// Restore session-level counters from a snapshot.
pub fn restore_session_counters(&mut self, session_commands: u64, session_exec_calls: u64) {
self.counters.session_commands = session_commands;
self.counters.session_exec_calls = session_exec_calls;
}

/// Set an output callback for streaming output during execution.
///
/// When set, the interpreter calls this callback with `(stdout_chunk, stderr_chunk)`
Expand Down
2 changes: 2 additions & 0 deletions crates/bashkit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@ pub mod parser;
/// Requires the `scripted_tool` feature.
#[cfg(feature = "scripted_tool")]
pub mod scripted_tool;
mod snapshot;
/// Tool contract for LLM integration
pub mod tool;
/// Structured execution trace events.
Expand All @@ -434,6 +435,7 @@ pub use limits::{
ExecutionCounters, ExecutionLimits, LimitExceeded, MemoryBudget, MemoryLimits, SessionLimits,
};
pub use network::NetworkAllowlist;
pub use snapshot::Snapshot;
pub use tool::BashToolBuilder as ToolBuilder;
pub use tool::{
BashTool, BashToolBuilder, Tool, ToolError, ToolExecution, ToolImage, ToolOutput,
Expand Down
Loading
Loading