//! Persistent Workdir identity and Worker-bound operation sessions. //! //! 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 delegation; pub mod http; mod local; mod operation; pub mod workspace; use std::path::{Path, PathBuf}; use std::sync::Arc; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; pub use delegation::{ AppliedWorkdirDelegation, ReadOnlyWorkdirSession, WorkdirDelegation, WorkdirDelegationPermission, WorkdirDelegationRequest, WorkdirDelegationRule, apply_delegation_chain, delegation_capable_session, }; pub use fs_operation::{ ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath, GlobRequest, GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult, }; 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) -> 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 WorkdirSessionCapability { Read, Write, Edit, Glob, Grep, Command, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct WorkdirSessionCapabilities { bits: u8, } impl WorkdirSessionCapabilities { const READ: u8 = 1 << 0; const WRITE: u8 = 1 << 1; const EDIT: u8 = 1 << 2; const GLOB: u8 = 1 << 3; const GREP: u8 = 1 << 4; const COMMAND: u8 = 1 << 5; pub const EMPTY: Self = Self { bits: 0 }; pub fn from_capabilities( capabilities: impl IntoIterator, ) -> Self { capabilities .into_iter() .fold(Self::EMPTY, |set, capability| set.with(capability)) } pub const fn with(mut self, capability: WorkdirSessionCapability) -> Self { self.bits |= match capability { 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 } pub const ALL: Self = Self { bits: Self::READ | Self::WRITE | Self::EDIT | Self::GLOB | Self::GREP | Self::COMMAND, }; pub const READ_ONLY: Self = Self { bits: Self::READ | Self::GLOB | Self::GREP, }; pub const fn supports(self, capability: WorkdirSessionCapability) -> bool { let bit = match capability { 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 } } pub type WriteOutcome = WriteResult; /// Live, Worker-bound operations for one persistent [`Workdir`]. /// /// Implementations execute filesystem search and command work on the host /// 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 WorkdirSession: std::fmt::Debug + Send + Sync { fn workdir(&self) -> &Workdir; fn capabilities(&self) -> WorkdirSessionCapabilities; fn is_delegation_capable(&self) -> bool { false } /// Whether this session transports the delegation chain to another /// provider boundary that will apply logical cwd/path resolution there. fn transports_delegation_context(&self) -> bool { false } /// Capture a provider-specific source for a delegated child session. /// Remote providers use this boundary to pin attachment identity without /// exposing transport handles or host paths. async fn capture_delegation_source( &self, _request: &WorkdirDelegationRequest, ) -> Result { Err(WorkdirError::Denied( "workdir provider does not support delegated sessions".into(), )) } /// Attenuate this session into a revocable child lease. Only sessions /// created with [`delegation_capable_session`] implement this operation. async fn delegate( &self, _request: WorkdirDelegationRequest, ) -> Result { Err(WorkdirError::Denied( "workdir session is not delegation-capable".into(), )) } async fn stat(&self, request: StatRequest) -> Result; async fn read(&self, request: ReadRequest) -> Result; async fn write(&self, request: WriteRequest) -> Result; async fn edit(&self, request: EditRequest) -> Result; async fn list(&self, request: ListRequest) -> Result; async fn glob(&self, request: GlobRequest) -> Result; async fn grep(&self, request: GrepRequest) -> Result; async fn start_command(&self, request: CommandRequest) -> Result; async fn command_status(&self, handle: CommandHandle) -> Result; async fn command_output( &self, request: CommandOutputRequest, ) -> Result; async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError>; /// Subscribe to bounded provider-owned command telemetry. Implementations /// that do not expose live command observation may keep the default. fn subscribe_command_events(&self) -> Option> { None } /// Return the bounded current command state used to recover from a lagged /// provider subscription without replaying command output into history. fn command_snapshot(&self) -> Vec { Vec::new() } /// Terminal, idempotent release of this Worker-bound operation session. async fn close(&self) -> Result<(), WorkdirError>; } pub type WorkdirSessionHandle = Arc; #[derive(Debug, thiserror::Error)] pub enum WorkdirError { #[error("Workdir operation denied: {0}")] Denied(String), #[error("Workdir session is closed")] SessionClosed, #[error("Workdir session does not support {0:?}")] Unsupported(WorkdirSessionCapability), #[error("invalid Workdir path: {0}")] InvalidPath(String), #[error("Workdir session is unavailable: {0}")] Unavailable(String), #[error("Workdir transport failed: {0}")] Transport(String), #[error("Workdir content was modified externally before the operation could be applied: {0}")] Conflict(String), #[error("unknown Workdir session command: {0}")] UnknownCommand(String), #[error("path must be absolute: {}", .0.display())] RelativePath(PathBuf), #[error("path is outside allowed scope: {}", .0.display())] OutOfScope(PathBuf), #[error( "path resolves through a symlink outside allowed {required_permission} scope: {} -> {}; add the symlink target to the Worker {required_permission} scope, copy it into the workspace, or recreate the symlink with the correct target", .path.display(), .target.display() )] SymlinkOutOfScope { path: PathBuf, target: PathBuf, required_permission: &'static str, }, #[error( "broken symlink while resolving {}: {} -> {} (target does not exist); recreate the symlink with an absolute target or a correct relative target", .path.display(), .link.display(), .target.display() )] BrokenSymlink { path: PathBuf, link: PathBuf, target: PathBuf, }, #[error( "path resolves through a symlink to a directory, but this tool requires a file: {} -> {}; choose a file inside that directory", .path.display(), .target.display() )] SymlinkTargetIsDirectory { path: PathBuf, target: PathBuf }, #[error("path is read-only: {}", .0.display())] ReadOnly(PathBuf), #[error("expected file but path is a directory: {}", .0.display())] IsDirectory(PathBuf), #[error("file not found: {}", .0.display())] NotFound(PathBuf), #[error("invalid argument: {0}")] InvalidArgument(String), #[error("invalid glob pattern: {0}")] InvalidGlob(String), #[error("invalid regex pattern: {0}")] InvalidRegex(String), #[error("{tool} does not follow symlink directories: {} -> {}", .path.display(), .target.display())] SymlinkDirectoryNotTraversed { tool: &'static str, path: PathBuf, target: PathBuf, }, #[error("I/O error at {path}: {source}")] Io { path: PathBuf, #[source] source: std::io::Error, }, } impl WorkdirError { pub(crate) fn io(path: &Path, source: std::io::Error) -> Self { Self::Io { path: path.to_path_buf(), source, } } } impl From for WorkdirError { fn from(error: fs_operation::FsError) -> Self { match error { fs_operation::FsError::InvalidPath(message) => Self::InvalidPath(message), fs_operation::FsError::RelativePath(path) => Self::RelativePath(path), fs_operation::FsError::OutOfScope(path) => Self::OutOfScope(path), fs_operation::FsError::NotFound(path) => Self::NotFound(path), fs_operation::FsError::BrokenSymlink { path, link, target } => { Self::BrokenSymlink { path, link, target } } fs_operation::FsError::SymlinkOutOfScope { path, target, required_permission, } => Self::SymlinkOutOfScope { path, target, required_permission, }, fs_operation::FsError::SymlinkDirectoryNotTraversed { tool, path, target } => { Self::SymlinkDirectoryNotTraversed { tool, path, target } } fs_operation::FsError::ReadOnly(path) => Self::ReadOnly(path), fs_operation::FsError::IsDirectory(path) => Self::IsDirectory(path), fs_operation::FsError::NotDirectory(path) => { Self::InvalidArgument(format!("path is not a directory: {}", path.display())) } fs_operation::FsError::SymlinkTargetIsDirectory { path, target } => { Self::SymlinkTargetIsDirectory { path, target } } fs_operation::FsError::Conflict(message) => Self::Conflict(message), fs_operation::FsError::InvalidGlob(message) => Self::InvalidGlob(message), fs_operation::FsError::InvalidRegex(message) => Self::InvalidRegex(message), fs_operation::FsError::InvalidArgument(message) => Self::InvalidArgument(message), fs_operation::FsError::Io { path, source } => Self::Io { path, source }, } } }