workdir: separate identity from worker session

This commit is contained in:
2026-08-03 18:12:47 +09:00
parent 0ffaa6c741
commit 05e8b00bf0
22 changed files with 541 additions and 314 deletions
+75 -35
View File
@@ -1,8 +1,9 @@
//! Workdir authority and local materialization provider.
//! Persistent Workdir identity and Worker-bound operation sessions.
//!
//! A Workdir is the host-owned execution context bound to one Worker. Tools
//! consume this interface; they do not own Workdir identity, paths, scope, or
//! lifecycle.
//! A [`Workdir`] identifies a materialized repository execution context across
//! Worker lifetimes. A [`WorkdirSession`] is the live operation attachment
//! bound to one Worker. Tools consume sessions; they do not own Workdir
//! materialization or cleanup.
mod local;
mod operation;
@@ -14,12 +15,47 @@ use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
pub use local::{LocalWorkdir, SymlinkInfo, direct_symlink, first_symlink};
pub use local::{LocalWorkdirSession, SymlinkInfo, direct_symlink, first_symlink};
pub use operation::*;
/// Persistent, opaque identity of one materialized Workdir.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Workdir {
id: WorkdirId,
}
impl Workdir {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: WorkdirId(id.into()),
}
}
pub fn id(&self) -> &WorkdirId {
&self.id
}
}
/// Opaque Workdir identifier assigned by the materialization authority.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct WorkdirId(String);
impl WorkdirId {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for WorkdirId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkdirCapability {
pub enum WorkdirSessionCapability {
Read,
Write,
Edit,
@@ -29,11 +65,11 @@ pub enum WorkdirCapability {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkdirCapabilities {
pub struct WorkdirSessionCapabilities {
bits: u8,
}
impl WorkdirCapabilities {
impl WorkdirSessionCapabilities {
const READ: u8 = 1 << 0;
const WRITE: u8 = 1 << 1;
const EDIT: u8 = 1 << 2;
@@ -43,20 +79,22 @@ impl WorkdirCapabilities {
pub const EMPTY: Self = Self { bits: 0 };
pub fn from_capabilities(capabilities: impl IntoIterator<Item = WorkdirCapability>) -> Self {
pub fn from_capabilities(
capabilities: impl IntoIterator<Item = WorkdirSessionCapability>,
) -> Self {
capabilities
.into_iter()
.fold(Self::EMPTY, |set, capability| set.with(capability))
}
pub const fn with(mut self, capability: WorkdirCapability) -> Self {
pub const fn with(mut self, capability: WorkdirSessionCapability) -> Self {
self.bits |= match capability {
WorkdirCapability::Read => Self::READ,
WorkdirCapability::Write => Self::WRITE,
WorkdirCapability::Edit => Self::EDIT,
WorkdirCapability::Glob => Self::GLOB,
WorkdirCapability::Grep => Self::GREP,
WorkdirCapability::Command => Self::COMMAND,
WorkdirSessionCapability::Read => Self::READ,
WorkdirSessionCapability::Write => Self::WRITE,
WorkdirSessionCapability::Edit => Self::EDIT,
WorkdirSessionCapability::Glob => Self::GLOB,
WorkdirSessionCapability::Grep => Self::GREP,
WorkdirSessionCapability::Command => Self::COMMAND,
};
self
}
@@ -69,14 +107,14 @@ impl WorkdirCapabilities {
bits: Self::READ | Self::GLOB | Self::GREP,
};
pub const fn supports(self, capability: WorkdirCapability) -> bool {
pub const fn supports(self, capability: WorkdirSessionCapability) -> bool {
let bit = match capability {
WorkdirCapability::Read => Self::READ,
WorkdirCapability::Write => Self::WRITE,
WorkdirCapability::Edit => Self::EDIT,
WorkdirCapability::Glob => Self::GLOB,
WorkdirCapability::Grep => Self::GREP,
WorkdirCapability::Command => Self::COMMAND,
WorkdirSessionCapability::Read => Self::READ,
WorkdirSessionCapability::Write => Self::WRITE,
WorkdirSessionCapability::Edit => Self::EDIT,
WorkdirSessionCapability::Glob => Self::GLOB,
WorkdirSessionCapability::Grep => Self::GREP,
WorkdirSessionCapability::Command => Self::COMMAND,
};
self.bits & bit != 0
}
@@ -84,15 +122,16 @@ impl WorkdirCapabilities {
pub type WriteOutcome = WriteResult;
/// Network-capable operations available on one bound Workdir.
/// Live, Worker-bound operations for one persistent [`Workdir`].
///
/// Implementations execute filesystem search and command work on the host
/// that owns the materialization. Requests and results never contain the raw
/// materialized root.
/// that owns the materialization. Structured requests and results never
/// contain the raw materialized root. Closing a session is terminal and does
/// not delete the persistent Workdir or its materialization.
#[async_trait]
pub trait Workdir: std::fmt::Debug + Send + Sync {
fn binding_id(&self) -> Option<&str>;
fn capabilities(&self) -> WorkdirCapabilities;
pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
fn workdir(&self) -> &Workdir;
fn capabilities(&self) -> WorkdirSessionCapabilities;
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError>;
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError>;
@@ -108,26 +147,27 @@ pub trait Workdir: std::fmt::Debug + Send + Sync {
request: CommandOutputRequest,
) -> Result<CommandOutput, WorkdirError>;
async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError>;
async fn shutdown(&self) -> Result<(), WorkdirError>;
/// Terminal, idempotent release of this Worker-bound operation session.
async fn close(&self) -> Result<(), WorkdirError>;
}
pub type WorkdirHandle = Arc<dyn Workdir>;
pub type WorkdirSessionHandle = Arc<dyn WorkdirSession>;
#[derive(Debug, thiserror::Error)]
pub enum WorkdirError {
#[error("Workdir does not support {0:?}")]
Unsupported(WorkdirCapability),
#[error("Workdir session does not support {0:?}")]
Unsupported(WorkdirSessionCapability),
#[error("invalid Workdir path: {0}")]
InvalidPath(String),
#[error("Workdir provider is unavailable: {0}")]
#[error("Workdir session is unavailable: {0}")]
Unavailable(String),
#[error("Workdir content was modified externally before the operation could be applied: {0}")]
Conflict(String),
#[error("unknown Workdir command: {0}")]
#[error("unknown Workdir session command: {0}")]
UnknownCommand(String),
#[error("path must be absolute: {}", .0.display())]
+215 -91
View File
@@ -1,11 +1,11 @@
//! Scope-aware filesystem primitive.
//!
//! `LocalWorkdir` is the write/read gate layered on top of a [`manifest::Scope`]
//! `LocalWorkdirSession` is the write/read gate layered on top of a [`manifest::Scope`]
//! and a Worker's working directory. The scope decides which paths are
//! readable and writable; the cwd is carried alongside for convenience
//! (Glob/Grep default their search base to it).
//!
//! `LocalWorkdir` is cheap to clone (`Arc` inside). Tool-specific session
//! `LocalWorkdirSession` is cheap to clone (`Arc` inside). Tool-specific session
//! state, such as read-before-edit tracking, remains owned by the tool layer.
use std::collections::HashMap;
@@ -13,7 +13,7 @@ use std::io::{Read as _, Seek as _, SeekFrom, Write as _};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::Duration;
use async_trait::async_trait;
@@ -29,8 +29,8 @@ use crate::{
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
EditResult, EntryKind, GlobRequest, GlobResult, GrepRequest, GrepResult, ListEntry,
ListRequest, ListResult, ReadRequest, ReadResult, StatRequest, StatResult, Workdir,
WorkdirCapabilities, WorkdirCapability, WorkdirError, WorkdirPath, WriteOutcome, WriteRequest,
WriteResult,
WorkdirError, WorkdirPath, WorkdirSession, WorkdirSessionCapabilities,
WorkdirSessionCapability, WriteOutcome, WriteRequest, WriteResult,
};
#[derive(Debug)]
@@ -43,17 +43,19 @@ enum LocalCommand {
}
#[derive(Debug)]
struct LocalWorkdirInner {
binding_id: Option<String>,
struct LocalWorkdirSessionInner {
workdir: Workdir,
root: PathBuf,
scope: SharedScope,
cwd: PathBuf,
capabilities: WorkdirCapabilities,
capabilities: WorkdirSessionCapabilities,
closed: AtomicBool,
close_lock: Mutex<()>,
next_command_id: AtomicU64,
commands: Mutex<HashMap<String, LocalCommand>>,
}
impl Drop for LocalWorkdirInner {
impl Drop for LocalWorkdirSessionInner {
fn drop(&mut self) {
if let Ok(mut commands) = self.commands.try_lock() {
for (_, command) in commands.drain() {
@@ -68,13 +70,13 @@ impl Drop for LocalWorkdirInner {
/// Scope-aware filesystem handle. Clone-cheap (`Arc` inside).
///
/// The wrapped [`SharedScope`] is shared with every clone of this
/// `LocalWorkdir` and with whoever else holds the same `SharedScope`
/// `LocalWorkdirSession` and with whoever else holds the same `SharedScope`
/// handle (typically the owning Worker). Mutations to that `SharedScope`
/// propagate atomically; the next permission check inside any
/// `LocalWorkdir` reads the new view.
/// `LocalWorkdirSession` reads the new view.
#[derive(Debug, Clone)]
pub struct LocalWorkdir {
inner: Arc<LocalWorkdirInner>,
pub struct LocalWorkdirSession {
inner: Arc<LocalWorkdirSessionInner>,
}
/// First symlink encountered while resolving a path.
@@ -94,48 +96,63 @@ pub struct SymlinkInfo {
pub target_exists: bool,
}
impl LocalWorkdir {
/// Create a new [`LocalWorkdir`] wrapping `scope` and `cwd` in a fresh
/// [`SharedScope`]. Use [`LocalWorkdir::with_shared_scope`] when you
/// need the resulting `LocalWorkdir` to share scope state with another
fn local_workdir_identity(root: &Path) -> Workdir {
let canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
let digest = Sha256::digest(canonical.to_string_lossy().as_bytes());
let digest = digest
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
Workdir::new(format!("local-{digest}"))
}
impl LocalWorkdirSession {
/// Create a new [`LocalWorkdirSession`] wrapping `scope` and `cwd` in a fresh
/// [`SharedScope`]. Use [`LocalWorkdirSession::with_shared_scope`] when you
/// need the resulting `LocalWorkdirSession` to share scope state with another
/// holder of the `SharedScope` (typically the Worker).
pub fn new(scope: Scope, cwd: PathBuf) -> Self {
Self::materialized(
cwd.clone(),
cwd,
SharedScope::new(scope),
WorkdirCapabilities::ALL,
WorkdirSessionCapabilities::ALL,
)
}
pub fn with_shared_scope(scope: SharedScope, cwd: PathBuf) -> Self {
Self::materialized(cwd.clone(), cwd, scope, WorkdirCapabilities::ALL)
Self::materialized(cwd.clone(), cwd, scope, WorkdirSessionCapabilities::ALL)
}
/// Construct the local provider for an existing WorkerWorkdir binding.
/// Construct a standalone local session with a deterministic identity
/// derived from the canonical materialization root.
pub fn materialized(
root: PathBuf,
cwd: PathBuf,
scope: SharedScope,
capabilities: WorkdirCapabilities,
capabilities: WorkdirSessionCapabilities,
) -> Self {
Self::materialized_bound(None, root, cwd, scope, capabilities)
let workdir = local_workdir_identity(&root);
Self::materialized_bound(workdir, root, cwd, scope, capabilities)
}
/// Open a local session for an authority-assigned persistent Workdir.
pub fn materialized_bound(
binding_id: Option<String>,
workdir: Workdir,
root: PathBuf,
cwd: PathBuf,
scope: SharedScope,
capabilities: WorkdirCapabilities,
capabilities: WorkdirSessionCapabilities,
) -> Self {
Self {
inner: Arc::new(LocalWorkdirInner {
binding_id,
inner: Arc::new(LocalWorkdirSessionInner {
workdir,
root,
scope,
cwd,
capabilities,
closed: AtomicBool::new(false),
close_lock: Mutex::new(()),
next_command_id: AtomicU64::new(1),
commands: Mutex::new(HashMap::new()),
}),
@@ -153,7 +170,7 @@ impl LocalWorkdir {
self.inner.scope.snapshot()
}
/// Shared scope handle backing this `LocalWorkdir`. Cloning it lets a
/// Shared scope handle backing this `LocalWorkdirSession`. Cloning it lets a
/// caller (usually the Worker) hold the same view and push updates
/// that are immediately reflected in subsequent permission checks.
pub fn shared_scope(&self) -> &SharedScope {
@@ -298,7 +315,19 @@ impl LocalWorkdir {
})
}
fn ensure_capability(&self, capability: WorkdirCapability) -> Result<(), WorkdirError> {
fn ensure_open(&self) -> Result<(), WorkdirError> {
if self.inner.closed.load(Ordering::Acquire) {
Err(WorkdirError::Unavailable(format!(
"Workdir session for {} is closed",
self.inner.workdir.id()
)))
} else {
Ok(())
}
}
fn ensure_capability(&self, capability: WorkdirSessionCapability) -> Result<(), WorkdirError> {
self.ensure_open()?;
if self.inner.capabilities.supports(capability) {
Ok(())
} else {
@@ -323,17 +352,17 @@ impl LocalWorkdir {
}
#[async_trait]
impl Workdir for LocalWorkdir {
fn binding_id(&self) -> Option<&str> {
self.inner.binding_id.as_deref()
impl WorkdirSession for LocalWorkdirSession {
fn workdir(&self) -> &Workdir {
&self.inner.workdir
}
fn capabilities(&self) -> WorkdirCapabilities {
fn capabilities(&self) -> WorkdirSessionCapabilities {
self.inner.capabilities
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
self.ensure_capability(WorkdirCapability::Read)?;
self.ensure_capability(WorkdirSessionCapability::Read)?;
let path = self.resolve(&request.path);
let metadata = std::fs::symlink_metadata(&path).map_err(|error| {
let error = match error.kind() {
@@ -359,9 +388,9 @@ impl Workdir for LocalWorkdir {
}
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError> {
self.ensure_capability(WorkdirCapability::Read)?;
self.ensure_capability(WorkdirSessionCapability::Read)?;
let path = self.resolve(&request.path);
let bytes = LocalWorkdir::read_bytes(self, &path)
let bytes = LocalWorkdirSession::read_bytes(self, &path)
.map_err(|error| sanitize_error(error, &request.path))?;
let content_hash = Sha256::digest(&bytes).into();
let lines = bytes
@@ -403,10 +432,10 @@ impl Workdir for LocalWorkdir {
}
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
self.ensure_capability(WorkdirCapability::Write)?;
self.ensure_capability(WorkdirSessionCapability::Write)?;
let path = self.resolve(&request.path);
if path.exists() {
let current = LocalWorkdir::read_bytes(self, &path)
let current = LocalWorkdirSession::read_bytes(self, &path)
.map_err(|error| sanitize_error(error, &request.path))?;
let current_hash: [u8; 32] = Sha256::digest(&current).into();
if request.expected_hash != Some(current_hash) {
@@ -415,14 +444,14 @@ impl Workdir for LocalWorkdir {
} else if request.expected_hash.is_some() {
return Err(WorkdirError::Conflict(request.path.to_string()));
}
LocalWorkdir::write(self, &path, &request.content)
LocalWorkdirSession::write(self, &path, &request.content)
.map_err(|error| sanitize_error(error, &request.path))
}
async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError> {
self.ensure_capability(WorkdirCapability::Edit)?;
self.ensure_capability(WorkdirSessionCapability::Edit)?;
let path = self.resolve(&request.path);
let bytes = LocalWorkdir::read_bytes(self, &path)
let bytes = LocalWorkdirSession::read_bytes(self, &path)
.map_err(|error| sanitize_error(error, &request.path))?;
let current_hash: [u8; 32] = Sha256::digest(&bytes).into();
if current_hash != request.expected_hash {
@@ -446,7 +475,7 @@ impl Workdir for LocalWorkdir {
} else {
text.replacen(&request.old_string, &request.new_string, 1)
};
let outcome = LocalWorkdir::write(self, &path, edited.as_bytes())
let outcome = LocalWorkdirSession::write(self, &path, edited.as_bytes())
.map_err(|error| sanitize_error(error, &request.path))?;
let content_hash = Sha256::digest(edited.as_bytes()).into();
Ok(EditResult {
@@ -457,7 +486,7 @@ impl Workdir for LocalWorkdir {
}
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError> {
self.ensure_capability(WorkdirCapability::Read)?;
self.ensure_capability(WorkdirSessionCapability::Read)?;
let base = self.resolve(&request.path);
let scope = self.inner.scope.snapshot();
if !scope.is_readable(&base) {
@@ -520,7 +549,7 @@ impl Workdir for LocalWorkdir {
}
async fn glob(&self, request: GlobRequest) -> Result<GlobResult, WorkdirError> {
self.ensure_capability(WorkdirCapability::Glob)?;
self.ensure_capability(WorkdirSessionCapability::Glob)?;
let base = self.resolve(&request.path);
if let Some(info) = direct_symlink(&base)
&& info.target_exists
@@ -562,7 +591,7 @@ impl Workdir for LocalWorkdir {
}
async fn grep(&self, request: GrepRequest) -> Result<GrepResult, WorkdirError> {
self.ensure_capability(WorkdirCapability::Grep)?;
self.ensure_capability(WorkdirSessionCapability::Grep)?;
let base = self.resolve(&request.path);
let logical = request.path.clone();
crate::search::run_grep(
@@ -575,7 +604,7 @@ impl Workdir for LocalWorkdir {
}
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
self.ensure_capability(WorkdirCapability::Command)?;
self.ensure_capability(WorkdirSessionCapability::Command)?;
let id = self.inner.next_command_id.fetch_add(1, Ordering::Relaxed);
let handle = CommandHandle(format!("command-{id}"));
let cwd = self.inner.cwd.clone();
@@ -586,16 +615,18 @@ impl Workdir for LocalWorkdir {
task_completion.notify_one();
output
});
self.inner
.commands
.lock()
.await
.insert(handle.0.clone(), LocalCommand::Running { task, completion });
let mut commands = self.inner.commands.lock().await;
if let Err(error) = self.ensure_open() {
task.abort();
completion.notify_one();
return Err(error);
}
commands.insert(handle.0.clone(), LocalCommand::Running { task, completion });
Ok(handle)
}
async fn command_status(&self, handle: CommandHandle) -> Result<CommandStatus, WorkdirError> {
self.ensure_capability(WorkdirCapability::Command)?;
self.ensure_capability(WorkdirSessionCapability::Command)?;
let commands = self.inner.commands.lock().await;
let command = commands
.get(&handle.0)
@@ -611,8 +642,9 @@ impl Workdir for LocalWorkdir {
&self,
request: CommandOutputRequest,
) -> Result<CommandOutput, WorkdirError> {
self.ensure_capability(WorkdirCapability::Command)?;
self.ensure_capability(WorkdirSessionCapability::Command)?;
let command = loop {
self.ensure_open()?;
let mut commands = self.inner.commands.lock().await;
let Some(command) = commands.get(&request.handle.0) else {
return Err(WorkdirError::UnknownCommand(request.handle.0.clone()));
@@ -651,17 +683,16 @@ impl Workdir for LocalWorkdir {
};
let page = command_output_page(&output, request.cursor, request.limit);
if page.next_cursor.is_some() {
self.inner
.commands
.lock()
.await
.insert(request.handle.0, LocalCommand::Completed(output));
let mut commands = self.inner.commands.lock().await;
if !self.inner.closed.load(Ordering::Acquire) {
commands.insert(request.handle.0, LocalCommand::Completed(output));
}
}
Ok(page)
}
async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> {
self.ensure_capability(WorkdirCapability::Command)?;
self.ensure_capability(WorkdirSessionCapability::Command)?;
let command = self
.inner
.commands
@@ -676,7 +707,11 @@ impl Workdir for LocalWorkdir {
Ok(())
}
async fn shutdown(&self) -> Result<(), WorkdirError> {
async fn close(&self) -> Result<(), WorkdirError> {
let _close_guard = self.inner.close_lock.lock().await;
if self.inner.closed.swap(true, Ordering::AcqRel) {
return Ok(());
}
let mut commands = self.inner.commands.lock().await;
for (_, command) in commands.drain() {
if let LocalCommand::Running { task, completion } = command {
@@ -955,8 +990,8 @@ mod tests {
use std::fs;
use tempfile::TempDir;
fn make_fs(dir: &TempDir) -> LocalWorkdir {
LocalWorkdir::new(
fn make_fs(dir: &TempDir) -> LocalWorkdirSession {
LocalWorkdirSession::new(
Scope::writable(dir.path()).unwrap(),
dir.path().to_path_buf(),
)
@@ -968,7 +1003,7 @@ mod tests {
let workdir = make_fs(&dir);
let path = WorkdirPath::new("notes/item.txt").unwrap();
let written = Workdir::write(
let written = WorkdirSession::write(
&workdir,
WriteRequest {
path: path.clone(),
@@ -980,7 +1015,7 @@ mod tests {
.unwrap();
assert!(written.created);
let read = Workdir::read(
let read = WorkdirSession::read(
&workdir,
ReadRequest {
path: path.clone(),
@@ -994,7 +1029,7 @@ mod tests {
assert_eq!(read.bytes, b"alpha\nbeta\n");
assert!(!read.truncated);
let bounded = Workdir::read(
let bounded = WorkdirSession::read(
&workdir,
ReadRequest {
path: path.clone(),
@@ -1009,7 +1044,7 @@ mod tests {
assert!(bounded.truncated);
assert_eq!(bounded.content_hash, read.content_hash);
let edited = Workdir::edit(
let edited = WorkdirSession::edit(
&workdir,
EditRequest {
path: path.clone(),
@@ -1023,13 +1058,13 @@ mod tests {
.unwrap();
assert_eq!(edited.replacements, 1);
let stat = Workdir::stat(&workdir, StatRequest { path: path.clone() })
let stat = WorkdirSession::stat(&workdir, StatRequest { path: path.clone() })
.await
.unwrap();
assert_eq!(stat.path, path);
assert_eq!(stat.kind, EntryKind::File);
let listed = Workdir::list(
let listed = WorkdirSession::list(
&workdir,
ListRequest {
path: WorkdirPath::new("notes").unwrap(),
@@ -1041,7 +1076,7 @@ mod tests {
assert_eq!(listed.total_entries, 1);
assert_eq!(listed.entries[0].path.as_str(), "notes/item.txt");
let error = Workdir::edit(
let error = WorkdirSession::edit(
&workdir,
EditRequest {
path: path.clone(),
@@ -1056,7 +1091,7 @@ mod tests {
assert!(matches!(error, WorkdirError::Conflict(_)));
std::fs::remove_file(dir.path().join("notes/item.txt")).unwrap();
let error = Workdir::write(
let error = WorkdirSession::write(
&workdir,
WriteRequest {
path,
@@ -1069,19 +1104,108 @@ mod tests {
assert!(matches!(error, WorkdirError::Conflict(_)));
}
#[tokio::test]
async fn close_is_terminal_for_one_session_without_deleting_workdir_identity() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("item.txt"), "persisted").unwrap();
let workdir = Workdir::new("working-directory-42");
let scope = SharedScope::new(Scope::writable(dir.path()).unwrap());
let session = LocalWorkdirSession::materialized_bound(
workdir.clone(),
dir.path().to_path_buf(),
dir.path().to_path_buf(),
scope.clone(),
WorkdirSessionCapabilities::ALL,
);
assert_eq!(session.workdir(), &workdir);
let command = WorkdirSession::start_command(
&session,
CommandRequest {
command: "sleep 30".to_owned(),
timeout_secs: 60,
output_limit: 1024,
},
)
.await
.unwrap();
let waiting_session = session.clone();
let waiting_command = command.clone();
let waiter = tokio::spawn(async move {
WorkdirSession::command_output(
&waiting_session,
CommandOutputRequest {
handle: waiting_command,
cursor: 0,
limit: 1024,
wait: true,
},
)
.await
});
tokio::task::yield_now().await;
WorkdirSession::close(&session).await.unwrap();
WorkdirSession::close(&session).await.unwrap();
let waiter_error = tokio::time::timeout(Duration::from_secs(1), waiter)
.await
.expect("close should wake command output waiters")
.unwrap()
.unwrap_err();
assert!(matches!(waiter_error, WorkdirError::Unavailable(_)));
assert!(matches!(
WorkdirSession::command_status(&session, command).await,
Err(WorkdirError::Unavailable(_))
));
assert!(matches!(
WorkdirSession::read(
&session,
ReadRequest {
path: WorkdirPath::new("item.txt").unwrap(),
offset: 0,
limit: 10,
max_bytes: 1024,
},
)
.await,
Err(WorkdirError::Unavailable(_))
));
let restored = LocalWorkdirSession::materialized_bound(
workdir.clone(),
dir.path().to_path_buf(),
dir.path().to_path_buf(),
scope,
WorkdirSessionCapabilities::ALL,
);
assert_eq!(restored.workdir(), &workdir);
let read = WorkdirSession::read(
&restored,
ReadRequest {
path: WorkdirPath::new("item.txt").unwrap(),
offset: 0,
limit: 10,
max_bytes: 1024,
},
)
.await
.unwrap();
assert_eq!(read.bytes, b"persisted");
}
#[tokio::test]
async fn capability_boundary_rejects_direct_unsupported_operation() {
let dir = TempDir::new().unwrap();
let workdir = LocalWorkdir::materialized(
let workdir = LocalWorkdirSession::materialized(
dir.path().to_path_buf(),
dir.path().to_path_buf(),
SharedScope::new(Scope::writable(dir.path()).unwrap()),
WorkdirCapabilities::READ_ONLY,
WorkdirSessionCapabilities::READ_ONLY,
);
assert_eq!(workdir.root(), dir.path());
assert_eq!(workdir.cwd(), dir.path());
let error = Workdir::write(
let error = WorkdirSession::write(
&workdir,
WriteRequest {
path: WorkdirPath::new("blocked.txt").unwrap(),
@@ -1093,7 +1217,7 @@ mod tests {
.unwrap_err();
assert!(matches!(
error,
WorkdirError::Unsupported(WorkdirCapability::Write)
WorkdirError::Unsupported(WorkdirSessionCapability::Write)
));
assert!(!dir.path().join("blocked.txt").exists());
}
@@ -1331,7 +1455,7 @@ mod tests {
}],
};
let scope = Scope::from_config(&cfg).unwrap();
let scoped = LocalWorkdir::new(scope, dir.path().to_path_buf());
let scoped = LocalWorkdirSession::new(scope, dir.path().to_path_buf());
let err = scoped.write(&sub.join("locked.txt"), b"x").unwrap_err();
assert!(
matches!(err, WorkdirError::ReadOnly(_)),
@@ -1365,7 +1489,7 @@ mod tests {
}
// -------------------------------------------------------------------------
// Dynamic scope: SharedScope mutations propagate into LocalWorkdir decisions
// Dynamic scope: SharedScope mutations propagate into LocalWorkdirSession decisions
// -------------------------------------------------------------------------
#[test]
@@ -1378,7 +1502,7 @@ mod tests {
fs::write(&extra_file, b"hi").unwrap();
let shared = SharedScope::new(Scope::writable(dir.path()).unwrap());
let fs = LocalWorkdir::with_shared_scope(shared.clone(), dir.path().to_path_buf());
let fs = LocalWorkdirSession::with_shared_scope(shared.clone(), dir.path().to_path_buf());
// Before: extra is out of scope.
let err = fs.read_bytes(&extra_file).unwrap_err();
@@ -1415,7 +1539,7 @@ mod tests {
let target = sub.join("a.txt");
let shared = SharedScope::new(Scope::writable(dir.path()).unwrap());
let fs = LocalWorkdir::with_shared_scope(shared.clone(), dir.path().to_path_buf());
let fs = LocalWorkdirSession::with_shared_scope(shared.clone(), dir.path().to_path_buf());
// Write succeeds initially.
fs.write(&target, b"first").unwrap();
@@ -1449,7 +1573,7 @@ mod tests {
let target = dir.path().join("a.txt");
let shared = SharedScope::new(Scope::writable(dir.path()).unwrap());
let fs1 = LocalWorkdir::with_shared_scope(shared.clone(), dir.path().to_path_buf());
let fs1 = LocalWorkdirSession::with_shared_scope(shared.clone(), dir.path().to_path_buf());
let fs2 = fs1.clone();
// fs1 writes; both clones see the file.
@@ -1488,7 +1612,7 @@ mod tests {
)
.unwrap();
let workdir = make_fs(&dir);
let glob = Workdir::glob(
let glob = WorkdirSession::glob(
&workdir,
GlobRequest {
pattern: "**/*.rs".into(),
@@ -1499,7 +1623,7 @@ mod tests {
.await
.unwrap();
assert_eq!(glob.paths, [WorkdirPath::new("src/main.rs").unwrap()]);
let grep = Workdir::grep(
let grep = WorkdirSession::grep(
&workdir,
GrepRequest {
pattern: "NEEDLE".into(),
@@ -1520,7 +1644,7 @@ mod tests {
assert_eq!(grep.match_count, 1);
assert!(grep.output.contains("src/main.rs"));
assert!(!grep.output.contains(dir.path().to_string_lossy().as_ref()));
let handle = Workdir::start_command(
let handle = WorkdirSession::start_command(
&workdir,
CommandRequest {
command: "pwd && printf provider-command".into(),
@@ -1530,7 +1654,7 @@ mod tests {
)
.await
.unwrap();
let output = Workdir::command_output(
let output = WorkdirSession::command_output(
&workdir,
CommandOutputRequest {
handle,
@@ -1554,7 +1678,7 @@ mod tests {
async fn completed_command_output_can_be_read_in_bounded_unicode_pages() {
let dir = TempDir::new().unwrap();
let workdir = make_fs(&dir);
let handle = Workdir::start_command(
let handle = WorkdirSession::start_command(
&workdir,
CommandRequest {
command: "printf 'aéz'".into(),
@@ -1564,7 +1688,7 @@ mod tests {
)
.await
.unwrap();
let first = Workdir::command_output(
let first = WorkdirSession::command_output(
&workdir,
CommandOutputRequest {
handle: handle.clone(),
@@ -1578,7 +1702,7 @@ mod tests {
assert_eq!(first.content, "");
assert_eq!(first.next_cursor, Some(2));
let second = Workdir::command_output(
let second = WorkdirSession::command_output(
&workdir,
CommandOutputRequest {
handle: handle.clone(),
@@ -1592,7 +1716,7 @@ mod tests {
assert_eq!(second.content, "z");
assert_eq!(second.next_cursor, None);
assert!(matches!(
Workdir::command_status(&workdir, handle).await,
WorkdirSession::command_status(&workdir, handle).await,
Err(WorkdirError::UnknownCommand(_))
));
}
@@ -1601,7 +1725,7 @@ mod tests {
async fn provider_cancels_active_command() {
let dir = TempDir::new().unwrap();
let workdir = make_fs(&dir);
let handle = Workdir::start_command(
let handle = WorkdirSession::start_command(
&workdir,
CommandRequest {
command: "sleep 30".into(),
@@ -1612,7 +1736,7 @@ mod tests {
.await
.unwrap();
assert_eq!(
Workdir::command_status(&workdir, handle.clone())
WorkdirSession::command_status(&workdir, handle.clone())
.await
.unwrap(),
CommandStatus::Running
@@ -1620,7 +1744,7 @@ mod tests {
let waiting_workdir = workdir.clone();
let waiting_handle = handle.clone();
let waiter = tokio::spawn(async move {
Workdir::command_output(
WorkdirSession::command_output(
&waiting_workdir,
CommandOutputRequest {
handle: waiting_handle,
@@ -1632,7 +1756,7 @@ mod tests {
.await
});
tokio::task::yield_now().await;
Workdir::cancel_command(&workdir, handle.clone())
WorkdirSession::cancel_command(&workdir, handle.clone())
.await
.unwrap();
let waiter_error = tokio::time::timeout(Duration::from_secs(1), waiter)
@@ -1642,7 +1766,7 @@ mod tests {
.unwrap_err();
assert!(matches!(waiter_error, WorkdirError::UnknownCommand(_)));
assert!(matches!(
Workdir::command_status(&workdir, handle).await,
WorkdirSession::command_status(&workdir, handle).await,
Err(WorkdirError::UnknownCommand(_))
));
}