chore: merge develop into hare/develop
This commit is contained in:
Generated
+3
@@ -3490,6 +3490,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"ts-rs",
|
||||
"uuid",
|
||||
@@ -5374,6 +5375,7 @@ dependencies = [
|
||||
"toml",
|
||||
"unicode-width",
|
||||
"uuid",
|
||||
"worker-runtime",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6416,6 +6418,7 @@ dependencies = [
|
||||
"agen",
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"clap",
|
||||
|
||||
@@ -10,6 +10,7 @@ pub use server_api::{
|
||||
WorkerLaunchProfileCandidate as BackendWorkerLaunchProfileCandidate,
|
||||
WorkerLaunchRuntimeOption as BackendWorkerLaunchRuntimeOption,
|
||||
WorkerOperationState as BackendWorkerOperationState,
|
||||
WorkerRestoreRequest as BackendWorkerRestoreRequest,
|
||||
WorkerRestoreResponse as BackendWorkerRestoreResponse,
|
||||
WorkerRestoreResult as BackendWorkerRestoreResult,
|
||||
WorkerRestoreState as BackendWorkerRestoreState, WorkerSummary as BackendWorkerSummary,
|
||||
@@ -259,6 +260,15 @@ impl fmt::Display for BackendRuntimeClientError {
|
||||
}
|
||||
}
|
||||
|
||||
impl BackendRuntimeClientError {
|
||||
/// A stale intent is not a rejected Restore operation and must not be retried
|
||||
/// with a silently refreshed token.
|
||||
pub fn is_restore_observation_conflict(&self) -> bool {
|
||||
matches!(self, Self::ContractApi(server_api::client_support::ClientError::Public { status, error })
|
||||
if *status == reqwest::StatusCode::CONFLICT && error.error == "restore_observation_conflict")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for BackendRuntimeClientError {}
|
||||
|
||||
impl From<server_api::ServerApiClientError> for BackendRuntimeClientError {
|
||||
@@ -502,6 +512,7 @@ pub async fn list_backend_stopped_workers(
|
||||
|
||||
pub async fn restore_backend_worker(
|
||||
target: &BackendRuntimeTarget,
|
||||
request: BackendWorkerRestoreRequest,
|
||||
) -> Result<BackendWorkerRestoreResponse, BackendRuntimeClientError> {
|
||||
validate_target(target)?;
|
||||
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||
@@ -514,6 +525,7 @@ pub async fn restore_backend_worker(
|
||||
ticket_id: None,
|
||||
assignment_operation_id: None,
|
||||
},
|
||||
request,
|
||||
)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
|
||||
@@ -26,12 +26,12 @@ pub use backend_runtime::{
|
||||
BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,
|
||||
BackendWorkerImplementationSummary, BackendWorkerLaunchOptions,
|
||||
BackendWorkerLaunchProfileCandidate, BackendWorkerLaunchRuntimeOption,
|
||||
BackendWorkerLaunchTarget, BackendWorkerOperationState, BackendWorkerRestoreResponse,
|
||||
BackendWorkerRestoreResult, BackendWorkerRestoreState, BackendWorkerSummary,
|
||||
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySource, BackendWorkingDirectorySummary,
|
||||
connect_backend_runtime, create_backend_worker, get_backend_worker_launch_options,
|
||||
list_backend_stopped_workers, list_backend_workers, observe_backend_worker_session,
|
||||
restore_backend_worker,
|
||||
BackendWorkerLaunchTarget, BackendWorkerOperationState, BackendWorkerRestoreRequest,
|
||||
BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerRestoreState,
|
||||
BackendWorkerSummary, BackendWorkerWorkspaceSummary, BackendWorkingDirectorySource,
|
||||
BackendWorkingDirectorySummary, connect_backend_runtime, create_backend_worker,
|
||||
get_backend_worker_launch_options, list_backend_stopped_workers, list_backend_workers,
|
||||
observe_backend_worker_session, restore_backend_worker,
|
||||
};
|
||||
pub use backend_workspace::{
|
||||
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
|
||||
|
||||
@@ -13,9 +13,7 @@ ignore = "0.4.25"
|
||||
libc = "0.2"
|
||||
schemars.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
sha2 = "0.10.9"
|
||||
tempfile.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
//! Identity-fenced access adapter for the existing read/edit/write engine.
|
||||
//!
|
||||
//! Descriptor-rooted resolution is Linux-only and symlinks/mount crossings fail
|
||||
//! closed. The final compare and atomic rename are NOT a kernel CAS against an
|
||||
//! arbitrary external editor: external editors must cooperate with provider
|
||||
//! serialization. A hostile rename in that last syscall gap is not excluded.
|
||||
use crate::{AtomicWriteMode, FsAccessPolicy};
|
||||
use std::fs::{File, Metadata};
|
||||
use std::io::{Seek, SeekFrom};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Identity and state, deliberately not just a content digest. atime is omitted
|
||||
/// so reading does not invalidate an observation. No host paths are encoded.
|
||||
pub fn identity_validator(metadata: &Metadata) -> std::io::Result<Vec<u8>> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let mut bytes = b"yoi-checkout-v1".to_vec();
|
||||
for value in [
|
||||
metadata.dev(),
|
||||
metadata.ino(),
|
||||
metadata.len(),
|
||||
metadata.mode() as u64,
|
||||
metadata.mtime() as u64,
|
||||
metadata.mtime_nsec() as u64,
|
||||
metadata.ctime() as u64,
|
||||
metadata.ctime_nsec() as u64,
|
||||
] {
|
||||
bytes.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = metadata;
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
"identity validators unavailable",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// One pinned target and its save parent. Retained through the shared operation.
|
||||
pub struct CheckedTarget<'a> {
|
||||
policy: &'a dyn FsAccessPolicy,
|
||||
root: File,
|
||||
relative: PathBuf,
|
||||
logical: PathBuf,
|
||||
file: File,
|
||||
parent: Option<File>,
|
||||
validator: Vec<u8>,
|
||||
effects_possible: AtomicBool,
|
||||
}
|
||||
impl<'a> CheckedTarget<'a> {
|
||||
pub fn pin(
|
||||
root: &File,
|
||||
relative: &Path,
|
||||
logical: &Path,
|
||||
policy: &'a dyn FsAccessPolicy,
|
||||
write: bool,
|
||||
) -> std::io::Result<Self> {
|
||||
policy.check_cancelled()?;
|
||||
if !(if write {
|
||||
policy.is_writable_paths(logical, logical)
|
||||
} else {
|
||||
policy.is_readable_paths(logical, logical)
|
||||
}) {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"checkout target denied",
|
||||
));
|
||||
}
|
||||
let parent = if relative.as_os_str().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(crate::open_beneath_no_symlinks_at(
|
||||
root,
|
||||
relative.parent().unwrap_or(Path::new("")),
|
||||
)?)
|
||||
};
|
||||
let file = if let Some(parent) = &parent {
|
||||
crate::open_beneath_no_symlinks_at(parent, Path::new(relative.file_name().unwrap()))?
|
||||
} else {
|
||||
crate::open_beneath_no_symlinks_at(root, Path::new(""))?
|
||||
};
|
||||
let metadata = file.metadata()?;
|
||||
if !metadata.is_file() && !metadata.is_dir() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"checkout requires regular file or directory",
|
||||
));
|
||||
}
|
||||
let result = Self {
|
||||
policy,
|
||||
root: root.try_clone()?,
|
||||
relative: relative.to_path_buf(),
|
||||
logical: logical.to_path_buf(),
|
||||
file,
|
||||
parent,
|
||||
validator: identity_validator(&metadata)?,
|
||||
effects_possible: AtomicBool::new(false),
|
||||
};
|
||||
result.confirm()?;
|
||||
Ok(result)
|
||||
}
|
||||
pub fn validator(&self) -> &[u8] {
|
||||
&self.validator
|
||||
}
|
||||
pub fn metadata(&self) -> std::io::Result<Metadata> {
|
||||
self.file.metadata()
|
||||
}
|
||||
pub fn effects_possible(&self) -> bool {
|
||||
self.effects_possible.load(Ordering::Acquire)
|
||||
}
|
||||
pub fn confirm(&self) -> std::io::Result<()> {
|
||||
self.policy.check_cancelled()?;
|
||||
if let Some(parent) = &self.parent {
|
||||
let named_parent = crate::open_beneath_no_symlinks_at(
|
||||
&self.root,
|
||||
self.relative.parent().unwrap_or(Path::new("")),
|
||||
)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let pinned_metadata = parent.metadata()?;
|
||||
let named_metadata = named_parent.metadata()?;
|
||||
if (pinned_metadata.dev(), pinned_metadata.ino())
|
||||
!= (named_metadata.dev(), named_metadata.ino())
|
||||
{
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::WouldBlock,
|
||||
"checkout parent identity changed",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let named = crate::open_beneath_no_symlinks_at(&self.root, &self.relative)?;
|
||||
if identity_validator(&named.metadata()?)? != self.validator
|
||||
|| identity_validator(&self.file.metadata()?)? != self.validator
|
||||
{
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::WouldBlock,
|
||||
"checkout observation changed",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Refresh a directory observation after Create, retaining the original
|
||||
/// pinned target/root/parent handles. A replacement named directory cannot
|
||||
/// supply the validator for effects performed in the original directory.
|
||||
pub fn refresh_directory_observation(&mut self) -> std::io::Result<()> {
|
||||
let metadata = self.file.metadata()?;
|
||||
if !metadata.is_dir() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"Create target must be directory",
|
||||
));
|
||||
}
|
||||
self.validator = identity_validator(&metadata)?;
|
||||
self.confirm()
|
||||
}
|
||||
|
||||
/// Adapter for no-clobber creation beneath a pinned directory. Both target
|
||||
/// and destination authorization remain in the provider's normal policy.
|
||||
pub fn create_access<'b>(&'b self, suffix: PathBuf, logical: PathBuf) -> CheckedCreate<'b, 'a> {
|
||||
CheckedCreate {
|
||||
target: self,
|
||||
suffix,
|
||||
logical,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl FsAccessPolicy for CheckedTarget<'_> {
|
||||
fn is_readable(&self, p: &Path) -> bool {
|
||||
self.policy.is_readable(p)
|
||||
}
|
||||
fn is_writable(&self, p: &Path) -> bool {
|
||||
self.policy.is_writable(p)
|
||||
}
|
||||
fn is_readable_paths(&self, p: &Path, r: &Path) -> bool {
|
||||
p == self.logical && r == p && self.policy.is_readable_paths(p, r)
|
||||
}
|
||||
fn is_writable_paths(&self, p: &Path, r: &Path) -> bool {
|
||||
p == self.logical && r == p && self.policy.is_writable_paths(p, r)
|
||||
}
|
||||
fn check_cancelled(&self) -> std::io::Result<()> {
|
||||
self.policy.check_cancelled()
|
||||
}
|
||||
fn resolve_access_path(&self, p: &Path) -> std::io::Result<PathBuf> {
|
||||
Ok(p.to_path_buf())
|
||||
}
|
||||
fn read_metadata(&self, _: &Path, _: &Path) -> std::io::Result<Metadata> {
|
||||
self.file.metadata()
|
||||
}
|
||||
fn open_read_file(&self, _: &Path, _: &Path) -> std::io::Result<File> {
|
||||
self.confirm()?;
|
||||
let mut file = self.file.try_clone()?;
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
Ok(file)
|
||||
}
|
||||
fn max_write_bytes(&self) -> Option<usize> {
|
||||
Some(
|
||||
self.policy
|
||||
.max_write_bytes()
|
||||
.unwrap_or(8 * 1024 * 1024)
|
||||
.min(8 * 1024 * 1024),
|
||||
)
|
||||
}
|
||||
fn max_edit_replacements(&self) -> Option<usize> {
|
||||
Some(
|
||||
self.policy
|
||||
.max_edit_replacements()
|
||||
.unwrap_or(10_000)
|
||||
.min(10_000),
|
||||
)
|
||||
}
|
||||
fn atomic_write_file(
|
||||
&self,
|
||||
_: &Path,
|
||||
_: &Path,
|
||||
content: &[u8],
|
||||
mode: AtomicWriteMode,
|
||||
) -> std::io::Result<()> {
|
||||
self.confirm()?;
|
||||
let parent = self.parent.as_ref().ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidInput, "cannot save root")
|
||||
})?;
|
||||
crate::atomic_write_beneath_no_symlinks_at(
|
||||
parent,
|
||||
Path::new(self.relative.file_name().unwrap()),
|
||||
content,
|
||||
mode,
|
||||
|| {
|
||||
self.confirm()?;
|
||||
// From here rename/encoding/post-observation failures have an unknown outcome.
|
||||
self.effects_possible.store(true, Ordering::Release);
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CheckedCreate<'b, 'a> {
|
||||
target: &'b CheckedTarget<'a>,
|
||||
suffix: PathBuf,
|
||||
logical: PathBuf,
|
||||
}
|
||||
impl FsAccessPolicy for CheckedCreate<'_, '_> {
|
||||
fn is_readable(&self, p: &Path) -> bool {
|
||||
self.target.policy.is_readable(p)
|
||||
}
|
||||
fn is_writable(&self, p: &Path) -> bool {
|
||||
p == self.logical && self.target.policy.is_writable_paths(p, p)
|
||||
}
|
||||
fn resolve_access_path(&self, p: &Path) -> std::io::Result<PathBuf> {
|
||||
Ok(p.to_path_buf())
|
||||
}
|
||||
fn check_cancelled(&self) -> std::io::Result<()> {
|
||||
self.target.policy.check_cancelled()
|
||||
}
|
||||
fn max_write_bytes(&self) -> Option<usize> {
|
||||
self.target.max_write_bytes()
|
||||
}
|
||||
fn read_metadata(&self, _: &Path, _: &Path) -> std::io::Result<Metadata> {
|
||||
match crate::open_beneath_no_symlinks_at(&self.target.file, &self.suffix) {
|
||||
Ok(_) => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::AlreadyExists,
|
||||
"Create destination exists",
|
||||
)),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
fn atomic_write_file(
|
||||
&self,
|
||||
_: &Path,
|
||||
_: &Path,
|
||||
content: &[u8],
|
||||
mode: AtomicWriteMode,
|
||||
) -> std::io::Result<()> {
|
||||
self.target.confirm()?;
|
||||
if mode != AtomicWriteMode::CreateNew || !self.target.file.metadata()?.is_dir() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"Create requires directory",
|
||||
));
|
||||
}
|
||||
// Creating parents must not turn a narrow destination grant into
|
||||
// permission to populate ungranted intermediate directories.
|
||||
let mut parent = self.target.logical.clone();
|
||||
let mut components = self.suffix.components().peekable();
|
||||
while let Some(component) = components.next() {
|
||||
parent.push(component.as_os_str());
|
||||
if components.peek().is_some()
|
||||
&& !self.target.policy.is_writable_paths(&parent, &parent)
|
||||
{
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"Create parent denied",
|
||||
));
|
||||
}
|
||||
}
|
||||
// Nested mkdir may have effects before failure; never auto-retry.
|
||||
self.target.effects_possible.store(true, Ordering::Release);
|
||||
crate::atomic_write_beneath_no_symlinks_at(
|
||||
&self.target.file,
|
||||
&self.suffix,
|
||||
content,
|
||||
mode,
|
||||
|| self.target.policy.check_cancelled(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{FsError, FsPath, ReadRequest, WriteRequest};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
struct Access {
|
||||
path: PathBuf,
|
||||
counter: AtomicUsize,
|
||||
trigger: usize,
|
||||
replacement: bool,
|
||||
}
|
||||
impl FsAccessPolicy for Access {
|
||||
fn is_readable(&self, _: &Path) -> bool {
|
||||
true
|
||||
}
|
||||
fn is_writable(&self, _: &Path) -> bool {
|
||||
true
|
||||
}
|
||||
fn check_cancelled(&self) -> std::io::Result<()> {
|
||||
if self.counter.fetch_add(1, Ordering::SeqCst) + 1 == self.trigger {
|
||||
if self.replacement {
|
||||
std::fs::rename(&self.path, self.path.with_extension("old"))?;
|
||||
std::fs::write(&self.path, "replacement")?;
|
||||
} else {
|
||||
std::fs::write(&self.path, "updated")?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn checked_save_boundary_rejects_name_substitution_without_mutating_replacement() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let path = root.path().join("a");
|
||||
std::fs::write(&path, "original").unwrap();
|
||||
let policy = Access {
|
||||
path: path.clone(),
|
||||
counter: AtomicUsize::new(0),
|
||||
trigger: 4,
|
||||
replacement: true,
|
||||
};
|
||||
let handle = crate::open_root_no_symlinks(root.path()).unwrap();
|
||||
let checked = CheckedTarget::pin(&handle, Path::new("a"), &path, &policy, true).unwrap();
|
||||
policy.counter.store(0, Ordering::SeqCst);
|
||||
let error = crate::run_write(
|
||||
root.path(),
|
||||
WriteRequest {
|
||||
path: FsPath::new("a").unwrap(),
|
||||
content: b"worker".to_vec(),
|
||||
expected_hash: Some(Sha256::digest(b"original").into()),
|
||||
},
|
||||
&checked,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, FsError::Io { source, .. } if source.kind() == std::io::ErrorKind::WouldBlock)
|
||||
);
|
||||
assert!(!checked.effects_possible());
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "replacement");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(path.with_extension("old")).unwrap(),
|
||||
"original"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn checked_read_midread_name_replacement_never_pairs_old_bytes_with_new_validator() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let path = root.path().join("a");
|
||||
std::fs::write(&path, "original").unwrap();
|
||||
let policy = Access {
|
||||
path: path.clone(),
|
||||
counter: AtomicUsize::new(0),
|
||||
trigger: 3,
|
||||
replacement: true,
|
||||
};
|
||||
let handle = crate::open_root_no_symlinks(root.path()).unwrap();
|
||||
let checked = CheckedTarget::pin(&handle, Path::new("a"), &path, &policy, false).unwrap();
|
||||
let validator = checked.validator().to_vec();
|
||||
policy.counter.store(0, Ordering::SeqCst);
|
||||
let result = crate::run_read(
|
||||
root.path(),
|
||||
ReadRequest {
|
||||
path: FsPath::new("a").unwrap(),
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
max_bytes: 1024,
|
||||
},
|
||||
&checked,
|
||||
);
|
||||
match result {
|
||||
Err(FsError::Conflict(_)) => {}
|
||||
Ok(read) => {
|
||||
// Same pinned inode supplies bytes and hash. Execute's final
|
||||
// named-target check must reject, not refresh to replacement.
|
||||
assert_eq!(read.bytes, b"original");
|
||||
let expected_hash: crate::ContentHash = Sha256::digest(b"original").into();
|
||||
assert_eq!(read.content_hash, expected_hash);
|
||||
assert_eq!(
|
||||
checked.confirm().unwrap_err().kind(),
|
||||
std::io::ErrorKind::WouldBlock
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected Read result: {other:?}"),
|
||||
}
|
||||
assert_eq!(checked.validator(), validator);
|
||||
assert_ne!(
|
||||
identity_validator(&std::fs::metadata(&path).unwrap()).unwrap(),
|
||||
validator
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "replacement");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_post_parent_observation_retains_bound_directory_and_rejects_substitution() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let directory = root.path().join("d");
|
||||
std::fs::create_dir(&directory).unwrap();
|
||||
let policy = Access {
|
||||
path: directory.clone(),
|
||||
counter: AtomicUsize::new(0),
|
||||
trigger: usize::MAX,
|
||||
replacement: false,
|
||||
};
|
||||
let handle = crate::open_root_no_symlinks(root.path()).unwrap();
|
||||
let mut checked =
|
||||
CheckedTarget::pin(&handle, Path::new("d"), &directory, &policy, true).unwrap();
|
||||
let initial = checked.validator().to_vec();
|
||||
let create = checked.create_access(PathBuf::from("new"), directory.join("new"));
|
||||
crate::run_write(
|
||||
root.path(),
|
||||
WriteRequest {
|
||||
path: FsPath::new("d/new").unwrap(),
|
||||
content: b"new".to_vec(),
|
||||
expected_hash: None,
|
||||
},
|
||||
&create,
|
||||
)
|
||||
.unwrap();
|
||||
checked.refresh_directory_observation().unwrap();
|
||||
assert_ne!(initial, checked.validator());
|
||||
assert_eq!(
|
||||
checked.validator(),
|
||||
identity_validator(&std::fs::metadata(&directory).unwrap()).unwrap()
|
||||
);
|
||||
std::fs::rename(&directory, root.path().join("old")).unwrap();
|
||||
std::fs::create_dir(&directory).unwrap();
|
||||
assert_eq!(
|
||||
checked.refresh_directory_observation().unwrap_err().kind(),
|
||||
std::io::ErrorKind::WouldBlock
|
||||
);
|
||||
assert!(checked.effects_possible());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_parent_swap_is_rejected_even_with_a_hardlinked_same_inode_target() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let directory = root.path().join("d");
|
||||
std::fs::create_dir(&directory).unwrap();
|
||||
let path = directory.join("a");
|
||||
std::fs::write(&path, "original").unwrap();
|
||||
let policy = Access {
|
||||
path: path.clone(),
|
||||
counter: AtomicUsize::new(0),
|
||||
trigger: usize::MAX,
|
||||
replacement: false,
|
||||
};
|
||||
let handle = crate::open_root_no_symlinks(root.path()).unwrap();
|
||||
let checked = CheckedTarget::pin(&handle, Path::new("d/a"), &path, &policy, true).unwrap();
|
||||
std::fs::rename(&directory, root.path().join("old")).unwrap();
|
||||
std::fs::create_dir(&directory).unwrap();
|
||||
std::fs::hard_link(root.path().join("old/a"), &path).unwrap();
|
||||
assert_eq!(
|
||||
checked.confirm().unwrap_err().kind(),
|
||||
std::io::ErrorKind::WouldBlock
|
||||
);
|
||||
assert!(!checked.effects_possible());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_text_read_rejects_mid_read_metadata_change() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let path = root.path().join("a");
|
||||
std::fs::write(&path, "original").unwrap();
|
||||
let policy = Access {
|
||||
path,
|
||||
counter: AtomicUsize::new(0),
|
||||
trigger: 2,
|
||||
replacement: false,
|
||||
};
|
||||
let error = crate::run_read(
|
||||
root.path(),
|
||||
ReadRequest {
|
||||
path: FsPath::new("a").unwrap(),
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
max_bytes: 1024,
|
||||
},
|
||||
&policy,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, FsError::Conflict(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
//! Ignore rules loaded only through the provider's authorized descriptor opens.
|
||||
//! Never use GitignoreBuilder::add/build_global: those reopen host pathnames.
|
||||
use crate::{FsAccessPolicy, FsError};
|
||||
use ignore::gitignore::{Gitignore, GitignoreBuilder};
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(crate) const MAX_IGNORE_SOURCE_BYTES: u64 = 8 * 1024 * 1024;
|
||||
const MAX_IGNORE_FILE_BYTES: u64 = 1024 * 1024;
|
||||
const MAX_IGNORE_LINE_BYTES: u64 = 16 * 1024;
|
||||
|
||||
pub(crate) struct SourceBoundedReader<'a, R> {
|
||||
pub inner: R,
|
||||
pub remaining: &'a mut u64,
|
||||
pub access: &'a dyn FsAccessPolicy,
|
||||
}
|
||||
impl<R: Read> Read for SourceBoundedReader<'_, R> {
|
||||
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
|
||||
self.access.check_cancelled()?;
|
||||
if buffer.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
if *self.remaining == 0 {
|
||||
let mut probe = [0_u8; 1];
|
||||
return match self.inner.read(&mut probe)? {
|
||||
0 => Ok(0),
|
||||
_ => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"search source exceeded provider byte limit",
|
||||
)),
|
||||
};
|
||||
}
|
||||
let limit = usize::try_from(*self.remaining)
|
||||
.unwrap_or(usize::MAX)
|
||||
.min(buffer.len());
|
||||
let read = self.inner.read(&mut buffer[..limit])?;
|
||||
*self.remaining = (*self.remaining).saturating_sub(read as u64);
|
||||
Ok(read)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared Glob/Grep grammar and provider authorization. Glob, like WalkBuilder,
|
||||
/// ignores unavailable configuration files; Grep retains its existing explicit
|
||||
/// error on a rejected ignore-file open. Cancellation/bounds always fail closed.
|
||||
pub(crate) fn add_checked_ignore_file(
|
||||
builder: &mut GitignoreBuilder,
|
||||
path: &Path,
|
||||
remaining: &mut u64,
|
||||
access: &dyn FsAccessPolicy,
|
||||
skip_unavailable: bool,
|
||||
) -> Result<bool, FsError> {
|
||||
access.check_cancelled().map_err(|e| FsError::io(path, e))?;
|
||||
let resolved = match access.resolve_access_path(path) {
|
||||
Ok(path) => path,
|
||||
Err(error) if skip_unavailable && error.kind() != std::io::ErrorKind::Interrupted => {
|
||||
return Ok(false);
|
||||
}
|
||||
Err(error) => return Err(FsError::io(path, error)),
|
||||
};
|
||||
if !access.is_readable_paths(path, &resolved) {
|
||||
return Ok(false);
|
||||
}
|
||||
let file = match access.open_read_file(path, &resolved) {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||||
Err(error) if skip_unavailable && error.kind() != std::io::ErrorKind::Interrupted => {
|
||||
return Ok(false);
|
||||
}
|
||||
Err(error) => return Err(FsError::io(path, error)),
|
||||
};
|
||||
let metadata = file.metadata().map_err(|e| FsError::io(path, e))?;
|
||||
if !metadata.is_file() {
|
||||
return Ok(false);
|
||||
}
|
||||
if metadata.len() > MAX_IGNORE_FILE_BYTES {
|
||||
return Err(FsError::InvalidArgument(
|
||||
"ignore file exceeds provider limit 1 MiB".into(),
|
||||
));
|
||||
}
|
||||
let bounded = SourceBoundedReader {
|
||||
inner: file,
|
||||
remaining,
|
||||
access,
|
||||
};
|
||||
// Take also fences files that grow after metadata. Each line is bounded
|
||||
// before allocation, and every underlying read checks cancellation.
|
||||
let mut reader = BufReader::new(bounded.take(MAX_IGNORE_FILE_BYTES + 1));
|
||||
let mut line = Vec::new();
|
||||
let mut bytes = 0_u64;
|
||||
let mut added = false;
|
||||
loop {
|
||||
line.clear();
|
||||
// BufRead::read_line/read_until retry Interrupted internally. Explicit
|
||||
// fill_buf prevents a cooperative cancellation from becoming a retry
|
||||
// loop, and caps allocation before copying even a growing long line.
|
||||
loop {
|
||||
access.check_cancelled().map_err(|e| FsError::io(path, e))?;
|
||||
let buffer = reader.fill_buf().map_err(|e| FsError::io(path, e))?;
|
||||
if buffer.is_empty() {
|
||||
break;
|
||||
}
|
||||
let count = buffer
|
||||
.iter()
|
||||
.position(|byte| *byte == b'\n')
|
||||
.map_or(buffer.len(), |index| index + 1);
|
||||
if line.len().saturating_add(count) as u64 > MAX_IGNORE_LINE_BYTES {
|
||||
return Err(FsError::InvalidArgument(
|
||||
"ignore line exceeds provider limit 16 KiB".into(),
|
||||
));
|
||||
}
|
||||
let finished = buffer[count - 1] == b'\n';
|
||||
line.extend_from_slice(&buffer[..count]);
|
||||
reader.consume(count);
|
||||
if finished {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if line.is_empty() {
|
||||
break;
|
||||
}
|
||||
bytes = bytes.saturating_add(line.len() as u64);
|
||||
if bytes > MAX_IGNORE_FILE_BYTES {
|
||||
return Err(FsError::InvalidArgument(
|
||||
"ignore file exceeds provider limit 1 MiB".into(),
|
||||
));
|
||||
}
|
||||
let line = std::str::from_utf8(&line)
|
||||
.map_err(|_| FsError::InvalidArgument("ignore file must be UTF-8".into()))?
|
||||
.trim_end_matches(['\r', '\n']);
|
||||
// One invalid glob must not discard other valid rules.
|
||||
if builder.add_line(Some(path.to_path_buf()), line).is_ok() {
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
Ok(added)
|
||||
}
|
||||
|
||||
struct DirectoryRules {
|
||||
path: PathBuf,
|
||||
ignore: Gitignore,
|
||||
git_ignore: Gitignore,
|
||||
exclude: Gitignore,
|
||||
has_git: bool,
|
||||
}
|
||||
|
||||
/// Request-local ancestor stack, not a file-tree cache. Only the current
|
||||
/// directory's ancestry is retained; source bytes and depth bound its matchers.
|
||||
pub(crate) struct DescriptorIgnores {
|
||||
directories: Vec<DirectoryRules>,
|
||||
remaining: u64,
|
||||
require_git: bool,
|
||||
skip_unavailable: bool,
|
||||
}
|
||||
impl DescriptorIgnores {
|
||||
pub fn new(root: &Path, base: &Path, access: &dyn FsAccessPolicy) -> Result<Self, FsError> {
|
||||
Self::with_options(root, base, access, true, true)
|
||||
}
|
||||
pub fn grep(root: &Path, base: &Path, access: &dyn FsAccessPolicy) -> Result<Self, FsError> {
|
||||
// Preserve the existing descriptor Grep provider contract: gitignore
|
||||
// sources apply without a .git marker and rejected opens fail closed.
|
||||
Self::with_options(root, base, access, false, false)
|
||||
}
|
||||
fn with_options(
|
||||
root: &Path,
|
||||
base: &Path,
|
||||
access: &dyn FsAccessPolicy,
|
||||
require_git: bool,
|
||||
skip_unavailable: bool,
|
||||
) -> Result<Self, FsError> {
|
||||
let relative = base.strip_prefix(root).map_err(|_| {
|
||||
FsError::InvalidArgument("glob base is outside its provider root".into())
|
||||
})?;
|
||||
if relative.components().count() >= 128 {
|
||||
return Err(FsError::InvalidArgument(
|
||||
"ignore ancestry exceeds provider depth limit 128".into(),
|
||||
));
|
||||
}
|
||||
let mut this = Self {
|
||||
directories: Vec::new(),
|
||||
remaining: MAX_IGNORE_SOURCE_BYTES,
|
||||
require_git,
|
||||
skip_unavailable,
|
||||
};
|
||||
let mut directory = root.to_path_buf();
|
||||
for component in relative.components() {
|
||||
this.load_directory(&directory, access)?;
|
||||
directory.push(component);
|
||||
}
|
||||
Ok(this)
|
||||
}
|
||||
|
||||
pub fn load_directory(
|
||||
&mut self,
|
||||
directory: &Path,
|
||||
access: &dyn FsAccessPolicy,
|
||||
) -> Result<(), FsError> {
|
||||
self.directories
|
||||
.retain(|rules| directory.starts_with(&rules.path));
|
||||
if self
|
||||
.directories
|
||||
.last()
|
||||
.is_some_and(|rules| rules.path == directory)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if self.directories.len() >= 128 {
|
||||
return Err(FsError::InvalidArgument(
|
||||
"ignore ancestry exceeds provider depth limit 128".into(),
|
||||
));
|
||||
}
|
||||
access
|
||||
.check_cancelled()
|
||||
.map_err(|e| FsError::io(directory, e))?;
|
||||
let marker = directory.join(".git");
|
||||
let git_kind = access
|
||||
.resolve_access_path(&marker)
|
||||
.ok()
|
||||
.and_then(|resolved| {
|
||||
if !access.is_readable_paths(&marker, &resolved) {
|
||||
return None;
|
||||
}
|
||||
access
|
||||
.read_metadata(&marker, &resolved)
|
||||
.ok()
|
||||
.map(|metadata| metadata.file_type())
|
||||
});
|
||||
let has_git = git_kind.is_some_and(|kind| kind.is_dir() || kind.is_file());
|
||||
let mut load = |name: &str| -> Result<Gitignore, FsError> {
|
||||
let mut builder = GitignoreBuilder::new(directory);
|
||||
add_checked_ignore_file(
|
||||
&mut builder,
|
||||
&directory.join(name),
|
||||
&mut self.remaining,
|
||||
access,
|
||||
self.skip_unavailable,
|
||||
)?;
|
||||
builder.build().map_err(|_| {
|
||||
FsError::InvalidArgument("ignore patterns could not be compiled".into())
|
||||
})
|
||||
};
|
||||
let ignore = load(".ignore")?;
|
||||
let git_ignore = load(".gitignore")?;
|
||||
// A linked-worktree .git file activates repository rules but is not a
|
||||
// directory. Never chase its arbitrary host gitdir/commondir pointers.
|
||||
let exclude = if git_kind.is_some_and(|kind| kind.is_dir()) {
|
||||
load(".git/info/exclude")?
|
||||
} else {
|
||||
Gitignore::empty()
|
||||
};
|
||||
self.directories.push(DirectoryRules {
|
||||
path: directory.to_path_buf(),
|
||||
ignore,
|
||||
git_ignore,
|
||||
exclude,
|
||||
has_git,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_ignored(&mut self, path: &Path, is_dir: bool) -> bool {
|
||||
self.directories
|
||||
.retain(|rules| path.starts_with(&rules.path));
|
||||
// .ignore has priority over all .gitignore/exclude rules. Within each
|
||||
// class the nearest directory's last matching rule wins. Match entries,
|
||||
// not any parents: ignored directories are pruned by the walker, while
|
||||
// an explicitly selected ignored search root remains traversable.
|
||||
for rules in self.directories.iter().rev() {
|
||||
let matched = rules.ignore.matched(path, is_dir);
|
||||
if !matched.is_none() {
|
||||
return matched.is_ignore();
|
||||
}
|
||||
}
|
||||
if self.require_git && !self.directories.iter().any(|rules| rules.has_git) {
|
||||
return false;
|
||||
}
|
||||
let mut exclude = None;
|
||||
for rules in self.directories.iter().rev() {
|
||||
let matched = rules.git_ignore.matched(path, is_dir);
|
||||
if !matched.is_none() {
|
||||
return matched.is_ignore();
|
||||
}
|
||||
if exclude.is_none() {
|
||||
let matched = rules.exclude.matched(path, is_dir);
|
||||
if !matched.is_none() {
|
||||
exclude = Some(matched.is_ignore());
|
||||
}
|
||||
}
|
||||
if rules.has_git {
|
||||
break;
|
||||
}
|
||||
}
|
||||
exclude.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
@@ -44,34 +44,29 @@ pub fn run_glob(
|
||||
.as_ref()
|
||||
.map(|traversal| traversal.path())
|
||||
.unwrap_or(base);
|
||||
let mut walker = WalkBuilder::new(walker_root);
|
||||
walker.hidden(false).follow_links(false);
|
||||
if traversal.is_some() {
|
||||
// Descriptor providers must never let WalkBuilder open pathname-based
|
||||
// ignore files, which may be symlinked outside the pinned root. Glob's
|
||||
// explicit pattern remains the complete selection contract.
|
||||
walker
|
||||
.git_ignore(false)
|
||||
.git_global(false)
|
||||
.git_exclude(false)
|
||||
.ignore(false)
|
||||
.parents(false);
|
||||
let search_relative = base.strip_prefix(root).map_err(|_| {
|
||||
FsError::InvalidArgument("glob base is outside its provider root".to_string())
|
||||
})?;
|
||||
let search_relative = search_relative.to_path_buf();
|
||||
let filter_root = walker_root.to_path_buf();
|
||||
walker.filter_entry(move |entry| {
|
||||
entry
|
||||
.path()
|
||||
.strip_prefix(&filter_root)
|
||||
.is_ok_and(|relative| {
|
||||
relative.starts_with(&search_relative) || search_relative.starts_with(relative)
|
||||
})
|
||||
});
|
||||
}
|
||||
let logical_walk_root = traversal
|
||||
.as_ref()
|
||||
.and_then(|t| t.logical_root())
|
||||
.unwrap_or(root);
|
||||
let mut descriptor_ignores = traversal
|
||||
.as_ref()
|
||||
.map(|_| crate::descriptor_ignore::DescriptorIgnores::new(root, logical_walk_root, access))
|
||||
.transpose()?;
|
||||
let mut visited = 0_usize;
|
||||
for entry in walker.build().flatten() {
|
||||
let mut walk = if traversal.is_some() {
|
||||
// Configuration is read via authorized provider opens, never by
|
||||
// WalkBuilder's host-path ignore discovery.
|
||||
crate::walk::Walk::descriptor(logical_walk_root, access)
|
||||
} else {
|
||||
crate::walk::Walk::Plain(
|
||||
WalkBuilder::new(walker_root)
|
||||
.hidden(false)
|
||||
.follow_links(false)
|
||||
.build(),
|
||||
)
|
||||
};
|
||||
while let Some(entry) = walk.next() {
|
||||
let entry = entry?;
|
||||
access.check_cancelled().map_err(|source| FsError::Io {
|
||||
path: PathBuf::from(request.path.as_str()),
|
||||
source,
|
||||
@@ -83,21 +78,27 @@ pub fn run_glob(
|
||||
crate::MAX_TRAVERSAL_ENTRIES
|
||||
)));
|
||||
}
|
||||
let walked_path = entry.path();
|
||||
let path = if traversal.is_some() {
|
||||
let relative = walked_path.strip_prefix(walker_root).map_err(|_| {
|
||||
FsError::InvalidArgument(
|
||||
"descriptor traversal returned a path outside its root".to_string(),
|
||||
)
|
||||
})?;
|
||||
root.join(relative)
|
||||
} else {
|
||||
walked_path.to_path_buf()
|
||||
};
|
||||
let path = entry.path;
|
||||
if path.strip_prefix(root).unwrap_or(&path).to_str().is_none() {
|
||||
continue;
|
||||
}
|
||||
let resolved = match access.resolve_access_path(&path) {
|
||||
Ok(resolved) if access.is_readable_paths(&path, &resolved) => resolved,
|
||||
_ => continue,
|
||||
};
|
||||
if let Some(ignores) = descriptor_ignores.as_mut() {
|
||||
let is_dir = entry.kind.is_some_and(|kind| kind.is_dir());
|
||||
if path != logical_walk_root && ignores.is_ignored(&path, is_dir) {
|
||||
if is_dir {
|
||||
walk.skip_current_dir();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if is_dir {
|
||||
ignores.load_directory(&path, access)?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let Ok(file) = access.open_read_file(&path, &resolved) else {
|
||||
continue;
|
||||
};
|
||||
@@ -111,7 +112,12 @@ pub fn run_glob(
|
||||
let logical = path.strip_prefix(root).map_err(|_| {
|
||||
FsError::InvalidArgument("provider returned a path outside its root".to_string())
|
||||
})?;
|
||||
let result_path = FsPath::new(logical.to_string_lossy())?;
|
||||
let Some(logical) = logical.to_str() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(result_path) = FsPath::new(logical) else {
|
||||
continue;
|
||||
};
|
||||
let retained = result_path.as_str().len().saturating_add(16);
|
||||
if retained_path_bytes.saturating_add(retained) > crate::MAX_RESULT_PATH_BYTES {
|
||||
provider_truncated = true;
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
#![cfg(target_os = "linux")]
|
||||
use crate::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{
|
||||
Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
struct Access {
|
||||
root: PathBuf,
|
||||
denied: Option<PathBuf>,
|
||||
opened: Mutex<Vec<PathBuf>>,
|
||||
enumerated: Mutex<Vec<PathBuf>>,
|
||||
checks: AtomicUsize,
|
||||
cancel_at: usize,
|
||||
}
|
||||
impl Access {
|
||||
fn new(root: &Path) -> Self {
|
||||
Self {
|
||||
root: root.into(),
|
||||
denied: None,
|
||||
opened: Mutex::new(Vec::new()),
|
||||
enumerated: Mutex::new(Vec::new()),
|
||||
checks: AtomicUsize::new(0),
|
||||
cancel_at: usize::MAX,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl FsAccessPolicy for Access {
|
||||
fn is_readable(&self, path: &Path) -> bool {
|
||||
path.starts_with(&self.root) && self.denied.as_ref().is_none_or(|denied| path != denied)
|
||||
}
|
||||
fn is_writable(&self, _: &Path) -> bool {
|
||||
false
|
||||
}
|
||||
fn resolve_access_path(&self, path: &Path) -> std::io::Result<PathBuf> {
|
||||
Ok(path.into())
|
||||
}
|
||||
fn check_cancelled(&self) -> std::io::Result<()> {
|
||||
if self.checks.fetch_add(1, Ordering::Relaxed) >= self.cancel_at {
|
||||
Err(std::io::ErrorKind::Interrupted.into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn open_read_file(&self, logical: &Path, resolved: &Path) -> std::io::Result<std::fs::File> {
|
||||
assert!(
|
||||
self.is_readable(logical),
|
||||
"denied ignore/source file opened"
|
||||
);
|
||||
self.opened.lock().unwrap().push(logical.into());
|
||||
open_beneath_no_symlinks(&self.root, resolved)
|
||||
}
|
||||
fn read_metadata(&self, _: &Path, resolved: &Path) -> std::io::Result<std::fs::Metadata> {
|
||||
open_beneath_no_symlinks(&self.root, resolved)?.metadata()
|
||||
}
|
||||
fn open_traversal_root(
|
||||
&self,
|
||||
_: &Path,
|
||||
resolved: &Path,
|
||||
) -> std::io::Result<Option<FsTraversalRoot>> {
|
||||
use std::os::fd::AsRawFd;
|
||||
let directory = open_beneath_no_symlinks(&self.root, resolved)?;
|
||||
let walk = PathBuf::from(format!("/proc/self/fd/{}", directory.as_raw_fd()));
|
||||
Ok(Some(FsTraversalRoot::new_at(
|
||||
walk,
|
||||
directory,
|
||||
resolved.into(),
|
||||
)))
|
||||
}
|
||||
fn open_read_dir(&self, logical: &Path, resolved: &Path) -> std::io::Result<std::fs::ReadDir> {
|
||||
use std::os::fd::AsRawFd;
|
||||
self.enumerated.lock().unwrap().push(logical.into());
|
||||
let directory = open_beneath_no_symlinks(&self.root, resolved)?;
|
||||
std::fs::read_dir(format!("/proc/self/fd/{}", directory.as_raw_fd()))
|
||||
}
|
||||
}
|
||||
fn glob(root: &Path, access: &dyn FsAccessPolicy) -> Result<GlobResult, FsError> {
|
||||
run_glob(
|
||||
root,
|
||||
root,
|
||||
GlobRequest {
|
||||
path: FsPath::root(),
|
||||
pattern: "**/*.txt".into(),
|
||||
limit: 100,
|
||||
},
|
||||
access,
|
||||
)
|
||||
}
|
||||
#[test]
|
||||
fn descriptor_glob_prunes_ignored_directories_before_enumeration_and_loading_rules() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(root.path().join("ignored/deep")).unwrap();
|
||||
std::fs::write(root.path().join(".ignore"), "ignored/\n").unwrap();
|
||||
// This rejected directory must never even attempt to load ignore files.
|
||||
std::fs::File::create(root.path().join("ignored/.ignore"))
|
||||
.unwrap()
|
||||
.set_len(2 * 1024 * 1024)
|
||||
.unwrap();
|
||||
std::fs::write(root.path().join("ignored/deep/secret.txt"), "secret").unwrap();
|
||||
std::fs::write(root.path().join("visible.txt"), "visible").unwrap();
|
||||
let access = Access::new(root.path());
|
||||
assert_eq!(
|
||||
glob(root.path(), &access).unwrap().paths,
|
||||
vec![FsPath::new("visible.txt").unwrap()]
|
||||
);
|
||||
assert_eq!(
|
||||
*access.enumerated.lock().unwrap(),
|
||||
vec![root.path().to_path_buf()]
|
||||
);
|
||||
assert!(
|
||||
!access
|
||||
.opened
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|path| path.starts_with(root.path().join("ignored")))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn descriptor_glob_denied_and_symlinked_ignore_files_cannot_change_selection() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
std::fs::write(outside.path().join("rules"), "visible.txt\n").unwrap();
|
||||
std::fs::create_dir(root.path().join(".git")).unwrap();
|
||||
std::os::unix::fs::symlink(outside.path().join("rules"), root.path().join(".gitignore"))
|
||||
.unwrap();
|
||||
std::fs::write(root.path().join(".ignore"), "visible.txt\n").unwrap();
|
||||
std::fs::write(root.path().join("visible.txt"), "visible").unwrap();
|
||||
let mut access = Access::new(root.path());
|
||||
access.denied = Some(root.path().join(".ignore"));
|
||||
assert_eq!(
|
||||
glob(root.path(), &access).unwrap().paths,
|
||||
vec![FsPath::new("visible.txt").unwrap()]
|
||||
);
|
||||
assert!(
|
||||
!access
|
||||
.opened
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains(&root.path().join(".ignore"))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn descriptor_ignore_loading_checks_cancellation_without_retrying_interrupted() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::write(root.path().join(".ignore"), "ignored.txt\n".repeat(1000)).unwrap();
|
||||
let mut access = Access::new(root.path());
|
||||
// First check is before resolution, second at the first fill_buf.
|
||||
access.cancel_at = 1;
|
||||
let error = crate::descriptor_ignore::add_checked_ignore_file(
|
||||
&mut ignore::gitignore::GitignoreBuilder::new(root.path()),
|
||||
&root.path().join(".ignore"),
|
||||
&mut 1024_000,
|
||||
&access,
|
||||
true,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, FsError::Io { source, .. } if source.kind() == std::io::ErrorKind::Interrupted)
|
||||
);
|
||||
assert_eq!(access.checks.load(Ordering::Relaxed), 2);
|
||||
}
|
||||
#[test]
|
||||
fn descriptor_ignore_loading_bounds_file_line_and_aggregate_bytes() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let path = root.path().join(".ignore");
|
||||
let access = Access::new(root.path());
|
||||
std::fs::File::create(&path)
|
||||
.unwrap()
|
||||
.set_len(1024 * 1024 + 1)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
glob(root.path(), &access),
|
||||
Err(FsError::InvalidArgument(_))
|
||||
));
|
||||
std::fs::write(&path, "x".repeat(16 * 1024 + 1)).unwrap();
|
||||
assert!(matches!(
|
||||
glob(root.path(), &access),
|
||||
Err(FsError::InvalidArgument(_))
|
||||
));
|
||||
std::fs::write(&path, "ignored.txt\n").unwrap();
|
||||
let error = crate::descriptor_ignore::add_checked_ignore_file(
|
||||
&mut ignore::gitignore::GitignoreBuilder::new(root.path()),
|
||||
&path,
|
||||
&mut 3,
|
||||
&access,
|
||||
true,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, FsError::Io { source, .. } if source.kind() == std::io::ErrorKind::InvalidData)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn descriptor_ignore_ancestry_is_bounded_without_enumerating_a_tree() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let access = Access::new(root.path());
|
||||
let base = (0..128).fold(root.path().to_path_buf(), |path, _| path.join("d"));
|
||||
assert!(matches!(
|
||||
crate::descriptor_ignore::DescriptorIgnores::new(root.path(), &base, &access),
|
||||
Err(FsError::InvalidArgument(_))
|
||||
));
|
||||
assert!(access.opened.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
struct Plain(PathBuf);
|
||||
impl FsAccessPolicy for Plain {
|
||||
fn is_readable(&self, path: &Path) -> bool {
|
||||
path.starts_with(&self.0)
|
||||
}
|
||||
fn is_writable(&self, _: &Path) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn descriptor_glob_matches_plain_git_markers_exclude_and_nested_repository_boundaries() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(root.path().join("nested/.git/info")).unwrap();
|
||||
std::fs::create_dir_all(root.path().join(".git/info")).unwrap();
|
||||
std::fs::write(
|
||||
root.path().join(".gitignore"),
|
||||
"*.outer.txt\n!include.txt\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(root.path().join(".ignore"), "always.txt\n").unwrap();
|
||||
std::fs::write(
|
||||
root.path().join(".git/info/exclude"),
|
||||
"excluded.txt\ninclude.txt\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.path().join("nested/.git/info/exclude"),
|
||||
"/excluded.txt\n",
|
||||
)
|
||||
.unwrap();
|
||||
for name in [
|
||||
"a.outer.txt",
|
||||
"excluded.txt",
|
||||
"include.txt",
|
||||
"always.txt",
|
||||
"nested/a.outer.txt",
|
||||
"nested/excluded.txt",
|
||||
"nested/always.txt",
|
||||
] {
|
||||
std::fs::write(root.path().join(name), "data").unwrap();
|
||||
}
|
||||
let native = glob(root.path(), &Access::new(root.path())).unwrap();
|
||||
let ordinary = glob(root.path(), &Plain(root.path().into())).unwrap();
|
||||
assert_eq!(native, ordinary);
|
||||
assert_eq!(
|
||||
native.paths,
|
||||
vec![
|
||||
FsPath::new("include.txt").unwrap(),
|
||||
FsPath::new("nested/a.outer.txt").unwrap()
|
||||
]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn descriptor_glob_requires_authorized_git_marker_for_gitignore_not_for_ignore() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::write(root.path().join(".gitignore"), "gitignored.txt\n").unwrap();
|
||||
std::fs::write(root.path().join(".ignore"), "ignored.txt\n").unwrap();
|
||||
for name in ["gitignored.txt", "ignored.txt", "visible.txt"] {
|
||||
std::fs::write(root.path().join(name), "data").unwrap();
|
||||
}
|
||||
let native = glob(root.path(), &Access::new(root.path())).unwrap();
|
||||
// Ambient ancestors may themselves be repositories (e.g. the harness's
|
||||
// TMPDIR). Their markers/ignore files are outside this provider root and
|
||||
// cannot activate Git rules or be inspected by descriptor search.
|
||||
assert_eq!(native.paths.len(), 2);
|
||||
std::fs::create_dir(root.path().join(".git")).unwrap();
|
||||
let native = glob(root.path(), &Access::new(root.path())).unwrap();
|
||||
assert_eq!(
|
||||
native,
|
||||
glob(root.path(), &Plain(root.path().into())).unwrap()
|
||||
);
|
||||
assert_eq!(native.paths, vec![FsPath::new("visible.txt").unwrap()]);
|
||||
let mut denied = Access::new(root.path());
|
||||
denied.denied = Some(root.path().join(".git"));
|
||||
assert_eq!(glob(root.path(), &denied).unwrap().paths.len(), 2);
|
||||
std::fs::remove_dir(root.path().join(".git")).unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
std::fs::write(outside.path().join("exclude"), "visible.txt\n").unwrap();
|
||||
std::fs::write(
|
||||
root.path().join(".git"),
|
||||
format!("gitdir: {}\n", outside.path().display()),
|
||||
)
|
||||
.unwrap();
|
||||
// Real managed Workdirs use a .git file. Activate root .gitignore without
|
||||
// failing on .git/info/exclude or following external administration paths.
|
||||
let access = Access::new(root.path());
|
||||
assert_eq!(
|
||||
glob(root.path(), &access).unwrap().paths,
|
||||
vec![FsPath::new("visible.txt").unwrap()]
|
||||
);
|
||||
std::fs::write(root.path().join("visible.txt"), "needle\n").unwrap();
|
||||
let grep = run_grep(
|
||||
root.path(),
|
||||
root.path().into(),
|
||||
GrepRequest {
|
||||
path: FsPath::root(),
|
||||
pattern: "needle".into(),
|
||||
glob: None,
|
||||
file_type: None,
|
||||
case_insensitive: false,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
multiline: false,
|
||||
output_mode: GrepOutputMode::FilesWithMatches,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
},
|
||||
&access,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(grep.paths, vec![FsPath::new("visible.txt").unwrap()]);
|
||||
assert!(
|
||||
!access
|
||||
.opened
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|path| path.starts_with(outside.path()))
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,19 @@
|
||||
//! transport, or LLM Tool implementations. Paths are logical and root-relative;
|
||||
//! providers supply host roots and access policy.
|
||||
|
||||
mod checked;
|
||||
pub use checked::{CheckedTarget, identity_validator};
|
||||
mod descriptor_ignore;
|
||||
mod glob;
|
||||
#[cfg(test)]
|
||||
mod ignore_tests;
|
||||
mod local;
|
||||
mod operation;
|
||||
mod search;
|
||||
pub mod text;
|
||||
#[cfg(test)]
|
||||
mod traversal_tests;
|
||||
mod walk;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -272,6 +281,7 @@ pub fn atomic_write_beneath_no_symlinks_at(
|
||||
#[derive(Debug)]
|
||||
pub struct FsTraversalRoot {
|
||||
path: PathBuf,
|
||||
logical_root: Option<PathBuf>,
|
||||
_directory: std::fs::File,
|
||||
}
|
||||
|
||||
@@ -279,20 +289,44 @@ impl FsTraversalRoot {
|
||||
pub fn new(path: PathBuf, directory: std::fs::File) -> Self {
|
||||
Self {
|
||||
path,
|
||||
logical_root: None,
|
||||
_directory: directory,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map walked paths to this logical anchor rather than the provider root.
|
||||
pub fn new_at(path: PathBuf, directory: std::fs::File, logical_root: PathBuf) -> Self {
|
||||
Self {
|
||||
path,
|
||||
logical_root: Some(logical_root),
|
||||
_directory: directory,
|
||||
}
|
||||
}
|
||||
pub fn logical_root(&self) -> Option<&Path> {
|
||||
self.logical_root.as_deref()
|
||||
}
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
/// Owned predicate used before walker descent. Paths are provider-logical;
|
||||
/// true directories require enumeration authority, other entries require read.
|
||||
pub type FsTraversalFilter = std::sync::Arc<dyn Fn(&Path, bool) -> bool + Send + Sync>;
|
||||
|
||||
/// Provider-owned access policy used by local filesystem operations.
|
||||
pub trait FsAccessPolicy: Send + Sync {
|
||||
fn is_readable(&self, path: &Path) -> bool;
|
||||
fn is_writable(&self, path: &Path) -> bool;
|
||||
|
||||
fn can_enumerate_directory(&self, logical: &Path, resolved: &Path) -> bool {
|
||||
self.is_readable_paths(logical, resolved)
|
||||
}
|
||||
/// Owned because the shared walker requires a 'static entry predicate.
|
||||
fn traversal_filter(&self) -> Option<FsTraversalFilter> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Cooperatively stop provider-owned filesystem work. Implementations may
|
||||
/// use this for per-operation cancellation and deadlines.
|
||||
fn check_cancelled(&self) -> std::io::Result<()> {
|
||||
|
||||
@@ -162,6 +162,19 @@ fn run_read_with_limits(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
if crate::identity_validator(&metadata).map_err(|e| map_io(&logical, e))?
|
||||
!= crate::identity_validator(
|
||||
&reader
|
||||
.get_ref()
|
||||
.metadata()
|
||||
.map_err(|e| map_io(&logical, e))?,
|
||||
)
|
||||
.map_err(|e| map_io(&logical, e))?
|
||||
{
|
||||
return Err(FsError::Conflict(logical.as_str().to_string()));
|
||||
}
|
||||
|
||||
let total_lines =
|
||||
current_line.saturating_add(usize::from(last_byte.is_some_and(|byte| byte != b'\n')));
|
||||
if request.offset > total_lines && request.offset != 0 {
|
||||
@@ -176,15 +189,7 @@ fn run_read_with_limits(
|
||||
.min(total_lines);
|
||||
let byte_truncated = selected_bytes_seen > response_limit;
|
||||
if byte_truncated {
|
||||
let mut byte_end = response_limit.min(selected.len());
|
||||
while byte_end > 0 {
|
||||
match std::str::from_utf8(&selected[..byte_end]) {
|
||||
Ok(_) => break,
|
||||
Err(error) if error.error_len().is_none() => byte_end -= 1,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
selected.truncate(byte_end);
|
||||
selected.truncate(crate::text::bounded_utf8_len(&selected, response_limit));
|
||||
}
|
||||
Ok(ReadResult {
|
||||
path: logical,
|
||||
@@ -402,55 +407,33 @@ pub fn run_edit(
|
||||
let content = String::from_utf8(bytes).map_err(|_| {
|
||||
FsError::InvalidArgument(format!("{} is not valid UTF-8", logical.as_str()))
|
||||
})?;
|
||||
let occurrences = content.matches(&request.old_string).count();
|
||||
if occurrences == 0 {
|
||||
return Err(FsError::InvalidArgument(
|
||||
"old_string was not found".to_string(),
|
||||
));
|
||||
}
|
||||
if !request.replace_all && occurrences != 1 {
|
||||
return Err(FsError::InvalidArgument(format!(
|
||||
"old_string matched {occurrences} times; set replace_all=true or provide a unique string"
|
||||
)));
|
||||
}
|
||||
let replacement_count = if request.replace_all { occurrences } else { 1 };
|
||||
if let Some(limit) = access.max_edit_replacements()
|
||||
&& replacement_count > limit
|
||||
{
|
||||
return Err(FsError::InvalidArgument(format!(
|
||||
"{} exceeds provider Edit replacement limit {limit}",
|
||||
logical.as_str()
|
||||
)));
|
||||
}
|
||||
let removed_bytes = request
|
||||
.old_string
|
||||
.len()
|
||||
.checked_mul(replacement_count)
|
||||
.ok_or_else(|| FsError::InvalidArgument("edited content size overflowed".to_string()))?;
|
||||
let added_bytes = request
|
||||
.new_string
|
||||
.len()
|
||||
.checked_mul(replacement_count)
|
||||
.ok_or_else(|| FsError::InvalidArgument("edited content size overflowed".to_string()))?;
|
||||
let edited_len = content
|
||||
.len()
|
||||
.checked_sub(removed_bytes)
|
||||
.and_then(|size| size.checked_add(added_bytes))
|
||||
.ok_or_else(|| FsError::InvalidArgument("edited content size overflowed".to_string()))?;
|
||||
enforce_write_bound(&logical, edited_len, access)?;
|
||||
let edited = if request.replace_all {
|
||||
content.replace(&request.old_string, &request.new_string)
|
||||
} else {
|
||||
content.replacen(&request.old_string, &request.new_string, 1)
|
||||
let args = crate::text::EditArgs {
|
||||
old_string: request.old_string,
|
||||
new_string: request.new_string,
|
||||
replace_all: request.replace_all,
|
||||
};
|
||||
debug_assert_eq!(edited.len(), edited_len);
|
||||
let edited = crate::text::edit(
|
||||
&content,
|
||||
&args,
|
||||
crate::text::TextLimits {
|
||||
max_input_bytes: access.max_write_bytes(),
|
||||
max_output_bytes: access.max_write_bytes(),
|
||||
max_replacements: access.max_edit_replacements(),
|
||||
},
|
||||
)
|
||||
.map_err(|error| map_text_error(&logical, error))?;
|
||||
access
|
||||
.atomic_write_file(&path, &target, edited.as_bytes(), AtomicWriteMode::Replace)
|
||||
.atomic_write_file(
|
||||
&path,
|
||||
&target,
|
||||
edited.content.as_bytes(),
|
||||
AtomicWriteMode::Replace,
|
||||
)
|
||||
.map_err(|error| map_io(&logical, error))?;
|
||||
Ok(EditResult {
|
||||
replacements: if request.replace_all { occurrences } else { 1 },
|
||||
bytes_written: edited.len(),
|
||||
content_hash: hash_bytes(edited.as_bytes()),
|
||||
replacements: edited.replacements,
|
||||
bytes_written: edited.bytes_written,
|
||||
content_hash: hash_bytes(edited.content.as_bytes()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -473,6 +456,14 @@ pub fn run_list(
|
||||
if !metadata.is_dir() {
|
||||
return Err(FsError::NotDirectory(PathBuf::from(logical.as_str())));
|
||||
}
|
||||
if !access.can_enumerate_directory(&logical_base, &path) {
|
||||
return Ok(ListResult {
|
||||
entries: Vec::new(),
|
||||
total_entries: 0,
|
||||
total_bytes: 0,
|
||||
truncated: false,
|
||||
});
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
let mut retained_path_bytes = 0_usize;
|
||||
let mut provider_truncated = false;
|
||||
@@ -490,6 +481,9 @@ pub fn run_list(
|
||||
)));
|
||||
}
|
||||
let entry = entry.map_err(|error| map_io(&logical, error))?;
|
||||
if entry.file_name().to_str().is_none() {
|
||||
continue;
|
||||
}
|
||||
let logical_absolute = logical_base.join(entry.file_name());
|
||||
let resolved = match access.resolve_access_path(&logical_absolute) {
|
||||
Ok(resolved) => resolved,
|
||||
@@ -513,7 +507,12 @@ pub fn run_list(
|
||||
let relative = logical_absolute.strip_prefix(root).map_err(|_| {
|
||||
FsError::InvalidArgument("provider returned a path outside its root".to_string())
|
||||
})?;
|
||||
let result_path = FsPath::new(relative.to_string_lossy())?;
|
||||
let Some(relative) = relative.to_str() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(result_path) = FsPath::new(relative) else {
|
||||
continue;
|
||||
};
|
||||
let retained = result_path.as_str().len().saturating_add(64);
|
||||
if retained_path_bytes.saturating_add(retained) > crate::MAX_RESULT_PATH_BYTES {
|
||||
provider_truncated = true;
|
||||
@@ -550,15 +549,37 @@ fn enforce_write_bound(
|
||||
bytes: usize,
|
||||
access: &dyn FsAccessPolicy,
|
||||
) -> Result<(), FsError> {
|
||||
if let Some(limit) = access.max_write_bytes()
|
||||
&& bytes > limit
|
||||
{
|
||||
return Err(FsError::InvalidArgument(format!(
|
||||
"{} exceeds provider write limit {limit}",
|
||||
logical.as_str()
|
||||
)));
|
||||
crate::text::check_output_bytes(
|
||||
bytes,
|
||||
crate::text::TextLimits {
|
||||
max_output_bytes: access.max_write_bytes(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.map_err(|error| map_text_error(logical, error))
|
||||
}
|
||||
Ok(())
|
||||
|
||||
fn map_text_error(logical: &FsPath, error: crate::text::TextError) -> FsError {
|
||||
use crate::text::TextError;
|
||||
|
||||
let message = match error {
|
||||
TextError::InputTooLarge { limit, .. } | TextError::OutputTooLarge { limit, .. } => {
|
||||
format!("{} exceeds provider write limit {limit}", logical.as_str())
|
||||
}
|
||||
TextError::ReplacementLimitExceeded { limit, .. } => format!(
|
||||
"{} exceeds provider Edit replacement limit {limit}",
|
||||
logical.as_str()
|
||||
),
|
||||
TextError::Decode(error) => format!("invalid arguments: {error}"),
|
||||
TextError::EmptyOldString => "old_string must not be empty".to_string(),
|
||||
TextError::IdenticalStrings => "old_string and new_string are identical".to_string(),
|
||||
TextError::NotFound => "old_string was not found".to_string(),
|
||||
TextError::MultipleMatches { occurrences } => format!(
|
||||
"old_string matched {occurrences} times; set replace_all=true or provide a unique string"
|
||||
),
|
||||
TextError::Overflow => "edited content size overflowed".to_string(),
|
||||
};
|
||||
FsError::InvalidArgument(message)
|
||||
}
|
||||
|
||||
fn read_mutation_preimage(
|
||||
@@ -610,9 +631,21 @@ fn require_access(
|
||||
write: bool,
|
||||
allow_symlink_directory: bool,
|
||||
) -> Result<PathBuf, FsError> {
|
||||
let resolved = access
|
||||
.resolve_access_path(path)
|
||||
.map_err(|error| map_io(logical, error))?;
|
||||
let resolved = access.resolve_access_path(path).map_err(|error| {
|
||||
// A dangling direct link has no resolved identity, but still needs the
|
||||
// existing sanitized repair diagnostic rather than an ordinary missing-file error.
|
||||
if error.kind() == std::io::ErrorKind::NotFound
|
||||
&& direct_symlink(path).is_some_and(|info| !info.target_exists)
|
||||
{
|
||||
FsError::BrokenSymlink {
|
||||
path: PathBuf::from(logical.as_str()),
|
||||
link: PathBuf::from(logical.as_str()),
|
||||
target: PathBuf::from("<provider-internal target>"),
|
||||
}
|
||||
} else {
|
||||
map_io(logical, error)
|
||||
}
|
||||
})?;
|
||||
let symlink = (resolved != path).then(|| direct_symlink(path)).flatten();
|
||||
if let Some(info) = symlink.as_ref()
|
||||
&& !info.target_exists
|
||||
@@ -773,3 +806,189 @@ mod bounded_read_tests {
|
||||
assert!(result.truncated);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod text_policy_tests {
|
||||
use super::*;
|
||||
|
||||
struct Access {
|
||||
root: PathBuf,
|
||||
bytes: Option<usize>,
|
||||
replacements: Option<usize>,
|
||||
}
|
||||
|
||||
impl FsAccessPolicy for Access {
|
||||
fn is_readable(&self, path: &Path) -> bool {
|
||||
path.starts_with(&self.root)
|
||||
}
|
||||
|
||||
fn is_writable(&self, path: &Path) -> bool {
|
||||
path.starts_with(&self.root)
|
||||
}
|
||||
|
||||
fn max_write_bytes(&self) -> Option<usize> {
|
||||
self.bytes
|
||||
}
|
||||
|
||||
fn max_edit_replacements(&self) -> Option<usize> {
|
||||
self.replacements
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_policy_errors_preserve_the_preimage_and_provider_messages() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().canonicalize().unwrap();
|
||||
let source = "a a";
|
||||
fs::write(root.join("text.txt"), source).unwrap();
|
||||
let access = Access {
|
||||
root: root.clone(),
|
||||
bytes: Some(4),
|
||||
replacements: Some(1),
|
||||
};
|
||||
for (old, new, all, expected_message) in [
|
||||
("", "x", false, "old_string must not be empty"),
|
||||
("a", "a", false, "old_string and new_string are identical"),
|
||||
(
|
||||
"missing",
|
||||
"x",
|
||||
false,
|
||||
"text.txt exceeds provider write limit 4",
|
||||
),
|
||||
("b", "x", false, "old_string was not found"),
|
||||
(
|
||||
"a",
|
||||
"x",
|
||||
false,
|
||||
"old_string matched 2 times; set replace_all=true or provide a unique string",
|
||||
),
|
||||
(
|
||||
"a",
|
||||
"x",
|
||||
true,
|
||||
"text.txt exceeds provider Edit replacement limit 1",
|
||||
),
|
||||
(
|
||||
"a a",
|
||||
"😀x",
|
||||
false,
|
||||
"text.txt exceeds provider write limit 4",
|
||||
),
|
||||
] {
|
||||
let error = run_edit(
|
||||
&root,
|
||||
EditRequest {
|
||||
path: FsPath::new("text.txt").unwrap(),
|
||||
old_string: old.into(),
|
||||
new_string: new.into(),
|
||||
replace_all: all,
|
||||
expected_hash: hash_bytes(source.as_bytes()),
|
||||
},
|
||||
&access,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, FsError::InvalidArgument(message) if message == expected_message)
|
||||
);
|
||||
assert_eq!(fs::read_to_string(root.join("text.txt")).unwrap(), source);
|
||||
}
|
||||
let access = Access {
|
||||
replacements: None,
|
||||
..access
|
||||
};
|
||||
let error = run_edit(
|
||||
&root,
|
||||
EditRequest {
|
||||
path: FsPath::new("text.txt").unwrap(),
|
||||
old_string: "a".into(),
|
||||
new_string: "xxx".into(),
|
||||
replace_all: true,
|
||||
expected_hash: hash_bytes(source.as_bytes()),
|
||||
},
|
||||
&access,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, FsError::InvalidArgument(message) if message == "text.txt exceeds provider write limit 4")
|
||||
);
|
||||
assert_eq!(fs::read_to_string(root.join("text.txt")).unwrap(), source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_keeps_cas_before_content_policy_and_hashes_the_core_result() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().canonicalize().unwrap();
|
||||
fs::write(root.join("text.txt"), "é😀").unwrap();
|
||||
let access = Access {
|
||||
root: root.clone(),
|
||||
bytes: None,
|
||||
replacements: None,
|
||||
};
|
||||
let error = run_edit(
|
||||
&root,
|
||||
EditRequest {
|
||||
path: FsPath::new("text.txt").unwrap(),
|
||||
old_string: String::new(),
|
||||
new_string: "x".into(),
|
||||
replace_all: false,
|
||||
expected_hash: [0; 32],
|
||||
},
|
||||
&access,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, FsError::Conflict(_)));
|
||||
let result = run_edit(
|
||||
&root,
|
||||
EditRequest {
|
||||
path: FsPath::new("text.txt").unwrap(),
|
||||
old_string: "é".into(),
|
||||
new_string: "界".into(),
|
||||
replace_all: false,
|
||||
expected_hash: hash_bytes("é😀".as_bytes()),
|
||||
},
|
||||
&access,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.replacements, 1);
|
||||
assert_eq!(result.bytes_written, 7);
|
||||
assert_eq!(result.content_hash, hash_bytes("界😀".as_bytes()));
|
||||
assert_eq!(fs::read_to_string(root.join("text.txt")).unwrap(), "界😀");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_write_remains_binary_safe_and_maps_shared_bounds() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().canonicalize().unwrap();
|
||||
let access = Access {
|
||||
root: root.clone(),
|
||||
bytes: Some(2),
|
||||
replacements: None,
|
||||
};
|
||||
let result = run_write(
|
||||
&root,
|
||||
WriteRequest {
|
||||
path: FsPath::new("binary").unwrap(),
|
||||
content: vec![0xff, 0],
|
||||
expected_hash: None,
|
||||
},
|
||||
&access,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.bytes_written, 2);
|
||||
assert_eq!(fs::read(root.join("binary")).unwrap(), vec![0xff, 0]);
|
||||
let error = run_write(
|
||||
&root,
|
||||
WriteRequest {
|
||||
path: FsPath::new("large").unwrap(),
|
||||
content: vec![0xff, 0, 1],
|
||||
expected_hash: None,
|
||||
},
|
||||
&access,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, FsError::InvalidArgument(message) if message == "large exceeds provider write limit 2")
|
||||
);
|
||||
assert!(!root.join("large").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,6 +353,10 @@ pub struct GrepRequest {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GrepResult {
|
||||
/// Exact typed provider paths represented in this returned report, not all
|
||||
/// candidate/matched source files. Older providers may omit this field.
|
||||
#[serde(default)]
|
||||
pub paths: Vec<FsPath>,
|
||||
/// Provider-rendered bounded grep report. Keeping rendering here avoids
|
||||
/// transferring candidate files across a remote provider boundary.
|
||||
pub output: String,
|
||||
|
||||
@@ -1,50 +1,20 @@
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::Write as _;
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::descriptor_ignore::{DescriptorIgnores, SourceBoundedReader};
|
||||
|
||||
use crate::FsAccessPolicy;
|
||||
use grep_regex::RegexMatcherBuilder;
|
||||
use grep_searcher::sinks::UTF8 as UTF8Sink;
|
||||
use grep_searcher::{BinaryDetection, Searcher, SearcherBuilder, Sink, SinkContext, SinkMatch};
|
||||
use ignore::WalkBuilder;
|
||||
use ignore::gitignore::{Gitignore, GitignoreBuilder};
|
||||
use ignore::overrides::{Override, OverrideBuilder};
|
||||
use ignore::types::{Types, TypesBuilder};
|
||||
|
||||
use crate::{FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink};
|
||||
|
||||
struct SourceBoundedReader<'a, R> {
|
||||
inner: R,
|
||||
remaining: &'a mut u64,
|
||||
access: &'a dyn FsAccessPolicy,
|
||||
}
|
||||
|
||||
impl<R: Read> Read for SourceBoundedReader<'_, R> {
|
||||
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
|
||||
self.access.check_cancelled()?;
|
||||
if buffer.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
if *self.remaining == 0 {
|
||||
let mut probe = [0_u8; 1];
|
||||
return match self.inner.read(&mut probe)? {
|
||||
0 => Ok(0),
|
||||
_ => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"grep source exceeded provider byte limit",
|
||||
)),
|
||||
};
|
||||
}
|
||||
let limit = usize::try_from(*self.remaining)
|
||||
.unwrap_or(usize::MAX)
|
||||
.min(buffer.len());
|
||||
let read = self.inner.read(&mut buffer[..limit])?;
|
||||
*self.remaining = (*self.remaining).saturating_sub(read as u64);
|
||||
Ok(read)
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentLine {
|
||||
path: PathBuf,
|
||||
line_number: Option<u64>,
|
||||
@@ -79,6 +49,28 @@ fn reserve_report_bytes(report: &mut GrepReport, bytes: usize) -> bool {
|
||||
|
||||
impl GrepReport {
|
||||
fn into_result(self, root: &Path) -> GrepResult {
|
||||
// Build Object paths from retained provider identities, never by parsing
|
||||
// rendered text. Offset, limit and truncation already selected this set.
|
||||
let paths = match self.mode {
|
||||
GrepOutputMode::FilesWithMatches => self
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|path| logical_path(root, path))
|
||||
.collect(),
|
||||
GrepOutputMode::Count => self
|
||||
.counts
|
||||
.iter()
|
||||
.filter_map(|(path, _)| logical_path(root, path))
|
||||
.collect(),
|
||||
GrepOutputMode::Content => self
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.path.as_path())
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.filter_map(|path| logical_path(root, path))
|
||||
.collect(),
|
||||
};
|
||||
let (match_count, matched_files) = match self.mode {
|
||||
GrepOutputMode::FilesWithMatches => (self.files.len(), self.files.len()),
|
||||
GrepOutputMode::Count => (
|
||||
@@ -116,6 +108,7 @@ impl GrepReport {
|
||||
}
|
||||
}
|
||||
GrepResult {
|
||||
paths,
|
||||
output,
|
||||
match_count,
|
||||
matched_files,
|
||||
@@ -166,11 +159,15 @@ fn render_content_lines(root: &Path, lines: &[ContentLine], show_line_numbers: b
|
||||
output
|
||||
}
|
||||
|
||||
fn logical_path(root: &Path, path: &Path) -> Option<crate::FsPath> {
|
||||
crate::FsPath::new_scoped(path.strip_prefix(root).unwrap_or(path).to_str()?).ok()
|
||||
}
|
||||
|
||||
fn logical_display(root: &Path, path: &Path) -> String {
|
||||
path.strip_prefix(root)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
logical_path(root, path)
|
||||
.expect("only representable provider paths enter a report")
|
||||
.as_str()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
const DEFAULT_HEAD_LIMIT: usize = 250;
|
||||
@@ -207,119 +204,6 @@ fn direct_file_selected(path: &Path, overrides: Option<&Override>, types: Option
|
||||
&& !types.is_some_and(|filter| filter.matched(path, false).is_ignore())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DescriptorIgnoreMatchers {
|
||||
loaded_directories: HashSet<PathBuf>,
|
||||
by_directory: BTreeMap<PathBuf, Gitignore>,
|
||||
}
|
||||
|
||||
impl DescriptorIgnoreMatchers {
|
||||
fn load_directory(
|
||||
&mut self,
|
||||
directory: &Path,
|
||||
explicit_base: &Path,
|
||||
source_bytes_remaining: &mut u64,
|
||||
access: &dyn FsAccessPolicy,
|
||||
) -> Result<(), FsError> {
|
||||
if !self.loaded_directories.insert(directory.to_path_buf()) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut builder = GitignoreBuilder::new(directory);
|
||||
let mut has_patterns = false;
|
||||
for name in [".gitignore", ".ignore"] {
|
||||
let path = directory.join(name);
|
||||
let resolved = access
|
||||
.resolve_access_path(&path)
|
||||
.map_err(|error| FsError::io(&path, error))?;
|
||||
if !access.is_readable_paths(&path, &resolved) {
|
||||
continue;
|
||||
}
|
||||
let file = match access.open_read_file(&path, &resolved) {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(FsError::io(&path, error)),
|
||||
};
|
||||
if !file
|
||||
.metadata()
|
||||
.map_err(|error| FsError::io(&path, error))?
|
||||
.is_file()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let bounded = SourceBoundedReader {
|
||||
inner: file,
|
||||
remaining: source_bytes_remaining,
|
||||
access,
|
||||
};
|
||||
let mut reader = BufReader::new(bounded);
|
||||
let mut line = String::new();
|
||||
let mut line_number = 0_u64;
|
||||
loop {
|
||||
line.clear();
|
||||
let read = reader
|
||||
.read_line(&mut line)
|
||||
.map_err(|error| FsError::io(&path, error))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
line_number = line_number.saturating_add(1);
|
||||
if line.len() > 1024 * 1024 {
|
||||
return Err(FsError::InvalidArgument(format!(
|
||||
"ignore pattern line {line_number} exceeds provider limit"
|
||||
)));
|
||||
}
|
||||
let line = line.trim_end_matches(['\r', '\n']);
|
||||
// Match the ignore crate's partial-error behavior: one invalid
|
||||
// pattern does not discard the remaining valid lines.
|
||||
if builder.add_line(Some(path.clone()), line).is_ok() {
|
||||
has_patterns = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !has_patterns {
|
||||
return Ok(());
|
||||
}
|
||||
let matcher = builder.build().map_err(|_| {
|
||||
FsError::InvalidArgument("ignore patterns could not be compiled".to_string())
|
||||
})?;
|
||||
// An explicitly selected search root is traversed even when a strict
|
||||
// ancestor ignores that directory, matching path-backed Grep behavior.
|
||||
if directory != explicit_base
|
||||
&& explicit_base.starts_with(directory)
|
||||
&& matcher
|
||||
.matched_path_or_any_parents(explicit_base, true)
|
||||
.is_ignore()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
self.by_directory.insert(directory.to_path_buf(), matcher);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_ignored(&self, root: &Path, path: &Path) -> bool {
|
||||
let mut directories = path
|
||||
.parent()
|
||||
.into_iter()
|
||||
.flat_map(Path::ancestors)
|
||||
.take_while(|directory| directory.starts_with(root))
|
||||
.collect::<Vec<_>>();
|
||||
directories.reverse();
|
||||
let mut ignored = false;
|
||||
for directory in directories {
|
||||
let Some(matcher) = self.by_directory.get(directory) else {
|
||||
continue;
|
||||
};
|
||||
let matched = matcher.matched_path_or_any_parents(path, false);
|
||||
if matched.is_ignore() {
|
||||
ignored = true;
|
||||
} else if matched.is_whitelist() {
|
||||
ignored = false;
|
||||
}
|
||||
}
|
||||
ignored
|
||||
}
|
||||
}
|
||||
|
||||
struct GrepParams {
|
||||
pattern: String,
|
||||
path: Option<PathBuf>,
|
||||
@@ -464,6 +348,9 @@ pub fn run_grep(
|
||||
retained_bytes: 0,
|
||||
truncated: false,
|
||||
};
|
||||
if head_limit == 0 {
|
||||
return Ok(report.into_result(root));
|
||||
}
|
||||
let mut matching_files_seen = 0;
|
||||
let mut matches_seen = 0;
|
||||
|
||||
@@ -474,6 +361,7 @@ pub fn run_grep(
|
||||
&mut searcher,
|
||||
&matcher,
|
||||
&base,
|
||||
root,
|
||||
mode,
|
||||
&mut report,
|
||||
&mut matching_files_seen,
|
||||
@@ -494,6 +382,10 @@ pub fn run_grep(
|
||||
.as_ref()
|
||||
.map(|traversal| traversal.path())
|
||||
.unwrap_or(&base);
|
||||
let logical_walk_root = traversal
|
||||
.as_ref()
|
||||
.and_then(|t| t.logical_root())
|
||||
.unwrap_or(root);
|
||||
let mut walker = WalkBuilder::new(walker_root);
|
||||
if traversal.is_some() {
|
||||
walker
|
||||
@@ -506,19 +398,6 @@ pub fn run_grep(
|
||||
.ignore(false)
|
||||
.parents(false)
|
||||
.follow_links(false);
|
||||
let search_relative = base.strip_prefix(root).map_err(|_| {
|
||||
FsError::InvalidArgument("grep base is outside its provider root".to_string())
|
||||
})?;
|
||||
let search_relative = search_relative.to_path_buf();
|
||||
let filter_root = walker_root.to_path_buf();
|
||||
walker.filter_entry(move |entry| {
|
||||
entry
|
||||
.path()
|
||||
.strip_prefix(&filter_root)
|
||||
.is_ok_and(|relative| {
|
||||
relative.starts_with(&search_relative) || search_relative.starts_with(relative)
|
||||
})
|
||||
});
|
||||
} else {
|
||||
walker
|
||||
.hidden(true)
|
||||
@@ -538,32 +417,17 @@ pub fn run_grep(
|
||||
|
||||
let mut visited = 0_usize;
|
||||
let mut source_bytes_remaining = crate::MAX_GREP_SOURCE_BYTES;
|
||||
let mut descriptor_ignores = DescriptorIgnoreMatchers::default();
|
||||
if traversal.is_some() {
|
||||
let mut directory = root.to_path_buf();
|
||||
descriptor_ignores.load_directory(
|
||||
&directory,
|
||||
&base,
|
||||
&mut source_bytes_remaining,
|
||||
access,
|
||||
)?;
|
||||
for component in base
|
||||
.strip_prefix(root)
|
||||
.map_err(|_| {
|
||||
FsError::InvalidArgument("grep base is outside its provider root".to_string())
|
||||
})?
|
||||
.components()
|
||||
{
|
||||
directory.push(component);
|
||||
descriptor_ignores.load_directory(
|
||||
&directory,
|
||||
&base,
|
||||
&mut source_bytes_remaining,
|
||||
access,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
for entry in walker.build().flatten() {
|
||||
let mut descriptor_ignores = traversal
|
||||
.as_ref()
|
||||
.map(|_| DescriptorIgnores::grep(root, logical_walk_root, access))
|
||||
.transpose()?;
|
||||
let mut walk = if traversal.is_some() {
|
||||
crate::walk::Walk::descriptor(logical_walk_root, access)
|
||||
} else {
|
||||
crate::walk::Walk::Plain(walker.build())
|
||||
};
|
||||
while let Some(entry) = walk.next() {
|
||||
let entry = entry?;
|
||||
access
|
||||
.check_cancelled()
|
||||
.map_err(|error| FsError::io(&base, error))?;
|
||||
@@ -574,39 +438,41 @@ pub fn run_grep(
|
||||
crate::MAX_TRAVERSAL_ENTRIES
|
||||
)));
|
||||
}
|
||||
let walked_path = entry.path();
|
||||
let path = if traversal.is_some() {
|
||||
let relative = walked_path.strip_prefix(walker_root).map_err(|_| {
|
||||
FsError::InvalidArgument(
|
||||
"descriptor traversal returned a path outside its root".to_string(),
|
||||
)
|
||||
})?;
|
||||
root.join(relative)
|
||||
} else {
|
||||
walked_path.to_path_buf()
|
||||
};
|
||||
let file_type = entry.file_type();
|
||||
if traversal.is_some() && file_type.as_ref().is_some_and(|kind| kind.is_dir()) {
|
||||
descriptor_ignores.load_directory(&path, &base, &mut source_bytes_remaining, access)?;
|
||||
let path = entry.path;
|
||||
if logical_path(root, &path).is_none() {
|
||||
continue;
|
||||
}
|
||||
if !file_type.map(|kind| kind.is_file()).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
if traversal.is_some() {
|
||||
let file_type = entry.kind;
|
||||
if let Some(ignores) = descriptor_ignores.as_mut() {
|
||||
let relative = path.strip_prefix(&base).map_err(|_| {
|
||||
FsError::InvalidArgument(
|
||||
"descriptor traversal returned a path outside its search base".to_string(),
|
||||
)
|
||||
})?;
|
||||
if relative
|
||||
.components()
|
||||
.any(|component| component.as_os_str().to_string_lossy().starts_with('.'))
|
||||
|| descriptor_ignores.is_ignored(root, &path)
|
||||
|| !direct_file_selected(&path, overrides.as_ref(), types.as_ref())
|
||||
let is_dir = file_type.is_some_and(|kind| kind.is_dir());
|
||||
if path != logical_walk_root
|
||||
&& (relative.components().any(|component| {
|
||||
component
|
||||
.as_os_str()
|
||||
.to_str()
|
||||
.is_some_and(|name| name.starts_with('.'))
|
||||
}) || ignores.is_ignored(&path, is_dir))
|
||||
{
|
||||
if is_dir {
|
||||
walk.skip_current_dir();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if is_dir {
|
||||
ignores.load_directory(&path, access)?;
|
||||
continue;
|
||||
}
|
||||
if !direct_file_selected(&path, overrides.as_ref(), types.as_ref()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if !file_type.map(|kind| kind.is_file()).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let readable = access
|
||||
.resolve_access_path(&path)
|
||||
@@ -618,6 +484,7 @@ pub fn run_grep(
|
||||
&mut searcher,
|
||||
&matcher,
|
||||
&path,
|
||||
root,
|
||||
mode,
|
||||
&mut report,
|
||||
&mut matching_files_seen,
|
||||
@@ -639,6 +506,7 @@ fn scan_path(
|
||||
searcher: &mut Searcher,
|
||||
matcher: &grep_regex::RegexMatcher,
|
||||
path: &Path,
|
||||
root: &Path,
|
||||
mode: GrepOutputMode,
|
||||
report: &mut GrepReport,
|
||||
matching_files_seen: &mut usize,
|
||||
@@ -648,6 +516,9 @@ fn scan_path(
|
||||
source_bytes_remaining: &mut u64,
|
||||
access: &dyn FsAccessPolicy,
|
||||
) -> Result<bool, FsError> {
|
||||
if logical_path(root, path).is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
let resolved = access
|
||||
.resolve_access_path(path)
|
||||
.map_err(|error| FsError::io(path, error))?;
|
||||
@@ -678,7 +549,7 @@ fn scan_path(
|
||||
return Ok(false);
|
||||
}
|
||||
if *matching_files_seen >= offset {
|
||||
if !reserve_report_bytes(report, path.to_string_lossy().len().saturating_add(1)) {
|
||||
if !reserve_report_bytes(report, path.as_os_str().len().saturating_add(1)) {
|
||||
return Ok(true);
|
||||
}
|
||||
report.files.push(path.to_path_buf());
|
||||
@@ -695,7 +566,7 @@ fn scan_path(
|
||||
return Ok(false);
|
||||
}
|
||||
if *matching_files_seen >= offset {
|
||||
if !reserve_report_bytes(report, path.to_string_lossy().len().saturating_add(32)) {
|
||||
if !reserve_report_bytes(report, path.as_os_str().len().saturating_add(32)) {
|
||||
return Ok(true);
|
||||
}
|
||||
report.counts.push((path.to_path_buf(), count));
|
||||
@@ -780,7 +651,7 @@ impl ContentSink<'_> {
|
||||
fn reserve(&mut self, content_bytes: usize) -> bool {
|
||||
let retained = self
|
||||
.path
|
||||
.to_string_lossy()
|
||||
.as_os_str()
|
||||
.len()
|
||||
.saturating_add(content_bytes)
|
||||
.saturating_add(64);
|
||||
|
||||
@@ -0,0 +1,900 @@
|
||||
//! Pure text-operation policy shared by filesystem providers and operation frontends.
|
||||
//!
|
||||
//! This module owns no paths, descriptors, version fences, or I/O. Limits count
|
||||
//! UTF-8 bytes, not characters, and default to unlimited.
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, de::DeserializeOwned};
|
||||
use thiserror::Error;
|
||||
|
||||
/// The shared text Operation definitions, independent of paths and frontends.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TextOperation {
|
||||
Read,
|
||||
ReadLines,
|
||||
Edit,
|
||||
Write,
|
||||
}
|
||||
|
||||
impl TextOperation {
|
||||
/// Resolve a lower-case Operation name. A frontend chooses whether its
|
||||
/// `read` Operation accepts whole-text or line-range arguments.
|
||||
pub fn from_name(name: &str, line_read: bool) -> Option<Self> {
|
||||
match name {
|
||||
"read" if line_read => Some(Self::ReadLines),
|
||||
"read" => Some(Self::Read),
|
||||
"edit" => Some(Self::Edit),
|
||||
"write" => Some(Self::Write),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn argument_schema(self) -> schemars::Schema {
|
||||
match self {
|
||||
Self::Read => schemars::schema_for!(ReadArgs),
|
||||
Self::ReadLines => schemars::schema_for!(LineReadArgs),
|
||||
Self::Edit => schemars::schema_for!(EditArgs),
|
||||
Self::Write => schemars::schema_for!(WriteArgs),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
|
||||
pub struct EditArgs {
|
||||
/// String to replace. Must be nonempty and unique unless replace_all is true.
|
||||
pub old_string: String,
|
||||
/// Replacement text. Must differ from old_string; may be empty to delete it.
|
||||
pub new_string: String,
|
||||
/// Replace every non-overlapping occurrence. Defaults to false.
|
||||
#[serde(default)]
|
||||
pub replace_all: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
|
||||
pub struct WriteArgs {
|
||||
/// Full text to write.
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Whole-text reads accept no operation-specific arguments.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ReadArgs {}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, JsonSchema)]
|
||||
pub struct LineReadArgs {
|
||||
/// Zero-based line offset. Omitted or null means zero.
|
||||
#[serde(default)]
|
||||
pub offset: Option<usize>,
|
||||
/// Maximum lines to return. Omitted or null uses the frontend's default.
|
||||
#[serde(default)]
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
impl LineReadArgs {
|
||||
pub fn range(&self, default_limit: usize) -> (usize, usize) {
|
||||
(
|
||||
self.offset.unwrap_or(0),
|
||||
self.limit.unwrap_or(default_limit).max(1),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct TextLimits {
|
||||
pub max_input_bytes: Option<usize>,
|
||||
pub max_output_bytes: Option<usize>,
|
||||
pub max_replacements: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TextError {
|
||||
#[error("invalid arguments: {0}")]
|
||||
Decode(#[source] serde_json::Error),
|
||||
#[error("old_string must not be empty")]
|
||||
EmptyOldString,
|
||||
#[error("old_string and new_string are identical")]
|
||||
IdenticalStrings,
|
||||
#[error("old_string was not found")]
|
||||
NotFound,
|
||||
#[error(
|
||||
"old_string matched {occurrences} times; set replace_all=true or provide a unique string"
|
||||
)]
|
||||
MultipleMatches { occurrences: usize },
|
||||
#[error("input size {bytes} exceeds limit {limit}")]
|
||||
InputTooLarge { bytes: usize, limit: usize },
|
||||
#[error("output size {bytes} exceeds limit {limit}")]
|
||||
OutputTooLarge { bytes: usize, limit: usize },
|
||||
#[error("replacement count {replacements} exceeds limit {limit}")]
|
||||
ReplacementLimitExceeded { replacements: usize, limit: usize },
|
||||
#[error("edited content size overflowed")]
|
||||
Overflow,
|
||||
}
|
||||
|
||||
pub fn decode<T: DeserializeOwned>(value: serde_json::Value) -> Result<T, TextError> {
|
||||
serde_json::from_value(value).map_err(TextError::Decode)
|
||||
}
|
||||
|
||||
/// Decode raw Tool JSON directly, retaining duplicate-field rejection rather
|
||||
/// than losing that information through an intermediate JSON object.
|
||||
pub fn decode_json<T: DeserializeOwned>(input: &str) -> Result<T, TextError> {
|
||||
serde_json::from_str(input).map_err(TextError::Decode)
|
||||
}
|
||||
|
||||
impl EditArgs {
|
||||
/// Validate argument-only policy before a caller acquires a preimage.
|
||||
pub fn validate(&self, limits: TextLimits) -> Result<(), TextError> {
|
||||
if self.old_string.is_empty() {
|
||||
return Err(TextError::EmptyOldString);
|
||||
}
|
||||
if self.old_string == self.new_string {
|
||||
return Err(TextError::IdenticalStrings);
|
||||
}
|
||||
check_input_bytes(self.old_string.len(), limits)?;
|
||||
check_input_bytes(self.new_string.len(), limits)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A numbered, bounded text response with provider line metadata.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Rendered {
|
||||
pub body: String,
|
||||
pub line_count: usize,
|
||||
pub total_lines: usize,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
const RENDERED_TRUNCATION_MARKER: &str = "\n[truncated at rendered output byte limit]\n";
|
||||
|
||||
/// Render selected text with one-based line numbers and a hard UTF-8 byte bound.
|
||||
///
|
||||
/// As in the Tool renderer, room for the truncation marker is always reserved,
|
||||
/// and a partially emitted numbered line counts as a line. Tiny bounds emit
|
||||
/// only the marker prefix that fits. Line numbers saturate rather than wrap.
|
||||
pub fn render_numbered(
|
||||
text: &str,
|
||||
start_line: usize,
|
||||
total_lines: usize,
|
||||
truncated: bool,
|
||||
max_bytes: usize,
|
||||
) -> Rendered {
|
||||
let budget = max_bytes.saturating_sub(RENDERED_TRUNCATION_MARKER.len());
|
||||
let mut body = String::with_capacity(text.len().min(max_bytes));
|
||||
let mut line_count = 0usize;
|
||||
let mut output_truncated = false;
|
||||
for (index, line) in text.lines().enumerate() {
|
||||
// Build only the small numeric prefix, not an unbounded copy of a line.
|
||||
let number = start_line.saturating_add(index).saturating_add(1);
|
||||
let prefix = format!("{number:>6}\t");
|
||||
let numbered_len = prefix.len().saturating_add(line.len()).saturating_add(1);
|
||||
let mut remaining = budget.saturating_sub(body.len());
|
||||
if remaining > 0 {
|
||||
line_count = line_count.saturating_add(1);
|
||||
}
|
||||
let line_truncated = numbered_len > remaining;
|
||||
for fragment in [prefix.as_str(), line, "\n"] {
|
||||
let mut end = remaining.min(fragment.len());
|
||||
while !fragment.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
body.push_str(&fragment[..end]);
|
||||
remaining -= end;
|
||||
// Do not skip a character that failed to fit and then emit later
|
||||
// fragments; the response must be a prefix of the numbered line.
|
||||
if end < fragment.len() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if line_truncated {
|
||||
let marker_len = max_bytes
|
||||
.saturating_sub(body.len())
|
||||
.min(RENDERED_TRUNCATION_MARKER.len());
|
||||
body.push_str(&RENDERED_TRUNCATION_MARKER[..marker_len]);
|
||||
output_truncated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Rendered {
|
||||
body,
|
||||
line_count,
|
||||
total_lines,
|
||||
truncated: start_line > 0 || truncated || output_truncated,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EditOutcome {
|
||||
pub content: String,
|
||||
pub replacements: usize,
|
||||
pub bytes_written: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TextOutcome {
|
||||
pub content: String,
|
||||
pub bytes: usize,
|
||||
}
|
||||
|
||||
pub fn edit(original: &str, args: &EditArgs, limits: TextLimits) -> Result<EditOutcome, TextError> {
|
||||
args.validate(limits)?;
|
||||
check_input_bytes(original.len(), limits)?;
|
||||
let occurrences = original.matches(&args.old_string).count();
|
||||
if occurrences == 0 {
|
||||
return Err(TextError::NotFound);
|
||||
}
|
||||
if !args.replace_all && occurrences != 1 {
|
||||
return Err(TextError::MultipleMatches { occurrences });
|
||||
}
|
||||
let replacements = occurrences;
|
||||
if let Some(limit) = limits.max_replacements
|
||||
&& replacements > limit
|
||||
{
|
||||
return Err(TextError::ReplacementLimitExceeded {
|
||||
replacements,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
// All arithmetic and output policy are checked before allocating the result.
|
||||
let bytes_written = edited_len(
|
||||
original.len(),
|
||||
args.old_string.len(),
|
||||
args.new_string.len(),
|
||||
replacements,
|
||||
)?;
|
||||
check_output_bytes(bytes_written, limits)?;
|
||||
let mut content = String::with_capacity(bytes_written);
|
||||
let mut cursor = 0;
|
||||
for (start, matched) in original.match_indices(&args.old_string) {
|
||||
content.push_str(&original[cursor..start]);
|
||||
content.push_str(&args.new_string);
|
||||
cursor = start + matched.len();
|
||||
}
|
||||
content.push_str(&original[cursor..]);
|
||||
debug_assert_eq!(content.len(), bytes_written);
|
||||
Ok(EditOutcome {
|
||||
content,
|
||||
replacements,
|
||||
bytes_written,
|
||||
})
|
||||
}
|
||||
|
||||
fn edited_len(
|
||||
original: usize,
|
||||
old: usize,
|
||||
new: usize,
|
||||
replacements: usize,
|
||||
) -> Result<usize, TextError> {
|
||||
let removed = old.checked_mul(replacements).ok_or(TextError::Overflow)?;
|
||||
let added = new.checked_mul(replacements).ok_or(TextError::Overflow)?;
|
||||
let bytes = original
|
||||
.checked_sub(removed)
|
||||
.and_then(|bytes| bytes.checked_add(added))
|
||||
.ok_or(TextError::Overflow)?;
|
||||
// String/Vec capacity is bounded by isize::MAX even on platforms where usize
|
||||
// arithmetic still fits. Reject that case before String::with_capacity.
|
||||
if bytes > isize::MAX as usize {
|
||||
return Err(TextError::Overflow);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub fn write(content: String, limits: TextLimits) -> Result<TextOutcome, TextError> {
|
||||
text_outcome(content, limits)
|
||||
}
|
||||
|
||||
pub fn read(content: String, limits: TextLimits) -> Result<TextOutcome, TextError> {
|
||||
text_outcome(content, limits)
|
||||
}
|
||||
|
||||
fn text_outcome(content: String, limits: TextLimits) -> Result<TextOutcome, TextError> {
|
||||
let bytes = content.len();
|
||||
check_input_bytes(bytes, limits)?;
|
||||
check_output_bytes(bytes, limits)?;
|
||||
Ok(TextOutcome { content, bytes })
|
||||
}
|
||||
|
||||
pub(crate) fn check_input_bytes(bytes: usize, limits: TextLimits) -> Result<(), TextError> {
|
||||
if let Some(limit) = limits.max_input_bytes
|
||||
&& bytes > limit
|
||||
{
|
||||
return Err(TextError::InputTooLarge { bytes, limit });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn check_output_bytes(bytes: usize, limits: TextLimits) -> Result<(), TextError> {
|
||||
if let Some(limit) = limits.max_output_bytes
|
||||
&& bytes > limit
|
||||
{
|
||||
return Err(TextError::OutputTooLarge { bytes, limit });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Keep valid UTF-8 prefixes intact without changing byte-read behavior on
|
||||
/// genuinely invalid UTF-8. Providers must remain able to return binary data.
|
||||
pub(crate) fn bounded_utf8_len(bytes: &[u8], max_bytes: usize) -> usize {
|
||||
let mut end = max_bytes.min(bytes.len());
|
||||
while end > 0 {
|
||||
match std::str::from_utf8(&bytes[..end]) {
|
||||
Ok(_) => break,
|
||||
Err(error) if error.error_len().is_none() => end -= 1,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
end
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn args(old: &str, new: &str, replace_all: bool) -> EditArgs {
|
||||
EditArgs {
|
||||
old_string: old.into(),
|
||||
new_string: new.into(),
|
||||
replace_all,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_uses_shared_argument_defaults_and_types() {
|
||||
let edit = decode::<EditArgs>(json!({"old_string": "a", "new_string": "b"})).unwrap();
|
||||
assert!(!edit.replace_all);
|
||||
assert!(
|
||||
decode::<EditArgs>(json!({"old_string": "a", "new_string": "b", "replace_all": true}))
|
||||
.unwrap()
|
||||
.replace_all
|
||||
);
|
||||
assert_eq!(
|
||||
decode::<WriteArgs>(json!({"content": "😀"}))
|
||||
.unwrap()
|
||||
.content,
|
||||
"😀"
|
||||
);
|
||||
assert_eq!(decode::<ReadArgs>(json!({})).unwrap(), ReadArgs {});
|
||||
for value in [
|
||||
json!({"old_string": "a"}),
|
||||
json!({"old_string": 2, "new_string": "b"}),
|
||||
json!({"old_string": "a", "new_string": "b", "replace_all": null}),
|
||||
] {
|
||||
assert!(matches!(
|
||||
decode::<EditArgs>(value),
|
||||
Err(TextError::Decode(_))
|
||||
));
|
||||
}
|
||||
assert!(matches!(
|
||||
decode::<WriteArgs>(json!({})),
|
||||
Err(TextError::Decode(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
decode::<ReadArgs>(json!({"offset": 0})),
|
||||
Err(TextError::Decode(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
decode::<ReadArgs>(json!(null)),
|
||||
Err(TextError::Decode(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_range_preserves_null_defaults_and_minimum_one() {
|
||||
for value in [json!({}), json!({"offset": null, "limit": null})] {
|
||||
assert_eq!(
|
||||
decode::<LineReadArgs>(value).unwrap().range(2000),
|
||||
(0, 2000)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
decode::<LineReadArgs>(json!({"offset": 7, "limit": 0}))
|
||||
.unwrap()
|
||||
.range(99),
|
||||
(7, 1)
|
||||
);
|
||||
assert_eq!(LineReadArgs::default().range(0), (0, 1));
|
||||
assert_eq!(
|
||||
LineReadArgs {
|
||||
offset: Some(usize::MAX),
|
||||
limit: Some(usize::MAX)
|
||||
}
|
||||
.range(1),
|
||||
(usize::MAX, usize::MAX)
|
||||
);
|
||||
for value in [
|
||||
json!({"offset": -1}),
|
||||
json!({"limit": "2"}),
|
||||
json!({"limit": 1.5}),
|
||||
] {
|
||||
assert!(matches!(
|
||||
decode::<LineReadArgs>(value),
|
||||
Err(TextError::Decode(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_is_the_argument_contract() {
|
||||
let edit = serde_json::to_value(schemars::schema_for!(EditArgs)).unwrap();
|
||||
assert_eq!(edit["required"], json!(["old_string", "new_string"]));
|
||||
assert_eq!(edit["properties"]["replace_all"]["default"], json!(false));
|
||||
let read = serde_json::to_value(schemars::schema_for!(ReadArgs)).unwrap();
|
||||
assert_eq!(read["additionalProperties"], json!(false));
|
||||
assert!(
|
||||
read.get("properties")
|
||||
.is_none_or(|properties| properties == &json!({}))
|
||||
);
|
||||
let lines = serde_json::to_value(schemars::schema_for!(LineReadArgs)).unwrap();
|
||||
assert_eq!(
|
||||
lines["properties"]["offset"]["type"],
|
||||
json!(["integer", "null"])
|
||||
);
|
||||
assert_eq!(lines["properties"]["limit"]["default"], json!(null));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_rejects_empty_identical_missing_and_ambiguous_matches() {
|
||||
assert!(matches!(
|
||||
args("", "x", true).validate(TextLimits::default()),
|
||||
Err(TextError::EmptyOldString)
|
||||
));
|
||||
assert!(matches!(
|
||||
edit("a", &args("a", "a", false), TextLimits::default()),
|
||||
Err(TextError::IdenticalStrings)
|
||||
));
|
||||
assert!(matches!(
|
||||
edit("a", &args("b", "c", false), TextLimits::default()),
|
||||
Err(TextError::NotFound)
|
||||
));
|
||||
assert!(matches!(
|
||||
edit("aa", &args("a", "b", false), TextLimits::default()),
|
||||
Err(TextError::MultipleMatches { occurrences: 2 })
|
||||
));
|
||||
assert!(matches!(
|
||||
edit("", &args("a", "", false), TextLimits::default()),
|
||||
Err(TextError::NotFound)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_handles_unique_all_deletion_literal_and_nonoverlapping_matches() {
|
||||
for (original, old, new, all, expected, replacements) in [
|
||||
(
|
||||
"before old after",
|
||||
"old",
|
||||
"new",
|
||||
false,
|
||||
"before new after",
|
||||
1,
|
||||
),
|
||||
("aaa", "aa", "x", true, "xa", 1),
|
||||
("a-a-a", "a", "", true, "--", 3),
|
||||
("[a].* [a].*", "[a].*", "$1", true, "$1 $1", 2),
|
||||
("x", "x", "", false, "", 1),
|
||||
] {
|
||||
let result = edit(original, &args(old, new, all), TextLimits::default()).unwrap();
|
||||
assert_eq!(result.content, expected);
|
||||
assert_eq!(result.replacements, replacements);
|
||||
assert_eq!(result.bytes_written, expected.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_preserves_unicode_and_counts_bytes() {
|
||||
let result = edit("é😀\né😀", &args("é😀", "界", true), TextLimits::default()).unwrap();
|
||||
assert_eq!(result.content, "界\n界");
|
||||
assert_eq!(result.replacements, 2);
|
||||
assert_eq!(result.bytes_written, 7);
|
||||
// Matching is literal, without Unicode normalization.
|
||||
assert!(matches!(
|
||||
edit("e\u{301}", &args("é", "x", false), TextLimits::default()),
|
||||
Err(TextError::NotFound)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_input_limits_cover_arguments_and_preimage_with_inclusive_boundaries() {
|
||||
let limits = TextLimits {
|
||||
max_input_bytes: Some(4),
|
||||
..Default::default()
|
||||
};
|
||||
args("😀", "a", false).validate(limits).unwrap();
|
||||
assert!(matches!(
|
||||
args("😀x", "a", false).validate(limits),
|
||||
Err(TextError::InputTooLarge { bytes: 5, limit: 4 })
|
||||
));
|
||||
assert!(matches!(
|
||||
args("a", "😀x", false).validate(limits),
|
||||
Err(TextError::InputTooLarge { bytes: 5, limit: 4 })
|
||||
));
|
||||
assert!(matches!(
|
||||
edit("aaaaa", &args("a", "b", true), limits),
|
||||
Err(TextError::InputTooLarge { bytes: 5, limit: 4 })
|
||||
));
|
||||
assert_eq!(
|
||||
edit("aaaa", &args("a", "b", true), limits).unwrap().content,
|
||||
"bbbb"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_output_and_replacement_limits_apply_before_constructing_result() {
|
||||
let args = args("a", "😀", true);
|
||||
let limits = TextLimits {
|
||||
max_output_bytes: Some(7),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
edit("aa", &args, limits),
|
||||
Err(TextError::OutputTooLarge { bytes: 8, limit: 7 })
|
||||
));
|
||||
assert_eq!(
|
||||
edit(
|
||||
"aa",
|
||||
&args,
|
||||
TextLimits {
|
||||
max_output_bytes: Some(8),
|
||||
..limits
|
||||
}
|
||||
)
|
||||
.unwrap()
|
||||
.bytes_written,
|
||||
8
|
||||
);
|
||||
assert!(matches!(
|
||||
edit(
|
||||
"aa",
|
||||
&args,
|
||||
TextLimits {
|
||||
max_replacements: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
),
|
||||
Err(TextError::ReplacementLimitExceeded {
|
||||
replacements: 2,
|
||||
limit: 1
|
||||
})
|
||||
));
|
||||
assert!(matches!(
|
||||
edit(
|
||||
"a",
|
||||
&args,
|
||||
TextLimits {
|
||||
max_replacements: Some(0),
|
||||
..Default::default()
|
||||
}
|
||||
),
|
||||
Err(TextError::ReplacementLimitExceeded {
|
||||
replacements: 1,
|
||||
limit: 0
|
||||
})
|
||||
));
|
||||
assert_eq!(
|
||||
edit(
|
||||
"aa",
|
||||
&args,
|
||||
TextLimits {
|
||||
max_replacements: Some(2),
|
||||
..Default::default()
|
||||
}
|
||||
)
|
||||
.unwrap()
|
||||
.replacements,
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
edit(
|
||||
"a",
|
||||
&EditArgs {
|
||||
old_string: "a".into(),
|
||||
new_string: String::new(),
|
||||
replace_all: false
|
||||
},
|
||||
TextLimits {
|
||||
max_output_bytes: Some(0),
|
||||
..Default::default()
|
||||
}
|
||||
)
|
||||
.unwrap()
|
||||
.content,
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_json_decode_preserves_duplicate_argument_rejection() {
|
||||
for input in [
|
||||
r#"{"old_string":"a","old_string":"b","new_string":"c"}"#,
|
||||
r#"{"old_string":"a","new_string":"b","replace_all":false,"replace_all":true}"#,
|
||||
] {
|
||||
assert!(matches!(
|
||||
decode_json::<EditArgs>(input),
|
||||
Err(TextError::Decode(_))
|
||||
));
|
||||
}
|
||||
assert!(matches!(
|
||||
decode_json::<WriteArgs>(r#"{"content":"a","content":"b"}"#),
|
||||
Err(TextError::Decode(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_arithmetic_rejects_every_overflow_before_preallocation() {
|
||||
// Exercise impossible-to-allocate sizes through the exact helper edit
|
||||
// invokes before allocating; no giant strings need to be constructed.
|
||||
for (original, old, new, count) in [
|
||||
(usize::MAX, usize::MAX, 0, 2), // removed multiplication
|
||||
(2, 1, usize::MAX, 2), // added multiplication
|
||||
(1, 2, 0, 1), // subtraction
|
||||
(usize::MAX, 1, 2, 1), // addition
|
||||
(1, 1, isize::MAX as usize + 1, 1), // allocation capacity
|
||||
] {
|
||||
assert!(matches!(
|
||||
edited_len(original, old, new, count),
|
||||
Err(TextError::Overflow)
|
||||
));
|
||||
}
|
||||
assert_eq!(
|
||||
edited_len(1, 1, isize::MAX as usize, 1).unwrap(),
|
||||
isize::MAX as usize
|
||||
);
|
||||
assert_eq!(edited_len(8, 2, 1, 3).unwrap(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_and_write_preserve_whole_text_and_enforce_both_byte_limits() {
|
||||
for operation in [read, write] {
|
||||
assert_eq!(
|
||||
operation(String::new(), TextLimits::default()).unwrap(),
|
||||
TextOutcome {
|
||||
content: String::new(),
|
||||
bytes: 0
|
||||
}
|
||||
);
|
||||
let content = "é\r\n😀\n";
|
||||
let limits = TextLimits {
|
||||
max_input_bytes: Some(content.len()),
|
||||
max_output_bytes: Some(content.len()),
|
||||
max_replacements: Some(0),
|
||||
};
|
||||
let result = operation(content.into(), limits).unwrap();
|
||||
assert_eq!(result.content, content);
|
||||
assert_eq!(result.bytes, content.len());
|
||||
assert!(matches!(
|
||||
operation(
|
||||
"😀".into(),
|
||||
TextLimits {
|
||||
max_input_bytes: Some(3),
|
||||
..Default::default()
|
||||
}
|
||||
),
|
||||
Err(TextError::InputTooLarge { bytes: 4, limit: 3 })
|
||||
));
|
||||
assert!(matches!(
|
||||
operation(
|
||||
"😀".into(),
|
||||
TextLimits {
|
||||
max_output_bytes: Some(3),
|
||||
..Default::default()
|
||||
}
|
||||
),
|
||||
Err(TextError::OutputTooLarge { bytes: 4, limit: 3 })
|
||||
));
|
||||
assert!(matches!(
|
||||
operation(
|
||||
"a".into(),
|
||||
TextLimits {
|
||||
max_output_bytes: Some(0),
|
||||
..Default::default()
|
||||
}
|
||||
),
|
||||
Err(TextError::OutputTooLarge { bytes: 1, limit: 0 })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_byte_bounds_preserve_binary_compatibility() {
|
||||
for (limit, expected) in [
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(2, 1),
|
||||
(3, 1),
|
||||
(4, 1),
|
||||
(5, 5),
|
||||
(usize::MAX, 6),
|
||||
] {
|
||||
assert_eq!(bounded_utf8_len("a😀z".as_bytes(), limit), expected);
|
||||
}
|
||||
assert_eq!(bounded_utf8_len(&[0xff, 0xfe, 0x00], 2), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_names_and_schemas_are_shared_authority() {
|
||||
assert_eq!(
|
||||
TextOperation::from_name("read", false),
|
||||
Some(TextOperation::Read)
|
||||
);
|
||||
assert_eq!(
|
||||
TextOperation::from_name("read", true),
|
||||
Some(TextOperation::ReadLines)
|
||||
);
|
||||
for line_read in [false, true] {
|
||||
assert_eq!(
|
||||
TextOperation::from_name("edit", line_read),
|
||||
Some(TextOperation::Edit)
|
||||
);
|
||||
assert_eq!(
|
||||
TextOperation::from_name("write", line_read),
|
||||
Some(TextOperation::Write)
|
||||
);
|
||||
for name in ["Read", "WRITE", "read_lines", "stat", "", " read"] {
|
||||
assert_eq!(TextOperation::from_name(name, line_read), None);
|
||||
}
|
||||
}
|
||||
for (operation, expected) in [
|
||||
(TextOperation::Read, schemars::schema_for!(ReadArgs)),
|
||||
(
|
||||
TextOperation::ReadLines,
|
||||
schemars::schema_for!(LineReadArgs),
|
||||
),
|
||||
(TextOperation::Edit, schemars::schema_for!(EditArgs)),
|
||||
(TextOperation::Write, schemars::schema_for!(WriteArgs)),
|
||||
] {
|
||||
assert_eq!(operation.argument_schema(), expected);
|
||||
}
|
||||
let schema = serde_json::to_value(TextOperation::Edit.argument_schema()).unwrap();
|
||||
assert!(
|
||||
schema["properties"]["new_string"]["description"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("Must differ")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renderer_preserves_numbering_lines_and_provider_metadata() {
|
||||
let result = render_numbered("alpha\r\n\nβeta\n", 0, 3, false, 1024);
|
||||
assert_eq!(result.body, " 1\talpha\n 2\t\n 3\tβeta\n");
|
||||
assert_eq!(result.line_count, 3);
|
||||
assert_eq!(result.total_lines, 3);
|
||||
assert!(!result.truncated);
|
||||
let result = render_numbered("last", 9, 10, false, 1024);
|
||||
assert_eq!(result.body, " 10\tlast\n");
|
||||
assert!(result.truncated);
|
||||
assert!(render_numbered("last", 0, 10, true, 1024).truncated);
|
||||
assert_eq!(
|
||||
render_numbered("", 0, 0, false, 0),
|
||||
Rendered {
|
||||
body: String::new(),
|
||||
line_count: 0,
|
||||
total_lines: 0,
|
||||
truncated: false,
|
||||
}
|
||||
);
|
||||
assert!(render_numbered("", 1, 1, false, 0).truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renderer_reserves_marker_and_counts_partial_lines_as_before() {
|
||||
let marker = RENDERED_TRUNCATION_MARKER;
|
||||
let result = render_numbered("a\nb", 0, 2, false, marker.len() + 9);
|
||||
assert_eq!(result.body, format!(" 1\ta\n{marker}"));
|
||||
assert_eq!(result.line_count, 1);
|
||||
assert_eq!(result.total_lines, 2);
|
||||
assert!(result.truncated);
|
||||
let result = render_numbered("a", 0, 1, false, marker.len() + 1);
|
||||
assert_eq!(result.body, format!(" {marker}"));
|
||||
assert_eq!(result.line_count, 1);
|
||||
assert!(result.truncated);
|
||||
let result = render_numbered("a", 0, 1, false, marker.len() + 9);
|
||||
assert_eq!(result.body, " 1\ta\n");
|
||||
assert_eq!(result.line_count, 1);
|
||||
assert!(!result.truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renderer_tiny_bounds_unicode_and_large_offsets_never_overflow() {
|
||||
let marker = RENDERED_TRUNCATION_MARKER;
|
||||
for max_bytes in 0..=marker.len() {
|
||||
let result = render_numbered("😀", 0, 1, false, max_bytes);
|
||||
assert_eq!(result.body, marker[..max_bytes]);
|
||||
assert_eq!(result.line_count, 0);
|
||||
assert!(result.truncated);
|
||||
}
|
||||
for extra in 1..=4 {
|
||||
let result = render_numbered("😀😀", 0, 1, false, marker.len() + 7 + extra);
|
||||
let expected = if extra < 4 {
|
||||
" 1\t"
|
||||
} else {
|
||||
" 1\t😀"
|
||||
};
|
||||
assert_eq!(result.body, format!("{expected}{marker}"));
|
||||
assert_eq!(result.line_count, 1);
|
||||
assert!(result.body.len() <= marker.len() + 7 + extra);
|
||||
assert!(result.truncated);
|
||||
}
|
||||
let result = render_numbered("a\nb", usize::MAX, usize::MAX, false, usize::MAX);
|
||||
assert_eq!(
|
||||
result.body,
|
||||
format!("{}\ta\n{}\tb\n", usize::MAX, usize::MAX)
|
||||
);
|
||||
assert_eq!(result.line_count, 2);
|
||||
assert_eq!(result.total_lines, usize::MAX);
|
||||
assert!(result.truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renderer_matches_original_behavior_for_ordinary_bounds() {
|
||||
fn original(
|
||||
text: &str,
|
||||
start_line: usize,
|
||||
total_lines: usize,
|
||||
truncated: bool,
|
||||
max_bytes: usize,
|
||||
) -> Rendered {
|
||||
let marker = RENDERED_TRUNCATION_MARKER;
|
||||
let budget = max_bytes - marker.len();
|
||||
let mut body = String::with_capacity(text.len().min(max_bytes));
|
||||
let mut line_count = 0;
|
||||
let mut output_truncated = false;
|
||||
for (index, line) in text.lines().enumerate() {
|
||||
let numbered = format!("{:>6}\t{}\n", start_line + index + 1, line);
|
||||
let remaining = budget.saturating_sub(body.len());
|
||||
if remaining > 0 {
|
||||
line_count += 1;
|
||||
}
|
||||
if numbered.len() > remaining {
|
||||
let mut end = remaining;
|
||||
while !numbered.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
body.push_str(&numbered[..end]);
|
||||
body.push_str(marker);
|
||||
output_truncated = true;
|
||||
break;
|
||||
}
|
||||
body.push_str(&numbered);
|
||||
}
|
||||
Rendered {
|
||||
body,
|
||||
line_count,
|
||||
total_lines,
|
||||
truncated: start_line > 0 || truncated || output_truncated,
|
||||
}
|
||||
}
|
||||
for text in ["", "a", "\n", "😀é界\nsecond\n", "first\r\n\r\nlast"] {
|
||||
for start in [0, 1, 999_999] {
|
||||
for bound in RENDERED_TRUNCATION_MARKER.len()..=160 {
|
||||
let result = render_numbered(text, start, 99, false, bound);
|
||||
assert_eq!(result, original(text, start, 99, false, bound));
|
||||
assert!(result.body.len() <= bound);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_keep_actionable_messages_and_decode_source() {
|
||||
assert_eq!(
|
||||
TextError::EmptyOldString.to_string(),
|
||||
"old_string must not be empty"
|
||||
);
|
||||
assert_eq!(
|
||||
TextError::IdenticalStrings.to_string(),
|
||||
"old_string and new_string are identical"
|
||||
);
|
||||
assert_eq!(TextError::NotFound.to_string(), "old_string was not found");
|
||||
assert_eq!(
|
||||
TextError::MultipleMatches { occurrences: 3 }.to_string(),
|
||||
"old_string matched 3 times; set replace_all=true or provide a unique string"
|
||||
);
|
||||
assert_eq!(
|
||||
TextError::Overflow.to_string(),
|
||||
"edited content size overflowed"
|
||||
);
|
||||
let error = decode::<ReadArgs>(json!({"unknown": true})).unwrap_err();
|
||||
assert!(std::error::Error::source(&error).is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
#![cfg(target_os = "linux")]
|
||||
use crate::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
struct Filtered {
|
||||
root: PathBuf,
|
||||
seen: Arc<Mutex<Vec<PathBuf>>>,
|
||||
}
|
||||
impl FsAccessPolicy for Filtered {
|
||||
fn is_readable(&self, path: &Path) -> bool {
|
||||
path.starts_with(&self.root) && !path.starts_with(self.root.join("search/denied"))
|
||||
}
|
||||
fn is_writable(&self, _: &Path) -> bool {
|
||||
false
|
||||
}
|
||||
fn open_traversal_root(
|
||||
&self,
|
||||
_: &Path,
|
||||
resolved: &Path,
|
||||
) -> std::io::Result<Option<FsTraversalRoot>> {
|
||||
use std::os::fd::AsRawFd;
|
||||
let file = std::fs::File::open(resolved)?;
|
||||
let walk = PathBuf::from(format!("/proc/self/fd/{}", file.as_raw_fd()));
|
||||
Ok(Some(FsTraversalRoot::new_at(
|
||||
walk,
|
||||
file,
|
||||
resolved.to_path_buf(),
|
||||
)))
|
||||
}
|
||||
fn traversal_filter(&self) -> Option<FsTraversalFilter> {
|
||||
let denied = self.root.join("search/denied");
|
||||
let seen = self.seen.clone();
|
||||
Some(Arc::new(move |path, _| {
|
||||
seen.lock().unwrap().push(path.to_path_buf());
|
||||
!path.starts_with(&denied)
|
||||
}))
|
||||
}
|
||||
fn open_read_file(&self, _: &Path, resolved: &Path) -> std::io::Result<std::fs::File> {
|
||||
assert!(
|
||||
!resolved.starts_with(self.root.join("search/denied")),
|
||||
"denied source opened"
|
||||
);
|
||||
std::fs::File::open(resolved)
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn descriptor_search_anchor_and_predicate_prune_before_denied_directory_descent() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(root.path().join("search/denied/deep")).unwrap();
|
||||
std::fs::write(root.path().join("search/allowed.txt"), "needle allowed").unwrap();
|
||||
std::fs::write(
|
||||
root.path().join("search/denied/deep/secret.txt"),
|
||||
"needle secret",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(root.path().join("outside.txt"), "needle outside").unwrap();
|
||||
let policy = Filtered {
|
||||
root: root.path().to_path_buf(),
|
||||
seen: Arc::new(Mutex::new(Vec::new())),
|
||||
};
|
||||
let glob = run_glob(
|
||||
root.path(),
|
||||
&root.path().join("search"),
|
||||
GlobRequest {
|
||||
path: FsPath::new("search").unwrap(),
|
||||
pattern: "**/*.txt".into(),
|
||||
limit: 100,
|
||||
},
|
||||
&policy,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(glob.paths, vec![FsPath::new("search/allowed.txt").unwrap()]);
|
||||
let grep = run_grep(
|
||||
root.path(),
|
||||
root.path().join("search"),
|
||||
GrepRequest {
|
||||
path: FsPath::new("search").unwrap(),
|
||||
pattern: "needle".into(),
|
||||
glob: None,
|
||||
file_type: None,
|
||||
case_insensitive: false,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
multiline: false,
|
||||
output_mode: GrepOutputMode::Content,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
},
|
||||
&policy,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(grep.paths, vec![FsPath::new("search/allowed.txt").unwrap()]);
|
||||
let seen = policy.seen.lock().unwrap();
|
||||
assert!(
|
||||
seen.iter()
|
||||
.any(|path| path == &root.path().join("search/denied"))
|
||||
);
|
||||
assert!(
|
||||
!seen
|
||||
.iter()
|
||||
.any(|path| path.starts_with(root.path().join("search/denied/deep")))
|
||||
);
|
||||
assert!(
|
||||
!seen
|
||||
.iter()
|
||||
.any(|path| path == &root.path().join("outside.txt"))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Bounded descriptor-provider walk shared by Glob and Grep. Unlike a
|
||||
//! WalkBuilder predicate, this iterator counts even rejected entries and checks
|
||||
//! cancellation while scanning them. Denied directories are never enumerated.
|
||||
use crate::{FsAccessPolicy, FsError, FsTraversalFilter};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(crate) struct Entry {
|
||||
pub path: PathBuf,
|
||||
pub kind: Option<std::fs::FileType>,
|
||||
}
|
||||
pub(crate) enum Walk<'a> {
|
||||
Plain(ignore::Walk),
|
||||
Descriptor(DescriptorWalk<'a>),
|
||||
}
|
||||
impl<'a> Walk<'a> {
|
||||
/// A rejected directory has been yielded but not enumerated yet. Keep
|
||||
/// ignore pruning inside the same bounded descriptor walk as scope pruning.
|
||||
pub fn skip_current_dir(&mut self) {
|
||||
if let Self::Descriptor(walk) = self {
|
||||
walk.pending = None;
|
||||
}
|
||||
}
|
||||
pub fn descriptor(root: &Path, access: &'a dyn FsAccessPolicy) -> Self {
|
||||
Self::Descriptor(DescriptorWalk {
|
||||
access,
|
||||
filter: access.traversal_filter(),
|
||||
pending: Some((root.to_path_buf(), 0)),
|
||||
root: Some(root.to_path_buf()),
|
||||
stack: Vec::new(),
|
||||
visited: 0,
|
||||
failed: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
impl Iterator for Walk<'_> {
|
||||
type Item = Result<Entry, FsError>;
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
match self {
|
||||
Self::Plain(walk) => loop {
|
||||
match walk.next()? {
|
||||
Ok(entry) => {
|
||||
return Some(Ok(Entry {
|
||||
path: entry.path().to_path_buf(),
|
||||
kind: entry.file_type(),
|
||||
}));
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
},
|
||||
Self::Descriptor(walk) => walk.next(),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) struct DescriptorWalk<'a> {
|
||||
access: &'a dyn FsAccessPolicy,
|
||||
filter: Option<FsTraversalFilter>,
|
||||
root: Option<PathBuf>,
|
||||
pending: Option<(PathBuf, usize)>,
|
||||
stack: Vec<(PathBuf, usize, std::fs::ReadDir)>,
|
||||
visited: usize,
|
||||
failed: bool,
|
||||
}
|
||||
impl DescriptorWalk<'_> {
|
||||
fn next_entry(&mut self) -> Result<Option<Entry>, FsError> {
|
||||
if let Some(root) = self.root.take() {
|
||||
self.access
|
||||
.check_cancelled()
|
||||
.map_err(|e| FsError::io(&root, e))?;
|
||||
let resolved = self
|
||||
.access
|
||||
.resolve_access_path(&root)
|
||||
.map_err(|e| FsError::io(&root, e))?;
|
||||
let metadata = self
|
||||
.access
|
||||
.read_metadata(&root, &resolved)
|
||||
.map_err(|e| FsError::io(&root, e))?;
|
||||
return Ok(Some(Entry {
|
||||
path: root,
|
||||
kind: Some(metadata.file_type()),
|
||||
}));
|
||||
}
|
||||
loop {
|
||||
if let Some((path, depth)) = self.pending.take() {
|
||||
self.access
|
||||
.check_cancelled()
|
||||
.map_err(|e| FsError::io(&path, e))?;
|
||||
let selected = self
|
||||
.filter
|
||||
.as_ref()
|
||||
.is_none_or(|filter| filter(&path, true));
|
||||
let resolved = self
|
||||
.access
|
||||
.resolve_access_path(&path)
|
||||
.map_err(|e| FsError::io(&path, e))?;
|
||||
if selected && self.access.can_enumerate_directory(&path, &resolved) {
|
||||
if depth >= 128 {
|
||||
return Err(FsError::InvalidArgument(
|
||||
"descriptor traversal depth exceeds provider limit 128".into(),
|
||||
));
|
||||
}
|
||||
let entries = self
|
||||
.access
|
||||
.open_read_dir(&path, &resolved)
|
||||
.map_err(|e| FsError::io(&path, e))?;
|
||||
self.stack.push((path, depth, entries));
|
||||
}
|
||||
}
|
||||
let Some((parent, depth, entries)) = self.stack.last_mut() else {
|
||||
return Ok(None);
|
||||
};
|
||||
self.access
|
||||
.check_cancelled()
|
||||
.map_err(|e| FsError::io(&*parent, e))?;
|
||||
let Some(entry) = entries.next() else {
|
||||
self.stack.pop();
|
||||
continue;
|
||||
};
|
||||
self.visited = self.visited.saturating_add(1);
|
||||
if self.visited > crate::MAX_TRAVERSAL_ENTRIES {
|
||||
return Err(FsError::InvalidArgument(
|
||||
"descriptor traversal exceeds provider entry limit".into(),
|
||||
));
|
||||
}
|
||||
let entry = entry.map_err(|e| FsError::io(&*parent, e))?;
|
||||
if entry.file_name().to_str().is_none() {
|
||||
continue;
|
||||
}
|
||||
let path = parent.join(entry.file_name());
|
||||
let kind = entry.file_type().map_err(|e| FsError::io(&path, e))?;
|
||||
let selected = self
|
||||
.filter
|
||||
.as_ref()
|
||||
.is_none_or(|filter| filter(&path, kind.is_dir()));
|
||||
if !selected {
|
||||
continue;
|
||||
}
|
||||
let resolved = match self.access.resolve_access_path(&path) {
|
||||
Ok(path) => path,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !self.access.is_readable_paths(&path, &resolved) {
|
||||
continue;
|
||||
}
|
||||
if kind.is_dir() {
|
||||
self.pending = Some((path.clone(), depth.saturating_add(1)));
|
||||
}
|
||||
return Ok(Some(Entry {
|
||||
path,
|
||||
kind: Some(kind),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Iterator for DescriptorWalk<'_> {
|
||||
type Item = Result<Entry, FsError>;
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if self.failed {
|
||||
return None;
|
||||
}
|
||||
match self.next_entry() {
|
||||
Ok(entry) => entry.map(Ok),
|
||||
Err(error) => {
|
||||
self.failed = true;
|
||||
Some(Err(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
struct RejectFiles {
|
||||
calls: AtomicUsize,
|
||||
cancel_at: usize,
|
||||
}
|
||||
impl FsAccessPolicy for RejectFiles {
|
||||
fn is_readable(&self, _: &Path) -> bool {
|
||||
true
|
||||
}
|
||||
fn is_writable(&self, _: &Path) -> bool {
|
||||
false
|
||||
}
|
||||
fn traversal_filter(&self) -> Option<FsTraversalFilter> {
|
||||
Some(Arc::new(|_, directory| directory))
|
||||
}
|
||||
fn check_cancelled(&self) -> std::io::Result<()> {
|
||||
if self.calls.fetch_add(1, Ordering::Relaxed) >= self.cancel_at {
|
||||
Err(std::io::Error::from(std::io::ErrorKind::Interrupted))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn descriptor_walk_counts_rejected_entries_toward_the_entry_budget() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::write(root.path().join("rejected"), "must not be opened").unwrap();
|
||||
let policy = RejectFiles {
|
||||
calls: AtomicUsize::new(0),
|
||||
cancel_at: usize::MAX,
|
||||
};
|
||||
let Walk::Descriptor(mut walk) = Walk::descriptor(root.path(), &policy) else {
|
||||
panic!()
|
||||
};
|
||||
assert!(walk.next().unwrap().is_ok());
|
||||
walk.visited = crate::MAX_TRAVERSAL_ENTRIES;
|
||||
assert!(matches!(
|
||||
walk.next().unwrap(),
|
||||
Err(FsError::InvalidArgument(_))
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn descriptor_walk_checks_cancellation_while_scanning_only_rejected_entries() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
for index in 0..20 {
|
||||
std::fs::write(root.path().join(index.to_string()), "must not be opened").unwrap();
|
||||
}
|
||||
let policy = RejectFiles {
|
||||
calls: AtomicUsize::new(0),
|
||||
cancel_at: 4,
|
||||
};
|
||||
let mut walk = Walk::descriptor(root.path(), &policy);
|
||||
assert!(walk.next().unwrap().is_ok());
|
||||
assert!(
|
||||
matches!(walk.next().unwrap(), Err(FsError::Io { source, .. }) if source.kind() == std::io::ErrorKind::Interrupted)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ Does not own:
|
||||
|
||||
Profiles are reusable recipes; resolved Manifests are runtime contracts. Keep runtime-bound fields such as Worker name, concrete delegated scope, sockets, session pointers, and raw secrets out of reusable Profiles.
|
||||
|
||||
`feature.workdir_catalog.enabled` defaults to `true` for read-only Repository/Workdir/attachment reference discovery in WIP mode. It is independent of `feature.manage_workdir` (which defaults disabled), registers no normal Tools, and does not grant filesystem, attachment, or Workspace authority. Permissions remain independent. Standalone Tools-mode profiles may retain this default; only WIP installation consumes the flag. Set it to `false` to opt out.
|
||||
|
||||
## See also
|
||||
|
||||
- [`../../docs/design/profiles-manifests-prompts.md`](../../docs/design/profiles-manifests-prompts.md)
|
||||
|
||||
@@ -51,8 +51,9 @@ pub struct WorkerManifestConfig {
|
||||
/// is disabled; `Some` requires `default_action` during final resolve.
|
||||
#[serde(default)]
|
||||
pub permissions: Option<PermissionConfigPartial>,
|
||||
/// Explicit built-in feature/tool-surface enablement. Absent flags resolve
|
||||
/// disabled after cascade merge.
|
||||
/// Built-in feature/tool-surface enablement. Absent tool flags resolve
|
||||
/// disabled after cascade merge; the read-only WIP workdir catalog defaults
|
||||
/// enabled without granting authority.
|
||||
#[serde(default)]
|
||||
pub feature: FeatureConfigPartial,
|
||||
/// Explicit Model Context Protocol provider declarations. Config parsing
|
||||
@@ -95,6 +96,10 @@ pub struct FeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
pub manage_workdir: Option<FeatureFlagConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub workdir_catalog: Option<FeatureFlagConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub workspace_config: Option<WorkspaceConfigFeatureConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub ticket: Option<TicketFeatureConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub merge_request: Option<MergeRequestFeatureConfigPartial>,
|
||||
@@ -136,6 +141,16 @@ impl FeatureConfigPartial {
|
||||
other.manage_workdir,
|
||||
FeatureFlagConfigPartial::merge,
|
||||
),
|
||||
workdir_catalog: merge_option(
|
||||
self.workdir_catalog,
|
||||
other.workdir_catalog,
|
||||
FeatureFlagConfigPartial::merge,
|
||||
),
|
||||
workspace_config: merge_option(
|
||||
self.workspace_config,
|
||||
other.workspace_config,
|
||||
WorkspaceConfigFeatureConfigPartial::merge,
|
||||
),
|
||||
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
|
||||
merge_request: merge_option(
|
||||
self.merge_request,
|
||||
@@ -165,6 +180,38 @@ impl FeatureFlagConfigPartial {
|
||||
}
|
||||
}
|
||||
|
||||
/// Activation only: no Workspace/grant/source/permission fields are accepted.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigFeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
impl WorkspaceConfigFeatureConfigPartial {
|
||||
fn merge(self, other: Self) -> Self {
|
||||
Self {
|
||||
enabled: other.enabled.or(self.enabled),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WorkspaceConfigFeatureConfigPartial> for FeatureFlagConfig {
|
||||
fn from(value: WorkspaceConfigFeatureConfigPartial) -> Self {
|
||||
Self {
|
||||
enabled: value.enabled.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FeatureFlagConfig> for WorkspaceConfigFeatureConfigPartial {
|
||||
fn from(value: FeatureFlagConfig) -> Self {
|
||||
Self {
|
||||
enabled: Some(value.enabled),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct WorkerFeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
@@ -241,6 +288,8 @@ pub struct SubjektivFeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub consolidation_tools: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub extraction: Option<MemoryExtractionProfileConfigPartial>,
|
||||
}
|
||||
|
||||
@@ -248,6 +297,7 @@ impl SubjektivFeatureConfigPartial {
|
||||
fn merge(self, other: Self) -> Self {
|
||||
Self {
|
||||
enabled: other.enabled.or(self.enabled),
|
||||
consolidation_tools: other.consolidation_tools.or(self.consolidation_tools),
|
||||
extraction: merge_option(
|
||||
self.extraction,
|
||||
other.extraction,
|
||||
@@ -388,6 +438,16 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
||||
.manage_workdir
|
||||
.map(FeatureFlagConfig::from)
|
||||
.unwrap_or_default(),
|
||||
workdir_catalog: FeatureFlagConfig {
|
||||
enabled: value
|
||||
.workdir_catalog
|
||||
.and_then(|flag| flag.enabled)
|
||||
.unwrap_or(defaults::WORKDIR_CATALOG_ENABLED),
|
||||
},
|
||||
workspace_config: value
|
||||
.workspace_config
|
||||
.map(FeatureFlagConfig::from)
|
||||
.unwrap_or_default(),
|
||||
ticket: value
|
||||
.ticket
|
||||
.map(TicketFeatureConfig::from)
|
||||
@@ -487,6 +547,7 @@ impl From<SubjektivFeatureConfigPartial> for ResolvedSubjektivFeatureConfig {
|
||||
Self {
|
||||
profile: SubjektivFeatureProfileConfig {
|
||||
enabled: value.enabled.unwrap_or_default(),
|
||||
consolidation_tools: value.consolidation_tools.unwrap_or_default(),
|
||||
extraction: MemoryExtractionProfileConfig {
|
||||
enabled: extraction.enabled.unwrap_or(true),
|
||||
model: extraction.model,
|
||||
@@ -506,6 +567,7 @@ impl From<ResolvedSubjektivFeatureConfig> for SubjektivFeatureConfigPartial {
|
||||
fn from(value: ResolvedSubjektivFeatureConfig) -> Self {
|
||||
Self {
|
||||
enabled: Some(value.profile.enabled),
|
||||
consolidation_tools: Some(value.profile.consolidation_tools),
|
||||
extraction: Some(MemoryExtractionProfileConfigPartial {
|
||||
enabled: Some(value.profile.extraction.enabled),
|
||||
model: value.profile.extraction.model,
|
||||
@@ -579,6 +641,8 @@ impl From<FeatureConfig> for FeatureConfigPartial {
|
||||
workspace_worker_discovery: Some(value.workspace_worker_discovery.into()),
|
||||
objective: Some(value.objective.into()),
|
||||
manage_workdir: Some(value.manage_workdir.into()),
|
||||
workdir_catalog: Some(value.workdir_catalog.into()),
|
||||
workspace_config: Some(value.workspace_config.into()),
|
||||
ticket: Some(value.ticket.into()),
|
||||
merge_request: Some(value.merge_request.into()),
|
||||
orchestration: Some(value.orchestration.into()),
|
||||
@@ -766,7 +830,8 @@ impl WorkerManifestConfig {
|
||||
|
||||
/// Base config populated with the in-code per-field defaults listed in
|
||||
/// [`crate::defaults`]. This is not a selectable Profile and does not
|
||||
/// enable a launch capability surface. Profile and one-file Manifest
|
||||
/// enable launch authority or normal Tools. The read-only WIP workdir
|
||||
/// catalog defaults enabled independently. Profile and one-file Manifest
|
||||
/// resolvers start from this layer so every per-field default lives at
|
||||
/// exactly one call site (the `defaults` module).
|
||||
///
|
||||
@@ -2232,7 +2297,7 @@ worker_max_turns = 7
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_flags_default_disabled_in_resolved_manifest() {
|
||||
fn tool_feature_flags_default_disabled_except_read_only_workdir_catalog() {
|
||||
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
|
||||
assert!(!manifest.feature.task.enabled);
|
||||
assert!(!manifest.feature.memory.profile.enabled);
|
||||
@@ -2240,10 +2305,213 @@ worker_max_turns = 7
|
||||
assert!(!manifest.feature.sub_worker.enabled);
|
||||
assert!(!manifest.feature.objective.enabled);
|
||||
assert!(!manifest.feature.manage_workdir.enabled);
|
||||
assert!(manifest.feature.workdir_catalog.enabled);
|
||||
assert!(!manifest.feature.workspace_config.enabled);
|
||||
assert!(!manifest.feature.ticket.enabled);
|
||||
assert!(!manifest.feature.merge_request.any());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_config_defaults_disabled_for_partial_and_resolved_configs() {
|
||||
assert!(!FeatureConfig::default().workspace_config.enabled);
|
||||
assert!(FeatureConfigPartial::default().workspace_config.is_none());
|
||||
for source in ["", "[feature]\n", "[feature.workspace_config]\n"] {
|
||||
let partial = WorkerManifestConfig::from_toml(source).unwrap();
|
||||
assert!(
|
||||
!FeatureConfig::from(partial.feature)
|
||||
.workspace_config
|
||||
.enabled
|
||||
);
|
||||
}
|
||||
for source in ["{}", r#"{"workspace_config": {}}"#] {
|
||||
let feature: FeatureConfig = serde_json::from_str(source).unwrap();
|
||||
assert!(!feature.workspace_config.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_config_enabled_only_merges_and_roundtrips_snapshots() {
|
||||
for enabled in [false, true] {
|
||||
let lower = WorkerManifestConfig::from_toml(&format!(
|
||||
"[feature.workspace_config]\nenabled = {enabled}\n"
|
||||
))
|
||||
.unwrap();
|
||||
let empty = WorkerManifestConfig::from_toml("[feature.workspace_config]\n").unwrap();
|
||||
let manifest: WorkerManifest = minimal_valid()
|
||||
.merge(lower.clone())
|
||||
.merge(empty)
|
||||
.try_into()
|
||||
.unwrap();
|
||||
assert_eq!(manifest.feature.workspace_config.enabled, enabled);
|
||||
assert!(!manifest.feature.manage_workdir.enabled);
|
||||
let partial = FeatureConfigPartial::from(manifest.feature.clone());
|
||||
assert_eq!(
|
||||
partial.workspace_config.as_ref().unwrap().enabled,
|
||||
Some(enabled)
|
||||
);
|
||||
let serialized = toml::to_string(&partial).unwrap();
|
||||
let restored: FeatureConfigPartial = toml::from_str(&serialized).unwrap();
|
||||
assert_eq!(FeatureConfig::from(restored), manifest.feature);
|
||||
let json = serde_json::to_value(&manifest.feature).unwrap();
|
||||
assert_eq!(
|
||||
json["workspace_config"],
|
||||
serde_json::json!({"enabled": enabled})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<FeatureConfig>(json).unwrap(),
|
||||
manifest.feature
|
||||
);
|
||||
let snapshot = crate::write_persisted_worker_manifest_snapshot(&manifest).unwrap();
|
||||
assert_eq!(
|
||||
crate::read_persisted_worker_manifest_snapshot(snapshot.clone())
|
||||
.unwrap()
|
||||
.feature
|
||||
.workspace_config
|
||||
.enabled,
|
||||
enabled
|
||||
);
|
||||
let mut old_snapshot = snapshot;
|
||||
old_snapshot["manifest"]["feature"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("workspace_config");
|
||||
assert!(
|
||||
!crate::read_persisted_worker_manifest_snapshot(old_snapshot)
|
||||
.unwrap()
|
||||
.feature
|
||||
.workspace_config
|
||||
.enabled
|
||||
);
|
||||
let upper = WorkerManifestConfig::from_toml(&format!(
|
||||
"[feature.workspace_config]\nenabled = {}\n",
|
||||
!enabled
|
||||
))
|
||||
.unwrap();
|
||||
let manifest: WorkerManifest = minimal_valid()
|
||||
.merge(lower)
|
||||
.merge(upper)
|
||||
.try_into()
|
||||
.unwrap();
|
||||
assert_eq!(manifest.feature.workspace_config.enabled, !enabled);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_config_rejects_manifest_authority_fields() {
|
||||
for (field, value) in [
|
||||
("grant_id", "\"grant-1\""),
|
||||
("workspace_id", "\"workspace-1\""),
|
||||
("access", "\"read_write\""),
|
||||
("config_root", "\"/host/config\""),
|
||||
("source", "\"config\""),
|
||||
] {
|
||||
let source = format!("[feature.workspace_config]\nenabled = true\n{field} = {value}\n");
|
||||
assert!(WorkerManifestConfig::from_toml(&source).is_err(), "{field}");
|
||||
assert!(
|
||||
toml::from_str::<FeatureConfig>(&format!(
|
||||
"[workspace_config]\nenabled = true\n{field} = {value}\n"
|
||||
))
|
||||
.is_err(),
|
||||
"{field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workdir_catalog_defaults_agree_for_partial_and_resolved_configs() {
|
||||
assert!(FeatureConfig::default().workdir_catalog.enabled);
|
||||
assert!(FeatureConfigPartial::default().workdir_catalog.is_none());
|
||||
for source in ["", "[feature]\n", "[feature.workdir_catalog]\n"] {
|
||||
let partial = WorkerManifestConfig::from_toml(source).unwrap();
|
||||
let feature = FeatureConfig::from(partial.feature);
|
||||
assert!(feature.workdir_catalog.enabled, "{source}");
|
||||
let value: toml::Value = toml::from_str(source).unwrap();
|
||||
let resolved: FeatureConfig = serde_json::from_value(
|
||||
serde_json::to_value(
|
||||
value
|
||||
.get("feature")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| toml::Value::Table(Default::default())),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(feature, resolved);
|
||||
}
|
||||
for source in ["{}", r#"{"workdir_catalog": {}}"#] {
|
||||
let feature: FeatureConfig = serde_json::from_str(source).unwrap();
|
||||
assert!(feature.workdir_catalog.enabled);
|
||||
assert!(!feature.manage_workdir.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workdir_catalog_is_independent_and_merges_partial_layers() {
|
||||
for catalog in [false, true] {
|
||||
for manage in [false, true] {
|
||||
let source = format!(
|
||||
"[feature.workdir_catalog]\nenabled = {catalog}\n\
|
||||
[feature.manage_workdir]\nenabled = {manage}\n"
|
||||
);
|
||||
let lower = WorkerManifestConfig::from_toml(&source).unwrap();
|
||||
let empty = WorkerManifestConfig::from_toml("[feature.workdir_catalog]").unwrap();
|
||||
let cfg = minimal_valid().merge(lower.clone()).merge(empty);
|
||||
let manifest: WorkerManifest = cfg.try_into().unwrap();
|
||||
assert_eq!(manifest.feature.workdir_catalog.enabled, catalog);
|
||||
assert_eq!(manifest.feature.manage_workdir.enabled, manage);
|
||||
|
||||
let override_source =
|
||||
format!("[feature.workdir_catalog]\nenabled = {}\n", !catalog);
|
||||
let upper = WorkerManifestConfig::from_toml(&override_source).unwrap();
|
||||
let manifest: WorkerManifest = minimal_valid()
|
||||
.merge(lower)
|
||||
.merge(upper)
|
||||
.try_into()
|
||||
.unwrap();
|
||||
assert_eq!(manifest.feature.workdir_catalog.enabled, !catalog);
|
||||
assert_eq!(manifest.feature.manage_workdir.enabled, manage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workdir_catalog_roundtrips_config_and_persisted_snapshots() {
|
||||
for enabled in [false, true] {
|
||||
let mut cfg = minimal_valid();
|
||||
cfg.feature.workdir_catalog = Some(FeatureFlagConfigPartial {
|
||||
enabled: Some(enabled),
|
||||
});
|
||||
let manifest: WorkerManifest = cfg.try_into().unwrap();
|
||||
let partial = FeatureConfigPartial::from(manifest.feature.clone());
|
||||
assert_eq!(
|
||||
partial.workdir_catalog.as_ref().unwrap().enabled,
|
||||
Some(enabled)
|
||||
);
|
||||
let serialized = toml::to_string(&partial).unwrap();
|
||||
let partial: FeatureConfigPartial = toml::from_str(&serialized).unwrap();
|
||||
assert_eq!(FeatureConfig::from(partial), manifest.feature);
|
||||
let serialized = serde_json::to_value(&manifest.feature).unwrap();
|
||||
assert_eq!(serialized["workdir_catalog"]["enabled"], enabled);
|
||||
let feature: FeatureConfig = serde_json::from_value(serialized).unwrap();
|
||||
assert_eq!(feature, manifest.feature);
|
||||
|
||||
let snapshot = crate::write_persisted_worker_manifest_snapshot(&manifest).unwrap();
|
||||
let restored =
|
||||
crate::read_persisted_worker_manifest_snapshot(snapshot.clone()).unwrap();
|
||||
assert_eq!(restored.feature.workdir_catalog.enabled, enabled);
|
||||
assert!(!restored.feature.manage_workdir.enabled);
|
||||
|
||||
let mut legacy = snapshot;
|
||||
legacy["manifest"]["feature"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("workdir_catalog");
|
||||
let restored = crate::read_persisted_worker_manifest_snapshot(legacy).unwrap();
|
||||
assert!(restored.feature.workdir_catalog.enabled);
|
||||
assert!(!restored.feature.manage_workdir.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_toml_parses_explicit_feature_flags() {
|
||||
let cfg = WorkerManifestConfig::from_toml(
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
//! `pub const`. Both paths read from this module, so changing a
|
||||
//! default requires editing exactly one line.
|
||||
|
||||
/// Read-only Repository/Workdir/attachment reference discovery in WIP mode.
|
||||
/// This enables a reference provider, not normal Tools or any authority grant.
|
||||
pub const WORKDIR_CATALOG_ENABLED: bool = true;
|
||||
|
||||
/// Byte-size cap applied to any tool's `content` output when no
|
||||
/// per-tool override is set. See [`crate::ToolOutputLimits`].
|
||||
pub const TOOL_OUTPUT_MAX_BYTES: usize = 64 * 1024;
|
||||
|
||||
@@ -65,8 +65,9 @@ pub struct WorkerManifest {
|
||||
/// permission layer is disabled and tool calls run as before.
|
||||
#[serde(default)]
|
||||
pub permissions: Option<ToolPermissionConfig>,
|
||||
/// Explicit built-in feature/tool-surface enablement. Omitted feature flags
|
||||
/// resolve disabled so Profile authors choose the exposed built-in surfaces.
|
||||
/// Built-in feature/tool-surface enablement. Omitted tool features resolve
|
||||
/// disabled; the read-only WIP workdir catalog defaults enabled without
|
||||
/// granting authority or registering normal Tools.
|
||||
#[serde(default)]
|
||||
pub feature: FeatureConfig,
|
||||
/// Explicit external Model Context Protocol provider configuration. This
|
||||
@@ -131,6 +132,19 @@ pub struct FeatureConfig {
|
||||
pub objective: FeatureFlagConfig,
|
||||
#[serde(default)]
|
||||
pub manage_workdir: FeatureFlagConfig,
|
||||
/// Read-only Repository/Workdir/attachment reference discovery. Defaults
|
||||
/// enabled, is installed only in WIP mode, and exposes no normal Tools.
|
||||
/// Independent of `manage_workdir`; permissions and host authority remain
|
||||
/// separate, and discovering a reference grants no attachment or file access.
|
||||
#[serde(
|
||||
default = "default_workdir_catalog",
|
||||
deserialize_with = "deserialize_workdir_catalog"
|
||||
)]
|
||||
pub workdir_catalog: FeatureFlagConfig,
|
||||
/// Opt-in WIP-only logical Workspace configuration feature. This flag is
|
||||
/// not an access grant; Backend-owned Worker grants remain authoritative.
|
||||
#[serde(default, deserialize_with = "deserialize_workspace_config")]
|
||||
pub workspace_config: FeatureFlagConfig,
|
||||
#[serde(default)]
|
||||
pub ticket: TicketFeatureConfig,
|
||||
#[serde(default)]
|
||||
@@ -139,6 +153,29 @@ pub struct FeatureConfig {
|
||||
pub orchestration: FeatureFlagConfig,
|
||||
}
|
||||
|
||||
fn default_workdir_catalog() -> FeatureFlagConfig {
|
||||
FeatureFlagConfig {
|
||||
enabled: defaults::WORKDIR_CATALOG_ENABLED,
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_workdir_catalog<'de, D>(deserializer: D) -> Result<FeatureFlagConfig, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let flag = config::FeatureFlagConfigPartial::deserialize(deserializer)?;
|
||||
Ok(FeatureFlagConfig {
|
||||
enabled: flag.enabled.unwrap_or(defaults::WORKDIR_CATALOG_ENABLED),
|
||||
})
|
||||
}
|
||||
|
||||
fn deserialize_workspace_config<'de, D>(deserializer: D) -> Result<FeatureFlagConfig, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
Ok(config::WorkspaceConfigFeatureConfigPartial::deserialize(deserializer)?.into())
|
||||
}
|
||||
|
||||
impl Default for FeatureConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -153,6 +190,8 @@ impl Default for FeatureConfig {
|
||||
workspace_worker_discovery: FeatureFlagConfig::disabled(),
|
||||
objective: FeatureFlagConfig::disabled(),
|
||||
manage_workdir: FeatureFlagConfig::disabled(),
|
||||
workdir_catalog: default_workdir_catalog(),
|
||||
workspace_config: FeatureFlagConfig::disabled(),
|
||||
ticket: TicketFeatureConfig::default(),
|
||||
merge_request: MergeRequestFeatureConfig::default(),
|
||||
orchestration: FeatureFlagConfig::disabled(),
|
||||
@@ -363,6 +402,8 @@ impl ResolvedMemoryFeatureConfig {
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct SubjektivFeatureProfileConfig {
|
||||
pub enabled: bool,
|
||||
/// Explicit consolidation tool policy; never grants Job or subject authority.
|
||||
pub consolidation_tools: bool,
|
||||
pub extraction: MemoryExtractionProfileConfig,
|
||||
}
|
||||
|
||||
@@ -370,6 +411,7 @@ impl Default for SubjektivFeatureProfileConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
consolidation_tools: false,
|
||||
extraction: MemoryExtractionProfileConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +181,8 @@ pub fn validate_profile_execution_target(
|
||||
if feature.flow.enabled {
|
||||
requirements.insert(WorkspaceAuthorityRequirement::Flow);
|
||||
}
|
||||
// workdir_catalog is a WIP-only reference provider, not Workspace authority
|
||||
// or normal Tools. It must not prevent standalone Tools-mode launches.
|
||||
if feature.manage_workdir.enabled {
|
||||
requirements.insert(WorkspaceAuthorityRequirement::ManageWorkdir);
|
||||
}
|
||||
@@ -1482,6 +1484,59 @@ mod tests {
|
||||
assert!(!resolved.manifest.feature.manage_workdir.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workdir_catalog_does_not_require_workspace_authority_or_grant_scope() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
for catalog in [None, Some(false), Some(true)] {
|
||||
let mut artifact = serde_json::json!({
|
||||
"worker": { "mode": "tools" },
|
||||
"feature": { "manage_workdir": { "enabled": false } },
|
||||
"permissions": { "default_action": "deny" }
|
||||
});
|
||||
if let Some(enabled) = catalog {
|
||||
artifact["feature"]["workdir_catalog"] = serde_json::json!({ "enabled": enabled });
|
||||
}
|
||||
let resolved = resolve_profile_artifact_value(
|
||||
artifact,
|
||||
ProfileSource::Archive {
|
||||
archive_id: "catalog-test".into(),
|
||||
source: "test".into(),
|
||||
},
|
||||
tmp.path(),
|
||||
"catalog-worker",
|
||||
)
|
||||
.unwrap();
|
||||
let manifest = resolved.manifest;
|
||||
assert_eq!(
|
||||
manifest.feature.workdir_catalog.enabled,
|
||||
catalog.unwrap_or(true)
|
||||
);
|
||||
assert!(!manifest.feature.manage_workdir.enabled);
|
||||
assert_eq!(manifest.worker.mode, crate::WorkerMode::Tools);
|
||||
assert!(manifest.scope.allow.is_empty());
|
||||
assert!(manifest.delegation_scope.allow.is_empty());
|
||||
assert_eq!(
|
||||
manifest.permissions.as_ref().unwrap().default_action,
|
||||
crate::ToolPermissionAction::Deny
|
||||
);
|
||||
validate_profile_execution_target(&manifest, ProfileExecutionTarget::Standalone)
|
||||
.unwrap();
|
||||
|
||||
let mut manage_manifest = manifest;
|
||||
manage_manifest.feature.manage_workdir.enabled = true;
|
||||
let error = validate_profile_execution_target(
|
||||
&manage_manifest,
|
||||
ProfileExecutionTarget::Standalone,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
ProfileError::UnsupportedExecutionTarget { requirements, .. }
|
||||
if requirements == vec![WorkspaceAuthorityRequirement::ManageWorkdir]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_rejects_profiles_that_require_workspace_authority() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
@@ -14,6 +14,7 @@ json-schema = ["dep:schemars"]
|
||||
schemars = { workspace = true, optional = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
sha2.workspace = true
|
||||
tokio = { workspace = true, features = ["io-util"], optional = true }
|
||||
ts-rs = { version = "12.0.1", optional = true }
|
||||
|
||||
@@ -0,0 +1,949 @@
|
||||
//! Declarative chat Feature invocation contract.
|
||||
//!
|
||||
//! The input spelling is deliberately bounded and unambiguous:
|
||||
//! `/name(arg=value, "positional value")`. Parentheses terminate arguments,
|
||||
//! so following prose is never consumed. Strings use JSON-style quoting and
|
||||
//! escaping. Unquoted values are limited to booleans, integers, enum-like words,
|
||||
//! and paths without comma/parenthesis/whitespace. This module parses input for
|
||||
//! clients and validates the structured payload again at the host boundary.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
|
||||
/// UTF-8 byte bound shared by input validation and durable recovery keys.
|
||||
pub const MAX_FEATURE_INVOCATION_ID_BYTES: usize = 128;
|
||||
/// Installed descriptors must fit a public completion entry without truncation.
|
||||
pub const MAX_FEATURE_INVOCATION_DESCRIPTOR_BYTES: usize = 32 * 1024;
|
||||
|
||||
/// Source-qualified identity of one invocation contribution, for example
|
||||
/// `builtin:attachments/attach`. Display names and aliases are never authority.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub struct FeatureInvocationIdentity(pub String);
|
||||
|
||||
impl fmt::Display for FeatureInvocationIdentity {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FeatureInvocationSyntax {
|
||||
/// `/name(value, key=value)` with JSON-style quoted strings and `)` as the
|
||||
/// explicit boundary before following natural language.
|
||||
Parenthesized,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum InvocationArgumentType {
|
||||
String,
|
||||
Integer,
|
||||
Boolean,
|
||||
Enum {
|
||||
values: Vec<String>,
|
||||
},
|
||||
/// A Worker-readable path. This does not grant filesystem authority.
|
||||
WorkerFile,
|
||||
/// A resource selected and staged by the client. It must never be resolved
|
||||
/// as a path on the Worker host.
|
||||
ClientFile,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum InvocationCompletion {
|
||||
None,
|
||||
Static {
|
||||
values: Vec<String>,
|
||||
},
|
||||
WorkerFile,
|
||||
ClientFile,
|
||||
/// Host-owned provider selected by source-qualified id. Metadata lookup is
|
||||
/// side-effect free; implementations never cross this wire boundary.
|
||||
Provider {
|
||||
provider: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for InvocationCompletion {
|
||||
fn default() -> Self {
|
||||
Self::None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub struct InvocationArgumentDescriptor {
|
||||
pub name: String,
|
||||
/// Zero-based positional index. `None` means named-only. A positional
|
||||
/// argument may also be supplied by name; this makes chip editing stable.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub position: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub required: bool,
|
||||
pub value_type: InvocationArgumentType,
|
||||
#[serde(default)]
|
||||
pub completion: InvocationCompletion,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InvocationClientAdapter {
|
||||
/// The client stages a local file and replaces the draft invocation with an
|
||||
/// `UploadedFile` chip. Local paths are not sent to or persisted by Worker.
|
||||
Attachment,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub struct FeatureInvocationDescriptor {
|
||||
pub identity: FeatureInvocationIdentity,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub aliases: Vec<String>,
|
||||
pub display_name: String,
|
||||
pub description: String,
|
||||
pub syntax: FeatureInvocationSyntax,
|
||||
#[serde(default)]
|
||||
pub arguments: Vec<InvocationArgumentDescriptor>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub client_adapter: Option<InvocationClientAdapter>,
|
||||
}
|
||||
|
||||
impl FeatureInvocationDescriptor {
|
||||
pub fn usage(&self) -> String {
|
||||
let mut usage = format!("/{}(", self.name);
|
||||
for (index, argument) in self.arguments.iter().enumerate() {
|
||||
if index > 0 {
|
||||
usage.push_str(", ");
|
||||
}
|
||||
if argument.position.is_none() {
|
||||
usage.push_str(&argument.name);
|
||||
usage.push('=');
|
||||
}
|
||||
let marker = match &argument.value_type {
|
||||
InvocationArgumentType::String => "text",
|
||||
InvocationArgumentType::Integer => "integer",
|
||||
InvocationArgumentType::Boolean => "true|false",
|
||||
InvocationArgumentType::Enum { .. } => "choice",
|
||||
InvocationArgumentType::WorkerFile => "worker-path",
|
||||
InvocationArgumentType::ClientFile => "local-file",
|
||||
};
|
||||
if argument.required {
|
||||
usage.push('<');
|
||||
usage.push_str(marker);
|
||||
usage.push('>');
|
||||
} else {
|
||||
usage.push('[');
|
||||
usage.push_str(marker);
|
||||
usage.push(']');
|
||||
}
|
||||
}
|
||||
usage.push(')');
|
||||
usage
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), InvocationValidationError> {
|
||||
const MAX_PUBLIC_NAMES: usize = 16;
|
||||
const MAX_ARGUMENTS: usize = 32;
|
||||
const MAX_METADATA_BYTES: usize = 4096;
|
||||
validate_invocation_name(&self.name)?;
|
||||
if self.aliases.len() > MAX_PUBLIC_NAMES || self.arguments.len() > MAX_ARGUMENTS {
|
||||
return Err(InvocationValidationError::InvalidDescriptor(
|
||||
"invocation descriptor exceeds collection limits".into(),
|
||||
));
|
||||
}
|
||||
if self.display_name.len() > MAX_METADATA_BYTES
|
||||
|| self.description.len() > MAX_METADATA_BYTES
|
||||
{
|
||||
return Err(InvocationValidationError::InvalidDescriptor(
|
||||
"invocation metadata exceeds byte limits".into(),
|
||||
));
|
||||
}
|
||||
if self.identity.0.trim().is_empty()
|
||||
|| !self.identity.0.contains(':')
|
||||
|| self.identity.0.len() > 256
|
||||
{
|
||||
return Err(InvocationValidationError::InvalidDescriptor(
|
||||
"invocation identity must be source-qualified".into(),
|
||||
));
|
||||
}
|
||||
let mut names = BTreeSet::new();
|
||||
let mut positions = BTreeSet::new();
|
||||
for name in std::iter::once(&self.name).chain(self.aliases.iter()) {
|
||||
validate_invocation_name(name)?;
|
||||
if !names.insert(name) {
|
||||
return Err(InvocationValidationError::InvalidDescriptor(format!(
|
||||
"duplicate invocation name or alias `{name}`"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let mut argument_names = BTreeSet::new();
|
||||
for argument in &self.arguments {
|
||||
validate_argument_name(&argument.name)?;
|
||||
if !argument_names.insert(argument.name.as_str()) {
|
||||
return Err(InvocationValidationError::InvalidDescriptor(format!(
|
||||
"duplicate argument `{}`",
|
||||
argument.name
|
||||
)));
|
||||
}
|
||||
if let Some(position) = argument.position
|
||||
&& !positions.insert(position)
|
||||
{
|
||||
return Err(InvocationValidationError::InvalidDescriptor(format!(
|
||||
"duplicate positional index `{position}`"
|
||||
)));
|
||||
}
|
||||
if argument
|
||||
.description
|
||||
.as_ref()
|
||||
.is_some_and(|value| value.len() > MAX_METADATA_BYTES)
|
||||
{
|
||||
return Err(InvocationValidationError::InvalidDescriptor(format!(
|
||||
"argument `{}` description exceeds byte limits",
|
||||
argument.name
|
||||
)));
|
||||
}
|
||||
if let InvocationCompletion::Provider { provider } = &argument.completion
|
||||
&& (provider.trim().is_empty() || provider.len() > 256)
|
||||
{
|
||||
return Err(InvocationValidationError::InvalidDescriptor(format!(
|
||||
"argument `{}` has an invalid completion provider",
|
||||
argument.name
|
||||
)));
|
||||
}
|
||||
if matches!(&argument.value_type, InvocationArgumentType::Enum { values }
|
||||
if values.len() > 256 || values.iter().any(|value| value.len() > MAX_METADATA_BYTES))
|
||||
|| matches!(&argument.completion, InvocationCompletion::Static { values }
|
||||
if values.len() > 256 || values.iter().any(|value| value.len() > MAX_METADATA_BYTES))
|
||||
{
|
||||
return Err(InvocationValidationError::InvalidDescriptor(format!(
|
||||
"argument `{}` completion values exceed limits",
|
||||
argument.name
|
||||
)));
|
||||
}
|
||||
if matches!(&argument.value_type, InvocationArgumentType::Enum { values } if values.is_empty())
|
||||
{
|
||||
return Err(InvocationValidationError::InvalidDescriptor(format!(
|
||||
"enum argument `{}` has no values",
|
||||
argument.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
for (expected, actual) in positions.iter().copied().enumerate() {
|
||||
if actual as usize != expected {
|
||||
return Err(InvocationValidationError::InvalidDescriptor(
|
||||
"positional argument indices must be contiguous from zero".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if serde_json::to_vec(self).map_or(true, |encoded| {
|
||||
encoded.len() > MAX_FEATURE_INVOCATION_DESCRIPTOR_BYTES
|
||||
}) {
|
||||
return Err(InvocationValidationError::InvalidDescriptor(
|
||||
"invocation descriptor exceeds the public completion byte limit".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
|
||||
pub enum InvocationValue {
|
||||
String(String),
|
||||
Integer(i32),
|
||||
Boolean(bool),
|
||||
}
|
||||
|
||||
impl InvocationValue {
|
||||
pub fn display_input(&self) -> String {
|
||||
match self {
|
||||
Self::String(value) => quote_invocation_string(value),
|
||||
Self::Integer(value) => value.to_string(),
|
||||
Self::Boolean(value) => value.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub struct InvocationArgumentValue {
|
||||
pub name: String,
|
||||
pub value: InvocationValue,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub struct FeatureInvocation {
|
||||
/// Stable client-generated id retained across queueing and transport retry.
|
||||
pub invocation_id: String,
|
||||
pub identity: FeatureInvocationIdentity,
|
||||
/// Selected public name, retained for exact draft/history restoration. Host
|
||||
/// resolution is always by `identity`.
|
||||
pub name: String,
|
||||
pub arguments: Vec<InvocationArgumentValue>,
|
||||
}
|
||||
|
||||
impl FeatureInvocation {
|
||||
pub fn display_input(&self) -> String {
|
||||
let mut input = format!("/{}(", self.name);
|
||||
for (index, argument) in self.arguments.iter().enumerate() {
|
||||
if index > 0 {
|
||||
input.push_str(", ");
|
||||
}
|
||||
input.push_str(&argument.name);
|
||||
input.push('=');
|
||||
input.push_str(&argument.value.display_input());
|
||||
}
|
||||
input.push(')');
|
||||
input
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FeatureInvocationStatus {
|
||||
Succeeded,
|
||||
Failed,
|
||||
OutcomeUnknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub struct FeatureInvocationResult {
|
||||
pub invocation_id: String,
|
||||
pub identity: FeatureInvocationIdentity,
|
||||
pub status: FeatureInvocationStatus,
|
||||
pub message: String,
|
||||
/// Bounded model-visible context produced by the handler. Credentials and
|
||||
/// executable implementation details must not be included.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ParsedFeatureInvocation {
|
||||
pub invocation: FeatureInvocation,
|
||||
/// UTF-8 byte offset immediately after the closing parenthesis.
|
||||
pub end: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum InvocationValidationError {
|
||||
#[error("invalid invocation descriptor: {0}")]
|
||||
InvalidDescriptor(String),
|
||||
#[error("incomplete invocation at byte {0}")]
|
||||
Incomplete(usize),
|
||||
#[error("unknown argument `{0}`")]
|
||||
UnknownArgument(String),
|
||||
#[error("duplicate argument `{0}`")]
|
||||
DuplicateArgument(String),
|
||||
#[error("missing required argument `{0}`")]
|
||||
MissingRequired(String),
|
||||
#[error("too many positional arguments")]
|
||||
TooManyPositional,
|
||||
#[error("invalid value for `{argument}`: {message}")]
|
||||
InvalidValue { argument: String, message: String },
|
||||
#[error("invocation identity does not match descriptor")]
|
||||
IdentityMismatch,
|
||||
#[error("invocation name does not match descriptor")]
|
||||
NameMismatch,
|
||||
#[error("invalid invocation syntax at byte {offset}: {message}")]
|
||||
Syntax { offset: usize, message: String },
|
||||
}
|
||||
|
||||
pub fn validate_feature_invocation(
|
||||
descriptor: &FeatureInvocationDescriptor,
|
||||
invocation: &FeatureInvocation,
|
||||
) -> Result<(), InvocationValidationError> {
|
||||
descriptor.validate()?;
|
||||
if invocation.identity != descriptor.identity {
|
||||
return Err(InvocationValidationError::IdentityMismatch);
|
||||
}
|
||||
if invocation.name != descriptor.name && !descriptor.aliases.contains(&invocation.name) {
|
||||
return Err(InvocationValidationError::NameMismatch);
|
||||
}
|
||||
if invocation.invocation_id.trim().is_empty()
|
||||
|| invocation.invocation_id.len() > MAX_FEATURE_INVOCATION_ID_BYTES
|
||||
{
|
||||
return Err(InvocationValidationError::InvalidValue {
|
||||
argument: "invocation_id".into(),
|
||||
message: "must be nonempty and at most 128 UTF-8 bytes".into(),
|
||||
});
|
||||
}
|
||||
if invocation.arguments.len() > 32 {
|
||||
return Err(InvocationValidationError::InvalidValue {
|
||||
argument: "arguments".into(),
|
||||
message: "too many arguments".into(),
|
||||
});
|
||||
}
|
||||
let mut values = BTreeMap::new();
|
||||
for argument in &invocation.arguments {
|
||||
if values
|
||||
.insert(argument.name.as_str(), &argument.value)
|
||||
.is_some()
|
||||
{
|
||||
return Err(InvocationValidationError::DuplicateArgument(
|
||||
argument.name.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
for name in values.keys() {
|
||||
if !descriptor
|
||||
.arguments
|
||||
.iter()
|
||||
.any(|argument| argument.name == **name)
|
||||
{
|
||||
return Err(InvocationValidationError::UnknownArgument(
|
||||
(*name).to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
for argument in &descriptor.arguments {
|
||||
match values.get(argument.name.as_str()) {
|
||||
Some(value) => validate_value(argument, value)?,
|
||||
None if argument.required => {
|
||||
return Err(InvocationValidationError::MissingRequired(
|
||||
argument.name.clone(),
|
||||
));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse one complete parenthesized invocation starting at `start`. Callers
|
||||
/// decide when input becomes a typed chip; parsing alone never executes it.
|
||||
pub fn parse_feature_invocation(
|
||||
input: &str,
|
||||
start: usize,
|
||||
descriptor: &FeatureInvocationDescriptor,
|
||||
invocation_id: impl Into<String>,
|
||||
) -> Result<ParsedFeatureInvocation, InvocationValidationError> {
|
||||
descriptor.validate()?;
|
||||
if !input.is_char_boundary(start) || input.as_bytes().get(start) != Some(&b'/') {
|
||||
return Err(InvocationValidationError::Syntax {
|
||||
offset: start,
|
||||
message: "expected `/`".into(),
|
||||
});
|
||||
}
|
||||
let mut cursor = start + 1;
|
||||
let name_start = cursor;
|
||||
while let Some(byte) = input.as_bytes().get(cursor) {
|
||||
if byte.is_ascii_alphanumeric() || matches!(*byte, b'-' | b'_') {
|
||||
cursor += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let name = &input[name_start..cursor];
|
||||
if name != descriptor.name && !descriptor.aliases.iter().any(|alias| alias == name) {
|
||||
return Err(InvocationValidationError::NameMismatch);
|
||||
}
|
||||
skip_ws(input, &mut cursor);
|
||||
if input.as_bytes().get(cursor) != Some(&b'(') {
|
||||
return Err(InvocationValidationError::Incomplete(cursor));
|
||||
}
|
||||
cursor += 1;
|
||||
let positional = descriptor
|
||||
.arguments
|
||||
.iter()
|
||||
.filter_map(|argument| argument.position.map(|position| (position, argument)))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut next_position = 0u16;
|
||||
let mut parsed = Vec::<InvocationArgumentValue>::new();
|
||||
loop {
|
||||
skip_ws(input, &mut cursor);
|
||||
match input.as_bytes().get(cursor) {
|
||||
Some(b')') => {
|
||||
cursor += 1;
|
||||
break;
|
||||
}
|
||||
None => return Err(InvocationValidationError::Incomplete(cursor)),
|
||||
_ => {}
|
||||
}
|
||||
let token_start = cursor;
|
||||
let possible_name = parse_identifier(input, &mut cursor);
|
||||
let after_name = cursor;
|
||||
skip_ws(input, &mut cursor);
|
||||
let (argument, raw_value) = if !possible_name.is_empty()
|
||||
&& input.as_bytes().get(cursor) == Some(&b'=')
|
||||
{
|
||||
cursor += 1;
|
||||
skip_ws(input, &mut cursor);
|
||||
let argument = descriptor
|
||||
.arguments
|
||||
.iter()
|
||||
.find(|argument| argument.name == possible_name)
|
||||
.ok_or_else(|| InvocationValidationError::UnknownArgument(possible_name.into()))?;
|
||||
(argument, parse_raw_value(input, &mut cursor)?)
|
||||
} else {
|
||||
cursor = token_start;
|
||||
let argument = positional
|
||||
.get(&next_position)
|
||||
.copied()
|
||||
.ok_or(InvocationValidationError::TooManyPositional)?;
|
||||
next_position += 1;
|
||||
(argument, parse_raw_value(input, &mut cursor)?)
|
||||
};
|
||||
if parsed.iter().any(|value| value.name == argument.name) {
|
||||
return Err(InvocationValidationError::DuplicateArgument(
|
||||
argument.name.clone(),
|
||||
));
|
||||
}
|
||||
let value = coerce_value(argument, raw_value)?;
|
||||
parsed.push(InvocationArgumentValue {
|
||||
name: argument.name.clone(),
|
||||
value,
|
||||
});
|
||||
skip_ws(input, &mut cursor);
|
||||
match input.as_bytes().get(cursor) {
|
||||
Some(b',') => cursor += 1,
|
||||
Some(b')') => {}
|
||||
None => return Err(InvocationValidationError::Incomplete(cursor)),
|
||||
_ => {
|
||||
return Err(InvocationValidationError::Syntax {
|
||||
offset: after_name.max(cursor),
|
||||
message: "expected `,` or `)`".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let invocation = FeatureInvocation {
|
||||
invocation_id: invocation_id.into(),
|
||||
identity: descriptor.identity.clone(),
|
||||
name: name.to_string(),
|
||||
arguments: parsed,
|
||||
};
|
||||
validate_feature_invocation(descriptor, &invocation)?;
|
||||
Ok(ParsedFeatureInvocation {
|
||||
invocation,
|
||||
end: cursor,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum RawValue {
|
||||
Quoted(String),
|
||||
Bare(String),
|
||||
}
|
||||
|
||||
fn parse_raw_value(input: &str, cursor: &mut usize) -> Result<RawValue, InvocationValidationError> {
|
||||
if input.as_bytes().get(*cursor) == Some(&b'"') {
|
||||
let start = *cursor;
|
||||
*cursor += 1;
|
||||
let mut escaped = false;
|
||||
loop {
|
||||
let Some(ch) = input[*cursor..].chars().next() else {
|
||||
return Err(InvocationValidationError::Incomplete(*cursor));
|
||||
};
|
||||
*cursor += ch.len_utf8();
|
||||
if ch == '"' && !escaped {
|
||||
let value =
|
||||
serde_json::from_str::<String>(&input[start..*cursor]).map_err(|error| {
|
||||
InvocationValidationError::Syntax {
|
||||
offset: start,
|
||||
message: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
return Ok(RawValue::Quoted(value));
|
||||
}
|
||||
escaped = ch == '\\' && !escaped;
|
||||
}
|
||||
}
|
||||
let start = *cursor;
|
||||
while let Some(ch) = input[*cursor..].chars().next() {
|
||||
if ch == ',' || ch == ')' || ch.is_whitespace() {
|
||||
break;
|
||||
}
|
||||
*cursor += ch.len_utf8();
|
||||
}
|
||||
if *cursor == start {
|
||||
return Err(InvocationValidationError::Syntax {
|
||||
offset: start,
|
||||
message: "expected argument value".into(),
|
||||
});
|
||||
}
|
||||
Ok(RawValue::Bare(input[start..*cursor].to_string()))
|
||||
}
|
||||
|
||||
fn coerce_value(
|
||||
descriptor: &InvocationArgumentDescriptor,
|
||||
raw: RawValue,
|
||||
) -> Result<InvocationValue, InvocationValidationError> {
|
||||
let text = match raw {
|
||||
RawValue::Quoted(value) | RawValue::Bare(value) => value,
|
||||
};
|
||||
let invalid = |message: &str| InvocationValidationError::InvalidValue {
|
||||
argument: descriptor.name.clone(),
|
||||
message: message.into(),
|
||||
};
|
||||
match &descriptor.value_type {
|
||||
InvocationArgumentType::String
|
||||
| InvocationArgumentType::WorkerFile
|
||||
| InvocationArgumentType::ClientFile => Ok(InvocationValue::String(text)),
|
||||
InvocationArgumentType::Integer => {
|
||||
let digits = text.strip_prefix('-').unwrap_or(&text);
|
||||
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(invalid("expected a decimal integer"));
|
||||
}
|
||||
text.parse::<i32>()
|
||||
.map(InvocationValue::Integer)
|
||||
.map_err(|_| invalid("expected a signed 32-bit integer"))
|
||||
}
|
||||
InvocationArgumentType::Boolean => match text.as_str() {
|
||||
"true" => Ok(InvocationValue::Boolean(true)),
|
||||
"false" => Ok(InvocationValue::Boolean(false)),
|
||||
_ => Err(invalid("expected `true` or `false`")),
|
||||
},
|
||||
InvocationArgumentType::Enum { values } => {
|
||||
if values.contains(&text) {
|
||||
Ok(InvocationValue::String(text))
|
||||
} else {
|
||||
Err(invalid("value is not in the declared enum"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_value(
|
||||
descriptor: &InvocationArgumentDescriptor,
|
||||
value: &InvocationValue,
|
||||
) -> Result<(), InvocationValidationError> {
|
||||
let valid = match (&descriptor.value_type, value) {
|
||||
(
|
||||
InvocationArgumentType::String
|
||||
| InvocationArgumentType::WorkerFile
|
||||
| InvocationArgumentType::ClientFile,
|
||||
InvocationValue::String(value),
|
||||
) => value.len() <= 16 * 1024,
|
||||
(InvocationArgumentType::Integer, InvocationValue::Integer(_)) => true,
|
||||
(InvocationArgumentType::Boolean, InvocationValue::Boolean(_)) => true,
|
||||
(InvocationArgumentType::Enum { values }, InvocationValue::String(value)) => {
|
||||
values.contains(value)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(InvocationValidationError::InvalidValue {
|
||||
argument: descriptor.name.clone(),
|
||||
message: "structured value does not match the declared type".into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_invocation_name(name: &str) -> Result<(), InvocationValidationError> {
|
||||
if name.is_empty()
|
||||
|| !name
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||
|| !name.as_bytes()[0].is_ascii_lowercase()
|
||||
{
|
||||
return Err(InvocationValidationError::InvalidDescriptor(format!(
|
||||
"invalid invocation name `{name}`"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_argument_name(name: &str) -> Result<(), InvocationValidationError> {
|
||||
if name.is_empty()
|
||||
|| !name
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
|
||||
|| !name.as_bytes()[0].is_ascii_lowercase()
|
||||
{
|
||||
return Err(InvocationValidationError::InvalidDescriptor(format!(
|
||||
"invalid argument name `{name}`"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_identifier<'a>(input: &'a str, cursor: &mut usize) -> &'a str {
|
||||
let start = *cursor;
|
||||
while let Some(byte) = input.as_bytes().get(*cursor) {
|
||||
if byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_' {
|
||||
*cursor += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
&input[start..*cursor]
|
||||
}
|
||||
|
||||
fn skip_ws(input: &str, cursor: &mut usize) {
|
||||
while let Some(ch) = input[*cursor..].chars().next() {
|
||||
if !ch.is_whitespace() {
|
||||
break;
|
||||
}
|
||||
*cursor += ch.len_utf8();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn quote_invocation_string(value: &str) -> String {
|
||||
// Keep the spelling identical to Web's JSON.stringify, including control
|
||||
// characters and JSON unicode escapes accepted by both common parsers.
|
||||
serde_json::to_string(value).expect("serializing a string cannot fail")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn descriptor() -> FeatureInvocationDescriptor {
|
||||
FeatureInvocationDescriptor {
|
||||
identity: FeatureInvocationIdentity("builtin:test/run".into()),
|
||||
name: "run".into(),
|
||||
aliases: vec!["execute".into()],
|
||||
display_name: "Run".into(),
|
||||
description: "test invocation".into(),
|
||||
syntax: FeatureInvocationSyntax::Parenthesized,
|
||||
arguments: vec![
|
||||
InvocationArgumentDescriptor {
|
||||
name: "path".into(),
|
||||
position: Some(0),
|
||||
required: true,
|
||||
value_type: InvocationArgumentType::String,
|
||||
completion: InvocationCompletion::WorkerFile,
|
||||
description: None,
|
||||
},
|
||||
InvocationArgumentDescriptor {
|
||||
name: "mode".into(),
|
||||
position: None,
|
||||
required: false,
|
||||
value_type: InvocationArgumentType::Enum {
|
||||
values: vec!["fast".into(), "safe".into()],
|
||||
},
|
||||
completion: InvocationCompletion::Static {
|
||||
values: vec!["fast".into(), "safe".into()],
|
||||
},
|
||||
description: None,
|
||||
},
|
||||
],
|
||||
client_adapter: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_unicode_quoted_paths_and_stops_before_natural_language() {
|
||||
let input = "/run(\"資料/my file / x\", mode=safe) この後は本文";
|
||||
let parsed = parse_feature_invocation(input, 0, &descriptor(), "invoke-1").unwrap();
|
||||
assert_eq!(&input[parsed.end..], " この後は本文");
|
||||
assert_eq!(
|
||||
parsed.invocation.arguments,
|
||||
vec![
|
||||
InvocationArgumentValue {
|
||||
name: "path".into(),
|
||||
value: InvocationValue::String("資料/my file / x".into()),
|
||||
},
|
||||
InvocationArgumentValue {
|
||||
name: "mode".into(),
|
||||
value: InvocationValue::String("safe".into()),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_incomplete_unknown_duplicate_missing_and_type_errors() {
|
||||
assert!(matches!(
|
||||
parse_feature_invocation("/run(\"x\"", 0, &descriptor(), "1"),
|
||||
Err(InvocationValidationError::Incomplete(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_feature_invocation("/run(path=x, nope=y)", 0, &descriptor(), "1"),
|
||||
Err(InvocationValidationError::UnknownArgument(name)) if name == "nope"
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_feature_invocation("/run(path=x, path=y)", 0, &descriptor(), "1"),
|
||||
Err(InvocationValidationError::DuplicateArgument(name)) if name == "path"
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_feature_invocation("/run(mode=fast)", 0, &descriptor(), "1"),
|
||||
Err(InvocationValidationError::MissingRequired(name)) if name == "path"
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_feature_invocation("/run(x, mode=unknown)", 0, &descriptor(), "1"),
|
||||
Err(InvocationValidationError::InvalidValue { argument, .. }) if argument == "mode"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptors_cannot_install_with_undiscoverable_oversized_metadata() {
|
||||
let mut descriptor = descriptor();
|
||||
descriptor.arguments[0].completion = InvocationCompletion::Static {
|
||||
values: vec!["a".repeat(1024); 40],
|
||||
};
|
||||
assert!(
|
||||
matches!(descriptor.validate(), Err(InvocationValidationError::InvalidDescriptor(message)) if message.contains("completion byte limit"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_and_recovery_invocation_key_bound_is_shared() {
|
||||
let descriptor = descriptor();
|
||||
assert!(
|
||||
parse_feature_invocation(
|
||||
"/run(x)",
|
||||
0,
|
||||
&descriptor,
|
||||
"a".repeat(MAX_FEATURE_INVOCATION_ID_BYTES)
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
parse_feature_invocation(
|
||||
"/run(x)",
|
||||
0,
|
||||
&descriptor,
|
||||
"a".repeat(MAX_FEATURE_INVOCATION_ID_BYTES + 1)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(parse_feature_invocation("/run(x)", 0, &descriptor, "界".repeat(43)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_payload_validation_does_not_trust_client_identity_or_types() {
|
||||
let descriptor = descriptor();
|
||||
let original = parse_feature_invocation("/run(path=x)", 0, &descriptor, "stable")
|
||||
.unwrap()
|
||||
.invocation;
|
||||
let mut changed = original.clone();
|
||||
changed.identity = FeatureInvocationIdentity("builtin:other/run".into());
|
||||
assert_eq!(
|
||||
validate_feature_invocation(&descriptor, &changed),
|
||||
Err(InvocationValidationError::IdentityMismatch)
|
||||
);
|
||||
changed = original.clone();
|
||||
changed.name = "unregistered".into();
|
||||
assert_eq!(
|
||||
validate_feature_invocation(&descriptor, &changed),
|
||||
Err(InvocationValidationError::NameMismatch)
|
||||
);
|
||||
changed = original.clone();
|
||||
changed.arguments[0].value = InvocationValue::Boolean(true);
|
||||
assert!(matches!(
|
||||
validate_feature_invocation(&descriptor, &changed),
|
||||
Err(InvocationValidationError::InvalidValue { .. })
|
||||
));
|
||||
changed = original.clone();
|
||||
changed.arguments.push(changed.arguments[0].clone());
|
||||
assert!(matches!(
|
||||
validate_feature_invocation(&descriptor, &changed),
|
||||
Err(InvocationValidationError::DuplicateArgument(_))
|
||||
));
|
||||
changed = original;
|
||||
changed.invocation_id.clear();
|
||||
assert!(validate_feature_invocation(&descriptor, &changed).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_boolean_and_utf8_offsets_use_the_common_typed_contract() {
|
||||
let mut descriptor = descriptor();
|
||||
descriptor.arguments[0].value_type = InvocationArgumentType::Integer;
|
||||
descriptor.arguments[1].value_type = InvocationArgumentType::Boolean;
|
||||
let parsed = parse_feature_invocation(
|
||||
"本文 /execute(-2147483648, mode=true) 後",
|
||||
"本文 ".len(),
|
||||
&descriptor,
|
||||
"typed",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
parsed.invocation.arguments[0].value,
|
||||
InvocationValue::Integer(i32::MIN)
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.invocation.arguments[1].value,
|
||||
InvocationValue::Boolean(true)
|
||||
);
|
||||
for invalid in ["/run(2147483648)", "/run(1.0)", "/run(1, mode=yes)"] {
|
||||
assert!(parse_feature_invocation(invalid, 0, &descriptor, "typed").is_err());
|
||||
}
|
||||
assert!(parse_feature_invocation("本文 /run(1)", 1, &descriptor, "typed").is_err());
|
||||
assert!(parse_feature_invocation("/run(1)", usize::MAX, &descriptor, "typed").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_escapes_and_multiple_invocations_have_explicit_boundaries() {
|
||||
let input = r#"/run("\u8cc7\u6599\/a\b\f", mode=safe) prose /execute("second") end"#;
|
||||
let first = parse_feature_invocation(input, 0, &descriptor(), "first").unwrap();
|
||||
assert_eq!(
|
||||
first.invocation.arguments[0].value,
|
||||
InvocationValue::String("資料/a\u{8}\u{c}".into())
|
||||
);
|
||||
let next = input[first.end..].find("/execute").unwrap() + first.end;
|
||||
let second = parse_feature_invocation(input, next, &descriptor(), "second").unwrap();
|
||||
assert_eq!(&input[second.end..], " end");
|
||||
assert!(matches!(
|
||||
parse_feature_invocation(r#"/run("\uD800")"#, 0, &descriptor(), "bad"),
|
||||
Err(InvocationValidationError::Syntax { .. })
|
||||
));
|
||||
let value = "controls \u{0}\u{8}\u{c}";
|
||||
let spelling = format!("/run({})", quote_invocation_string(value));
|
||||
assert_eq!(
|
||||
parse_feature_invocation(&spelling, 0, &descriptor(), "control")
|
||||
.unwrap()
|
||||
.invocation
|
||||
.arguments[0]
|
||||
.value,
|
||||
InvocationValue::String(value.into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_segment_round_trips_without_flattening_identity_or_arguments() {
|
||||
let parsed =
|
||||
parse_feature_invocation("/run(\"x y\", mode=fast)", 0, &descriptor(), "1").unwrap();
|
||||
let segment = crate::Segment::FeatureInvoke {
|
||||
invocation: parsed.invocation,
|
||||
};
|
||||
let json = serde_json::to_string(&segment).unwrap();
|
||||
assert!(json.contains("\"kind\":\"feature_invoke\""));
|
||||
assert!(json.contains("builtin:test/run"));
|
||||
assert_eq!(
|
||||
serde_json::from_str::<crate::Segment>(&json).unwrap(),
|
||||
segment
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoting_round_trips_spaces_unicode_slashes_and_escapes() {
|
||||
let value = "a b/界\\\"\n";
|
||||
let input = format!("/run({})", quote_invocation_string(value));
|
||||
let parsed = parse_feature_invocation(&input, 0, &descriptor(), "1").unwrap();
|
||||
assert_eq!(
|
||||
parsed.invocation.arguments[0].value,
|
||||
InvocationValue::String(value.into())
|
||||
);
|
||||
}
|
||||
}
|
||||
+133
-10
@@ -1,4 +1,5 @@
|
||||
pub mod identity;
|
||||
pub mod invocation;
|
||||
#[cfg(feature = "stream")]
|
||||
pub mod stream;
|
||||
pub mod subscription;
|
||||
@@ -10,6 +11,14 @@ use std::path::PathBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use identity::{WorkerId, WorkerIdParseError};
|
||||
pub use invocation::{
|
||||
FeatureInvocation, FeatureInvocationDescriptor, FeatureInvocationIdentity,
|
||||
FeatureInvocationResult, FeatureInvocationStatus, FeatureInvocationSyntax,
|
||||
InvocationArgumentDescriptor, InvocationArgumentType, InvocationArgumentValue,
|
||||
InvocationClientAdapter, InvocationCompletion, InvocationValidationError, InvocationValue,
|
||||
ParsedFeatureInvocation, parse_feature_invocation, quote_invocation_string,
|
||||
validate_feature_invocation,
|
||||
};
|
||||
|
||||
/// Allocate an opaque idempotency key for one client Submit request.
|
||||
pub fn new_submission_request_id() -> String {
|
||||
@@ -62,6 +71,17 @@ impl Default for AuthenticatedInputSource {
|
||||
}
|
||||
}
|
||||
|
||||
// Tracked methods may carry serialized transport provenance, but wire values
|
||||
// never grant authority. Keep this field recognized under fail-closed decoding
|
||||
// while replacing it with UntrustedWire, including malformed/forged values.
|
||||
fn ignore_wire_input_source<'de, D>(deserializer: D) -> Result<AuthenticatedInputSource, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let _ = serde::de::IgnoredAny::deserialize(deserializer)?;
|
||||
Ok(AuthenticatedInputSource::UntrustedWire)
|
||||
}
|
||||
|
||||
impl AuthenticatedInputSource {
|
||||
pub fn namespace(&self) -> String {
|
||||
match self {
|
||||
@@ -224,7 +244,12 @@ impl From<WorkerStatus> for WorkerStateSnapshot {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(tag = "method", content = "params", rename_all = "snake_case")]
|
||||
#[serde(
|
||||
tag = "method",
|
||||
content = "params",
|
||||
rename_all = "snake_case",
|
||||
deny_unknown_fields
|
||||
)]
|
||||
pub enum Method {
|
||||
/// Durably accept typed input for immediate activation or the session FIFO.
|
||||
/// Running or Paused Workers queue Submit for a later turn; use Notify for
|
||||
@@ -244,7 +269,7 @@ pub enum Method {
|
||||
SubmitTracked {
|
||||
submission_request_id: String,
|
||||
input: Vec<Segment>,
|
||||
#[serde(skip_deserializing, default)]
|
||||
#[serde(default, deserialize_with = "ignore_wire_input_source")]
|
||||
source: AuthenticatedInputSource,
|
||||
},
|
||||
/// Agent-controlled input: accept only at an Idle controller boundary, never
|
||||
@@ -254,7 +279,7 @@ pub enum Method {
|
||||
SubmitIfIdle {
|
||||
submission_request_id: String,
|
||||
input: Vec<Segment>,
|
||||
#[serde(skip_deserializing, default)]
|
||||
#[serde(default, deserialize_with = "ignore_wire_input_source")]
|
||||
source: AuthenticatedInputSource,
|
||||
},
|
||||
/// Human-readable text injected into the target Worker's LLM context as a
|
||||
@@ -272,7 +297,7 @@ pub enum Method {
|
||||
NotifyTracked {
|
||||
notification_request_id: String,
|
||||
message: String,
|
||||
#[serde(skip_deserializing, default)]
|
||||
#[serde(default, deserialize_with = "ignore_wire_input_source")]
|
||||
source: AuthenticatedInputSource,
|
||||
},
|
||||
/// Typed lifecycle report from a child Worker to its direct parent.
|
||||
@@ -336,6 +361,12 @@ pub enum Method {
|
||||
ListCompletions {
|
||||
kind: CompletionKind,
|
||||
prefix: String,
|
||||
/// Client query generation. Echoed verbatim so identical-prefix ABA
|
||||
/// responses cannot cross edits, target switches, or permission changes.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
request_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
context: Option<CompletionContext>,
|
||||
},
|
||||
/// List Workers visible to this Worker from durable Worker state and the spawned-child
|
||||
/// registry. This is not a host-wide Worker universe query.
|
||||
@@ -609,6 +640,10 @@ pub enum Segment {
|
||||
/// `[Dir: <path>]` listings; the flattened user text keeps the literal
|
||||
/// `@<path>` placeholder either way.
|
||||
FileRef { path: String },
|
||||
/// Explicitly selected, source-qualified Feature invocation. The host never
|
||||
/// promotes free text to this variant and validates it against the installed
|
||||
/// declaration before execution.
|
||||
FeatureInvoke { invocation: FeatureInvocation },
|
||||
/// Source-qualified request for the host to start a Flow before committing
|
||||
/// this user input. Runtime resolves it through Workspace authority and
|
||||
/// replaces it with the entered state's instructions before Worker-side
|
||||
@@ -677,6 +712,9 @@ impl Segment {
|
||||
out.push('@');
|
||||
out.push_str(path);
|
||||
}
|
||||
Segment::FeatureInvoke { invocation } => {
|
||||
out.push_str(&invocation.display_input());
|
||||
}
|
||||
Segment::Flow { selector } => {
|
||||
out.push_str("[Flow: ");
|
||||
out.push_str(selector);
|
||||
@@ -1348,6 +1386,12 @@ pub enum Event {
|
||||
/// wired up.
|
||||
Completions {
|
||||
kind: CompletionKind,
|
||||
#[serde(default)]
|
||||
prefix: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
request_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
context: Option<CompletionContext>,
|
||||
entries: Vec<CompletionEntry>,
|
||||
},
|
||||
/// Reply to `Method::ListRewindTargets`. Clients should only open a picker
|
||||
@@ -1460,25 +1504,42 @@ pub enum AlertSource {
|
||||
|
||||
/// Kind of completion requested by `Method::ListCompletions`.
|
||||
///
|
||||
/// Mirrors the completion prefix sigil: `@` → `File`.
|
||||
/// `File` is the existing `@` path lane. `Feature` lists installed chat
|
||||
/// invocations. `FeatureArgument` resolves a declared argument using the
|
||||
/// source-qualified [`CompletionContext`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CompletionKind {
|
||||
File,
|
||||
Feature,
|
||||
FeatureArgument,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub struct CompletionContext {
|
||||
pub invocation: FeatureInvocationIdentity,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub argument: Option<String>,
|
||||
}
|
||||
|
||||
/// One completion candidate for a prefix query.
|
||||
///
|
||||
/// `value` is a path (file kind).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub struct CompletionEntry {
|
||||
pub value: String,
|
||||
#[serde(default)]
|
||||
pub is_dir: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub usage: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub invocation: Option<FeatureInvocationDescriptor>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -2114,6 +2175,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notify_rejects_typed_intent_instead_of_silently_discarding_it() {
|
||||
for method in ["notify", "notify_tracked"] {
|
||||
for key in ["input", "segments", "invocation"] {
|
||||
let mut params =
|
||||
serde_json::json!({"notification_request_id":"request", "message":"advisory"});
|
||||
params[key] = serde_json::json!([{ "kind":"feature_invoke", "invocation": { "identity":"builtin:test/run" } }]);
|
||||
assert!(
|
||||
serde_json::from_value::<Method>(
|
||||
serde_json::json!({"method":method,"params":params})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
// Text is still text: no decoding or Host scan promotes it to an invoke.
|
||||
assert!(
|
||||
matches!(serde_json::from_value::<Method>(serde_json::json!({"method":"notify", "params":{"notification_request_id":"text","message":"/run()"}})).unwrap(), Method::Notify { message, .. } if message == "/run()")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_unknown_variant_decodes_as_unknown() {
|
||||
// A future client sends a segment kind this Worker has never heard of.
|
||||
@@ -2352,6 +2434,8 @@ mod tests {
|
||||
let method = Method::ListCompletions {
|
||||
kind: CompletionKind::File,
|
||||
prefix: "src/".into(),
|
||||
request_id: None,
|
||||
context: None,
|
||||
};
|
||||
let json = serde_json::to_string(&method).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
@@ -2361,7 +2445,7 @@ mod tests {
|
||||
|
||||
let decoded: Method = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
Method::ListCompletions { kind, prefix } => {
|
||||
Method::ListCompletions { kind, prefix, .. } => {
|
||||
assert_eq!(kind, CompletionKind::File);
|
||||
assert_eq!(prefix, "src/");
|
||||
}
|
||||
@@ -2369,13 +2453,52 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_generation_round_trips_for_identical_query_correlation() {
|
||||
let context = CompletionContext {
|
||||
invocation: FeatureInvocationIdentity("builtin:test/run".into()),
|
||||
argument: Some("mode".into()),
|
||||
};
|
||||
for request_id in ["query-1", "query-2"] {
|
||||
let method = Method::ListCompletions {
|
||||
kind: CompletionKind::FeatureArgument,
|
||||
prefix: "s".into(),
|
||||
request_id: Some(request_id.into()),
|
||||
context: Some(context.clone()),
|
||||
};
|
||||
let decoded: Method =
|
||||
serde_json::from_str(&serde_json::to_string(&method).unwrap()).unwrap();
|
||||
assert!(
|
||||
matches!(decoded, Method::ListCompletions { request_id: Some(id), context: Some(restored), .. } if id == request_id && restored == context)
|
||||
);
|
||||
let event = Event::Completions {
|
||||
kind: CompletionKind::FeatureArgument,
|
||||
prefix: "s".into(),
|
||||
request_id: Some(request_id.into()),
|
||||
context: Some(context.clone()),
|
||||
entries: Vec::new(),
|
||||
};
|
||||
let decoded: Event =
|
||||
serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
|
||||
assert!(
|
||||
matches!(decoded, Event::Completions { request_id: Some(id), context: Some(restored), .. } if id == request_id && restored == context)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_completions_format_and_default_is_dir() {
|
||||
let event = Event::Completions {
|
||||
kind: CompletionKind::File,
|
||||
prefix: "src/".into(),
|
||||
request_id: None,
|
||||
context: None,
|
||||
entries: vec![CompletionEntry {
|
||||
value: "src/main.rs".into(),
|
||||
is_dir: false,
|
||||
description: None,
|
||||
usage: None,
|
||||
invocation: None,
|
||||
}],
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
@@ -2389,7 +2512,7 @@ mod tests {
|
||||
r#"{"event":"completions","data":{"kind":"file","entries":[{"value":"main.rs"}]}}"#;
|
||||
let decoded: Event = serde_json::from_str(inbound).unwrap();
|
||||
match decoded {
|
||||
Event::Completions { kind, entries } => {
|
||||
Event::Completions { kind, entries, .. } => {
|
||||
assert_eq!(kind, CompletionKind::File);
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].value, "main.rs");
|
||||
|
||||
@@ -651,6 +651,10 @@ impl SubscriptionWorkerJob {
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub struct SubscriptionWorker {
|
||||
/// Opaque Runtime-owned Restore observation identity, never a display-state hash.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(optional = nullable))]
|
||||
pub restore_observation_token: Option<String>,
|
||||
pub worker_id: SubscriptionWorkerId,
|
||||
/// Set by the Workspace Server when projecting a Runtime-owned Worker to clients.
|
||||
/// Runtime producers leave this unset because the connection identifies the Runtime.
|
||||
@@ -689,6 +693,9 @@ pub struct SubscriptionWorker {
|
||||
impl SubscriptionWorker {
|
||||
pub fn validate(&self) -> Result<(), SubscriptionValidationError> {
|
||||
self.worker_id.validate()?;
|
||||
if let Some(token) = &self.restore_observation_token {
|
||||
validate_identifier("restore_observation_token", token, MAX_RESOURCE_ID_BYTES)?;
|
||||
}
|
||||
if let Some(runtime_id) = &self.runtime_id {
|
||||
validate_identifier("runtime_id", runtime_id, MAX_RESOURCE_ID_BYTES)?;
|
||||
}
|
||||
@@ -962,6 +969,7 @@ mod tests {
|
||||
|
||||
fn worker(value: &str) -> SubscriptionWorker {
|
||||
SubscriptionWorker {
|
||||
restore_observation_token: None,
|
||||
worker_id: worker_id(value),
|
||||
runtime_id: None,
|
||||
resource_key: None,
|
||||
@@ -1197,6 +1205,8 @@ mod tests {
|
||||
method: crate::Method::ListCompletions {
|
||||
kind: crate::CompletionKind::File,
|
||||
prefix: "src/".to_string(),
|
||||
request_id: None,
|
||||
context: None,
|
||||
},
|
||||
},
|
||||
));
|
||||
|
||||
@@ -6,20 +6,23 @@ use ts_rs::{Config, TS};
|
||||
use crate::{
|
||||
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
|
||||
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompactionPhase,
|
||||
CompactionTrigger, CompletionEntry, CompletionKind, ContextTokenSource, ContextUsage,
|
||||
ErrorCode, Event, Greeting, InFlightBlock, InFlightCompaction, InFlightSnapshot,
|
||||
CompactionTrigger, CompletionContext, CompletionEntry, CompletionKind, ContextTokenSource,
|
||||
ContextUsage, ErrorCode, Event, FeatureInvocation, FeatureInvocationDescriptor,
|
||||
FeatureInvocationIdentity, FeatureInvocationResult, FeatureInvocationStatus,
|
||||
FeatureInvocationSyntax, Greeting, InFlightBlock, InFlightCompaction, InFlightSnapshot,
|
||||
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
|
||||
InvokeKind, MemoryWorkerEvent, Method, PasteArtifactAvailability, PasteArtifactMediaType,
|
||||
PasteArtifactRef, PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission,
|
||||
ReasoningConfig, RewindSummary, RewindTarget, RewindTargetId, RunFailureKind, RunResult,
|
||||
RunResumeSource, RunYieldReason, ScopeRule, Segment, SessionContentPart,
|
||||
SessionConversationTurn, SessionEntryProvenance, SessionHistoryLineageBoundary,
|
||||
SessionHistoryPage, SessionMessageRole, SessionSnapshot, SessionSnapshotEntry,
|
||||
SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition, SymlinkPolicy,
|
||||
ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerBusyState,
|
||||
WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
|
||||
WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState,
|
||||
WorkerStateSnapshot, WorkerStatus,
|
||||
InvocationArgumentDescriptor, InvocationArgumentType, InvocationArgumentValue,
|
||||
InvocationClientAdapter, InvocationCompletion, InvocationValue, InvokeKind, MemoryWorkerEvent,
|
||||
Method, PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef,
|
||||
PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission, ReasoningConfig,
|
||||
RewindSummary, RewindTarget, RewindTargetId, RunFailureKind, RunResult, RunResumeSource,
|
||||
RunYieldReason, ScopeRule, Segment, SessionContentPart, SessionConversationTurn,
|
||||
SessionEntryProvenance, SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole,
|
||||
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
|
||||
SubmissionDisposition, SymlinkPolicy, ToolResultDisposition, TurnResult,
|
||||
UploadedFileAvailability, UploadedFileRef, WorkerBusyState, WorkerCommandAcknowledgement,
|
||||
WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind, WorkerEvent,
|
||||
WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus,
|
||||
subscription::{
|
||||
EventSubscriptionSelector, MAX_CORRELATION_ID_BYTES, MAX_REJECTION_MESSAGE_BYTES,
|
||||
MAX_RESOURCE_ID_BYTES, MAX_SUBSCRIPTION_COLLECTION_ITEMS,
|
||||
@@ -64,6 +67,19 @@ pub fn generated_protocol_types() -> String {
|
||||
push_decl::<AlertLevel>(&cfg, &mut output);
|
||||
push_decl::<AlertSource>(&cfg, &mut output);
|
||||
push_decl::<CompletionKind>(&cfg, &mut output);
|
||||
push_decl::<CompletionContext>(&cfg, &mut output);
|
||||
push_decl::<FeatureInvocationIdentity>(&cfg, &mut output);
|
||||
push_decl::<FeatureInvocationSyntax>(&cfg, &mut output);
|
||||
push_decl::<InvocationArgumentType>(&cfg, &mut output);
|
||||
push_decl::<InvocationCompletion>(&cfg, &mut output);
|
||||
push_decl::<InvocationArgumentDescriptor>(&cfg, &mut output);
|
||||
push_decl::<InvocationClientAdapter>(&cfg, &mut output);
|
||||
push_decl::<FeatureInvocationDescriptor>(&cfg, &mut output);
|
||||
push_decl::<InvocationValue>(&cfg, &mut output);
|
||||
push_decl::<InvocationArgumentValue>(&cfg, &mut output);
|
||||
push_decl::<FeatureInvocation>(&cfg, &mut output);
|
||||
push_decl::<FeatureInvocationStatus>(&cfg, &mut output);
|
||||
push_decl::<FeatureInvocationResult>(&cfg, &mut output);
|
||||
push_decl::<WorkerStatus>(&cfg, &mut output);
|
||||
push_decl::<WorkerCommandEnvelope>(&cfg, &mut output);
|
||||
push_decl::<WorkerCommandKind>(&cfg, &mut output);
|
||||
@@ -230,6 +246,7 @@ fn rust_serialized_subscription_frame_fixtures() -> Vec<SubscriptionFrame> {
|
||||
let external_workdir_id = SubscriptionWorkdirId::new("fixture-external-workdir")
|
||||
.expect("fixture external Workdir id must be valid");
|
||||
let worker = SubscriptionWorker {
|
||||
restore_observation_token: Some("fixture-restore-observation".into()),
|
||||
worker_id: worker_id.clone(),
|
||||
runtime_id: Some("fixture-runtime".to_string()),
|
||||
resource_key: Some("fixture-worker-resource".to_string()),
|
||||
|
||||
@@ -378,6 +378,7 @@ function validateWorkers(workers: SubscriptionWorker[]): boolean {
|
||||
}
|
||||
|
||||
function validateWorker(worker: SubscriptionWorker): boolean {
|
||||
if (worker.restore_observation_token != null && !validateIdentifier(worker.restore_observation_token, MAX_RESOURCE_ID_BYTES)) return false;
|
||||
if (!validateIdentifier(worker.worker_id, MAX_RESOURCE_ID_BYTES)) return false;
|
||||
if (worker.runtime_id != null && !validateIdentifier(worker.runtime_id, MAX_RESOURCE_ID_BYTES)) {
|
||||
return false;
|
||||
|
||||
@@ -420,6 +420,18 @@ pub struct WorkerInput {
|
||||
pub segments: Option<Vec<Segment>>,
|
||||
}
|
||||
|
||||
/// Host-authored Job/attempt identity, independent of Profile selection.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BackendJobExecutionBinding {
|
||||
pub job_id: String,
|
||||
pub attempt_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub input_revision: Option<String>,
|
||||
#[serde(default)]
|
||||
pub subjektiv_consolidation: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct CreateWorkerRequest {
|
||||
pub worker_id: WorkerId,
|
||||
@@ -448,10 +460,14 @@ pub struct CreateWorkerRequest {
|
||||
/// never activates subjektiv for an ordinary Workspace Worker.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub subjektiv_attached: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub backend_job: Option<BackendJobExecutionBinding>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct WorkerSummary {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub restore_observation_token: Option<String>,
|
||||
pub worker_ref: WorkerRef,
|
||||
pub worker_id: WorkerId,
|
||||
pub status: WorkerStatus,
|
||||
@@ -484,6 +500,62 @@ pub struct WorkerResponse {
|
||||
pub worker: WorkerDetail,
|
||||
}
|
||||
|
||||
/// Explicit observed Restore intent. Preparation is authored by the Workspace
|
||||
/// Backend, not browser/Tool JSON, and applied only after Runtime admission.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkerRestoreRequest {
|
||||
pub expected_observation_token: String,
|
||||
pub request_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub preparation: Option<WorkerRestorePreparation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkerRestorePreparation {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_api: Option<WorkspaceApiRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workdir_attachments: Option<Vec<LogicalWorkdirAttachment>>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub repository_access: Vec<WorkingDirectoryRepositoryAccessRequest>,
|
||||
/// Read-only references resolved by Workspace only after this operation owns admission.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub repository_access_workdirs: Vec<RepositoryAccessWorkdirReference>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositoryAccessWorkdirReference {
|
||||
pub runtime_id: String,
|
||||
pub working_directory_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkerRestoreCoordinationRequest {
|
||||
pub expected_observation_token: String,
|
||||
pub request_id: String,
|
||||
/// None means owner/result recovery only; it can never admit a new intent.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub preparation: Option<WorkerRestorePreparation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct WorkerRestoreCoordinationResponse {
|
||||
pub result: Option<WorkerRestoreResponse>,
|
||||
/// Original read-only preparation, only when the existing operation still needs it.
|
||||
pub preparation: Option<WorkerRestorePreparation>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkingDirectoryRepositoryAccessRequest {
|
||||
pub working_directory_id: String,
|
||||
pub materialization: RepositoryMaterializationContext,
|
||||
}
|
||||
|
||||
/// Explicit restore outcome returned for every reachable Runtime restore attempt,
|
||||
/// including preflight rejection and uncertain post-side-effect reconciliation.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
@@ -567,12 +639,20 @@ pub struct CompletionRequest {
|
||||
pub kind: CompletionKind,
|
||||
#[serde(default)]
|
||||
pub prefix: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<protocol::CompletionContext>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub request_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct CompletionResponse {
|
||||
pub kind: CompletionKind,
|
||||
pub prefix: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<protocol::CompletionContext>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub request_id: Option<String>,
|
||||
pub entries: Vec<CompletionEntry>,
|
||||
}
|
||||
|
||||
@@ -992,11 +1072,18 @@ pub trait RuntimeApi {
|
||||
#[body] request: WorkerLifecycleRequest,
|
||||
) -> Result<WorkerLifecycleResponse, RuntimeApiError>;
|
||||
|
||||
#[post("/v1/workers/{worker_id}/restore", status = 200, error_status = 400)]
|
||||
#[post("/v1/workers/{worker_id}/restore/coordinate", status = 200, error_status = 400, additional_error_statuses = [409])]
|
||||
async fn coordinate_worker_restore(
|
||||
&self,
|
||||
#[path] worker_id: String,
|
||||
#[body] request: WorkerRestoreCoordinationRequest,
|
||||
) -> Result<WorkerRestoreCoordinationResponse, RuntimeApiError>;
|
||||
|
||||
#[post("/v1/workers/{worker_id}/restore", status = 200, error_status = 400, additional_error_statuses = [409])]
|
||||
async fn restore_worker(
|
||||
&self,
|
||||
#[path] worker_id: String,
|
||||
#[body] request: EmptyObjectRequest,
|
||||
#[body] request: WorkerRestoreRequest,
|
||||
) -> Result<WorkerRestoreResponse, RuntimeApiError>;
|
||||
|
||||
#[post(
|
||||
@@ -1202,6 +1289,61 @@ pub const REMAINING_RUNTIME_ROUTES: &[RemainingRuntimeRoute] = &[
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn restore_request_is_mandatory_and_conflict_is_not_an_operation_state() {
|
||||
for invalid in [
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({"request_id":"r"}),
|
||||
serde_json::json!({"expected_observation_token":"t"}),
|
||||
] {
|
||||
assert!(serde_json::from_value::<WorkerRestoreRequest>(invalid).is_err());
|
||||
}
|
||||
let request: WorkerRestoreRequest = serde_json::from_value(
|
||||
serde_json::json!({"expected_observation_token":"t", "request_id":"r"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(request.preparation.is_none());
|
||||
for state in [
|
||||
"accepted",
|
||||
"rejected",
|
||||
"rolled_back",
|
||||
"reconciliation_required",
|
||||
] {
|
||||
assert!(serde_json::from_value::<WorkerRestoreState>(serde_json::json!(state)).is_ok());
|
||||
}
|
||||
assert!(
|
||||
serde_json::from_value::<WorkerRestoreState>(serde_json::json!(
|
||||
"restore_observation_conflict"
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
let conflict = RuntimeApiError::new(
|
||||
409,
|
||||
"restore_observation_conflict",
|
||||
"refresh before a new intent",
|
||||
);
|
||||
assert_eq!(conflict.status(), 409);
|
||||
assert_eq!(
|
||||
serde_json::to_value(conflict).unwrap()["error"]["code"],
|
||||
"restore_observation_conflict"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_argument_completion_wire_preserves_source_and_argument() {
|
||||
let json = serde_json::json!({"kind":"feature_argument", "prefix":"資料/", "context": {"invocation":"builtin:test/prepare", "argument":"path"}});
|
||||
let request: CompletionRequest = serde_json::from_value(json.clone()).unwrap();
|
||||
assert_eq!(request.kind, CompletionKind::FeatureArgument);
|
||||
assert_eq!(
|
||||
request.context.as_ref().unwrap().argument.as_deref(),
|
||||
Some("path")
|
||||
);
|
||||
assert_eq!(serde_json::to_value(request).unwrap(), json);
|
||||
let legacy: CompletionRequest =
|
||||
serde_json::from_value(serde_json::json!({"kind":"file"})).unwrap();
|
||||
assert!(legacy.context.is_none());
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RoundTripService;
|
||||
|
||||
@@ -1365,10 +1507,17 @@ mod tests {
|
||||
) -> Result<WorkerLifecycleResponse, RuntimeApiError> {
|
||||
Err(test_error(501))
|
||||
}
|
||||
async fn coordinate_worker_restore(
|
||||
&self,
|
||||
_worker_id: String,
|
||||
_request: WorkerRestoreCoordinationRequest,
|
||||
) -> Result<WorkerRestoreCoordinationResponse, RuntimeApiError> {
|
||||
Err(test_error(501))
|
||||
}
|
||||
async fn restore_worker(
|
||||
&self,
|
||||
_worker_id: String,
|
||||
_request: EmptyObjectRequest,
|
||||
_request: WorkerRestoreRequest,
|
||||
) -> Result<WorkerRestoreResponse, RuntimeApiError> {
|
||||
Err(test_error(501))
|
||||
}
|
||||
@@ -1468,7 +1617,7 @@ mod tests {
|
||||
#[test]
|
||||
fn contract_inventory_is_complete_and_unique() {
|
||||
let operations = RuntimeApiMetadata::OPERATIONS;
|
||||
assert_eq!(operations.len(), 19);
|
||||
assert_eq!(operations.len(), 20);
|
||||
let mut routes = operations
|
||||
.iter()
|
||||
.map(|operation| (format!("{:?}", operation.method), operation.path))
|
||||
|
||||
@@ -11,6 +11,7 @@ pub use api_macros::axum as server_support;
|
||||
pub use api_macros::reqwest as client_support;
|
||||
pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation};
|
||||
pub mod repository_openapi_typescript;
|
||||
mod workspace_config;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -18,6 +19,7 @@ use webauthn_rs_proto::{
|
||||
CreationChallengeResponse, PublicKeyCredential, RegisterPublicKeyCredential,
|
||||
RequestChallengeResponse,
|
||||
};
|
||||
pub use workspace_config::*;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(JsonSchema)]
|
||||
@@ -190,10 +192,32 @@ impl_openapi_schema!(
|
||||
WorkingDirectoryListResponse,
|
||||
WorkingDirectoryDetailResponse,
|
||||
WorkingDirectoryCreateResponse,
|
||||
CurrentWorkerWorkdirCatalogQuery,
|
||||
CurrentWorkerWorkdirCatalogResponse,
|
||||
CurrentWorkerWorkdirAttachRequest,
|
||||
CurrentWorkerWorkdirAttachmentResponse,
|
||||
CurrentWorkerWorkdirAttachmentItem,
|
||||
CurrentWorkerWorkdirAttachmentListQuery,
|
||||
CurrentWorkerWorkdirAttachmentListResponse,
|
||||
CurrentWorkerWorkdirDetachQuery,
|
||||
CurrentWorkerWorkdirOperationRequest,
|
||||
CurrentWorkerWorkdirOperationResponse,
|
||||
WorkspaceConfigAccess,
|
||||
WorkspaceConfigNodeKind,
|
||||
WorkspaceConfigFailureClassification,
|
||||
WorkspaceConfigAttachRequest,
|
||||
WorkspaceConfigAttachment,
|
||||
WorkspaceConfigCurrentResponse,
|
||||
WorkspaceConfigObserveRequest,
|
||||
WorkspaceConfigNode,
|
||||
WorkspaceConfigObserveResponse,
|
||||
WorkspaceConfigReadRequest,
|
||||
WorkspaceConfigReadResponse,
|
||||
WorkspaceConfigCommitRequest,
|
||||
WorkspaceConfigCommitResponse,
|
||||
WorkspaceConfigApiError,
|
||||
WorkspaceConfigGrantCreateRequest,
|
||||
WorkspaceConfigGrantResponse,
|
||||
ExternalWorkdirGrantCreateRequest,
|
||||
ExternalWorkdirGrantResponse,
|
||||
WorkspaceResponse,
|
||||
@@ -237,6 +261,7 @@ impl_openapi_schema!(
|
||||
MemoryConsolidateStagingRequest,
|
||||
MemoryConsolidationResponse,
|
||||
SubjektivSubjectCreateRequest,
|
||||
SubjektivSubjectBehaviorUpdateRequest,
|
||||
SubjektivSubjectListQuery,
|
||||
SubjektivSubjectListResponse,
|
||||
SubjektivSubjectResponse,
|
||||
@@ -364,6 +389,7 @@ impl_openapi_schema!(
|
||||
WorkspaceRuntimeListResponse,
|
||||
RuntimeWorkerListResponse,
|
||||
WorkerRestoreResponse,
|
||||
WorkerRestoreRequest,
|
||||
WorkerControlSubject,
|
||||
WorkerControlRecord,
|
||||
WorkerControlListResponse,
|
||||
@@ -1163,6 +1189,20 @@ pub trait ServerApi {
|
||||
#[path] workspace_id: String,
|
||||
#[path] subject_id: String,
|
||||
) -> Result<SubjektivSubjectResponse, RepositoryApiError>;
|
||||
#[patch(
|
||||
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/behavior",
|
||||
status = 200,
|
||||
error_status = 400,
|
||||
additional_error_statuses = [401, 403, 404, 409, 500],
|
||||
bearer_auth = true,
|
||||
browser_auth = true,
|
||||
)]
|
||||
async fn subjektiv_subject_behavior_update(
|
||||
&self,
|
||||
#[path] workspace_id: String,
|
||||
#[path] subject_id: String,
|
||||
#[body] request: SubjektivSubjectBehaviorUpdateRequest,
|
||||
) -> Result<SubjektivSubjectResponse, RepositoryApiError>;
|
||||
#[get(
|
||||
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/surface",
|
||||
status = 200,
|
||||
@@ -1513,6 +1553,7 @@ pub trait ServerApi {
|
||||
#[path] workspace_id: String,
|
||||
#[path] runtime_id: String,
|
||||
#[path] worker_id: String,
|
||||
#[body] request: WorkerRestoreRequest,
|
||||
) -> Result<WorkerRestoreResponse, RepositoryApiError>;
|
||||
#[get(
|
||||
"/api/w/{workspace_id}/worker-observation/sessions",
|
||||
@@ -2038,8 +2079,10 @@ pub trait ServerApi {
|
||||
)]
|
||||
async fn runtime_worker_restore_alias(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] runtime_id: String,
|
||||
#[path] worker_id: String,
|
||||
#[body] request: WorkerRestoreRequest,
|
||||
) -> Result<WorkerRestoreResponse, RepositoryApiError>;
|
||||
|
||||
#[post(
|
||||
@@ -2052,10 +2095,12 @@ pub trait ServerApi {
|
||||
)]
|
||||
async fn runtime_worker_restore(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[path] runtime_id: String,
|
||||
#[path] worker_id: String,
|
||||
#[query] query: RestoreTicketAssignmentQuery,
|
||||
#[body] request: WorkerRestoreRequest,
|
||||
) -> Result<WorkerRestoreResponse, RepositoryApiError>;
|
||||
|
||||
#[put(
|
||||
@@ -2068,6 +2113,7 @@ pub trait ServerApi {
|
||||
)]
|
||||
async fn runtime_worker_pin(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[path] runtime_id: String,
|
||||
#[path] worker_id: String,
|
||||
@@ -2083,6 +2129,7 @@ pub trait ServerApi {
|
||||
)]
|
||||
async fn runtime_worker_unpin(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[path] runtime_id: String,
|
||||
#[path] worker_id: String,
|
||||
@@ -2112,6 +2159,7 @@ pub trait ServerApi {
|
||||
)]
|
||||
async fn runtime_cleanup_execute(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[path] runtime_id: String,
|
||||
#[body] request: ExecuteRuntimeCleanupRequest,
|
||||
@@ -2127,6 +2175,7 @@ pub trait ServerApi {
|
||||
)]
|
||||
async fn runtime_worker_input_alias(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] runtime_id: String,
|
||||
#[path] worker_id: String,
|
||||
#[body] request: RuntimeWorkerInputRequest,
|
||||
@@ -2142,6 +2191,7 @@ pub trait ServerApi {
|
||||
)]
|
||||
async fn runtime_worker_input(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[path] runtime_id: String,
|
||||
#[path] worker_id: String,
|
||||
@@ -2258,6 +2308,7 @@ pub trait ServerApi {
|
||||
)]
|
||||
async fn runtime_worker_stop_alias(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] runtime_id: String,
|
||||
#[path] worker_id: String,
|
||||
#[body] request: RuntimeWorkerLifecycleRequest,
|
||||
@@ -2273,6 +2324,7 @@ pub trait ServerApi {
|
||||
)]
|
||||
async fn runtime_worker_stop(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[path] runtime_id: String,
|
||||
#[path] worker_id: String,
|
||||
@@ -2289,6 +2341,7 @@ pub trait ServerApi {
|
||||
)]
|
||||
async fn runtime_worker_cancel_alias(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] runtime_id: String,
|
||||
#[path] worker_id: String,
|
||||
#[body] request: RuntimeWorkerLifecycleRequest,
|
||||
@@ -2304,6 +2357,7 @@ pub trait ServerApi {
|
||||
)]
|
||||
async fn runtime_worker_cancel(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[path] runtime_id: String,
|
||||
#[path] worker_id: String,
|
||||
@@ -2549,6 +2603,81 @@ pub trait ServerApi {
|
||||
#[path] working_directory_id: String,
|
||||
#[body] request: WorkingDirectoryRemovalRequest,
|
||||
) -> Result<WorkingDirectoryRemovalResponse, RepositoryApiError>;
|
||||
#[get("/api/w/{workspace_id}/workers/self/workspace-config", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,500,503], openapi = false)]
|
||||
async fn current_worker_workspace_config_get(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
) -> Result<WorkspaceConfigCurrentResponse, WorkspaceConfigApiError>;
|
||||
#[post("/api/w/{workspace_id}/workers/self/workspace-config", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,500,503], openapi = false)]
|
||||
async fn current_worker_workspace_config_attach(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[body] request: WorkspaceConfigAttachRequest,
|
||||
) -> Result<WorkspaceConfigAttachment, WorkspaceConfigApiError>;
|
||||
#[post("/api/w/{workspace_id}/workers/self/workspace-config/observe", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,500,503], openapi = false)]
|
||||
async fn current_worker_workspace_config_observe(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[body] request: WorkspaceConfigObserveRequest,
|
||||
) -> Result<WorkspaceConfigObserveResponse, WorkspaceConfigApiError>;
|
||||
#[post("/api/w/{workspace_id}/workers/self/workspace-config/read", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,500,503], openapi = false)]
|
||||
async fn current_worker_workspace_config_read(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[body] request: WorkspaceConfigReadRequest,
|
||||
) -> Result<WorkspaceConfigReadResponse, WorkspaceConfigApiError>;
|
||||
#[post("/api/w/{workspace_id}/workers/self/workspace-config/commit", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,500,503], openapi = false)]
|
||||
async fn current_worker_workspace_config_commit(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[body] request: WorkspaceConfigCommitRequest,
|
||||
) -> Result<WorkspaceConfigCommitResponse, WorkspaceConfigApiError>;
|
||||
#[post("/api/w/{workspace_id}/workspace-config-grants", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,500], bearer_auth = true, browser_auth = true)]
|
||||
async fn workspace_config_grant_create(
|
||||
&self,
|
||||
#[extension] actor: RequestActor,
|
||||
#[path] workspace_id: String,
|
||||
#[body] request: WorkspaceConfigGrantCreateRequest,
|
||||
) -> Result<WorkspaceConfigGrantResponse, WorkspaceConfigApiError>;
|
||||
#[delete("/api/w/{workspace_id}/workspace-config-grants/{grant_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,500], bearer_auth = true, browser_auth = true)]
|
||||
async fn workspace_config_grant_revoke(
|
||||
&self,
|
||||
#[extension] actor: RequestActor,
|
||||
#[path] workspace_id: String,
|
||||
#[path] grant_id: String,
|
||||
) -> Result<WorkspaceConfigGrantResponse, WorkspaceConfigApiError>;
|
||||
|
||||
#[get(
|
||||
"/api/w/{workspace_id}/workers/self/workdir-catalog",
|
||||
status = 200,
|
||||
error_status = 400,
|
||||
additional_error_statuses = [401, 403, 404, 500],
|
||||
openapi = false,
|
||||
)]
|
||||
async fn current_worker_workdir_catalog(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[query] query: CurrentWorkerWorkdirCatalogQuery,
|
||||
) -> Result<CurrentWorkerWorkdirCatalogResponse, RepositoryApiError>;
|
||||
#[get(
|
||||
"/api/w/{workspace_id}/workers/self/workdir-attachments",
|
||||
status = 200,
|
||||
error_status = 400,
|
||||
additional_error_statuses = [401, 403, 404, 500],
|
||||
openapi = false,
|
||||
)]
|
||||
async fn current_worker_workdir_attachment_list(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[query] query: CurrentWorkerWorkdirAttachmentListQuery,
|
||||
) -> Result<CurrentWorkerWorkdirAttachmentListResponse, RepositoryApiError>;
|
||||
#[post(
|
||||
"/api/w/{workspace_id}/workers/self/workdir-attachments",
|
||||
status = 200,
|
||||
@@ -2574,6 +2703,7 @@ pub trait ServerApi {
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[path] alias: String,
|
||||
#[query] query: CurrentWorkerWorkdirDetachQuery,
|
||||
) -> Result<CurrentWorkerWorkdirAttachmentResponse, RepositoryApiError>;
|
||||
#[post(
|
||||
"/api/w/{workspace_id}/workers/self/workdir-session/operations",
|
||||
@@ -4579,9 +4709,20 @@ pub enum SubjektivSubjectState {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjektivSubjectCreateRequest {
|
||||
pub role: String,
|
||||
#[serde(default)]
|
||||
pub behavior_md: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjektivSubjectBehaviorUpdateRequest {
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub expected_behavior_revision: u64,
|
||||
pub behavior_md: String,
|
||||
}
|
||||
|
||||
pub const SUBJEKTIV_BROWSER_MAX_LIST_LIMIT: usize = 100;
|
||||
pub const SUBJEKTIV_MAX_BEHAVIOR_BYTES: usize = 16 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -4609,6 +4750,9 @@ pub struct SubjektivSubjectListResponse {
|
||||
pub struct SubjektivSubjectResponse {
|
||||
pub id: String,
|
||||
pub role: String,
|
||||
pub behavior_md: String,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub behavior_revision: u64,
|
||||
pub state: SubjektivSubjectState,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub store_revision: u64,
|
||||
@@ -5386,6 +5530,9 @@ pub struct SubjektivSurfacePrepareRequest {}
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjektivSurfacePrepareResponse {
|
||||
pub generation_id: String,
|
||||
/// Existing ready surface at this exact store revision, if no rebuild is needed.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_snapshot_id: Option<String>,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub store_revision: u64,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||
@@ -5442,10 +5589,22 @@ pub struct SubjektivSurfaceFailureResponse {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjektivResidentContextOutput {
|
||||
/// Exact user-managed document. Empty means explicitly unset.
|
||||
pub behavior_md: String,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub behavior_revision: u64,
|
||||
/// Independently-fresh generated Memory projection.
|
||||
pub memory_surface: memory::backend::MemoryResidentSummaryOutput,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(tag = "operation", content = "input", rename_all = "snake_case")]
|
||||
pub enum SubjektivMemoryBackendOperation {
|
||||
ResidentSummary(memory::backend::MemoryResidentSummaryOperation),
|
||||
ResidentContext(memory::backend::MemoryResidentSummaryOperation),
|
||||
Query(SubjektivMemoryQueryRequest),
|
||||
Read(SubjektivMemoryReadRequest),
|
||||
ListRevisions(SubjektivMemoryListRevisionsRequest),
|
||||
@@ -5618,6 +5777,7 @@ pub struct SubjektivMemoryStageExplicitResponse {
|
||||
#[serde(tag = "result", content = "data", rename_all = "snake_case")]
|
||||
pub enum SubjektivMemoryBackendResponse {
|
||||
ResidentSummary(memory::backend::MemoryResidentSummaryOutput),
|
||||
ResidentContext(SubjektivResidentContextOutput),
|
||||
Query(SubjektivMemoryQueryResponse),
|
||||
Read(SubjektivMemoryReadResponse),
|
||||
ListRevisions(SubjektivMemoryListRevisionsResponse),
|
||||
@@ -6248,6 +6408,7 @@ pub struct Diagnostic {
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkingDirectoryMaterializerKind {
|
||||
LogicalWorkspaceConfig,
|
||||
#[default]
|
||||
RuntimeGitClone,
|
||||
ClientHostedExternal,
|
||||
@@ -6387,6 +6548,11 @@ pub struct RuntimeWorkingDirectorySummary {
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum WorkingDirectorySource {
|
||||
WorkspaceConfig {
|
||||
access: WorkspaceConfigAccess,
|
||||
content_path: String,
|
||||
purpose: String,
|
||||
},
|
||||
Repository {
|
||||
repository_key: String,
|
||||
},
|
||||
@@ -6754,6 +6920,69 @@ pub struct CurrentWorkerWorkdirAttachRequest {
|
||||
pub working_directory_id: String,
|
||||
}
|
||||
|
||||
/// Stable Workdir-ID keyset paging over the authoritative inventory, not a Runtime snapshot.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CurrentWorkerWorkdirCatalogQuery {
|
||||
/// Defaults to 50; valid range is 1..=100 scanned registry records.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<u32>,
|
||||
/// Exclusive stable Workdir ID. An empty policy-filtered page can still have a next cursor.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CurrentWorkerWorkdirCatalogResponse {
|
||||
pub workspace_id: String,
|
||||
pub items: Vec<WorkingDirectorySummary>,
|
||||
pub next_cursor: Option<String>,
|
||||
/// Opaque digest of the complete authoritative inventory and its occupancy.
|
||||
pub revision: String,
|
||||
}
|
||||
|
||||
/// Bounded, alias-ordered paging over the current Worker's active attachments.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CurrentWorkerWorkdirAttachmentListQuery {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub limit: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub offset: Option<u32>,
|
||||
/// Exact caller-scoped connection lookup, applied before paging; 1..=128 bytes.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub connection_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CurrentWorkerWorkdirAttachmentItem {
|
||||
pub alias: String,
|
||||
pub working_directory_id: String,
|
||||
pub capabilities: workdir::WorkdirSessionCapabilities,
|
||||
/// Opaque durable identity of this attachment lifetime, not a Workdir ID.
|
||||
pub connection_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CurrentWorkerWorkdirAttachmentListResponse {
|
||||
pub workspace_id: String,
|
||||
pub items: Vec<CurrentWorkerWorkdirAttachmentItem>,
|
||||
pub next_offset: Option<u32>,
|
||||
/// Opaque digest of this caller's complete active connection set, independent of query paging/filtering.
|
||||
pub revision: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CurrentWorkerWorkdirDetachQuery {
|
||||
/// When omitted, retain the normal Tool's unconditional detach contract.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expected_connection_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CurrentWorkerWorkdirAttachmentResponse {
|
||||
@@ -8233,7 +8462,7 @@ pub struct RuntimeWorkerInputRequest {
|
||||
pub kind: Option<String>,
|
||||
pub content: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub segments: Option<Vec<serde_json::Value>>,
|
||||
pub segments: Option<Vec<protocol::Segment>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
@@ -8268,9 +8497,13 @@ pub struct RuntimeWorkerLifecycleResult {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RuntimeWorkerCompletionsRequest {
|
||||
pub kind: serde_json::Value,
|
||||
pub kind: protocol::CompletionKind,
|
||||
#[serde(default)]
|
||||
pub prefix: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<protocol::CompletionContext>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub request_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
|
||||
@@ -8278,9 +8511,13 @@ pub struct RuntimeWorkerCompletionsRequest {
|
||||
pub struct RuntimeWorkerCompletionsResult {
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
pub kind: serde_json::Value,
|
||||
pub kind: protocol::CompletionKind,
|
||||
pub prefix: String,
|
||||
pub entries: Vec<serde_json::Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context: Option<protocol::CompletionContext>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub request_id: Option<String>,
|
||||
pub entries: Vec<protocol::CompletionEntry>,
|
||||
#[serde(default)]
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
@@ -9364,6 +9601,10 @@ pub struct RuntimeWorkerWorkdirAttachmentSummary {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct WorkerSummary {
|
||||
/// Opaque Runtime-owned observation fence used by explicit Restore intent.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(optional = nullable))]
|
||||
pub restore_observation_token: Option<String>,
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
pub resource_key: String,
|
||||
@@ -9600,6 +9841,16 @@ pub enum WorkerRestoreState {
|
||||
ReconciliationRequired,
|
||||
}
|
||||
|
||||
/// One explicit Restore intent. Retries retain both fields; conflicts require a
|
||||
/// fresh observation and a deliberate new intent, not automatic token replacement.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkerRestoreRequest {
|
||||
pub expected_observation_token: String,
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -9861,9 +10112,11 @@ pub fn memory_api_typescript() -> String {
|
||||
// `ts-rs` solely for this read-only projection.
|
||||
let subjektiv_browser_declarations = r#"export type SubjektivSubjectState = "active" | "retired";
|
||||
|
||||
export type SubjektivSubjectCreateRequest = { role: string, };
|
||||
export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, };
|
||||
|
||||
export type SubjektivSubjectResponse = { id: string, role: string, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
|
||||
export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_revision: number, behavior_md: string, };
|
||||
|
||||
export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, behavior_revision: number, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
|
||||
|
||||
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
|
||||
|
||||
@@ -9896,8 +10149,11 @@ export type SubjektivMemoryListRevisionsResponse = { memory_id: string, current_
|
||||
let limits = format!(
|
||||
"export const MEMORY_API_LIMITS = {{\n maxResponseBytes: {MEMORY_API_MAX_RESPONSE_BYTES},\n maxDocumentBytes: {MEMORY_API_MAX_DOCUMENT_BYTES},\n maxCollectionItems: {MEMORY_API_MAX_COLLECTION_ITEMS},\n maxStringBytes: {MEMORY_API_MAX_STRING_BYTES},\n maxIdentifierBytes: {MEMORY_API_MAX_IDENTIFIER_BYTES},\n}} as const;"
|
||||
);
|
||||
let subjektiv_limits = format!(
|
||||
"export const SUBJEKTIV_API_LIMITS = {{\n maxBehaviorBytes: {SUBJEKTIV_MAX_BEHAVIOR_BYTES},\n}} as const;"
|
||||
);
|
||||
format!(
|
||||
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{limits}\n\n{}\n\n{subjektiv_browser_declarations}\n",
|
||||
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{limits}\n\n{subjektiv_limits}\n\n{}\n\n{subjektiv_browser_declarations}\n",
|
||||
declarations
|
||||
.into_iter()
|
||||
.map(|declaration| format!("export {declaration}"))
|
||||
@@ -10572,6 +10828,22 @@ pub fn legacy_catalog_typescript() -> String {
|
||||
ToolchainContract::decl(&config),
|
||||
WorkspaceConfigTreeResponse::decl(&config),
|
||||
ConfigCommitRequest::decl(&config),
|
||||
WorkspaceConfigAccess::decl(&config),
|
||||
WorkspaceConfigNodeKind::decl(&config),
|
||||
WorkspaceConfigFailureClassification::decl(&config),
|
||||
WorkspaceConfigAttachRequest::decl(&config),
|
||||
WorkspaceConfigAttachment::decl(&config),
|
||||
WorkspaceConfigCurrentResponse::decl(&config),
|
||||
WorkspaceConfigObserveRequest::decl(&config),
|
||||
WorkspaceConfigNode::decl(&config),
|
||||
WorkspaceConfigObserveResponse::decl(&config),
|
||||
WorkspaceConfigReadRequest::decl(&config),
|
||||
WorkspaceConfigReadResponse::decl(&config),
|
||||
WorkspaceConfigCommitRequest::decl(&config),
|
||||
WorkspaceConfigCommitResponse::decl(&config),
|
||||
WorkspaceConfigApiError::decl(&config),
|
||||
WorkspaceConfigGrantCreateRequest::decl(&config),
|
||||
WorkspaceConfigGrantResponse::decl(&config),
|
||||
ProfileSettingsResponse::decl(&config),
|
||||
WorkspaceProfileSummary::decl(&config),
|
||||
WorkspaceProfileSourceSummary::decl(&config),
|
||||
@@ -11443,6 +11715,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_restore_request_requires_observation_and_request_identity() {
|
||||
let valid = serde_json::json!({"expected_observation_token":"opaque-generation", "request_id":"restore-request"});
|
||||
let request: WorkerRestoreRequest = serde_json::from_value(valid.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(request).unwrap(), valid);
|
||||
for invalid in [
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({"request_id":"restore-request"}),
|
||||
serde_json::json!({"expected_observation_token":"opaque-generation"}),
|
||||
serde_json::json!({"expected_observation_token":"opaque-generation", "request_id":"restore-request", "command_id":701}),
|
||||
] {
|
||||
assert!(serde_json::from_value::<WorkerRestoreRequest>(invalid).is_err());
|
||||
}
|
||||
let contract = canonical_openapi_document().unwrap().to_json().unwrap();
|
||||
let contract: serde_json::Value = serde_json::from_str(&contract).unwrap();
|
||||
let restore = &contract["paths"]["/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/restore"]
|
||||
["post"];
|
||||
assert_eq!(restore["requestBody"]["required"], true);
|
||||
assert!(restore["responses"]["409"].is_object());
|
||||
assert!(contract["components"]["schemas"]["WorkerRestoreRequest"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_restore_result_rejects_unknown_fields() {
|
||||
let result = serde_json::from_value::<WorkerRestoreResult>(serde_json::json!({
|
||||
@@ -11930,6 +12224,135 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_worker_attachment_contract_keeps_normal_tool_response_closed() {
|
||||
let lookup = CurrentWorkerWorkdirAttachmentListQuery {
|
||||
limit: Some(1),
|
||||
offset: None,
|
||||
connection_id: Some("opaque-connection".to_string()),
|
||||
};
|
||||
let value = serde_json::json!({"limit": 1, "connection_id": "opaque-connection"});
|
||||
assert_eq!(serde_json::to_value(&lookup).unwrap(), value);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<CurrentWorkerWorkdirAttachmentListQuery>(value).unwrap(),
|
||||
lookup
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(CurrentWorkerWorkdirAttachmentListQuery::default()).unwrap(),
|
||||
serde_json::json!({})
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<CurrentWorkerWorkdirAttachmentListQuery>(
|
||||
serde_json::json!({"connection_id": 123})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<CurrentWorkerWorkdirAttachmentListQuery>(
|
||||
serde_json::json!({"expected_connection_id": "wrong-field"})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let query: CurrentWorkerWorkdirDetachQuery =
|
||||
serde_json::from_value(serde_json::json!({})).unwrap();
|
||||
assert_eq!(query.expected_connection_id, None);
|
||||
assert_eq!(serde_json::to_value(query).unwrap(), serde_json::json!({}));
|
||||
assert!(
|
||||
serde_json::from_value::<CurrentWorkerWorkdirDetachQuery>(
|
||||
serde_json::json!({"connection_id":"wrong-field"})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let response = CurrentWorkerWorkdirAttachmentResponse {
|
||||
workspace_id: "workspace".to_string(),
|
||||
alias: "checkout".to_string(),
|
||||
working_directory_id: "workdir".to_string(),
|
||||
capabilities: workdir::WorkdirSessionCapabilities::EMPTY,
|
||||
attached: false,
|
||||
};
|
||||
let value = serde_json::to_value(&response).unwrap();
|
||||
assert_eq!(value.as_object().unwrap().len(), 5);
|
||||
assert!(value.get("connection_id").is_none());
|
||||
assert_eq!(
|
||||
serde_json::from_value::<CurrentWorkerWorkdirAttachmentResponse>(value).unwrap(),
|
||||
response
|
||||
);
|
||||
let list = CurrentWorkerWorkdirAttachmentListResponse {
|
||||
workspace_id: "workspace".to_string(),
|
||||
revision: "opaque-revision".to_string(),
|
||||
next_offset: Some(1),
|
||||
items: vec![CurrentWorkerWorkdirAttachmentItem {
|
||||
alias: "checkout".to_string(),
|
||||
working_directory_id: "workdir".to_string(),
|
||||
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||
connection_id: "opaque".to_string(),
|
||||
}],
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::from_value::<CurrentWorkerWorkdirAttachmentListResponse>(
|
||||
serde_json::to_value(&list).unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
list
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_worker_workdir_catalog_contract_is_bounded_and_revision_is_required() {
|
||||
let query = CurrentWorkerWorkdirCatalogQuery {
|
||||
limit: Some(100),
|
||||
cursor: Some("stable-id".to_string()),
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::from_value::<CurrentWorkerWorkdirCatalogQuery>(
|
||||
serde_json::to_value(&query).unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
query
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(CurrentWorkerWorkdirCatalogQuery::default()).unwrap(),
|
||||
serde_json::json!({})
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<CurrentWorkerWorkdirCatalogQuery>(
|
||||
serde_json::json!({"offset": 0})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<CurrentWorkerWorkdirCatalogQuery>(
|
||||
serde_json::json!({"limit": -1})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let response = CurrentWorkerWorkdirCatalogResponse {
|
||||
workspace_id: "workspace".to_string(),
|
||||
items: Vec::new(),
|
||||
next_cursor: Some("scanned-legacy-row".to_string()),
|
||||
revision: "opaque".to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::from_value::<CurrentWorkerWorkdirCatalogResponse>(
|
||||
serde_json::to_value(&response).unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
response
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<CurrentWorkerWorkdirCatalogResponse>(
|
||||
serde_json::json!({"workspace_id": "workspace", "items": [], "next_cursor": null})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<CurrentWorkerWorkdirAttachmentListResponse>(
|
||||
serde_json::json!({"workspace_id": "workspace", "items": [], "next_offset": null})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_and_repository_response_shapes_round_trip() {
|
||||
let workspace = serde_json::json!({
|
||||
@@ -12661,6 +13084,45 @@ mod tests {
|
||||
assert!(serde_json::from_value::<CompanionTranscriptItem>(private_item).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_invocation_input_round_trips_through_typed_api() {
|
||||
let json = serde_json::json!({"kind":"user", "content":"", "segments":[{"kind":"feature_invoke", "invocation":{
|
||||
"invocation_id":"stable-invoke", "identity":"builtin:test/prepare", "name":"prepare",
|
||||
"arguments":[{"name":"path", "value":{"kind":"string","value":"資料/a b"}}]
|
||||
}}]});
|
||||
let request: RuntimeWorkerInputRequest = serde_json::from_value(json.clone()).unwrap();
|
||||
assert!(
|
||||
matches!(&request.segments.as_ref().unwrap()[0], protocol::Segment::FeatureInvoke { invocation }
|
||||
if invocation.identity.0 == "builtin:test/prepare" && invocation.invocation_id == "stable-invoke")
|
||||
);
|
||||
assert_eq!(serde_json::to_value(request).unwrap(), json);
|
||||
let mut invalid = json;
|
||||
invalid["segments"][0]["invocation"]["arguments"][0]["value"] =
|
||||
serde_json::json!({"kind":"integer","value":2147483648_i64});
|
||||
assert!(serde_json::from_value::<RuntimeWorkerInputRequest>(invalid).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_completion_contract_is_typed_and_preserves_argument_context() {
|
||||
let json = serde_json::json!({"kind":"feature_argument", "prefix":"資料/", "context":{"invocation":"builtin:test/prepare","argument":"path"}});
|
||||
let request: RuntimeWorkerCompletionsRequest =
|
||||
serde_json::from_value(json.clone()).unwrap();
|
||||
assert_eq!(request.kind, protocol::CompletionKind::FeatureArgument);
|
||||
assert_eq!(serde_json::to_value(request).unwrap(), json);
|
||||
assert!(
|
||||
serde_json::from_value::<RuntimeWorkerCompletionsRequest>(
|
||||
serde_json::json!({"kind":"unknown"})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<RuntimeWorkerCompletionsRequest>(
|
||||
serde_json::json!({"kind":"feature_argument", "context":{"invocation":123}})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_companion_api_contract_is_current() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
@@ -12895,6 +13357,13 @@ mod openapi_artifact_tests {
|
||||
// identity, or a one-use Reviewer capability. They remain generated Rust client/Axum
|
||||
// operations, but must not appear as unauthenticated operations in the public OpenAPI.
|
||||
const SIGNED_INTERNAL: &[&str] = &[
|
||||
"current_worker_workspace_config_get",
|
||||
"current_worker_workspace_config_attach",
|
||||
"current_worker_workspace_config_observe",
|
||||
"current_worker_workspace_config_read",
|
||||
"current_worker_workspace_config_commit",
|
||||
"current_worker_workdir_catalog",
|
||||
"current_worker_workdir_attachment_list",
|
||||
"current_worker_workdir_attach",
|
||||
"current_worker_workdir_detach",
|
||||
"current_worker_workdir_operation",
|
||||
|
||||
@@ -872,14 +872,14 @@ fn validate_runtime_operations(
|
||||
"/api/runtimes/{runtime_id}/workers/{worker_id}/restore",
|
||||
"post",
|
||||
"runtime_worker_restore_alias",
|
||||
None,
|
||||
Some("WorkerRestoreRequest"),
|
||||
&[("200", "WorkerRestoreResponse")],
|
||||
),
|
||||
runtime_operation(
|
||||
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/restore",
|
||||
"post",
|
||||
"runtime_worker_restore",
|
||||
None,
|
||||
Some("WorkerRestoreRequest"),
|
||||
&[("200", "WorkerRestoreResponse")],
|
||||
),
|
||||
runtime_operation(
|
||||
@@ -1227,8 +1227,16 @@ fn json_content_schema<'a>(
|
||||
}
|
||||
|
||||
fn schema_reference<'a>(value: &'a Value, context: &str) -> Result<&'a str, GenerationError> {
|
||||
const ALLOWED_REFERENCE_SIBLINGS: &[&str] =
|
||||
&["$ref", "$comment", "deprecated", "description", "title"];
|
||||
// `default` is a JSON Schema annotation, not a constraint or a replacement
|
||||
// for the referenced type. Schemars emits it for defaulted enum fields.
|
||||
const ALLOWED_REFERENCE_SIBLINGS: &[&str] = &[
|
||||
"$ref",
|
||||
"$comment",
|
||||
"default",
|
||||
"deprecated",
|
||||
"description",
|
||||
"title",
|
||||
];
|
||||
let schema = object(value, context)?;
|
||||
if let Some(keyword) = schema
|
||||
.keys()
|
||||
@@ -2023,6 +2031,27 @@ mod tests {
|
||||
assert!(error.to_string().contains("ambiguous anyOf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_default_annotations_do_not_replace_the_referenced_type() {
|
||||
let value = serde_json::json!({"$ref":"#/components/schemas/InvocationCompletion", "default":{"kind":"none"}});
|
||||
assert_eq!(
|
||||
schema_reference(&value, "completion").unwrap(),
|
||||
"#/components/schemas/InvocationCompletion"
|
||||
);
|
||||
let output = generate_runtime_typescript(
|
||||
&crate::canonical_openapi_document()
|
||||
.unwrap()
|
||||
.to_json()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(output.contains("completion?: InvocationCompletion"));
|
||||
assert!(output.contains("export type FeatureInvocation ="));
|
||||
assert!(output.contains(
|
||||
"export type CompletionKind = \"file\" | \"feature\" | \"feature_argument\""
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generator_rejects_lossy_reference_siblings() {
|
||||
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Grant-bound logical Workspace configuration transport. No host filesystem contract.
|
||||
use super::{ConfigCommitRequest, ConfigContentType};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkspaceConfigAccess {
|
||||
ReadOnly,
|
||||
ReadWrite,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkspaceConfigNodeKind {
|
||||
Directory,
|
||||
File,
|
||||
Missing,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkspaceConfigFailureClassification {
|
||||
NotCommitted,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigAttachRequest {
|
||||
#[serde(default)]
|
||||
pub alias: Option<String>,
|
||||
#[serde(default)]
|
||||
pub access: Option<WorkspaceConfigAccess>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigAttachment {
|
||||
pub workspace_id: String,
|
||||
pub connection_id: String,
|
||||
pub alias: String,
|
||||
pub working_directory_id: String,
|
||||
pub access: WorkspaceConfigAccess,
|
||||
pub name: String,
|
||||
pub purpose: String,
|
||||
pub content_path: String,
|
||||
pub already_attached: bool,
|
||||
}
|
||||
/// Named transport wrapper required by the generated API; JSON remains attachment-or-null.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(transparent)]
|
||||
pub struct WorkspaceConfigCurrentResponse(pub Option<WorkspaceConfigAttachment>);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigObserveRequest {
|
||||
pub connection_id: String,
|
||||
pub paths: Vec<String>,
|
||||
pub depth: u32,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigNode {
|
||||
pub path: String,
|
||||
pub kind: WorkspaceConfigNodeKind,
|
||||
pub validator: String,
|
||||
pub digest: Option<String>,
|
||||
pub content_type: Option<ConfigContentType>,
|
||||
pub operations: Vec<String>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigObserveResponse {
|
||||
pub connection_id: String,
|
||||
pub validator: String,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub revision: u64,
|
||||
pub digest: String,
|
||||
/// Canonical commit entrypoints from this exact metadata snapshot.
|
||||
pub entrypoints: Vec<String>,
|
||||
pub nodes: Vec<WorkspaceConfigNode>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigReadRequest {
|
||||
pub connection_id: String,
|
||||
pub path: String,
|
||||
pub validator: String,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigReadResponse {
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
pub content_type: ConfigContentType,
|
||||
pub digest: String,
|
||||
pub validator: String,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigCommitRequest {
|
||||
pub connection_id: String,
|
||||
pub validator: String,
|
||||
pub request: ConfigCommitRequest,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigCommitResponse {
|
||||
pub validator: String,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub revision: u64,
|
||||
pub digest: String,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigApiError {
|
||||
pub status: u16,
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
pub classification: WorkspaceConfigFailureClassification,
|
||||
}
|
||||
impl api_macros::HttpError for WorkspaceConfigApiError {
|
||||
fn status_code(&self) -> u16 {
|
||||
self.status
|
||||
}
|
||||
}
|
||||
impl api_macros::HttpRequestError for WorkspaceConfigApiError {
|
||||
fn from_request_rejection(status: u16, _message: String) -> Self {
|
||||
Self {
|
||||
status,
|
||||
code: "invalid_request".into(),
|
||||
message: "Invalid configuration request".into(),
|
||||
classification: WorkspaceConfigFailureClassification::NotCommitted,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigGrantCreateRequest {
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
pub access: WorkspaceConfigAccess,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigGrantResponse {
|
||||
pub grant_id: String,
|
||||
pub workspace_id: String,
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
pub working_directory_id: String,
|
||||
pub access: WorkspaceConfigAccess,
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{ApiContract, HttpMethod, ServerApiMetadata};
|
||||
|
||||
#[test]
|
||||
fn workspace_config_observation_is_metadata_only_and_has_same_snapshot_cas_inputs() {
|
||||
let value = serde_json::json!({"connection_id":"c", "validator":"v", "revision":7, "digest":"d", "entrypoints":["main.dcdl"], "nodes":[{"path":"", "kind":"directory", "validator":"v", "digest":null, "content_type":null, "operations":["apply_changes"]}]});
|
||||
let response: WorkspaceConfigObserveResponse =
|
||||
serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(response).unwrap(), value);
|
||||
for field in ["revision", "digest", "entrypoints"] {
|
||||
let mut missing = value.clone();
|
||||
missing.as_object_mut().unwrap().remove(field);
|
||||
assert!(serde_json::from_value::<WorkspaceConfigObserveResponse>(missing).is_err());
|
||||
}
|
||||
let mut with_body = value;
|
||||
with_body["nodes"][0]["content"] = serde_json::json!("must never appear here");
|
||||
assert!(serde_json::from_value::<WorkspaceConfigObserveResponse>(with_body).is_err());
|
||||
assert!(
|
||||
serde_json::from_value::<WorkspaceConfigGrantCreateRequest>(
|
||||
serde_json::json!({"runtime_id":"r","worker_id":"w","access":"read_write_command"})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<WorkspaceConfigAttachRequest>(
|
||||
serde_json::json!({"grant_id":"invented"})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(WorkspaceConfigCurrentResponse(None)).unwrap(),
|
||||
serde_json::Value::Null
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_config_failure_classification_roundtrips_and_rejections_are_pre_effects() {
|
||||
for classification in [
|
||||
WorkspaceConfigFailureClassification::NotCommitted,
|
||||
WorkspaceConfigFailureClassification::Unknown,
|
||||
] {
|
||||
let error = WorkspaceConfigApiError {
|
||||
status: 500,
|
||||
code: "unavailable".into(),
|
||||
message: "safe".into(),
|
||||
classification,
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::from_value::<WorkspaceConfigApiError>(
|
||||
serde_json::to_value(&error).unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
error
|
||||
);
|
||||
assert_eq!(api_macros::HttpError::status_code(&error), 500);
|
||||
}
|
||||
let error =
|
||||
<WorkspaceConfigApiError as api_macros::HttpRequestError>::from_request_rejection(
|
||||
413,
|
||||
"/private/secret".into(),
|
||||
);
|
||||
assert_eq!(
|
||||
error.classification,
|
||||
WorkspaceConfigFailureClassification::NotCommitted
|
||||
);
|
||||
assert!(!error.message.contains("private"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_config_generated_routes_are_exact_and_grants_are_separate() {
|
||||
for (name, method, suffix) in [
|
||||
("get", HttpMethod::Get, ""),
|
||||
("attach", HttpMethod::Post, ""),
|
||||
("observe", HttpMethod::Post, "/observe"),
|
||||
("read", HttpMethod::Post, "/read"),
|
||||
("commit", HttpMethod::Post, "/commit"),
|
||||
] {
|
||||
let name = format!("current_worker_workspace_config_{name}");
|
||||
let operation = ServerApiMetadata::OPERATIONS
|
||||
.iter()
|
||||
.find(|operation| operation.operation_id == name)
|
||||
.unwrap();
|
||||
assert_eq!(operation.method, method);
|
||||
assert_eq!(
|
||||
operation.path,
|
||||
format!("/api/w/{{workspace_id}}/workers/self/workspace-config{suffix}")
|
||||
);
|
||||
}
|
||||
for (name, method, path) in [
|
||||
(
|
||||
"workspace_config_grant_create",
|
||||
HttpMethod::Post,
|
||||
"/api/w/{workspace_id}/workspace-config-grants",
|
||||
),
|
||||
(
|
||||
"workspace_config_grant_revoke",
|
||||
HttpMethod::Delete,
|
||||
"/api/w/{workspace_id}/workspace-config-grants/{grant_id}",
|
||||
),
|
||||
] {
|
||||
let operation = ServerApiMetadata::OPERATIONS
|
||||
.iter()
|
||||
.find(|operation| operation.operation_id == name)
|
||||
.unwrap();
|
||||
assert_eq!(operation.method, method);
|
||||
assert_eq!(operation.path, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -383,8 +383,11 @@ impl Store for FsStore {
|
||||
content.push_str(&serde_json::to_string(entry)?);
|
||||
content.push('\n');
|
||||
}
|
||||
fs::write(&path, content.as_bytes())?;
|
||||
Ok(())
|
||||
let _guard = self
|
||||
.append_lock
|
||||
.lock()
|
||||
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
|
||||
crate::worker_session_store::atomic_write_bytes(&path, content.as_bytes())
|
||||
}
|
||||
|
||||
fn exists(&self, session_id: SessionId, segment_id: SegmentId) -> Result<bool, StoreError> {
|
||||
|
||||
@@ -110,7 +110,14 @@ pub trait Store: Send + Sync {
|
||||
/// segment ID without its session ID (e.g. legacy `--session <UUID>`).
|
||||
fn lookup_session_of(&self, segment_id: SegmentId) -> Result<Option<SessionId>, StoreError>;
|
||||
|
||||
/// Create a new segment within `session_id`, with initial entries.
|
||||
/// Create or atomically replace a segment within `session_id` with all entries.
|
||||
///
|
||||
/// Readers and crash recovery must observe either the previous complete log
|
||||
/// or the complete replacement, never a truncated prefix or partial record.
|
||||
/// Failure before replacement leaves the old log intact; an error after the
|
||||
/// atomic commit (for example a durability sync failure) may leave the complete
|
||||
/// replacement. Filesystem implementations must sync replacement bytes before
|
||||
/// rename and the containing directory afterward before reporting success.
|
||||
fn create_segment(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
|
||||
@@ -123,6 +123,14 @@ pub enum SystemItem {
|
||||
prompt_provenance: Option<PromptRenderProvenance>,
|
||||
},
|
||||
|
||||
/// Result of an explicitly selected chat Feature invocation. The typed
|
||||
/// result is retained for clients; `body` is the exact bounded context made
|
||||
/// visible to the Worker model.
|
||||
FeatureInvocationResult {
|
||||
result: protocol::FeatureInvocationResult,
|
||||
body: String,
|
||||
},
|
||||
|
||||
/// `@<path>` file reference resolution. `body` is the rendered
|
||||
/// LLM-context text (`[File: <path>]\n…` for regular files,
|
||||
/// `[Dir: <path>]\n…` for directory listings, possibly with a
|
||||
@@ -167,6 +175,15 @@ pub enum SystemItem {
|
||||
prompt_provenance: Option<PromptRenderProvenance>,
|
||||
},
|
||||
|
||||
/// A Host-fetched change to user-managed Subject behavior. Each changed
|
||||
/// revision is appended rather than rewriting prior prompt/session content.
|
||||
SubjectBehaviorRefresh {
|
||||
body: String,
|
||||
behavior_revision: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
prompt_provenance: Option<PromptRenderProvenance>,
|
||||
},
|
||||
|
||||
/// Synthetic note inserted after an interrupted turn before the next
|
||||
/// user input. `body` is the exact LLM-context text explaining that the
|
||||
/// previous turn was cut short.
|
||||
@@ -184,6 +201,7 @@ impl SystemItem {
|
||||
match self {
|
||||
SystemItem::Notification { body, .. } => body.clone(),
|
||||
SystemItem::WorkerEvent { body, .. } => body.clone(),
|
||||
SystemItem::FeatureInvocationResult { body, .. } => body.clone(),
|
||||
SystemItem::FileAttachment { body, .. } => body.clone(),
|
||||
SystemItem::SkillActivation { body, .. } => body.clone(),
|
||||
SystemItem::LegacyKnowledgeIgnored { .. } => String::new(),
|
||||
@@ -192,6 +210,7 @@ impl SystemItem {
|
||||
}
|
||||
SystemItem::TaskReminder { body, .. } => body.clone(),
|
||||
SystemItem::ResidentSummaryRefresh { body, .. } => body.clone(),
|
||||
SystemItem::SubjectBehaviorRefresh { body, .. } => body.clone(),
|
||||
SystemItem::Interrupt { body, .. } => body.clone(),
|
||||
}
|
||||
}
|
||||
@@ -208,12 +227,14 @@ impl SystemItem {
|
||||
match self {
|
||||
SystemItem::Notification { .. } => "notification",
|
||||
SystemItem::WorkerEvent { .. } => "worker_event",
|
||||
SystemItem::FeatureInvocationResult { .. } => "feature_invocation_result",
|
||||
SystemItem::FileAttachment { .. } => "file_attachment",
|
||||
SystemItem::SkillActivation { .. } => "skill_activation",
|
||||
SystemItem::LegacyKnowledgeIgnored { .. } => "legacy_knowledge_ignored",
|
||||
SystemItem::LegacyIgnored { .. } => "legacy_ignored",
|
||||
SystemItem::TaskReminder { .. } => "task_reminder",
|
||||
SystemItem::ResidentSummaryRefresh { .. } => "resident_summary_refresh",
|
||||
SystemItem::SubjectBehaviorRefresh { .. } => "subject_behavior_refresh",
|
||||
SystemItem::Interrupt { .. } => "interrupt",
|
||||
}
|
||||
}
|
||||
@@ -270,6 +291,25 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_behavior_refresh_round_trips_with_revision_and_exact_body() {
|
||||
let item = SystemItem::SubjectBehaviorRefresh {
|
||||
body: "current Subject context".to_string(),
|
||||
behavior_revision: 7,
|
||||
prompt_provenance: None,
|
||||
};
|
||||
let raw = serde_json::to_string(&item).unwrap();
|
||||
let parsed: SystemItem = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(parsed.history_text(), "current Subject context");
|
||||
assert!(matches!(
|
||||
parsed,
|
||||
SystemItem::SubjectBehaviorRefresh {
|
||||
behavior_revision: 7,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_history_text_returns_stored_body() {
|
||||
let item = SystemItem::Notification {
|
||||
@@ -363,6 +403,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invocation_result_retains_typed_outcome_and_exact_context() {
|
||||
let result = protocol::FeatureInvocationResult {
|
||||
invocation_id: "stable-invoke".into(),
|
||||
identity: protocol::FeatureInvocationIdentity("builtin:test/prepare".into()),
|
||||
status: protocol::FeatureInvocationStatus::OutcomeUnknown,
|
||||
message: "operation may have committed".into(),
|
||||
context: None,
|
||||
};
|
||||
let body = "Outcome unknown; do not retry automatically";
|
||||
let item = SystemItem::FeatureInvocationResult {
|
||||
result: result.clone(),
|
||||
body: body.into(),
|
||||
};
|
||||
let restored: SystemItem =
|
||||
serde_json::from_str(&serde_json::to_string(&item).unwrap()).unwrap();
|
||||
assert_eq!(restored.history_text(), body);
|
||||
assert_eq!(restored.kind_label(), "feature_invocation_result");
|
||||
assert!(
|
||||
matches!(restored, SystemItem::FeatureInvocationResult { result: saved, .. } if saved == result)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_worker_event() {
|
||||
let item = SystemItem::WorkerEvent {
|
||||
|
||||
@@ -1166,7 +1166,7 @@ fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), StoreEr
|
||||
atomic_write_bytes(path, &bytes)
|
||||
}
|
||||
|
||||
fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> Result<(), StoreError> {
|
||||
pub(crate) fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> Result<(), StoreError> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| std::io::Error::other("Worker Session path has no parent"))?;
|
||||
|
||||
@@ -115,6 +115,100 @@ fn create_segment_writes_all_entries() {
|
||||
assert_eq!(state.session_id, Some(sid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_segment_replacement_keeps_open_old_log_and_commits_complete_new_log() {
|
||||
use std::io::Read;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = FsStore::new(dir.path()).unwrap();
|
||||
let sid = new_session_id();
|
||||
let segid = new_segment_id();
|
||||
let old = vec![
|
||||
nil_session_start(1, sid),
|
||||
LogEntry::TurnEnd {
|
||||
ts: 2,
|
||||
turn_count: 1,
|
||||
},
|
||||
];
|
||||
store.create_segment(sid, segid, &old).unwrap();
|
||||
let path = dir
|
||||
.path()
|
||||
.join(sid.to_string())
|
||||
.join(format!("{segid}.jsonl"));
|
||||
let old_bytes = std::fs::read(&path).unwrap();
|
||||
let mut old_reader = std::fs::File::open(&path).unwrap();
|
||||
let new = vec![
|
||||
nil_session_start(1, sid),
|
||||
LogEntry::Extension {
|
||||
ts: 3,
|
||||
domain: "recovery-checkpoint".into(),
|
||||
payload: serde_json::json!({"receipt": "retained"}),
|
||||
},
|
||||
];
|
||||
store.create_segment(sid, segid, &new).unwrap();
|
||||
let mut retained_inode = Vec::new();
|
||||
old_reader.read_to_end(&mut retained_inode).unwrap();
|
||||
// An in-place fs::write would truncate the inode held by this reader.
|
||||
assert_eq!(retained_inode, old_bytes);
|
||||
let reopened = FsStore::new(dir.path()).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(reopened.read_all(sid, segid).unwrap()).unwrap(),
|
||||
serde_json::to_value(&new).unwrap()
|
||||
);
|
||||
assert_eq!(reopened.read_entry_count(sid, segid).unwrap(), new.len());
|
||||
assert_eq!(
|
||||
std::fs::read_dir(path.parent().unwrap()).unwrap().count(),
|
||||
1,
|
||||
"successful replacement must not leave temporary files"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_segment_atomic_replacement_readers_only_observe_complete_old_or_new() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = FsStore::new(dir.path()).unwrap();
|
||||
let sid = new_session_id();
|
||||
let segid = new_segment_id();
|
||||
let old = vec![
|
||||
nil_session_start(1, sid),
|
||||
LogEntry::Extension {
|
||||
ts: 2,
|
||||
domain: "old-evidence".into(),
|
||||
payload: serde_json::json!({"result": "old"}),
|
||||
},
|
||||
];
|
||||
let new = vec![
|
||||
nil_session_start(1, sid),
|
||||
LogEntry::Extension {
|
||||
ts: 3,
|
||||
domain: "new-checkpoint".into(),
|
||||
payload: serde_json::json!({"result": "new"}),
|
||||
},
|
||||
];
|
||||
store.create_segment(sid, segid, &old).unwrap();
|
||||
let old_json = serde_json::to_value(&old).unwrap();
|
||||
let new_json = serde_json::to_value(&new).unwrap();
|
||||
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
|
||||
let reader_store = store.clone();
|
||||
let reader_barrier = barrier.clone();
|
||||
let reader = std::thread::spawn(move || {
|
||||
reader_barrier.wait();
|
||||
for _ in 0..1000 {
|
||||
let observed =
|
||||
serde_json::to_value(reader_store.read_all(sid, segid).unwrap()).unwrap();
|
||||
assert!(
|
||||
observed == old_json || observed == new_json,
|
||||
"replacement must never expose a partial log: {observed}"
|
||||
);
|
||||
}
|
||||
});
|
||||
barrier.wait();
|
||||
for _ in 0..50 {
|
||||
store.create_segment(sid, segid, &new).unwrap();
|
||||
store.create_segment(sid, segid, &old).unwrap();
|
||||
}
|
||||
reader.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_sessions_and_segments() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -606,6 +606,70 @@ fn rewound_fork_preserves_uploaded_file_segments_in_snapshot() {
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_invocations_survive_replay_snapshot_and_rewind_without_reparsing() {
|
||||
let (_dir, store) = make_store();
|
||||
let config = RequestConfig::default();
|
||||
let (sid, segid) = session_store::create_segment(
|
||||
&store,
|
||||
SegmentStartState {
|
||||
system_prompt: Some("System prompt"),
|
||||
config: &config,
|
||||
history: Vec::new(),
|
||||
user_segments: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let segments = vec![
|
||||
Segment::Text {
|
||||
content: "before ".into(),
|
||||
},
|
||||
Segment::FeatureInvoke {
|
||||
invocation: protocol::FeatureInvocation {
|
||||
invocation_id: "stable-request-1".into(),
|
||||
identity: protocol::FeatureInvocationIdentity("builtin:test/prepare".into()),
|
||||
name: "prepare".into(),
|
||||
arguments: vec![protocol::InvocationArgumentValue {
|
||||
name: "path".into(),
|
||||
value: protocol::InvocationValue::String("資料/a b".into()),
|
||||
}],
|
||||
},
|
||||
},
|
||||
Segment::Text {
|
||||
content: " after /prepare(unselected)".into(),
|
||||
},
|
||||
];
|
||||
session_store::save_user_input(
|
||||
&store,
|
||||
sid,
|
||||
segid,
|
||||
segments.clone(),
|
||||
annotated(&[Item::user_message(Segment::flatten_to_text(&segments))]),
|
||||
)
|
||||
.unwrap();
|
||||
session_store::save_turn_end(&store, sid, segid, 1).unwrap();
|
||||
let entries = store.read_all(sid, segid).unwrap();
|
||||
assert_eq!(
|
||||
collect_state(&entries).user_segments,
|
||||
vec![segments.clone()]
|
||||
);
|
||||
let fork_id = session_store::fork_at(&store, sid, segid, 1).unwrap();
|
||||
let fork_entries = store.read_all(sid, fork_id).unwrap();
|
||||
assert_eq!(
|
||||
collect_state(&fork_entries).user_segments,
|
||||
vec![segments.clone()]
|
||||
);
|
||||
let snapshot = session_store::public_snapshot::project_session_snapshot(sid, &fork_entries);
|
||||
assert!(snapshot.entries.iter().any(|entry| matches!(
|
||||
&entry.data, SessionSnapshotEntryData::UserInput { segments: restored } if restored == &segments
|
||||
)));
|
||||
// Original history remains append-only and retains the same exact typed intent.
|
||||
assert_eq!(
|
||||
serde_json::to_value(store.read_all(sid, segid).unwrap()).unwrap(),
|
||||
serde_json::to_value(entries).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_config_changed_logged() {
|
||||
let (_dir, store) = make_store();
|
||||
|
||||
@@ -24,6 +24,59 @@ Does not own:
|
||||
|
||||
A tool implementation must assume model input is untrusted. Permission policy, scope checks, output bounding, and redaction are part of the safety boundary, not optional UI behavior.
|
||||
|
||||
## Checkout-native processing
|
||||
|
||||
`execute_checkout_tool` (also exported through `tools::checkout`) accepts the
|
||||
original `WorkdirSessionRouter`, shared `Tracker`, route-bound alias/generation,
|
||||
logical target and provider validator. It does not create an attachment or router.
|
||||
The Host owns native publication and permission identity; provider scope and
|
||||
capabilities remain authoritative. `Create` uses the `Write` capability/identity.
|
||||
|
||||
| Operation | Arguments (only these keys accepted) | Target |
|
||||
| --- | --- | --- |
|
||||
| Read | `offset?`, `limit?` | Existing file; same numbered lines, defaults and byte bound as Tool Read |
|
||||
| Edit | `old_string`, `new_string`, `replace_all?` | Existing file; prior read/hash and provider validator required |
|
||||
| Write | `content` | Existing file only; prior read/hash and provider validator required |
|
||||
| Create | `path`, `content` | Bound parent directory; relative destination strictly below it; create-new, missing parents allowed |
|
||||
| Glob | `pattern`, `path?` | Bound directory; optional path relative beneath it |
|
||||
| Grep | `pattern`, `path?`, `glob?`, `type?`, `case_insensitive?`, `-B?`, `-A?`, `-C?`, `multiline?`, `output_mode?`, `head_limit?`, `offset?` | Bound directory; same provider search/options/rendering as Tool Grep |
|
||||
|
||||
Native arguments cannot override `file_path`, `target_workdir`, generation or
|
||||
validator. Absolute/parent-traversing directory-relative paths are rejected.
|
||||
Checkout file calls share Tool processing through an optional checked `FileTarget`
|
||||
and use `checkout_execute`, never a separate Worker-side filesystem engine.
|
||||
Read records the provider's full content hash **and total line count**, including
|
||||
partial reads, in the original alias/generation namespace. Normal and native
|
||||
observations/mutations interoperate. Only successful mutations update tracking.
|
||||
|
||||
Glob/Grep share normal request defaults/options and rendering through `SearchTarget`.
|
||||
Only native dispatch uses `CheckoutSearchRequest::new` and `checkout_search`.
|
||||
The constructor's empty scope layers/root output are refined by provider wrappers;
|
||||
Tools does not synthesize authority or parse/rebase rendered filenames. Ordinary
|
||||
Tool dispatch remains `session.glob`/`session.grep`, with its existing error mapping.
|
||||
|
||||
`CheckoutToolOutput.paths` contains typed checkout-root-relative search paths
|
||||
(from `GlobResult.paths` / `GrepResult.paths`) and the created path for Create.
|
||||
Rendered Grep text is never parsed to construct links. The optional validator is
|
||||
the provider's returned observation validator, unchanged. Create's result
|
||||
observation describes the bound parent target, while returned paths and tracker
|
||||
updates describe the destination. Search checks the bound observation before and
|
||||
after the bounded checkout provider search, but does **not** guarantee an atomic tree snapshot
|
||||
or prevent external edits to descendants. File validator/hash checks are inside
|
||||
the provider's checked execution boundary. Checked native provider conflicts and
|
||||
native generation/search fences use `ToolError::StructuredConflict` with code
|
||||
`checkout_stale`; ordinary Tool conflicts retain `ExecutionFailed`. Checked-only
|
||||
known provider refusals use `checkout_denied` (scope/capability/read-only policy),
|
||||
`checkout_invalid` (invalid arguments/paths/target kind/search syntax), or
|
||||
`checkout_unavailable` (closed session/unavailable capacity). A disappeared bound
|
||||
target is `checkout_stale`. These codes preserve known no-effect rejection for the
|
||||
Host; ordinary Tool error mappings are unchanged. Ambiguous transport/IO and
|
||||
operation failures remain `ExecutionFailed`, as does explicit provider
|
||||
`OutcomeUnknown`; a failure after mutation must not
|
||||
be automatically retried. A malformed post-mutation provider result is likewise
|
||||
reported explicitly as outcome unknown. Explicit mismatched Read/Glob/Grep results
|
||||
are readonly failures classified as `checkout_unavailable`, not stale or unknown.
|
||||
|
||||
## See also
|
||||
|
||||
- [`../../docs/design/tool-permissions-scope.md`](../../docs/design/tool-permissions-scope.md)
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
//! Native checkout entrypoint using the same processing as ordinary filesystem Tools.
|
||||
//!
|
||||
//! The Host binds alias, generation, path and validator from the checkout route; none
|
||||
//! can be overridden by Operation arguments. File reads/mutations dispatch through
|
||||
//! the provider's checked execution boundary. Search shares normal request and
|
||||
//! rendering processing but dispatches through checkout_search so provider scope
|
||||
//! layers and output rebasing are preserved; checks bracket the bound directory
|
||||
//! observation, not an atomic snapshot of the searched tree.
|
||||
use std::sync::Arc;
|
||||
|
||||
use agen::tool::{ToolError, ToolExecutionContext, ToolOutput};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
use workdir::{WorkdirPath, WorkdirSessionCapability, WorkdirSessionRouter};
|
||||
|
||||
use crate::{ToolsError, Tracker, file_target::FileTarget};
|
||||
|
||||
pub struct CheckoutToolOutput {
|
||||
pub output: ToolOutput,
|
||||
pub paths: Vec<WorkdirPath>,
|
||||
pub validator: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Execute an exact named native Operation on a route-bound checkout target.
|
||||
/// `Create.path` and search `path` are relative to the bound directory; returned
|
||||
/// paths are typed checkout-root-relative provider paths, never parsed output.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn execute_checkout_tool(
|
||||
router: Arc<WorkdirSessionRouter>,
|
||||
tracker: Tracker,
|
||||
alias: &str,
|
||||
generation: u64,
|
||||
path: WorkdirPath,
|
||||
validator: Vec<u8>,
|
||||
tool_name: &str,
|
||||
arguments: Value,
|
||||
ctx: ToolExecutionContext,
|
||||
) -> Result<CheckoutToolOutput, ToolError> {
|
||||
// Native file arguments come from the same definitions used by ordinary
|
||||
// Tools and both WIP projections. Route fields are intentionally not in them.
|
||||
let file_schema = fs_operation::text::TextOperation::from_name(&tool_name.to_lowercase(), true)
|
||||
.map(|operation| operation.argument_schema());
|
||||
let file_fields: Vec<&str> = file_schema
|
||||
.as_ref()
|
||||
.and_then(|schema| schema.as_value()["properties"].as_object())
|
||||
.map(|properties| properties.keys().map(String::as_str).collect())
|
||||
.unwrap_or_default();
|
||||
let (capability, allowed): (_, &[&str]) = match tool_name {
|
||||
"Read" => (WorkdirSessionCapability::Read, &file_fields),
|
||||
"Edit" => (WorkdirSessionCapability::Edit, &file_fields),
|
||||
"Write" => (WorkdirSessionCapability::Write, &file_fields),
|
||||
"Create" => (WorkdirSessionCapability::Write, &["path", "content"]),
|
||||
"Glob" => (WorkdirSessionCapability::Glob, &["pattern", "path"]),
|
||||
"Grep" => (
|
||||
WorkdirSessionCapability::Grep,
|
||||
&[
|
||||
"pattern",
|
||||
"path",
|
||||
"glob",
|
||||
"type",
|
||||
"case_insensitive",
|
||||
"-B",
|
||||
"-A",
|
||||
"-C",
|
||||
"multiline",
|
||||
"output_mode",
|
||||
"head_limit",
|
||||
"offset",
|
||||
],
|
||||
),
|
||||
_ => {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"unknown checkout Operation: {tool_name}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let mut arguments = arguments
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or_else(|| ToolError::InvalidArgument("checkout arguments must be an object".into()))?;
|
||||
if let Some(key) = arguments
|
||||
.keys()
|
||||
.find(|key| !allowed.contains(&key.as_str()))
|
||||
{
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"unknown or route override checkout argument: {key}"
|
||||
)));
|
||||
}
|
||||
// Do not rebuild a singleton router: this is the original router identity and
|
||||
// tracker namespace shared with normal Tool calls and its detach/reuse fence.
|
||||
let selected = crate::routing::resolve_session(&router, Some(alias), capability)?;
|
||||
if selected.generation != generation {
|
||||
return Err(stale_checkout(
|
||||
"checkout attachment generation changed; resolve the checkout again",
|
||||
));
|
||||
}
|
||||
// Checkout paths are always logical and cannot use Tool-mode absolute scope.
|
||||
let path = WorkdirPath::new(path.as_str()).map_err(ToolsError::from)?;
|
||||
let tracker = tracker.scoped_attachment(&selected.alias, selected.generation);
|
||||
let target = FileTarget {
|
||||
session: selected.session.clone(),
|
||||
path: path.clone(),
|
||||
validator: Some(validator.clone()),
|
||||
};
|
||||
match tool_name {
|
||||
"Read" | "Edit" | "Write" => {
|
||||
arguments.insert("file_path".into(), Value::String(path.to_string()));
|
||||
let arguments = Value::Object(arguments);
|
||||
match tool_name {
|
||||
"Read" => {
|
||||
crate::read::execute_read(target, tracker, decode(arguments, tool_name)?).await
|
||||
}
|
||||
"Edit" => {
|
||||
crate::edit::execute_edit(target, tracker, decode(arguments, tool_name)?, ctx)
|
||||
.await
|
||||
}
|
||||
_ => {
|
||||
let params: crate::write::WriteParams = decode(arguments, tool_name)?;
|
||||
crate::write::execute_write(target, tracker, params.write.content, None, ctx)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
"Create" => {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CreateParams {
|
||||
path: String,
|
||||
content: String,
|
||||
}
|
||||
let params: CreateParams = decode(Value::Object(arguments), tool_name)?;
|
||||
let destination = relative_target(&path, ¶ms.path, true)?;
|
||||
crate::write::execute_write(target, tracker, params.content, Some(destination), ctx)
|
||||
.await
|
||||
}
|
||||
"Glob" | "Grep" => {
|
||||
let before = selected
|
||||
.session
|
||||
.checkout_observe(path.clone())
|
||||
.await
|
||||
.map_err(crate::file_target::checked_error)?;
|
||||
if before.validator != validator {
|
||||
return Err(stale_search());
|
||||
}
|
||||
let mut result = if tool_name == "Glob" {
|
||||
let params: crate::glob::GlobParams = decode(Value::Object(arguments), tool_name)?;
|
||||
check_pattern(¶ms.pattern)?;
|
||||
let search_path =
|
||||
relative_target(&path, params.path.as_deref().unwrap_or("."), false)?;
|
||||
crate::glob::execute_glob(
|
||||
crate::search_target::SearchTarget::checkout(selected.session.clone()),
|
||||
search_path,
|
||||
params.pattern,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
let params: crate::grep::GrepParams = decode(Value::Object(arguments), tool_name)?;
|
||||
if let Some(glob) = ¶ms.glob {
|
||||
check_pattern(glob)?;
|
||||
}
|
||||
let search_path =
|
||||
relative_target(&path, params.path.as_deref().unwrap_or("."), false)?;
|
||||
crate::grep::execute_grep(
|
||||
crate::search_target::SearchTarget::checkout(selected.session.clone()),
|
||||
search_path,
|
||||
params,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
// Reject rather than emit a link/rendered path outside the binding.
|
||||
// Provider scope is still authoritative for both traversal and content.
|
||||
for result_path in &result.paths {
|
||||
let logical = WorkdirPath::new(result_path.as_str()).map_err(ToolsError::from)?;
|
||||
if !is_beneath(&path, &logical) {
|
||||
return Err(crate::file_target::readonly_result_error(
|
||||
"search paths escaped bound directory",
|
||||
));
|
||||
}
|
||||
}
|
||||
let after = selected
|
||||
.session
|
||||
.checkout_observe(path)
|
||||
.await
|
||||
.map_err(crate::file_target::checked_error)?;
|
||||
if after.validator != before.validator {
|
||||
return Err(stale_search());
|
||||
}
|
||||
result.validator = Some(after.validator);
|
||||
Ok(result)
|
||||
}
|
||||
_ => unreachable!("Operation names checked above"),
|
||||
}
|
||||
}
|
||||
|
||||
fn decode<T: DeserializeOwned>(arguments: Value, name: &str) -> Result<T, ToolError> {
|
||||
crate::error::decode_file_value(arguments, name)
|
||||
}
|
||||
|
||||
fn stale_search() -> ToolError {
|
||||
stale_checkout("checkout directory changed; resolve it again before searching")
|
||||
}
|
||||
|
||||
pub(crate) fn stale_checkout(message: impl Into<String>) -> ToolError {
|
||||
ToolError::StructuredConflict {
|
||||
code: "checkout_stale".into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_beneath(base: &WorkdirPath, path: &WorkdirPath) -> bool {
|
||||
base == &WorkdirPath::root()
|
||||
|| std::path::Path::new(path.as_str()).starts_with(std::path::Path::new(base.as_str()))
|
||||
}
|
||||
|
||||
fn relative_target(
|
||||
base: &WorkdirPath,
|
||||
relative: &str,
|
||||
strict: bool,
|
||||
) -> Result<WorkdirPath, ToolError> {
|
||||
if relative.is_empty()
|
||||
|| relative.starts_with('/')
|
||||
|| relative.contains('\\')
|
||||
|| relative.split('/').any(|part| part == "..")
|
||||
{
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"path must be relative beneath the bound checkout directory, without parent traversal"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let relative = WorkdirPath::new(relative).map_err(ToolsError::from)?;
|
||||
let joined = if relative == WorkdirPath::root() {
|
||||
base.clone()
|
||||
} else if base == &WorkdirPath::root() {
|
||||
relative
|
||||
} else {
|
||||
WorkdirPath::new(&format!("{}/{}", base.as_str(), relative.as_str()))
|
||||
.map_err(ToolsError::from)?
|
||||
};
|
||||
if !is_beneath(base, &joined) || (strict && joined == *base) {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"destination must be strictly beneath the bound checkout directory".into(),
|
||||
));
|
||||
}
|
||||
Ok(joined)
|
||||
}
|
||||
|
||||
fn check_pattern(pattern: &str) -> Result<(), ToolError> {
|
||||
// Patterns filter descendants, not an alternate filesystem starting point.
|
||||
if pattern.starts_with('/') || pattern.split('/').any(|part| part == "..") {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"search glob must stay beneath the bound checkout directory".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "checkout_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,562 @@
|
||||
use super::*;
|
||||
use manifest::Scope;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use workdir::{LocalWorkdirSession, WorkdirAttachmentAlias};
|
||||
|
||||
struct Fixture {
|
||||
dir: TempDir,
|
||||
router: Arc<WorkdirSessionRouter>,
|
||||
tracker: Tracker,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
let fixture = Self {
|
||||
dir,
|
||||
router,
|
||||
tracker: Tracker::new(),
|
||||
};
|
||||
fixture.attach("main");
|
||||
fixture
|
||||
}
|
||||
|
||||
fn attach(&self, alias: &str) {
|
||||
self.router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new(alias).unwrap(),
|
||||
Arc::new(LocalWorkdirSession::new(
|
||||
Scope::writable(self.dir.path()).unwrap(),
|
||||
self.dir.path().to_path_buf(),
|
||||
)),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn observation(&self, alias: &str, path: &str) -> (u64, Vec<u8>) {
|
||||
let selected = self.router.resolve(Some(alias)).unwrap();
|
||||
let observation = selected
|
||||
.session
|
||||
.checkout_observe(WorkdirPath::new(path).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
(selected.generation, observation.validator)
|
||||
}
|
||||
|
||||
async fn native(
|
||||
&self,
|
||||
alias: &str,
|
||||
path: &str,
|
||||
name: &str,
|
||||
arguments: Value,
|
||||
) -> Result<CheckoutToolOutput, ToolError> {
|
||||
let (generation, validator) = self.observation(alias, path).await;
|
||||
execute_checkout_tool(
|
||||
self.router.clone(),
|
||||
self.tracker.clone(),
|
||||
alias,
|
||||
generation,
|
||||
WorkdirPath::new(path).unwrap(),
|
||||
validator,
|
||||
name,
|
||||
arguments,
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn normal(&self, name: &str, arguments: Value) -> Result<ToolOutput, ToolError> {
|
||||
let definitions = crate::routed_builtin_tools(
|
||||
self.router.clone(),
|
||||
self.tracker.clone(),
|
||||
Default::default(),
|
||||
);
|
||||
for definition in definitions {
|
||||
let (meta, tool) = definition();
|
||||
if meta.name == name {
|
||||
return tool
|
||||
.execute(&arguments.to_string(), Default::default())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
panic!("missing Tool {name}")
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_checkout_stale(error: ToolError) {
|
||||
assert_checkout_code(error, "checkout_stale");
|
||||
}
|
||||
|
||||
fn assert_checkout_code(error: ToolError, expected: &str) {
|
||||
match error {
|
||||
ToolError::StructuredConflict { code, .. } => assert_eq!(code, expected),
|
||||
other => panic!("expected {expected} structured conflict, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_known_refusals_have_typed_codes_without_changing_normal_tools() {
|
||||
use workdir::{WorkdirError, WorkdirSessionCapability};
|
||||
let cases = [
|
||||
(WorkdirError::Denied("denied".into()), "checkout_denied"),
|
||||
(
|
||||
WorkdirError::OutOfScope("outside".into()),
|
||||
"checkout_denied",
|
||||
),
|
||||
(
|
||||
WorkdirError::ReadOnly("read only".into()),
|
||||
"checkout_denied",
|
||||
),
|
||||
(
|
||||
WorkdirError::Unsupported(WorkdirSessionCapability::Write),
|
||||
"checkout_denied",
|
||||
),
|
||||
(
|
||||
WorkdirError::InvalidArgument("bad argument".into()),
|
||||
"checkout_invalid",
|
||||
),
|
||||
(
|
||||
WorkdirError::InvalidPath("bad path".into()),
|
||||
"checkout_invalid",
|
||||
),
|
||||
(
|
||||
WorkdirError::Unavailable("budget exhausted".into()),
|
||||
"checkout_unavailable",
|
||||
),
|
||||
(WorkdirError::NotFound("missing".into()), "checkout_stale"),
|
||||
(WorkdirError::SessionClosed, "checkout_unavailable"),
|
||||
(
|
||||
WorkdirError::UnsupportedOperation("unsupported".into()),
|
||||
"checkout_denied",
|
||||
),
|
||||
(
|
||||
WorkdirError::UnknownCommand("missing command".into()),
|
||||
"checkout_invalid",
|
||||
),
|
||||
(
|
||||
WorkdirError::RelativePath("relative".into()),
|
||||
"checkout_invalid",
|
||||
),
|
||||
(
|
||||
WorkdirError::IsDirectory("directory".into()),
|
||||
"checkout_invalid",
|
||||
),
|
||||
(
|
||||
WorkdirError::InvalidGlob("bad glob".into()),
|
||||
"checkout_invalid",
|
||||
),
|
||||
(
|
||||
WorkdirError::InvalidRegex("bad regex".into()),
|
||||
"checkout_invalid",
|
||||
),
|
||||
];
|
||||
for (error, code) in cases {
|
||||
assert_checkout_code(crate::file_target::checked_error(error), code);
|
||||
}
|
||||
assert!(matches!(
|
||||
ToolError::from(ToolsError::from(WorkdirError::Unavailable(
|
||||
"unavailable".into()
|
||||
))),
|
||||
ToolError::ExecutionFailed(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
ToolError::from(ToolsError::from(WorkdirError::Unsupported(
|
||||
WorkdirSessionCapability::Write
|
||||
))),
|
||||
ToolError::InvalidArgument(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_conflict_classification_preserves_normal_tools_and_unknown_outcomes() {
|
||||
assert_checkout_stale(crate::file_target::checked_error(
|
||||
workdir::WorkdirError::Conflict("a.txt".into()),
|
||||
));
|
||||
assert_checkout_stale(stale_search());
|
||||
assert!(matches!(
|
||||
ToolError::from(ToolsError::from(workdir::WorkdirError::Conflict(
|
||||
"a.txt".into()
|
||||
))),
|
||||
ToolError::ExecutionFailed(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
crate::file_target::checked_error(workdir::WorkdirError::OutcomeUnknown(
|
||||
"may have committed".into()
|
||||
)),
|
||||
ToolError::ExecutionFailed(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_ambiguous_transport_and_operation_failures_remain_unknown() {
|
||||
use workdir::WorkdirError;
|
||||
// Even refusal-looking text in an ambiguous transport error is not proof
|
||||
// that the provider rejected the request before any effects.
|
||||
for error in [
|
||||
WorkdirError::Transport("denied (ambiguous response)".into()),
|
||||
WorkdirError::OperationFailed,
|
||||
] {
|
||||
assert!(matches!(
|
||||
crate::file_target::checked_error(error),
|
||||
ToolError::ExecutionFailed(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_mismatched_readonly_results_are_unavailable_not_stale_or_unknown() {
|
||||
use workdir::{CheckoutSearchResult, GlobResult, GrepResult};
|
||||
let glob = CheckoutSearchResult::Glob(GlobResult {
|
||||
paths: Vec::new(),
|
||||
truncated: false,
|
||||
});
|
||||
let grep = CheckoutSearchResult::Grep(GrepResult {
|
||||
output: String::new(),
|
||||
match_count: 0,
|
||||
matched_files: 0,
|
||||
truncated: false,
|
||||
paths: Vec::new(),
|
||||
});
|
||||
assert_checkout_code(
|
||||
crate::search_target::grep_result(glob).err().unwrap(),
|
||||
"checkout_unavailable",
|
||||
);
|
||||
assert_checkout_code(
|
||||
crate::search_target::glob_result(grep).err().unwrap(),
|
||||
"checkout_unavailable",
|
||||
);
|
||||
assert_checkout_code(
|
||||
crate::file_target::readonly_result_error("Read"),
|
||||
"checkout_unavailable",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_relative_path_boundaries() {
|
||||
let base = WorkdirPath::new("src").unwrap();
|
||||
assert_eq!(
|
||||
relative_target(&base, "nested/a.txt", true)
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
"src/nested/a.txt"
|
||||
);
|
||||
assert_eq!(relative_target(&base, ".", false).unwrap(), base);
|
||||
assert_eq!(
|
||||
relative_target(&WorkdirPath::root(), "a.txt", true)
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
"a.txt"
|
||||
);
|
||||
for bad in [
|
||||
"",
|
||||
"..",
|
||||
"../outside",
|
||||
"nested/../../outside",
|
||||
"/absolute",
|
||||
"a\\..\\outside",
|
||||
] {
|
||||
assert!(
|
||||
relative_target(&base, bad, false).is_err(),
|
||||
"accepted {bad}"
|
||||
);
|
||||
}
|
||||
assert!(relative_target(&base, ".", true).is_err());
|
||||
assert!(!is_beneath(
|
||||
&base,
|
||||
&WorkdirPath::new("src-other/a.txt").unwrap()
|
||||
));
|
||||
assert!(check_pattern("../*").is_err());
|
||||
assert!(check_pattern("/tmp/*").is_err());
|
||||
assert!(check_pattern("**/*.rs").is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_read_and_normal_edit_share_hash_and_line_count() {
|
||||
let fixture = Fixture::new();
|
||||
std::fs::write(fixture.dir.path().join("a.txt"), "alpha\nbeta\ngamma\n").unwrap();
|
||||
let read = fixture
|
||||
.native("main", "a.txt", "Read", json!({"offset": 1, "limit": 1}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(read.output.summary.contains("[2..2] of 3"));
|
||||
assert_eq!(read.output.content.as_deref(), Some(" 2\tbeta\n"));
|
||||
assert!(read.validator.is_some());
|
||||
let selected = fixture.router.resolve(Some("main")).unwrap();
|
||||
let tracker = fixture
|
||||
.tracker
|
||||
.scoped_attachment(&selected.alias, selected.generation);
|
||||
assert_eq!(
|
||||
tracker.observed_workdir_line_count(&WorkdirPath::new("a.txt").unwrap()),
|
||||
Some(3)
|
||||
);
|
||||
fixture.normal("Edit", json!({"target_workdir": "main", "file_path": "a.txt", "old_string": "beta", "new_string": "changed"})).await.unwrap();
|
||||
let write = fixture
|
||||
.native("main", "a.txt", "Write", json!({"content": "new\n"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(write.output.summary.contains("Overwrote"));
|
||||
assert_eq!(fixture.tracker.change_stat().deleted, 4);
|
||||
let reread = fixture
|
||||
.native("main", "a.txt", "Read", json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(reread.output.content.as_deref(), Some(" 1\tnew\n"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_normal_read_then_native_edit_uniqueness_and_replace_all() {
|
||||
let fixture = Fixture::new();
|
||||
std::fs::write(fixture.dir.path().join("a.txt"), "x x x\n").unwrap();
|
||||
let before_read = fixture
|
||||
.native(
|
||||
"main",
|
||||
"a.txt",
|
||||
"Edit",
|
||||
json!({"old_string": "x", "new_string": "y"}),
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(before_read.to_string().contains("has not been read"));
|
||||
fixture
|
||||
.normal(
|
||||
"Read",
|
||||
json!({"target_workdir": "main", "file_path": "a.txt"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let not_unique = fixture
|
||||
.native(
|
||||
"main",
|
||||
"a.txt",
|
||||
"Edit",
|
||||
json!({"old_string": "x", "new_string": "y"}),
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_checkout_code(not_unique, "checkout_invalid");
|
||||
assert_eq!(fixture.tracker.change_stat(), crate::ChangeStat::default());
|
||||
let edit = fixture
|
||||
.native(
|
||||
"main",
|
||||
"a.txt",
|
||||
"Edit",
|
||||
json!({"old_string": "x", "new_string": "y", "replace_all": true}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(edit.output.summary.contains("3 replacements"));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(fixture.dir.path().join("a.txt")).unwrap(),
|
||||
"y y y\n"
|
||||
);
|
||||
fixture.normal("Write", json!({"target_workdir": "main", "file_path": "a.txt", "content": "normal after native"})).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_alias_and_generation_fences() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.attach("other");
|
||||
std::fs::write(fixture.dir.path().join("a.txt"), "same\n").unwrap();
|
||||
fixture
|
||||
.native("main", "a.txt", "Read", json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
let wrong_alias = fixture
|
||||
.native("other", "a.txt", "Write", json!({"content": "not allowed"}))
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(wrong_alias.to_string().contains("has not been read"));
|
||||
let (old_generation, old_validator) = fixture.observation("main", "a.txt").await;
|
||||
fixture
|
||||
.router
|
||||
.detach(&WorkdirAttachmentAlias::new("main").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
fixture.attach("main");
|
||||
let error = execute_checkout_tool(
|
||||
fixture.router.clone(),
|
||||
fixture.tracker.clone(),
|
||||
"main",
|
||||
old_generation,
|
||||
WorkdirPath::new("a.txt").unwrap(),
|
||||
old_validator,
|
||||
"Read",
|
||||
json!({}),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(error.to_string().contains("generation changed"));
|
||||
assert_checkout_stale(error);
|
||||
let fresh_generation_no_read = fixture
|
||||
.native("main", "a.txt", "Write", json!({"content": "not allowed"}))
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(
|
||||
fresh_generation_no_read
|
||||
.to_string()
|
||||
.contains("has not been read")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_stale_validator_and_hash_are_independent() {
|
||||
let fixture = Fixture::new();
|
||||
std::fs::write(fixture.dir.path().join("a.txt"), "old\n").unwrap();
|
||||
fixture
|
||||
.native("main", "a.txt", "Read", json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
let (generation, validator) = fixture.observation("main", "a.txt").await;
|
||||
std::fs::write(fixture.dir.path().join("a.txt"), "externally changed\n").unwrap();
|
||||
let stale = execute_checkout_tool(
|
||||
fixture.router.clone(),
|
||||
fixture.tracker.clone(),
|
||||
"main",
|
||||
generation,
|
||||
WorkdirPath::new("a.txt").unwrap(),
|
||||
validator,
|
||||
"Write",
|
||||
json!({"content": "bad"}),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_checkout_stale(stale);
|
||||
let hash = fixture
|
||||
.native("main", "a.txt", "Write", json!({"content": "bad"}))
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_checkout_stale(hash);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(fixture.dir.path().join("a.txt")).unwrap(),
|
||||
"externally changed\n"
|
||||
);
|
||||
assert_eq!(fixture.tracker.change_stat(), crate::ChangeStat::default());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_create_new_missing_parents_never_overwrites() {
|
||||
let fixture = Fixture::new();
|
||||
let created = fixture
|
||||
.native(
|
||||
"main",
|
||||
".",
|
||||
"Create",
|
||||
json!({"path": "nested/deep/a.txt", "content": "new\n"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(created.output.summary.contains("Created"));
|
||||
assert_eq!(
|
||||
created.paths,
|
||||
vec![WorkdirPath::new("nested/deep/a.txt").unwrap()]
|
||||
);
|
||||
let duplicate = fixture
|
||||
.native(
|
||||
"main",
|
||||
".",
|
||||
"Create",
|
||||
json!({"path": "nested/deep/a.txt", "content": "overwrite"}),
|
||||
)
|
||||
.await;
|
||||
assert!(duplicate.is_err());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(fixture.dir.path().join("nested/deep/a.txt")).unwrap(),
|
||||
"new\n"
|
||||
);
|
||||
// Successful creation updates the same tracker as normal Tool Write.
|
||||
fixture.normal("Write", json!({"target_workdir": "main", "file_path": "nested/deep/a.txt", "content": "changed"})).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_rejects_argument_overrides_and_escapes() {
|
||||
let fixture = Fixture::new();
|
||||
std::fs::write(fixture.dir.path().join("a.txt"), "safe").unwrap();
|
||||
for (name, arguments) in [
|
||||
("Read", json!({"file_path": "other"})),
|
||||
("Read", json!({"target_workdir": "other"})),
|
||||
("Write", json!({"content": "bad", "validator": []})),
|
||||
("Create", json!({"path": "../outside", "content": "bad"})),
|
||||
("Glob", json!({"pattern": "*", "path": "/absolute"})),
|
||||
("Grep", json!({"pattern": "safe", "path": "../outside"})),
|
||||
("Other", json!({})),
|
||||
] {
|
||||
let path = if ["Create", "Glob", "Grep"].contains(&name) {
|
||||
"."
|
||||
} else {
|
||||
"a.txt"
|
||||
};
|
||||
let error = fixture
|
||||
.native("main", path, name, arguments)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(error, ToolError::InvalidArgument(_)),
|
||||
"{name}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_search_typed_paths_and_normal_rendering() {
|
||||
let fixture = Fixture::new();
|
||||
std::fs::create_dir_all(fixture.dir.path().join("src/nested")).unwrap();
|
||||
std::fs::write(
|
||||
fixture.dir.path().join("src/nested/a.txt"),
|
||||
"context\nhit\nafter\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(fixture.dir.path().join("outside.txt"), "hit\n").unwrap();
|
||||
let glob = fixture
|
||||
.native(
|
||||
"main",
|
||||
"src",
|
||||
"Glob",
|
||||
json!({"pattern": "**/*.txt", "path": "nested"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
glob.paths,
|
||||
vec![WorkdirPath::new("src/nested/a.txt").unwrap()]
|
||||
);
|
||||
let normal = fixture
|
||||
.normal(
|
||||
"Glob",
|
||||
json!({"target_workdir": "main", "pattern": "**/*.txt", "path": "src/nested"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(glob.output.summary, normal.summary);
|
||||
assert_eq!(glob.output.content, normal.content);
|
||||
for mode in ["files_with_matches", "content", "count"] {
|
||||
let grep = fixture
|
||||
.native(
|
||||
"main",
|
||||
"src",
|
||||
"Grep",
|
||||
json!({"pattern": "hit", "output_mode": mode, "-C": 1}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
grep.paths,
|
||||
vec![WorkdirPath::new("src/nested/a.txt").unwrap()]
|
||||
);
|
||||
let normal = fixture.normal("Grep", json!({"target_workdir": "main", "path": "src", "pattern": "hit", "output_mode": mode, "-C": 1})).await.unwrap();
|
||||
assert_eq!(grep.output.summary, normal.summary);
|
||||
assert_eq!(grep.output.content, normal.content);
|
||||
assert!(!grep.output.content.unwrap().contains("outside.txt"));
|
||||
}
|
||||
}
|
||||
+66
-39
@@ -9,7 +9,7 @@ use serde::Deserialize;
|
||||
|
||||
use crate::error::ToolsError;
|
||||
use crate::tracker::Tracker;
|
||||
use workdir::{EditRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter};
|
||||
use workdir::{WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter};
|
||||
|
||||
const DESCRIPTION: &str = "Replace a substring in an existing file in the selected Workdir attachment. By default \
|
||||
`old_string` must be unique in the file; set `replace_all: true` to replace \
|
||||
@@ -23,13 +23,8 @@ pub(crate) struct EditParams {
|
||||
pub target_workdir: Option<String>,
|
||||
/// Logical path relative to the bound Workdir root.
|
||||
pub file_path: String,
|
||||
/// String to replace. Must be unique in the file unless `replace_all` is true.
|
||||
pub old_string: String,
|
||||
/// Replacement string. Must differ from `old_string`.
|
||||
pub new_string: String,
|
||||
/// Replace all occurrences. Defaults to false.
|
||||
#[serde(default)]
|
||||
pub replace_all: bool,
|
||||
#[serde(flatten)]
|
||||
pub edit: fs_operation::text::EditArgs,
|
||||
}
|
||||
|
||||
pub(crate) struct EditTool {
|
||||
@@ -44,8 +39,7 @@ impl Tool for EditTool {
|
||||
input_json: &str,
|
||||
ctx: agen::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: EditParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid Edit input: {e}")))?;
|
||||
let params: EditParams = crate::error::decode_file_input(input_json, "Edit")?;
|
||||
|
||||
let selected = crate::routing::resolve_session(
|
||||
&self.router,
|
||||
@@ -56,57 +50,69 @@ impl Tool for EditTool {
|
||||
.tracker
|
||||
.scoped_attachment(&selected.alias, selected.generation);
|
||||
let path = WorkdirPath::new(¶ms.file_path).map_err(ToolsError::from)?;
|
||||
tracing::debug!(path = %path, replace_all = params.replace_all, "Edit");
|
||||
|
||||
if params.old_string.is_empty() {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"old_string must not be empty".into(),
|
||||
));
|
||||
Ok(execute_edit(
|
||||
crate::file_target::FileTarget {
|
||||
session: selected.session,
|
||||
path,
|
||||
validator: None,
|
||||
},
|
||||
tracker,
|
||||
params,
|
||||
ctx,
|
||||
)
|
||||
.await?
|
||||
.output)
|
||||
}
|
||||
if params.old_string == params.new_string {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"old_string and new_string are identical".into(),
|
||||
));
|
||||
}
|
||||
|
||||
pub(crate) async fn execute_edit(
|
||||
target: crate::file_target::FileTarget,
|
||||
tracker: Tracker,
|
||||
params: EditParams,
|
||||
ctx: agen::tool::ToolExecutionContext,
|
||||
) -> Result<crate::checkout::CheckoutToolOutput, ToolError> {
|
||||
let path = &target.path;
|
||||
tracing::debug!(path = %path, replace_all = params.edit.replace_all, "Edit");
|
||||
params
|
||||
.edit
|
||||
.validate(Default::default())
|
||||
.map_err(crate::error::text_error)?;
|
||||
let mutation_key = PathBuf::from(path.as_str());
|
||||
let _mutation_permit = tracker.acquire_mutation(&mutation_key, &ctx).await;
|
||||
let expected_hash = tracker.expected_workdir_hash(&path)?;
|
||||
let result = selected
|
||||
.session
|
||||
.edit(EditRequest {
|
||||
path: path.clone(),
|
||||
old_string: params.old_string.clone(),
|
||||
new_string: params.new_string.clone(),
|
||||
replace_all: params.replace_all,
|
||||
let expected_hash = tracker.expected_workdir_hash(path)?;
|
||||
let (result, validator) = target
|
||||
.edit(
|
||||
params.edit.old_string.clone(),
|
||||
params.edit.new_string.clone(),
|
||||
params.edit.replace_all,
|
||||
expected_hash,
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
)
|
||||
.await?;
|
||||
let replacements = result.replacements;
|
||||
tracker.record_workdir_edit(
|
||||
&path,
|
||||
path,
|
||||
result.content_hash,
|
||||
replacements,
|
||||
params.new_string.lines().count(),
|
||||
params.old_string.lines().count(),
|
||||
params.edit.new_string.lines().count(),
|
||||
params.edit.old_string.lines().count(),
|
||||
);
|
||||
|
||||
let summary = format!(
|
||||
"Edited {} ({} replacement{})",
|
||||
path,
|
||||
replacements,
|
||||
if replacements == 1 { "" } else { "s" }
|
||||
);
|
||||
let preview = make_preview(¶ms.new_string, ¶ms.new_string);
|
||||
|
||||
Ok(ToolOutput {
|
||||
let preview = make_preview(¶ms.edit.new_string, ¶ms.edit.new_string);
|
||||
Ok(crate::checkout::CheckoutToolOutput {
|
||||
output: ToolOutput {
|
||||
summary,
|
||||
content: Some(preview),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
paths: Vec::new(),
|
||||
validator,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a small line-numbered snippet centered on the first occurrence of
|
||||
/// `needle` in `text`. Shows ±3 surrounding lines.
|
||||
@@ -182,6 +188,27 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_shared_validation_rejects_identical_text_before_read_or_mutation() {
|
||||
let (dir, fs, tracker) = setup();
|
||||
let file = dir.path().join("a.txt");
|
||||
std::fs::write(&file, "same").unwrap();
|
||||
let (_, tool) = edit_tool(fs, tracker.clone())();
|
||||
let error = tool
|
||||
.execute(
|
||||
&serde_json::json!({"file_path":"a.txt","old_string":"same","new_string":"same"})
|
||||
.to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, ToolError::InvalidArgument(message) if message == "old_string and new_string are identical")
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(file).unwrap(), "same");
|
||||
assert_eq!(tracker.change_stat(), crate::ChangeStat::default());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_unique_replacement() {
|
||||
let (dir, fs, tracker) = setup();
|
||||
|
||||
@@ -44,7 +44,8 @@ impl From<ToolsError> for ToolError {
|
||||
| workdir::WorkdirError::Unavailable(_)
|
||||
| workdir::WorkdirError::OperationFailed
|
||||
| workdir::WorkdirError::Transport(_)
|
||||
| workdir::WorkdirError::Conflict(_),
|
||||
| workdir::WorkdirError::Conflict(_)
|
||||
| workdir::WorkdirError::OutcomeUnknown(_),
|
||||
) => ToolError::ExecutionFailed(err.to_string()),
|
||||
ToolsError::FileSystem(_)
|
||||
| ToolsError::WorkdirSession(_)
|
||||
@@ -57,11 +58,67 @@ impl From<ToolsError> for ToolError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure text failures are pre-dispatch argument failures, never unknown outcomes.
|
||||
pub(crate) fn text_error(error: fs_operation::text::TextError) -> ToolError {
|
||||
ToolError::InvalidArgument(error.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn decode_file_input<T: serde::de::DeserializeOwned>(
|
||||
input: &str,
|
||||
name: &str,
|
||||
) -> Result<T, ToolError> {
|
||||
fs_operation::text::decode_json(input).map_err(|error| decode_error(error, name))
|
||||
}
|
||||
|
||||
pub(crate) fn decode_file_value<T: serde::de::DeserializeOwned>(
|
||||
value: serde_json::Value,
|
||||
name: &str,
|
||||
) -> Result<T, ToolError> {
|
||||
fs_operation::text::decode(value).map_err(|error| decode_error(error, name))
|
||||
}
|
||||
|
||||
fn decode_error(error: fs_operation::text::TextError, name: &str) -> ToolError {
|
||||
match error {
|
||||
fs_operation::text::TextError::Decode(error) => {
|
||||
ToolError::InvalidArgument(format!("invalid {name} input: {error}"))
|
||||
}
|
||||
error => text_error(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use workdir::http::{WorkdirTransportError, WorkdirTransportErrorCode};
|
||||
|
||||
#[test]
|
||||
fn flattened_file_tool_arguments_keep_raw_json_duplicate_rejection() {
|
||||
let error = decode_file_input::<crate::edit::EditParams>(
|
||||
r#"{"file_path":"a.txt","old_string":"a","old_string":"b","new_string":"c"}"#,
|
||||
"Edit",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, ToolError::InvalidArgument(message) if message.contains("duplicate field `old_string`"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_outcome_unknown_remains_execution_failure() {
|
||||
let error = ToolError::from(ToolsError::WorkdirSession(
|
||||
workdir::WorkdirError::OutcomeUnknown(
|
||||
"mutation may have committed; do not retry automatically".into(),
|
||||
),
|
||||
));
|
||||
match error {
|
||||
ToolError::ExecutionFailed(message) => {
|
||||
assert!(message.contains("mutation may have committed"));
|
||||
assert!(message.contains("do not retry automatically"));
|
||||
}
|
||||
other => panic!("outcome unknown must not become invalid input: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_workdir_content_conflict_is_retryable_execution_failure() {
|
||||
let error = ToolError::from(ToolsError::WorkdirSession(
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Shared provider dispatch for Tool and checkout file processing.
|
||||
use agen::tool::ToolError;
|
||||
use workdir::{
|
||||
CheckoutOperation, CheckoutOutput, CheckoutRequest, ContentHash, EditRequest, EditResult,
|
||||
ReadRequest, ReadResult, WorkdirPath, WorkdirSessionHandle, WriteRequest, WriteResult,
|
||||
};
|
||||
|
||||
use crate::ToolsError;
|
||||
|
||||
pub(crate) struct FileTarget {
|
||||
pub session: WorkdirSessionHandle,
|
||||
pub path: WorkdirPath,
|
||||
pub validator: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl FileTarget {
|
||||
async fn checked(
|
||||
&self,
|
||||
operation: CheckoutOperation,
|
||||
) -> Result<(CheckoutOutput, Option<Vec<u8>>), ToolError> {
|
||||
let result = self
|
||||
.session
|
||||
.checkout_execute(CheckoutRequest {
|
||||
target: self.path.clone(),
|
||||
validator: self
|
||||
.validator
|
||||
.clone()
|
||||
.expect("checked dispatch requires validator"),
|
||||
operation,
|
||||
})
|
||||
.await
|
||||
.map_err(checked_error)?;
|
||||
Ok((result.output, Some(result.observation.validator)))
|
||||
}
|
||||
|
||||
pub async fn read(
|
||||
&self,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
max_bytes: usize,
|
||||
) -> Result<(ReadResult, Option<Vec<u8>>), ToolError> {
|
||||
if self.validator.is_some() {
|
||||
let (output, validator) = self
|
||||
.checked(CheckoutOperation::Read {
|
||||
offset,
|
||||
limit,
|
||||
max_bytes,
|
||||
})
|
||||
.await?;
|
||||
match output {
|
||||
CheckoutOutput::Read(result) => Ok((result, validator)),
|
||||
_ => Err(readonly_result_error("Read")),
|
||||
}
|
||||
} else {
|
||||
let result = self
|
||||
.session
|
||||
.read(ReadRequest {
|
||||
path: self.path.clone(),
|
||||
offset,
|
||||
limit,
|
||||
max_bytes,
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
Ok((result, None))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn edit(
|
||||
&self,
|
||||
old_string: String,
|
||||
new_string: String,
|
||||
replace_all: bool,
|
||||
expected_hash: ContentHash,
|
||||
) -> Result<(EditResult, Option<Vec<u8>>), ToolError> {
|
||||
if self.validator.is_some() {
|
||||
let (output, validator) = self
|
||||
.checked(CheckoutOperation::Edit {
|
||||
old_string,
|
||||
new_string,
|
||||
replace_all,
|
||||
expected_hash,
|
||||
})
|
||||
.await?;
|
||||
match output {
|
||||
CheckoutOutput::Edit(result) => Ok((result, validator)),
|
||||
_ => Err(unknown_result("Edit")),
|
||||
}
|
||||
} else {
|
||||
let result = self
|
||||
.session
|
||||
.edit(EditRequest {
|
||||
path: self.path.clone(),
|
||||
old_string,
|
||||
new_string,
|
||||
replace_all,
|
||||
expected_hash,
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
Ok((result, None))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn write(
|
||||
&self,
|
||||
content: Vec<u8>,
|
||||
expected_hash: Option<ContentHash>,
|
||||
create_path: Option<WorkdirPath>,
|
||||
) -> Result<(WriteResult, Option<Vec<u8>>), ToolError> {
|
||||
if self.validator.is_some() {
|
||||
let operation = match create_path {
|
||||
Some(path) => CheckoutOperation::Create { path, content },
|
||||
None => CheckoutOperation::Write {
|
||||
content,
|
||||
expected_hash: expected_hash
|
||||
.expect("existing checkout Write requires prior read"),
|
||||
},
|
||||
};
|
||||
let (output, validator) = self.checked(operation).await?;
|
||||
match output {
|
||||
CheckoutOutput::Write(result) => Ok((result, validator)),
|
||||
_ => Err(unknown_result("Write/Create")),
|
||||
}
|
||||
} else {
|
||||
let result = self
|
||||
.session
|
||||
.write(WriteRequest {
|
||||
path: self.path.clone(),
|
||||
content,
|
||||
expected_hash,
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
Ok((result, None))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn checked_error(error: workdir::WorkdirError) -> ToolError {
|
||||
use workdir::WorkdirError;
|
||||
let code = match &error {
|
||||
WorkdirError::Conflict(_) | WorkdirError::NotFound(_) => "checkout_stale",
|
||||
WorkdirError::Denied { .. }
|
||||
| WorkdirError::OutOfScope { .. }
|
||||
| WorkdirError::ReadOnly { .. }
|
||||
| WorkdirError::Unsupported { .. }
|
||||
| WorkdirError::UnsupportedOperation { .. }
|
||||
| WorkdirError::SymlinkOutOfScope { .. }
|
||||
| WorkdirError::SymlinkDirectoryNotTraversed { .. } => "checkout_denied",
|
||||
WorkdirError::InvalidArgument { .. }
|
||||
| WorkdirError::InvalidPath { .. }
|
||||
| WorkdirError::UnknownCommand { .. }
|
||||
| WorkdirError::RelativePath { .. }
|
||||
| WorkdirError::BrokenSymlink { .. }
|
||||
| WorkdirError::SymlinkTargetIsDirectory { .. }
|
||||
| WorkdirError::IsDirectory { .. }
|
||||
| WorkdirError::InvalidGlob { .. }
|
||||
| WorkdirError::InvalidRegex { .. } => "checkout_invalid",
|
||||
WorkdirError::Unavailable { .. } | WorkdirError::SessionClosed => "checkout_unavailable",
|
||||
// IO/transport failures may arrive after effects have begun. Do not infer
|
||||
// no-effects from their text or strip explicit OutcomeUnknown evidence.
|
||||
WorkdirError::Io { .. }
|
||||
| WorkdirError::Transport { .. }
|
||||
| WorkdirError::OutcomeUnknown { .. }
|
||||
| WorkdirError::OperationFailed => {
|
||||
return ToolsError::from(error).into();
|
||||
}
|
||||
};
|
||||
ToolError::StructuredConflict {
|
||||
code: code.into(),
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn readonly_result_error(operation: &str) -> ToolError {
|
||||
checked_error(workdir::WorkdirError::Unavailable(format!(
|
||||
"provider returned an invalid readonly checkout {operation} result"
|
||||
)))
|
||||
}
|
||||
|
||||
fn unknown_result(operation: &str) -> ToolError {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"Outcome unknown: provider returned wrong checkout {operation} result after mutation; do not automatically retry"
|
||||
))
|
||||
}
|
||||
+26
-11
@@ -11,15 +11,15 @@ use crate::ToolsError;
|
||||
const RESULT_LIMIT: usize = 1000;
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct GlobParams {
|
||||
pub(crate) struct GlobParams {
|
||||
/// Worker-local alias of the Workdir attachment to use.
|
||||
#[serde(default)]
|
||||
target_workdir: Option<String>,
|
||||
pub target_workdir: Option<String>,
|
||||
/// Glob pattern, for example `**/*.rs` or `src/**/test_*.py`.
|
||||
pattern: String,
|
||||
pub pattern: String,
|
||||
/// Logical Workdir-relative directory. Defaults to the Workdir root.
|
||||
#[serde(default)]
|
||||
path: Option<String>,
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
struct GlobTool {
|
||||
@@ -44,17 +44,29 @@ impl Tool for GlobTool {
|
||||
Some(path) => WorkdirPath::new(&path).map_err(ToolsError::from)?,
|
||||
None => WorkdirPath::root(),
|
||||
};
|
||||
let pattern = params.pattern;
|
||||
Ok(execute_glob(
|
||||
crate::search_target::SearchTarget::tool(selected.session),
|
||||
path,
|
||||
params.pattern,
|
||||
)
|
||||
.await?
|
||||
.output)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn execute_glob(
|
||||
target: crate::search_target::SearchTarget,
|
||||
path: WorkdirPath,
|
||||
pattern: String,
|
||||
) -> Result<crate::checkout::CheckoutToolOutput, ToolError> {
|
||||
tracing::debug!(%pattern, %path, "Glob");
|
||||
let result = selected
|
||||
.session
|
||||
let result = target
|
||||
.glob(GlobRequest {
|
||||
pattern: pattern.clone(),
|
||||
path,
|
||||
limit: RESULT_LIMIT,
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
.await?;
|
||||
let mut body = result
|
||||
.paths
|
||||
.iter()
|
||||
@@ -74,13 +86,16 @@ impl Tool for GlobTool {
|
||||
} else {
|
||||
format!("Found {} file(s) matching {pattern}", result.paths.len())
|
||||
};
|
||||
Ok(ToolOutput {
|
||||
Ok(crate::checkout::CheckoutToolOutput {
|
||||
output: ToolOutput {
|
||||
summary,
|
||||
content: (!body.is_empty()).then_some(body),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
paths: result.paths,
|
||||
validator: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn glob_tool(session: WorkdirSessionHandle) -> ToolDefinition {
|
||||
routed_glob_tool(crate::routing::singleton_router(session))
|
||||
|
||||
+27
-11
@@ -22,16 +22,16 @@ enum OutputMode {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct GrepParams {
|
||||
pub(crate) struct GrepParams {
|
||||
/// Worker-local alias of the Workdir attachment to use.
|
||||
#[serde(default)]
|
||||
target_workdir: Option<String>,
|
||||
pattern: String,
|
||||
/// Workdir-relative path, or an absolute path covered by readable scope. Defaults to the Workdir root.
|
||||
#[serde(default)]
|
||||
path: Option<String>,
|
||||
pub path: Option<String>,
|
||||
#[serde(default)]
|
||||
glob: Option<String>,
|
||||
pub glob: Option<String>,
|
||||
#[serde(default, rename = "type")]
|
||||
file_type: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -70,10 +70,25 @@ impl Tool for GrepTool {
|
||||
params.target_workdir.as_deref(),
|
||||
workdir::WorkdirSessionCapability::Grep,
|
||||
)?;
|
||||
let path = match params.path {
|
||||
Some(path) => WorkdirPath::new_scoped(&path).map_err(ToolsError::from)?,
|
||||
let path = match params.path.as_deref() {
|
||||
Some(path) => WorkdirPath::new_scoped(path).map_err(ToolsError::from)?,
|
||||
None => WorkdirPath::root(),
|
||||
};
|
||||
Ok(execute_grep(
|
||||
crate::search_target::SearchTarget::tool(selected.session),
|
||||
path,
|
||||
params,
|
||||
)
|
||||
.await?
|
||||
.output)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn execute_grep(
|
||||
target: crate::search_target::SearchTarget,
|
||||
path: WorkdirPath,
|
||||
params: GrepParams,
|
||||
) -> Result<crate::checkout::CheckoutToolOutput, ToolError> {
|
||||
let mode = match params.output_mode.unwrap_or_default() {
|
||||
OutputMode::FilesWithMatches => GrepOutputMode::FilesWithMatches,
|
||||
OutputMode::Content => GrepOutputMode::Content,
|
||||
@@ -84,8 +99,7 @@ impl Tool for GrepTool {
|
||||
.map(|context| (context, context))
|
||||
.unwrap_or((params.before.unwrap_or(0), params.after.unwrap_or(0)));
|
||||
let head_limit = params.head_limit.unwrap_or(DEFAULT_HEAD_LIMIT);
|
||||
let result = selected
|
||||
.session
|
||||
let result = target
|
||||
.grep(GrepRequest {
|
||||
pattern: params.pattern,
|
||||
path,
|
||||
@@ -99,8 +113,7 @@ impl Tool for GrepTool {
|
||||
limit: head_limit,
|
||||
offset: params.offset.unwrap_or(0),
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
.await?;
|
||||
|
||||
let summary = if result.match_count == 0 {
|
||||
match mode {
|
||||
@@ -127,13 +140,16 @@ impl Tool for GrepTool {
|
||||
} else {
|
||||
summary
|
||||
};
|
||||
Ok(ToolOutput {
|
||||
Ok(crate::checkout::CheckoutToolOutput {
|
||||
output: ToolOutput {
|
||||
summary,
|
||||
content: (!result.output.is_empty()).then_some(result.output),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
paths: result.paths,
|
||||
validator: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn grep_tool(session: WorkdirSessionHandle) -> ToolDefinition {
|
||||
routed_grep_tool(crate::routing::singleton_router(session))
|
||||
|
||||
@@ -9,20 +9,24 @@
|
||||
//! and command capability, while the Runtime process and OS user remain the
|
||||
//! trusted execution boundary.
|
||||
|
||||
pub mod checkout;
|
||||
pub mod error;
|
||||
pub mod tracker;
|
||||
|
||||
mod bash;
|
||||
mod edit;
|
||||
mod file_target;
|
||||
mod glob;
|
||||
mod grep;
|
||||
mod read;
|
||||
mod routing;
|
||||
mod search_target;
|
||||
mod view_image;
|
||||
mod web;
|
||||
mod write;
|
||||
|
||||
pub use bash::bash_tool;
|
||||
pub use checkout::{CheckoutToolOutput, execute_checkout_tool};
|
||||
pub use edit::edit_tool;
|
||||
pub use error::ToolsError;
|
||||
pub use glob::glob_tool;
|
||||
|
||||
+75
-78
@@ -8,7 +8,7 @@ use serde::Deserialize;
|
||||
|
||||
use crate::error::ToolsError;
|
||||
use crate::tracker::Tracker;
|
||||
use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter};
|
||||
use workdir::{WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter};
|
||||
|
||||
const DESCRIPTION: &str = "Read a text file from a Workdir attachment selected by its Worker-local alias. \
|
||||
Supports offset/limit for large files. Returns line-numbered output (1-based). \
|
||||
@@ -26,12 +26,8 @@ pub(crate) struct ReadParams {
|
||||
pub target_workdir: Option<String>,
|
||||
/// Workdir-relative path, or an absolute path covered by readable scope.
|
||||
pub file_path: String,
|
||||
/// 0-based line offset from the start. Defaults to 0.
|
||||
#[serde(default)]
|
||||
pub offset: Option<usize>,
|
||||
/// Maximum number of lines to return. Defaults to 2000.
|
||||
#[serde(default)]
|
||||
pub limit: Option<usize>,
|
||||
#[serde(flatten)]
|
||||
pub read: fs_operation::text::LineReadArgs,
|
||||
}
|
||||
|
||||
pub(crate) struct ReadTool {
|
||||
@@ -46,8 +42,7 @@ impl Tool for ReadTool {
|
||||
input_json: &str,
|
||||
_ctx: agen::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: ReadParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid Read input: {e}")))?;
|
||||
let params: ReadParams = crate::error::decode_file_input(input_json, "Read")?;
|
||||
let selected = crate::routing::resolve_session(
|
||||
&self.router,
|
||||
params.target_workdir.as_deref(),
|
||||
@@ -56,104 +51,80 @@ impl Tool for ReadTool {
|
||||
let tracker = self
|
||||
.tracker
|
||||
.scoped_attachment(&selected.alias, selected.generation);
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
let limit = params.limit.unwrap_or(DEFAULT_LIMIT).max(1);
|
||||
Ok(execute_read(
|
||||
crate::file_target::FileTarget {
|
||||
session: selected.session,
|
||||
path: WorkdirPath::new_scoped(¶ms.file_path).map_err(ToolsError::from)?,
|
||||
validator: None,
|
||||
},
|
||||
tracker,
|
||||
params,
|
||||
)
|
||||
.await?
|
||||
.output)
|
||||
}
|
||||
}
|
||||
|
||||
let path = WorkdirPath::new_scoped(¶ms.file_path).map_err(ToolsError::from)?;
|
||||
pub(crate) async fn execute_read(
|
||||
target: crate::file_target::FileTarget,
|
||||
tracker: Tracker,
|
||||
params: ReadParams,
|
||||
) -> Result<crate::checkout::CheckoutToolOutput, ToolError> {
|
||||
let (offset, limit) = params.read.range(DEFAULT_LIMIT);
|
||||
let path = &target.path;
|
||||
tracing::debug!(path = %path, offset, limit, "Read");
|
||||
|
||||
let result = selected
|
||||
.session
|
||||
.read(ReadRequest {
|
||||
path: path.clone(),
|
||||
offset,
|
||||
limit,
|
||||
max_bytes: PROVIDER_BYTE_LIMIT,
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
tracker.record_workdir_hash(&path, result.content_hash);
|
||||
|
||||
let text = String::from_utf8_lossy(&result.bytes).into_owned();
|
||||
let (result, validator) = target.read(offset, limit, PROVIDER_BYTE_LIMIT).await?;
|
||||
tracker.record_workdir_observation(path, result.content_hash, result.total_lines);
|
||||
// Source/response bounds were enforced by the streaming provider. Lossy
|
||||
// UTF-8 conversion is the existing Tool presentation contract; rendering
|
||||
// below independently bounds its possible byte expansion.
|
||||
let text = fs_operation::text::read(
|
||||
String::from_utf8_lossy(&result.bytes).into_owned(),
|
||||
Default::default(),
|
||||
)
|
||||
.map_err(crate::error::text_error)?;
|
||||
let rendered = render_provider_read(
|
||||
&text,
|
||||
&text.content,
|
||||
result.start_line,
|
||||
result.total_lines,
|
||||
result.truncated,
|
||||
);
|
||||
|
||||
let summary = if rendered.truncated {
|
||||
format!(
|
||||
"Read {} line(s) [{}..{}] of {} from {}",
|
||||
rendered.line_count,
|
||||
offset + 1,
|
||||
offset + rendered.line_count,
|
||||
offset.saturating_add(1),
|
||||
offset.saturating_add(rendered.line_count),
|
||||
rendered.total_lines,
|
||||
path
|
||||
)
|
||||
} else {
|
||||
format!("Read {} line(s) from {}", rendered.line_count, path)
|
||||
};
|
||||
|
||||
Ok(ToolOutput {
|
||||
Ok(crate::checkout::CheckoutToolOutput {
|
||||
output: ToolOutput {
|
||||
summary,
|
||||
content: Some(rendered.body),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
paths: Vec::new(),
|
||||
validator,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct Rendered {
|
||||
body: String,
|
||||
line_count: usize,
|
||||
total_lines: usize,
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
fn render_provider_read(
|
||||
text: &str,
|
||||
start_line: usize,
|
||||
total_lines: usize,
|
||||
truncated: bool,
|
||||
) -> Rendered {
|
||||
use std::fmt::Write as _;
|
||||
let lines = text.lines().collect::<Vec<_>>();
|
||||
let mut body = String::with_capacity(text.len().saturating_add(lines.len() * 8));
|
||||
for (index, line) in lines.iter().enumerate() {
|
||||
let _ = writeln!(&mut body, "{:>6}\t{}", start_line + index + 1, line);
|
||||
}
|
||||
Rendered {
|
||||
body,
|
||||
line_count: lines.len(),
|
||||
) -> fs_operation::text::Rendered {
|
||||
fs_operation::text::render_numbered(
|
||||
text,
|
||||
start_line,
|
||||
total_lines,
|
||||
truncated: start_line > 0 || truncated,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a slice of lines from `text` with `cat -n` style 1-based line
|
||||
/// numbers. Pure function — no I/O, no history touching.
|
||||
#[cfg(test)]
|
||||
fn render_numbered(text: &str, offset: usize, limit: usize) -> Rendered {
|
||||
let all_lines: Vec<&str> = text.lines().collect();
|
||||
let total_lines = all_lines.len();
|
||||
let start = offset.min(total_lines);
|
||||
let end = start.saturating_add(limit).min(total_lines);
|
||||
let slice = &all_lines[start..end];
|
||||
let line_count = slice.len();
|
||||
|
||||
use std::fmt::Write as _;
|
||||
let mut body = String::with_capacity(text.len().saturating_add(line_count * 8));
|
||||
for (i, line) in slice.iter().enumerate() {
|
||||
let lineno = start + i + 1;
|
||||
let _ = writeln!(&mut body, "{:>6}\t{}", lineno, line);
|
||||
}
|
||||
|
||||
Rendered {
|
||||
body,
|
||||
line_count,
|
||||
total_lines,
|
||||
truncated: start > 0 || end < total_lines,
|
||||
}
|
||||
truncated,
|
||||
PROVIDER_BYTE_LIMIT,
|
||||
)
|
||||
}
|
||||
|
||||
/// Factory for the `Read` tool bound to one compatibility session.
|
||||
@@ -195,6 +166,32 @@ mod tests {
|
||||
(dir, session, Tracker::new())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_numbering_and_unicode_expansion_keep_rendered_output_bounded() {
|
||||
for text in [
|
||||
"\n".repeat(PROVIDER_BYTE_LIMIT),
|
||||
"界".repeat(PROVIDER_BYTE_LIMIT),
|
||||
"\u{1}".repeat(PROVIDER_BYTE_LIMIT),
|
||||
] {
|
||||
let rendered = render_provider_read(&text, 0, text.lines().count(), false);
|
||||
assert!(rendered.body.len() <= PROVIDER_BYTE_LIMIT);
|
||||
assert!(serde_json::to_vec(&rendered.body).unwrap().len() < 2 * 1024 * 1024);
|
||||
}
|
||||
let rendered = render_provider_read(
|
||||
&"\n".repeat(PROVIDER_BYTE_LIMIT),
|
||||
0,
|
||||
PROVIDER_BYTE_LIMIT,
|
||||
false,
|
||||
);
|
||||
assert!(rendered.truncated);
|
||||
assert!(rendered.line_count < PROVIDER_BYTE_LIMIT);
|
||||
assert!(
|
||||
rendered
|
||||
.body
|
||||
.contains("truncated at rendered output byte limit")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_tool_basic_records_history() {
|
||||
let (dir, fs, tracker) = setup();
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Provider dispatch shared by native and ordinary search processing.
|
||||
//! Request construction/defaults and rendering remain in Glob/Grep; this only
|
||||
//! selects the provider surface and verifies the returned readonly result kind.
|
||||
use agen::tool::ToolError;
|
||||
use workdir::{
|
||||
CheckoutSearchOperation, CheckoutSearchRequest, CheckoutSearchResult, GlobRequest, GlobResult,
|
||||
GrepRequest, GrepResult, WorkdirSessionHandle,
|
||||
};
|
||||
|
||||
use crate::{ToolsError, file_target::checked_error};
|
||||
|
||||
pub(crate) struct SearchTarget {
|
||||
session: WorkdirSessionHandle,
|
||||
checkout: bool,
|
||||
}
|
||||
|
||||
impl SearchTarget {
|
||||
pub fn tool(session: WorkdirSessionHandle) -> Self {
|
||||
Self {
|
||||
session,
|
||||
checkout: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn checkout(session: WorkdirSessionHandle) -> Self {
|
||||
Self {
|
||||
session,
|
||||
checkout: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn glob(&self, request: GlobRequest) -> Result<GlobResult, ToolError> {
|
||||
if self.checkout {
|
||||
let result = self
|
||||
.session
|
||||
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::Glob(
|
||||
request,
|
||||
)))
|
||||
.await
|
||||
.map_err(checked_error)?;
|
||||
glob_result(result)
|
||||
} else {
|
||||
self.session
|
||||
.glob(request)
|
||||
.await
|
||||
.map_err(|error| ToolsError::from(error).into())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn grep(&self, request: GrepRequest) -> Result<GrepResult, ToolError> {
|
||||
if self.checkout {
|
||||
let result = self
|
||||
.session
|
||||
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::Grep(
|
||||
request,
|
||||
)))
|
||||
.await
|
||||
.map_err(checked_error)?;
|
||||
grep_result(result)
|
||||
} else {
|
||||
self.session
|
||||
.grep(request)
|
||||
.await
|
||||
.map_err(|error| ToolsError::from(error).into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn glob_result(result: CheckoutSearchResult) -> Result<GlobResult, ToolError> {
|
||||
match result {
|
||||
CheckoutSearchResult::Glob(result) => Ok(result),
|
||||
_ => Err(crate::file_target::readonly_result_error("Glob")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn grep_result(result: CheckoutSearchResult) -> Result<GrepResult, ToolError> {
|
||||
match result {
|
||||
CheckoutSearchResult::Grep(result) => Ok(result),
|
||||
_ => Err(crate::file_target::readonly_result_error("Grep")),
|
||||
}
|
||||
}
|
||||
@@ -256,6 +256,25 @@ impl Tracker {
|
||||
.copied()
|
||||
}
|
||||
|
||||
/// Record the full provider content hash and total line count, even for a sliced Read.
|
||||
/// Keeping both under one lock prevents pairing counts from a different observation.
|
||||
pub fn record_workdir_observation(
|
||||
&self,
|
||||
path: &workdir::WorkdirPath,
|
||||
hash: workdir::ContentHash,
|
||||
total_lines: usize,
|
||||
) {
|
||||
let key = self.key(PathBuf::from(path.as_str()));
|
||||
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
inner.line_counts.insert(key.clone(), total_lines);
|
||||
inner.hashes.insert(key.clone(), hash);
|
||||
inner.recency.retain(|candidate| candidate != &key);
|
||||
inner.recency.push_front(key);
|
||||
if inner.recency.len() > RECENCY_CAPACITY {
|
||||
inner.recency.pop_back();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_workdir_hash(&self, path: &workdir::WorkdirPath, hash: workdir::ContentHash) {
|
||||
let key = self.key(PathBuf::from(path.as_str()));
|
||||
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
+57
-27
@@ -9,10 +9,7 @@ use serde::Deserialize;
|
||||
|
||||
use crate::error::ToolsError;
|
||||
use crate::tracker::Tracker;
|
||||
use workdir::{
|
||||
StatRequest, WorkdirError, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter,
|
||||
WriteRequest,
|
||||
};
|
||||
use workdir::{StatRequest, WorkdirError, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter};
|
||||
|
||||
const DESCRIPTION: &str = "Create a new file or overwrite an existing one in the selected Workdir attachment. \
|
||||
Missing parent directories within scope are created automatically. Existing files must have been read first \
|
||||
@@ -25,8 +22,8 @@ pub(crate) struct WriteParams {
|
||||
pub target_workdir: Option<String>,
|
||||
/// Logical path relative to the bound Workdir root.
|
||||
pub file_path: String,
|
||||
/// Full content to write. Overwrites any existing content.
|
||||
pub content: String,
|
||||
#[serde(flatten)]
|
||||
pub write: fs_operation::text::WriteArgs,
|
||||
}
|
||||
|
||||
pub(crate) struct WriteTool {
|
||||
@@ -41,8 +38,7 @@ impl Tool for WriteTool {
|
||||
input_json: &str,
|
||||
ctx: agen::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: WriteParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid Write input: {e}")))?;
|
||||
let params: WriteParams = crate::error::decode_file_input(input_json, "Write")?;
|
||||
|
||||
let selected = crate::routing::resolve_session(
|
||||
&self.router,
|
||||
@@ -53,34 +49,65 @@ impl Tool for WriteTool {
|
||||
.tracker
|
||||
.scoped_attachment(&selected.alias, selected.generation);
|
||||
let path = WorkdirPath::new(¶ms.file_path).map_err(ToolsError::from)?;
|
||||
tracing::debug!(path = %path, bytes = params.content.len(), "Write");
|
||||
Ok(execute_write(
|
||||
crate::file_target::FileTarget {
|
||||
session: selected.session,
|
||||
path,
|
||||
validator: None,
|
||||
},
|
||||
tracker,
|
||||
params.write.content,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
.await?
|
||||
.output)
|
||||
}
|
||||
}
|
||||
|
||||
/// `create_path` is a checked parent-relative create-new target, never an overwrite.
|
||||
pub(crate) async fn execute_write(
|
||||
target: crate::file_target::FileTarget,
|
||||
tracker: Tracker,
|
||||
content: String,
|
||||
create_path: Option<WorkdirPath>,
|
||||
ctx: agen::tool::ToolExecutionContext,
|
||||
) -> Result<crate::checkout::CheckoutToolOutput, ToolError> {
|
||||
// Provider size/access checks still run inside the checked save. The shared
|
||||
// core owns text semantics, not the provider's bounds or lifecycle.
|
||||
let content = fs_operation::text::write(content, Default::default())
|
||||
.map_err(crate::error::text_error)?
|
||||
.content;
|
||||
let path = create_path.as_ref().unwrap_or(&target.path);
|
||||
tracing::debug!(path = %path, bytes = content.len(), "Write");
|
||||
let mutation_key = PathBuf::from(path.as_str());
|
||||
let _mutation_permit = tracker.acquire_mutation(&mutation_key, &ctx).await;
|
||||
let expected_hash = match selected
|
||||
let expected_hash = if create_path.is_some() {
|
||||
None
|
||||
} else if target.validator.is_some() {
|
||||
// Native Write is existing-file-only, and always requires a prior observation.
|
||||
Some(tracker.expected_workdir_hash(path)?)
|
||||
} else {
|
||||
match target
|
||||
.session
|
||||
.stat(StatRequest { path: path.clone() })
|
||||
.await
|
||||
{
|
||||
Ok(_) => Some(tracker.expected_workdir_hash(&path)?),
|
||||
Ok(_) => Some(tracker.expected_workdir_hash(path)?),
|
||||
Err(WorkdirError::NotFound(_)) => None,
|
||||
Err(error) => return Err(ToolsError::from(error).into()),
|
||||
}
|
||||
};
|
||||
|
||||
let old_line_count = tracker.observed_workdir_line_count(&path).unwrap_or(0);
|
||||
let outcome = selected
|
||||
.session
|
||||
.write(WriteRequest {
|
||||
path: path.clone(),
|
||||
content: params.content.as_bytes().to_vec(),
|
||||
let old_line_count = tracker.observed_workdir_line_count(path).unwrap_or(0);
|
||||
let (outcome, validator) = target
|
||||
.write(
|
||||
content.as_bytes().to_vec(),
|
||||
expected_hash,
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
|
||||
tracker.record_change(params.content.lines().count(), old_line_count);
|
||||
tracker.record_workdir_content(&path, params.content.as_bytes());
|
||||
|
||||
create_path.clone(),
|
||||
)
|
||||
.await?;
|
||||
tracker.record_change(content.lines().count(), old_line_count);
|
||||
tracker.record_workdir_content(path, content.as_bytes());
|
||||
let summary = format!(
|
||||
"{} {} ({} bytes)",
|
||||
if outcome.created {
|
||||
@@ -91,13 +118,16 @@ impl Tool for WriteTool {
|
||||
path,
|
||||
outcome.bytes_written
|
||||
);
|
||||
Ok(ToolOutput {
|
||||
Ok(crate::checkout::CheckoutToolOutput {
|
||||
output: ToolOutput {
|
||||
summary,
|
||||
content: None,
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
paths: create_path.into_iter().collect(),
|
||||
validator,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Factory for the `Write` tool bound to one compatibility session.
|
||||
pub fn write_tool(session: WorkdirSessionHandle, tracker: Tracker) -> ToolDefinition {
|
||||
|
||||
@@ -278,7 +278,7 @@ async fn target_alias_routes_each_tool_and_isolates_read_before_edit() {
|
||||
|
||||
let bash = call(
|
||||
®.get("Bash"),
|
||||
json!({ "target_workdir": "right", "command": "pwd" }),
|
||||
json!({ "target_workdir": "right", "command": "pwd", "cwd": "." }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
@@ -1005,7 +1005,7 @@ async fn bash_inherits_workdir_cwd() {
|
||||
// `pwd` should canonicalize to the workspace root we set up.
|
||||
let (dir, _spill, reg) = setup();
|
||||
let bash = reg.get("Bash");
|
||||
let out = call(&bash, json!({ "command": "pwd" })).await;
|
||||
let out = call(&bash, json!({ "command": "pwd", "cwd": "." })).await;
|
||||
let body = out.content.unwrap();
|
||||
let actual = std::fs::canonicalize(body.trim()).unwrap();
|
||||
let expected = std::fs::canonicalize(dir.path()).unwrap();
|
||||
@@ -1016,7 +1016,11 @@ async fn bash_inherits_workdir_cwd() {
|
||||
async fn bash_provider_output_exposes_readable_retained_path() {
|
||||
let (_dir, spill, reg) = setup();
|
||||
let bash = reg.get("Bash");
|
||||
let out = call(&bash, json!({ "command": "printf 'x%.0s' {1..20480}" })).await;
|
||||
let out = call(
|
||||
&bash,
|
||||
json!({ "command": "printf 'x%.0s' {1..20480}", "cwd": "." }),
|
||||
)
|
||||
.await;
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains("bounded WorkdirSession command output"));
|
||||
assert!(body.contains("full output saved to"));
|
||||
@@ -1038,7 +1042,7 @@ async fn bash_cancellation_returns_bounded_progress_as_terminal_output() {
|
||||
"printf 'before\\n'; printf 'err-before\\n' >&2; sleep 1; touch {}; printf 'after\\n'",
|
||||
marker.display()
|
||||
);
|
||||
let input = serde_json::to_string(&json!({ "command": command })).unwrap();
|
||||
let input = serde_json::to_string(&json!({ "command": command, "cwd": "." })).unwrap();
|
||||
let context = ToolExecutionContext::new("call-heavy", "attempt-heavy", 0);
|
||||
let bash = reg.get("Bash");
|
||||
let executing = bash.clone();
|
||||
@@ -1084,7 +1088,7 @@ async fn bash_force_close_cleanup_stops_command_and_keeps_session_reusable() {
|
||||
let (dir, _spill, reg) = setup();
|
||||
let marker = dir.path().join("must-not-survive-force-close");
|
||||
let command = format!("sleep 1; touch {}", marker.display());
|
||||
let input = serde_json::to_string(&json!({ "command": command })).unwrap();
|
||||
let input = serde_json::to_string(&json!({ "command": command, "cwd": "." })).unwrap();
|
||||
let bash = reg.get("Bash");
|
||||
let context = ToolExecutionContext::new("call-force", "attempt-force", 0);
|
||||
let (handle, terminal) = ToolExecutionHandle::start(bash.clone(), input, context);
|
||||
@@ -1103,7 +1107,10 @@ async fn bash_force_close_cleanup_stops_command_and_keeps_session_reusable() {
|
||||
);
|
||||
|
||||
let output = bash
|
||||
.execute(r#"{"command":"printf 'reused'"}"#, Default::default())
|
||||
.execute(
|
||||
r#"{"command":"printf 'reused'","cwd":"."}"#,
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.expect("workdir session remains reusable after cleanup");
|
||||
assert_eq!(output.content.as_deref(), Some("reused"));
|
||||
|
||||
@@ -31,5 +31,6 @@ async-trait.workspace = true
|
||||
agen.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
worker-runtime.workspace = true
|
||||
async-trait.workspace = true
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -28,3 +28,33 @@ The TUI should display committed events and Worker snapshots rather than inventi
|
||||
|
||||
- [`../../docs/design/context-history.md`](../../docs/design/context-history.md)
|
||||
- [`../../docs/design/worker-session-state.md`](../../docs/design/worker-session-state.md)
|
||||
|
||||
## Feature invocation editing
|
||||
|
||||
`/` discovers enabled Feature declarations with descriptions and usage. Choose a
|
||||
name or alias with Tab/Enter, complete its parenthesized arguments, and press
|
||||
Enter to confirm that selected occurrence as a typed chip. A later Enter sends
|
||||
the message. Raw slash text and paste content are never dispatched as commands.
|
||||
Use Alt+Enter beside a Feature chip to rediscover its declaration and reopen its
|
||||
arguments; Backspace/Delete removes a chip atomically. Typed chips survive local
|
||||
history, draft restoration, transport retry, and rewind.
|
||||
|
||||
Every completion and chip-re-edit query carries a fresh `request_id`. Replies
|
||||
must echo that nonce and match the active query context, composer edit/cursor
|
||||
revision, authority snapshot generation, and Worker target. Identical-prefix ABA
|
||||
edits, cancelled/reopened queries, permission snapshots, and Worker/view switches
|
||||
cannot reuse older replies or candidates. Uncorrelated replies are ignored, even
|
||||
for File queries where the wire field remains optional for legacy callers.
|
||||
|
||||
The declared attachment adapter stages a client-local file in place, replacing
|
||||
its staging chip with an `UploadedFile` reference when ready. Local file argument
|
||||
completion runs on the client, not Worker. Alt+Enter beside a failed staging chip
|
||||
retries with the same upload ID; deleting an unsent chip cancels only its owned
|
||||
staging resource. Accepted, restored, or history attachments are never deleted by
|
||||
composer cleanup. Staging blocks Send/Notify and history navigation until it is
|
||||
ready, retried, or removed. There is no raw `/attach` or `/clear-attachments`
|
||||
command lane.
|
||||
|
||||
Attachment staging remains Backend-only: the existing Standalone transport has
|
||||
no client-local file-upload staging API. The TUI reports that limitation and
|
||||
preserves the draft invocation; it never falls back to raw path dispatch.
|
||||
|
||||
+813
-42
File diff suppressed because it is too large
Load Diff
@@ -24,12 +24,19 @@ use crate::inline_terminal::with_inline_terminal;
|
||||
const MAX_ROWS: usize = 10;
|
||||
const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4;
|
||||
|
||||
#[derive(Debug)]
|
||||
enum RestoreAttemptError {
|
||||
Conflict,
|
||||
Unknown(io::Error),
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
trait BackendWorkerLifecycle {
|
||||
async fn restore(
|
||||
&self,
|
||||
target: &client::BackendRuntimeTarget,
|
||||
) -> Result<BackendWorkerRestoreResponse, io::Error>;
|
||||
request: client::BackendWorkerRestoreRequest,
|
||||
) -> Result<BackendWorkerRestoreResponse, RestoreAttemptError>;
|
||||
|
||||
async fn observe(
|
||||
&self,
|
||||
@@ -44,12 +51,18 @@ impl BackendWorkerLifecycle for LiveBackendWorkerLifecycle {
|
||||
async fn restore(
|
||||
&self,
|
||||
target: &client::BackendRuntimeTarget,
|
||||
) -> Result<BackendWorkerRestoreResponse, io::Error> {
|
||||
restore_backend_worker(target).await.map_err(|error| {
|
||||
io::Error::other(format!(
|
||||
request: client::BackendWorkerRestoreRequest,
|
||||
) -> Result<BackendWorkerRestoreResponse, RestoreAttemptError> {
|
||||
restore_backend_worker(target, request)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if error.is_restore_observation_conflict() {
|
||||
return RestoreAttemptError::Conflict;
|
||||
}
|
||||
RestoreAttemptError::Unknown(io::Error::other(format!(
|
||||
"failed to restore Backend Worker {}: {error}",
|
||||
target.display_label()
|
||||
))
|
||||
)))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -133,8 +146,21 @@ pub(crate) async fn run(
|
||||
let attach_target = target
|
||||
.runtime_target(selected.runtime_id.clone(), selected.worker_id.clone())
|
||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||
let attach_target =
|
||||
prepare_selected_worker(&LiveBackendWorkerLifecycle, attach_target, intent).await?;
|
||||
let restore_request =
|
||||
selected
|
||||
.restore_observation_token
|
||||
.map(|token| client::BackendWorkerRestoreRequest {
|
||||
expected_observation_token: token,
|
||||
request_id: uuid::Uuid::now_v7().to_string(),
|
||||
});
|
||||
let attach_target = prepare_selected_worker(
|
||||
&LiveBackendWorkerLifecycle,
|
||||
attach_target,
|
||||
intent,
|
||||
restore_request,
|
||||
confirm_restore_recovery,
|
||||
)
|
||||
.await?;
|
||||
return console::run_backend_runtime(attach_target).await;
|
||||
}
|
||||
}
|
||||
@@ -143,10 +169,62 @@ async fn prepare_selected_worker(
|
||||
lifecycle: &impl BackendWorkerLifecycle,
|
||||
mut target: client::BackendRuntimeTarget,
|
||||
intent: BackendWorkerPickerIntent,
|
||||
restore_request: Option<client::BackendWorkerRestoreRequest>,
|
||||
mut confirm_recovery: impl FnMut(&str) -> io::Result<bool>,
|
||||
) -> Result<client::BackendRuntimeTarget, io::Error> {
|
||||
if intent == BackendWorkerPickerIntent::Resume {
|
||||
let response = lifecycle.restore(&target).await?;
|
||||
ensure_restore_accepted(&response)?;
|
||||
let request = restore_request.ok_or_else(|| {
|
||||
io::Error::other("Worker has no current Restore observation; refresh and select again")
|
||||
})?;
|
||||
loop {
|
||||
let unknown = match lifecycle.restore(&target, request.clone()).await {
|
||||
Ok(response) if response.result.state == BackendWorkerRestoreState::Accepted => {
|
||||
break;
|
||||
}
|
||||
Ok(response) => {
|
||||
let error = ensure_restore_accepted(&response).unwrap_err();
|
||||
if response.result.state != BackendWorkerRestoreState::ReconciliationRequired {
|
||||
return Err(error);
|
||||
}
|
||||
error
|
||||
}
|
||||
Err(RestoreAttemptError::Conflict) => {
|
||||
return Err(io::Error::other(
|
||||
"Restore observation changed; refresh the Worker list and select again. No new Restore was started.",
|
||||
));
|
||||
}
|
||||
Err(RestoreAttemptError::Unknown(error)) => error,
|
||||
};
|
||||
// Refresh is evidence only: GET cannot reconcile a pending Restore.
|
||||
// A changed observation must never replace this admitted/unknown tuple.
|
||||
let observation = match lifecycle.observe(&target).await {
|
||||
Ok(observation) => match observation.observation {
|
||||
WorkerSessionAvailability::LiveProtocol => "live_protocol".into(),
|
||||
WorkerSessionAvailability::RetainedSnapshot { .. } => {
|
||||
"retained_snapshot".into()
|
||||
}
|
||||
WorkerSessionAvailability::Unavailable { reason, message } => format!(
|
||||
"{reason:?}: {}",
|
||||
message.chars().take(512).collect::<String>()
|
||||
),
|
||||
},
|
||||
Err(error) => format!("refresh failed: {error}"),
|
||||
};
|
||||
let detail = format!(
|
||||
"Restore outcome unknown/pending: {}. Current observation: {}. Recovery request_id={}, expected_observation_token={}. Retry only this exact request; do not start a new Restore intent.",
|
||||
unknown.to_string().chars().take(512).collect::<String>(),
|
||||
observation.chars().take(512).collect::<String>(),
|
||||
request.request_id,
|
||||
request.expected_observation_token,
|
||||
);
|
||||
// Every subsequent POST requires a new explicit user confirmation.
|
||||
let retry = confirm_recovery(&detail).map_err(|error| {
|
||||
io::Error::other(format!("{detail}; recovery prompt failed: {error}"))
|
||||
})?;
|
||||
if !retry {
|
||||
return Err(io::Error::other(detail));
|
||||
}
|
||||
}
|
||||
let observation = lifecycle.observe(&target).await?;
|
||||
require_live_resume_observation(observation.observation)?;
|
||||
return Ok(target);
|
||||
@@ -157,8 +235,34 @@ async fn prepare_selected_worker(
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
fn confirm_restore_recovery(detail: &str) -> io::Result<bool> {
|
||||
with_inline_terminal(8, |terminal| {
|
||||
loop {
|
||||
terminal.draw(|frame| {
|
||||
let message = vec![
|
||||
Line::from("Restore needs explicit result recovery"),
|
||||
Line::from("Enter: retry the SAME request Esc/Ctrl-C: cancel recovery"),
|
||||
Line::from(detail),
|
||||
];
|
||||
frame.render_widget(
|
||||
Paragraph::new(message).wrap(ratatui::widgets::Wrap { trim: false }),
|
||||
frame.area(),
|
||||
);
|
||||
})?;
|
||||
match poll_event()? {
|
||||
Some(Action::Submit) => return Ok(true),
|
||||
Some(Action::Cancel) => return Ok(false),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn is_resume_candidate(worker: &BackendWorkerSummary) -> bool {
|
||||
worker.worker_state.is_none()
|
||||
worker.availability == protocol::subscription::SubscriptionWorkerAvailability::Observed
|
||||
&& worker.state == "stopped"
|
||||
&& worker.worker_state.is_none()
|
||||
&& worker.restore_observation_token.is_some()
|
||||
}
|
||||
|
||||
fn ensure_restore_accepted(response: &BackendWorkerRestoreResponse) -> Result<(), io::Error> {
|
||||
@@ -526,6 +630,18 @@ fn working_directory_text(worker: &BackendWorkerSummary) -> String {
|
||||
BackendWorkingDirectorySource::Repository { repository_key } => {
|
||||
(repository_key.as_str(), String::new())
|
||||
}
|
||||
BackendWorkingDirectorySource::WorkspaceConfig { .. } => {
|
||||
// Logical config access is not host filesystem or COMMAND authority.
|
||||
let permissions = &attachment.effective_permissions;
|
||||
let mut categories = Vec::new();
|
||||
if permissions.read {
|
||||
categories.push("READ");
|
||||
}
|
||||
if permissions.write {
|
||||
categories.push("EDIT");
|
||||
}
|
||||
("workspace-config", format!("・{}", categories.join("・")))
|
||||
}
|
||||
BackendWorkingDirectorySource::ExternalGrant { .. } => {
|
||||
let permissions = &attachment.effective_permissions;
|
||||
let mut categories = Vec::new();
|
||||
@@ -558,6 +674,7 @@ mod tests {
|
||||
|
||||
fn worker(runtime_id: &str, worker_id: &str, profile: Option<&str>) -> BackendWorkerSummary {
|
||||
BackendWorkerSummary {
|
||||
restore_observation_token: Some("observed-worker-generation".into()),
|
||||
runtime_id: runtime_id.to_string(),
|
||||
worker_id: worker_id.to_string(),
|
||||
resource_key: "W-1".to_string(),
|
||||
@@ -630,7 +747,8 @@ mod tests {
|
||||
async fn restore(
|
||||
&self,
|
||||
_target: &client::BackendRuntimeTarget,
|
||||
) -> Result<BackendWorkerRestoreResponse, io::Error> {
|
||||
_request: client::BackendWorkerRestoreRequest,
|
||||
) -> Result<BackendWorkerRestoreResponse, RestoreAttemptError> {
|
||||
self.calls.lock().unwrap().push("restore");
|
||||
Ok(self.restore_response.clone())
|
||||
}
|
||||
@@ -661,6 +779,312 @@ mod tests {
|
||||
serde_json::from_value(value).unwrap()
|
||||
}
|
||||
|
||||
// Exercise the existing picker flow against real Runtime admission, not a
|
||||
// fake request -> operation-ID association. Only execution and transport are
|
||||
// faked; Runtime owns the pending operation and receipt reconciliation.
|
||||
#[derive(Default)]
|
||||
struct PendingRuntimeBackend {
|
||||
operations: std::sync::Mutex<Vec<worker_runtime::execution::WorkerLifecycleOperationId>>,
|
||||
}
|
||||
|
||||
impl worker_runtime::execution::WorkerExecutionBackend for PendingRuntimeBackend {
|
||||
fn backend_id(&self) -> &str {
|
||||
"tui-recovery-test"
|
||||
}
|
||||
|
||||
fn spawn_worker(
|
||||
&self,
|
||||
_: worker_runtime::execution::WorkerExecutionSpawnRequest,
|
||||
) -> worker_runtime::execution::WorkerExecutionSpawnResult {
|
||||
worker_runtime::execution::WorkerExecutionSpawnResult::connected(
|
||||
protocol::WorkerStateSnapshot {
|
||||
last_command_id: 0,
|
||||
last_finished_submission_request_id: None,
|
||||
state: protocol::WorkerState::Idle,
|
||||
},
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
|
||||
fn restore_worker(
|
||||
&self,
|
||||
request: worker_runtime::execution::WorkerExecutionRestoreRequest,
|
||||
) -> worker_runtime::execution::WorkerExecutionSpawnResult {
|
||||
use worker_runtime::execution::*;
|
||||
let mut operations = self.operations.lock().unwrap();
|
||||
operations.push(request.operation_id);
|
||||
if operations.len() == 1 {
|
||||
WorkerExecutionSpawnResult::ReconciliationRequired {
|
||||
result: WorkerExecutionResult::errored(
|
||||
WorkerExecutionOperation::Restore,
|
||||
"unknown execution acknowledgement",
|
||||
),
|
||||
worker_state: None,
|
||||
workdir_attachments: Vec::new(),
|
||||
}
|
||||
} else {
|
||||
WorkerExecutionSpawnResult::connected(
|
||||
protocol::WorkerStateSnapshot {
|
||||
last_command_id: 0,
|
||||
last_finished_submission_request_id: None,
|
||||
state: protocol::WorkerState::Idle,
|
||||
},
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_worker(
|
||||
&self,
|
||||
_: &worker_runtime::identity::WorkerRef,
|
||||
) -> worker_runtime::execution::WorkerExecutionResult {
|
||||
worker_runtime::execution::WorkerExecutionResult::accepted(
|
||||
worker_runtime::execution::WorkerExecutionOperation::Stop,
|
||||
)
|
||||
}
|
||||
|
||||
fn dispatch_input(
|
||||
&self,
|
||||
_: &worker_runtime::identity::WorkerRef,
|
||||
_: worker_runtime::interaction::WorkerInput,
|
||||
) -> worker_runtime::execution::WorkerExecutionResult {
|
||||
panic!("Restore must not dispatch input")
|
||||
}
|
||||
}
|
||||
|
||||
struct RuntimeRecoveryLifecycle {
|
||||
runtime: worker_runtime::Runtime,
|
||||
worker: worker_runtime::identity::WorkerRef,
|
||||
lose_first_reply: bool,
|
||||
requests: std::sync::Mutex<
|
||||
Vec<(
|
||||
client::BackendRuntimeTarget,
|
||||
client::BackendWorkerRestoreRequest,
|
||||
)>,
|
||||
>,
|
||||
observations: std::sync::Mutex<usize>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl BackendWorkerLifecycle for RuntimeRecoveryLifecycle {
|
||||
async fn restore(
|
||||
&self,
|
||||
target: &client::BackendRuntimeTarget,
|
||||
request: client::BackendWorkerRestoreRequest,
|
||||
) -> Result<BackendWorkerRestoreResponse, RestoreAttemptError> {
|
||||
self.requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((target.clone(), request.clone()));
|
||||
assert_eq!(target.worker_id, self.worker.worker_id.to_string());
|
||||
let result = self
|
||||
.runtime
|
||||
.restore_worker_operation(
|
||||
&self.worker,
|
||||
serde_json::from_value(serde_json::to_value(request).unwrap()).unwrap(),
|
||||
)
|
||||
.map_err(|error| match error {
|
||||
worker_runtime::error::RuntimeError::RestoreObservationConflict { .. } => {
|
||||
RestoreAttemptError::Conflict
|
||||
}
|
||||
error => RestoreAttemptError::Unknown(io::Error::other(error.to_string())),
|
||||
})?;
|
||||
if self.lose_first_reply && self.requests.lock().unwrap().len() == 1 {
|
||||
return Err(RestoreAttemptError::Unknown(io::Error::new(
|
||||
io::ErrorKind::ConnectionReset,
|
||||
"reply lost after admission",
|
||||
)));
|
||||
}
|
||||
let mut response = restore_response(result.state);
|
||||
response.runtime_id = target.runtime_id.clone();
|
||||
response.worker_id = target.worker_id.clone();
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn observe(
|
||||
&self,
|
||||
_: &client::BackendRuntimeTarget,
|
||||
) -> Result<WorkspaceWorkerSessionResponse, io::Error> {
|
||||
*self.observations.lock().unwrap() += 1;
|
||||
Ok(session_observation(
|
||||
serde_json::json!({"availability": "live_protocol"}),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_runtime_lifecycle(
|
||||
lose_first_reply: bool,
|
||||
) -> (
|
||||
RuntimeRecoveryLifecycle,
|
||||
std::sync::Arc<PendingRuntimeBackend>,
|
||||
client::BackendRuntimeTarget,
|
||||
client::BackendWorkerRestoreRequest,
|
||||
) {
|
||||
use worker_runtime::catalog::*;
|
||||
let backend = std::sync::Arc::new(PendingRuntimeBackend::default());
|
||||
let runtime =
|
||||
worker_runtime::Runtime::with_execution_backend(Default::default(), backend.clone())
|
||||
.unwrap();
|
||||
let archive = worker_runtime::profile_archive::ProfileSourceArchive::build(
|
||||
worker_runtime::profile_archive::ProfileSourceArchiveInput {
|
||||
id: "tui-recovery-profile".into(),
|
||||
entrypoints: std::collections::BTreeMap::from([(
|
||||
"builtin:coder".into(),
|
||||
"coder.dcdl".into(),
|
||||
)]),
|
||||
imports: Default::default(),
|
||||
sources: std::collections::BTreeMap::from([("coder.dcdl".into(), "{}".into())]),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let worker = runtime
|
||||
.create_worker(CreateWorkerRequest {
|
||||
worker_id: protocol::WorkerId::now_v7(),
|
||||
create_fingerprint: "tui-recovery-create".into(),
|
||||
profile: ProfileSelector::Builtin("builtin:coder".into()),
|
||||
display_name: None,
|
||||
profile_source: ProfileSourceArchiveSource::Embedded { archive },
|
||||
config_bundle: None,
|
||||
initial_input: None,
|
||||
workdir_attachment_requests: Vec::new(),
|
||||
workdir_attachments: Vec::new(),
|
||||
worker_observation_enabled: false,
|
||||
worker_observation_grants: Vec::new(),
|
||||
workspace_api: None,
|
||||
memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: "local".into(),
|
||||
settings_revision: 1,
|
||||
language: "English".into(),
|
||||
}),
|
||||
subjektiv_attached: false,
|
||||
})
|
||||
.unwrap();
|
||||
runtime.stop_worker(&worker.worker_ref, None).unwrap();
|
||||
let request = client::BackendWorkerRestoreRequest {
|
||||
expected_observation_token: runtime
|
||||
.worker_detail(&worker.worker_ref)
|
||||
.unwrap()
|
||||
.restore_observation_token
|
||||
.unwrap(),
|
||||
request_id: "tui-original-request".into(),
|
||||
};
|
||||
let target = client::BackendRuntimeTarget::new(
|
||||
"http://127.0.0.1:3000",
|
||||
"workspace-a",
|
||||
"runtime-a",
|
||||
worker.worker_id.to_string(),
|
||||
);
|
||||
(
|
||||
RuntimeRecoveryLifecycle {
|
||||
runtime,
|
||||
worker: worker.worker_ref,
|
||||
lose_first_reply,
|
||||
requests: Default::default(),
|
||||
observations: Default::default(),
|
||||
},
|
||||
backend,
|
||||
target,
|
||||
request,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_explicit_transport_and_pending_retries_preserve_tuple_and_runtime_operation() {
|
||||
for lost_reply in [true, false] {
|
||||
let (lifecycle, backend, target, request) = pending_runtime_lifecycle(lost_reply);
|
||||
let mut confirmations = 0;
|
||||
let prepared = prepare_selected_worker(
|
||||
&lifecycle,
|
||||
target.clone(),
|
||||
BackendWorkerPickerIntent::Resume,
|
||||
Some(request.clone()),
|
||||
|detail| {
|
||||
confirmations += 1;
|
||||
assert_eq!(
|
||||
backend.operations.lock().unwrap().len(),
|
||||
1,
|
||||
"no POST before explicit retry confirmation"
|
||||
);
|
||||
assert!(detail.contains(&format!("request_id={}", request.request_id)));
|
||||
assert!(detail.contains(&format!(
|
||||
"expected_observation_token={}",
|
||||
request.expected_observation_token
|
||||
)));
|
||||
assert_ne!(
|
||||
lifecycle
|
||||
.runtime
|
||||
.worker_detail(&lifecycle.worker)
|
||||
.unwrap()
|
||||
.restore_observation_token
|
||||
.as_ref(),
|
||||
Some(&request.expected_observation_token),
|
||||
"fresh observation must not replace recovery tuple"
|
||||
);
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(prepared, target);
|
||||
assert_eq!(confirmations, 1);
|
||||
assert_eq!(
|
||||
lifecycle.requests.lock().unwrap().as_slice(),
|
||||
&[(target.clone(), request.clone()), (target, request)]
|
||||
);
|
||||
let operations = backend.operations.lock().unwrap();
|
||||
assert_eq!(operations.len(), 2);
|
||||
assert_eq!(
|
||||
operations[0], operations[1],
|
||||
"Runtime pending lifecycle operation must not be replaced"
|
||||
);
|
||||
assert_eq!(
|
||||
*lifecycle.observations.lock().unwrap(),
|
||||
2,
|
||||
"GET after uncertainty and after acceptance"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_cancelled_unknown_recovery_has_no_automatic_retry_and_reports_original_identity()
|
||||
{
|
||||
let (lifecycle, backend, target, request) = pending_runtime_lifecycle(true);
|
||||
let error = prepare_selected_worker(
|
||||
&lifecycle,
|
||||
target,
|
||||
BackendWorkerPickerIntent::Resume,
|
||||
Some(request.clone()),
|
||||
|_| Ok(false),
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains(&request.request_id));
|
||||
assert!(error.contains(&request.expected_observation_token));
|
||||
assert_eq!(backend.operations.lock().unwrap().len(), 1);
|
||||
assert_eq!(lifecycle.requests.lock().unwrap().len(), 1);
|
||||
assert_eq!(*lifecycle.observations.lock().unwrap(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_stale_conflict_never_offers_same_intent_retry() {
|
||||
let (lifecycle, backend, target, mut request) = pending_runtime_lifecycle(false);
|
||||
request.expected_observation_token = "stale-token".into();
|
||||
let error = prepare_selected_worker(
|
||||
&lifecycle,
|
||||
target,
|
||||
BackendWorkerPickerIntent::Resume,
|
||||
Some(request),
|
||||
|_| panic!("stale conflict must not offer unknown-result retry"),
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("refresh the Worker list"));
|
||||
assert!(backend.operations.lock().unwrap().is_empty());
|
||||
assert_eq!(*lifecycle.observations.lock().unwrap(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_restores_before_requiring_live_observation() {
|
||||
let lifecycle = FakeBackendWorkerLifecycle {
|
||||
@@ -677,8 +1101,16 @@ mod tests {
|
||||
"worker-a",
|
||||
);
|
||||
|
||||
let prepared =
|
||||
prepare_selected_worker(&lifecycle, target, BackendWorkerPickerIntent::Resume)
|
||||
let prepared = prepare_selected_worker(
|
||||
&lifecycle,
|
||||
target,
|
||||
BackendWorkerPickerIntent::Resume,
|
||||
Some(client::BackendWorkerRestoreRequest {
|
||||
expected_observation_token: "observed-worker-generation".into(),
|
||||
request_id: "resume-operation".into(),
|
||||
}),
|
||||
|_| Ok(false),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -704,7 +1136,16 @@ mod tests {
|
||||
"worker-a",
|
||||
);
|
||||
|
||||
let error = prepare_selected_worker(&lifecycle, target, BackendWorkerPickerIntent::Resume)
|
||||
let error = prepare_selected_worker(
|
||||
&lifecycle,
|
||||
target,
|
||||
BackendWorkerPickerIntent::Resume,
|
||||
Some(client::BackendWorkerRestoreRequest {
|
||||
expected_observation_token: "observed-worker-generation".into(),
|
||||
request_id: "resume-operation".into(),
|
||||
}),
|
||||
|_| Ok(false),
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
@@ -724,7 +1165,7 @@ mod tests {
|
||||
|
||||
assert!(!is_resume_candidate(&live));
|
||||
assert!(is_resume_candidate(&stopped));
|
||||
assert!(is_resume_candidate(&unknown));
|
||||
assert!(!is_resume_candidate(&unknown));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -860,6 +1301,37 @@ mod tests {
|
||||
assert!(!text.contains("active clean"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logical_config_attachment_labels_editability_without_command_or_host_path() {
|
||||
for (access, write, expected) in [
|
||||
("read_only", false, "READ"),
|
||||
("read_write", true, "READ・EDIT"),
|
||||
] {
|
||||
let mut worker = worker("runtime-a", "worker-b", None);
|
||||
worker.workdir_attachments = vec![serde_json::from_value(serde_json::json!({
|
||||
"alias": "workspace-config",
|
||||
"effective_permissions": {"read": true, "write": write, "command": false},
|
||||
"working_directory": {
|
||||
"working_directory_id": "logical-config-1",
|
||||
"display_name": "Workspace configuration",
|
||||
"source": {"kind": "workspace_config", "access": access,
|
||||
"content_path": "/workspace-config", "purpose": "Workspace configuration"},
|
||||
"materializer_kind": "logical_workspace_config",
|
||||
"status": "active"
|
||||
}
|
||||
})).unwrap()];
|
||||
let text = working_directory_text(&worker);
|
||||
assert_eq!(
|
||||
text,
|
||||
format!(
|
||||
"wd:workspace-config:Workspace configuration・{expected}・logical-config-1"
|
||||
)
|
||||
);
|
||||
assert!(!text.contains("COMMAND"));
|
||||
assert!(!text.contains("/workspace-config"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_rows_align_identity_name_state_and_workdir_columns() {
|
||||
let mut short = worker("runtime-a", "worker-a", None);
|
||||
|
||||
+788
-268
File diff suppressed because it is too large
Load Diff
+654
-27
@@ -115,12 +115,31 @@ impl FlowRefAtom {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FeatureInvokeAtom {
|
||||
pub invocation: protocol::FeatureInvocation,
|
||||
}
|
||||
|
||||
impl FeatureInvokeAtom {
|
||||
pub fn label(&self) -> String {
|
||||
self.invocation.display_input()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Atom {
|
||||
Char(char),
|
||||
Unknown,
|
||||
AttachmentStage {
|
||||
id: String,
|
||||
file_name: String,
|
||||
failed: bool,
|
||||
},
|
||||
Paste(PasteRef),
|
||||
PasteArtifact(protocol::PasteArtifactRef),
|
||||
UploadedFile(protocol::UploadedFileRef),
|
||||
FileRef(FileRefAtom),
|
||||
FeatureInvoke(FeatureInvokeAtom),
|
||||
FlowRef(FlowRefAtom),
|
||||
}
|
||||
|
||||
@@ -130,6 +149,23 @@ impl Atom {
|
||||
fn chip(&self) -> Option<(Style, String)> {
|
||||
match self {
|
||||
Atom::Char(_) => None,
|
||||
Atom::Unknown => Some((
|
||||
Style::default().fg(Color::Red),
|
||||
"[unknown input segment]".into(),
|
||||
)),
|
||||
Atom::AttachmentStage {
|
||||
file_name, failed, ..
|
||||
} => Some((
|
||||
Style::default().fg(Color::Cyan),
|
||||
format!(
|
||||
"[Attached {file_name} | {}]",
|
||||
if *failed {
|
||||
"failed; Alt+Enter retries"
|
||||
} else {
|
||||
"uploading"
|
||||
}
|
||||
),
|
||||
)),
|
||||
Atom::Paste(p) => Some((Style::default().fg(Color::Magenta), p.label())),
|
||||
Atom::PasteArtifact(artifact) => Some((
|
||||
Style::default().fg(Color::Magenta),
|
||||
@@ -143,7 +179,17 @@ impl Atom {
|
||||
artifact.created_at_ms
|
||||
),
|
||||
)),
|
||||
Atom::UploadedFile(file) => Some((
|
||||
Style::default().fg(Color::Cyan),
|
||||
format!(
|
||||
"[Attached {} | {} bytes, ready]",
|
||||
file.file_name, file.byte_len
|
||||
),
|
||||
)),
|
||||
Atom::FileRef(r) => Some((Style::default().fg(Color::Cyan), r.label())),
|
||||
Atom::FeatureInvoke(invocation) => {
|
||||
Some((Style::default().fg(Color::Yellow), invocation.label()))
|
||||
}
|
||||
Atom::FlowRef(r) => Some((Style::default().fg(Color::Yellow), r.label())),
|
||||
}
|
||||
}
|
||||
@@ -173,9 +219,14 @@ enum WordKind {
|
||||
fn atom_class(atom: &Atom) -> AtomClass {
|
||||
match atom {
|
||||
Atom::Char(c) => char_class(*c),
|
||||
Atom::Paste(_) | Atom::PasteArtifact(_) | Atom::FileRef(_) | Atom::FlowRef(_) => {
|
||||
AtomClass::Chip
|
||||
}
|
||||
Atom::Unknown
|
||||
| Atom::AttachmentStage { .. }
|
||||
| Atom::Paste(_)
|
||||
| Atom::PasteArtifact(_)
|
||||
| Atom::UploadedFile(_)
|
||||
| Atom::FileRef(_)
|
||||
| Atom::FeatureInvoke(_)
|
||||
| Atom::FlowRef(_) => AtomClass::Chip,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,12 +246,53 @@ fn char_class(c: char) -> AtomClass {
|
||||
}
|
||||
}
|
||||
|
||||
fn first_unquoted_char(value: &str, target: char) -> Option<usize> {
|
||||
unquoted_char_indices(value).find_map(|(index, ch)| (ch == target).then_some(index))
|
||||
}
|
||||
|
||||
fn last_unquoted_char(value: &str, target: char) -> Option<usize> {
|
||||
unquoted_char_indices(value)
|
||||
.filter_map(|(index, ch)| (ch == target).then_some(index))
|
||||
.last()
|
||||
}
|
||||
|
||||
fn unquoted_char_indices(value: &str) -> impl Iterator<Item = (usize, char)> + '_ {
|
||||
let mut quoted = false;
|
||||
let mut escaped = false;
|
||||
value.char_indices().filter(move |(_, ch)| {
|
||||
if !escaped && *ch == '"' {
|
||||
quoted = !quoted;
|
||||
escaped = false;
|
||||
return false;
|
||||
}
|
||||
let visible = !quoted;
|
||||
escaped = quoted && !escaped && *ch == '\\';
|
||||
if *ch != '\\' {
|
||||
escaped = false;
|
||||
}
|
||||
visible
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SelectedInvocation {
|
||||
start: usize,
|
||||
descriptor: protocol::FeatureInvocationDescriptor,
|
||||
invocation_id: String,
|
||||
}
|
||||
|
||||
pub struct InputBuffer {
|
||||
/// Monotonic semantic edit/cursor revision, including ABA changes.
|
||||
revision: u64,
|
||||
atoms: Vec<Atom>,
|
||||
selected_invocations: Vec<SelectedInvocation>,
|
||||
removed_attachment_stages: Vec<String>,
|
||||
/// Insertion point in `0..=atoms.len()`.
|
||||
cursor: usize,
|
||||
/// Top wrapped row of the visible composer viewport.
|
||||
scroll_offset: usize,
|
||||
/// Uploaded draft chips removed by explicit editing and awaiting client-side cleanup.
|
||||
removed_uploaded_files: Vec<protocol::UploadedFileRef>,
|
||||
/// Monotonic counter reused across the TUI process lifetime.
|
||||
next_paste_id: u32,
|
||||
}
|
||||
@@ -208,20 +300,34 @@ pub struct InputBuffer {
|
||||
impl Default for InputBuffer {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
revision: 0,
|
||||
atoms: Vec::new(),
|
||||
selected_invocations: Vec::new(),
|
||||
removed_attachment_stages: Vec::new(),
|
||||
cursor: 0,
|
||||
scroll_offset: 0,
|
||||
removed_uploaded_files: Vec::new(),
|
||||
next_paste_id: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InputBuffer {
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.revision
|
||||
}
|
||||
|
||||
fn bump_revision(&mut self) {
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
}
|
||||
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.bump_revision();
|
||||
self.selected_invocations.clear();
|
||||
self.atoms.clear();
|
||||
self.cursor = 0;
|
||||
self.scroll_offset = 0;
|
||||
@@ -243,6 +349,8 @@ impl InputBuffer {
|
||||
/// by [`submit_segments`](Self::submit_segments), preserving typed chips
|
||||
/// and placing the cursor at the end of the restored input.
|
||||
pub fn replace_with_segments(&mut self, segments: &[protocol::Segment]) {
|
||||
self.bump_revision();
|
||||
self.selected_invocations.clear();
|
||||
self.atoms.clear();
|
||||
for segment in segments {
|
||||
match segment {
|
||||
@@ -267,24 +375,24 @@ impl InputBuffer {
|
||||
self.atoms.push(Atom::PasteArtifact(artifact.clone()));
|
||||
}
|
||||
protocol::Segment::UploadedFile { file } => {
|
||||
self.atoms.extend(
|
||||
format!("[Attached file: {}]", file.file_name)
|
||||
.chars()
|
||||
.map(Atom::Char),
|
||||
);
|
||||
self.atoms.push(Atom::UploadedFile(file.clone()));
|
||||
}
|
||||
protocol::Segment::FileRef { path } => {
|
||||
self.atoms
|
||||
.push(Atom::FileRef(FileRefAtom { path: path.clone() }));
|
||||
}
|
||||
protocol::Segment::FeatureInvoke { invocation } => {
|
||||
self.atoms.push(Atom::FeatureInvoke(FeatureInvokeAtom {
|
||||
invocation: invocation.clone(),
|
||||
}));
|
||||
}
|
||||
protocol::Segment::Flow { selector } => {
|
||||
self.atoms.push(Atom::FlowRef(FlowRefAtom {
|
||||
selector: selector.clone(),
|
||||
}));
|
||||
}
|
||||
protocol::Segment::Unknown => {
|
||||
self.atoms
|
||||
.extend("[unknown input segment]".chars().map(Atom::Char));
|
||||
self.atoms.push(Atom::Unknown);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -292,6 +400,8 @@ impl InputBuffer {
|
||||
}
|
||||
|
||||
pub fn insert_char(&mut self, c: char) {
|
||||
self.bump_revision();
|
||||
self.adjust_selection(self.cursor, self.cursor, 1);
|
||||
self.atoms.insert(self.cursor, Atom::Char(c));
|
||||
self.cursor += 1;
|
||||
}
|
||||
@@ -307,6 +417,10 @@ impl InputBuffer {
|
||||
for atom in &self.atoms {
|
||||
match atom {
|
||||
Atom::Char(c) => text.push(*c),
|
||||
Atom::Unknown => text.push_str("[unknown input segment]"),
|
||||
Atom::AttachmentStage { file_name, .. } => {
|
||||
text.push_str(&format!("[Staging {file_name}]"))
|
||||
}
|
||||
Atom::Paste(paste) => text.push_str(&paste.content),
|
||||
Atom::PasteArtifact(artifact) => {
|
||||
text.push_str(&protocol::Segment::flatten_to_text(&[
|
||||
@@ -315,7 +429,11 @@ impl InputBuffer {
|
||||
},
|
||||
]))
|
||||
}
|
||||
Atom::UploadedFile(file) => {
|
||||
text.push_str(&format!("[Attached file: {}]", file.file_name));
|
||||
}
|
||||
Atom::FileRef(file) => text.push_str(&file.path),
|
||||
Atom::FeatureInvoke(invocation) => text.push_str(&invocation.label()),
|
||||
Atom::FlowRef(flow) => text.push_str(&flow.selector),
|
||||
}
|
||||
}
|
||||
@@ -327,6 +445,7 @@ impl InputBuffer {
|
||||
}
|
||||
|
||||
pub fn insert_paste(&mut self, content: String) {
|
||||
self.bump_revision();
|
||||
let measurement = measure_paste(&content);
|
||||
if measurement.presentation() == PastePresentation::Text {
|
||||
self.insert_str(&content);
|
||||
@@ -335,6 +454,7 @@ impl InputBuffer {
|
||||
|
||||
let id = self.next_paste_id;
|
||||
self.next_paste_id = self.next_paste_id.wrapping_add(1);
|
||||
self.adjust_selection(self.cursor, self.cursor, 1);
|
||||
self.atoms.insert(
|
||||
self.cursor,
|
||||
Atom::Paste(PasteRef {
|
||||
@@ -347,14 +467,236 @@ impl InputBuffer {
|
||||
self.cursor += 1;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn insert_uploaded_file(&mut self, file: protocol::UploadedFileRef) {
|
||||
self.bump_revision();
|
||||
self.adjust_selection(self.cursor, self.cursor, 1);
|
||||
self.atoms.insert(self.cursor, Atom::UploadedFile(file));
|
||||
self.cursor += 1;
|
||||
}
|
||||
|
||||
// Selections belong to an exact occurrence, never to all text matching a name.
|
||||
fn adjust_selection(&mut self, start: usize, end: usize, inserted: usize) {
|
||||
self.selected_invocations.retain_mut(|selection| {
|
||||
if start < end && (start..end).contains(&selection.start) {
|
||||
return false;
|
||||
}
|
||||
if selection.start >= end {
|
||||
selection.start = selection.start - (end - start) + inserted;
|
||||
}
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
pub fn select_feature_invocation(
|
||||
&mut self,
|
||||
start: usize,
|
||||
descriptor: protocol::FeatureInvocationDescriptor,
|
||||
) {
|
||||
self.bump_revision();
|
||||
self.selected_invocations
|
||||
.retain(|selection| selection.start != start);
|
||||
self.selected_invocations.push(SelectedInvocation {
|
||||
start,
|
||||
descriptor,
|
||||
invocation_id: protocol::new_submission_request_id(),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn has_selected_feature_invocations(&self) -> bool {
|
||||
!self.selected_invocations.is_empty()
|
||||
}
|
||||
|
||||
pub fn retry_attachment_stage(&mut self, id: &str) {
|
||||
self.bump_revision();
|
||||
for atom in &mut self.atoms {
|
||||
if let Atom::AttachmentStage {
|
||||
id: stage_id,
|
||||
failed,
|
||||
..
|
||||
} = atom
|
||||
{
|
||||
if stage_id == id {
|
||||
*failed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finalize_feature_invocations(&mut self) -> Result<(), String> {
|
||||
// Validate first so invalid arguments cannot partially chip a draft.
|
||||
let mut parsed = Vec::new();
|
||||
for selection in &self.selected_invocations {
|
||||
let text = self.atoms[selection.start..]
|
||||
.iter()
|
||||
.take_while(|atom| matches!(atom, Atom::Char(_)))
|
||||
.filter_map(|atom| {
|
||||
if let Atom::Char(ch) = atom {
|
||||
Some(*ch)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
let invocation = protocol::parse_feature_invocation(
|
||||
&text,
|
||||
0,
|
||||
&selection.descriptor,
|
||||
selection.invocation_id.clone(),
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
parsed.push((
|
||||
selection.start,
|
||||
text[..invocation.end].chars().count(),
|
||||
invocation.invocation,
|
||||
));
|
||||
}
|
||||
parsed.sort_by_key(|(start, _, _)| std::cmp::Reverse(*start));
|
||||
for (start, count, invocation) in parsed {
|
||||
self.replace_atoms(
|
||||
start,
|
||||
start + count,
|
||||
vec![Atom::FeatureInvoke(FeatureInvokeAtom { invocation })],
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_atoms(&mut self, start: usize, end: usize, atoms: Vec<Atom>) {
|
||||
self.bump_revision();
|
||||
let inserted = atoms.len();
|
||||
self.adjust_selection(start, end, inserted);
|
||||
let removed = self.atoms.splice(start..end, atoms).collect::<Vec<_>>();
|
||||
for atom in removed {
|
||||
self.record_removed_atom(atom);
|
||||
}
|
||||
if self.cursor >= end {
|
||||
self.cursor = self.cursor - (end - start) + inserted;
|
||||
} else if self.cursor > start {
|
||||
self.cursor = start + inserted;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn adjacent_feature_invocation(&self) -> Option<protocol::FeatureInvocation> {
|
||||
[self.cursor.checked_sub(1), Some(self.cursor)]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find_map(|index| match self.atoms.get(index) {
|
||||
Some(Atom::FeatureInvoke(atom)) => Some(atom.invocation.clone()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn edit_feature_invocation(
|
||||
&mut self,
|
||||
id: &str,
|
||||
descriptor: protocol::FeatureInvocationDescriptor,
|
||||
) -> bool {
|
||||
let Some((index, invocation)) =
|
||||
self.atoms
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(index, atom)| match atom {
|
||||
Atom::FeatureInvoke(atom) if atom.invocation.invocation_id == id => {
|
||||
Some((index, atom.invocation.clone()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if invocation.identity != descriptor.identity {
|
||||
return false;
|
||||
}
|
||||
let text = invocation.display_input();
|
||||
self.replace_atoms(index, index + 1, text.chars().map(Atom::Char).collect());
|
||||
self.cursor = index + text.chars().count() - 1;
|
||||
self.selected_invocations.push(SelectedInvocation {
|
||||
start: index,
|
||||
descriptor,
|
||||
invocation_id: invocation.invocation_id,
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
pub fn stage_attachment(&mut self, invocation_id: &str, id: String, file_name: String) -> bool {
|
||||
let Some(index) = self.atoms.iter().position(|atom| matches!(atom, Atom::FeatureInvoke(atom) if atom.invocation.invocation_id == invocation_id)) else { return false; };
|
||||
self.replace_atoms(
|
||||
index,
|
||||
index + 1,
|
||||
vec![Atom::AttachmentStage {
|
||||
id,
|
||||
file_name,
|
||||
failed: false,
|
||||
}],
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn has_attachment_stages(&self) -> bool {
|
||||
self.atoms
|
||||
.iter()
|
||||
.any(|atom| matches!(atom, Atom::AttachmentStage { .. }))
|
||||
}
|
||||
|
||||
pub fn attachment_stage_ids(&self) -> Vec<String> {
|
||||
self.atoms
|
||||
.iter()
|
||||
.filter_map(|atom| {
|
||||
if let Atom::AttachmentStage { id, .. } = atom {
|
||||
Some(id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn adjacent_failed_attachment(&self) -> Option<String> {
|
||||
[self.cursor.checked_sub(1), Some(self.cursor)]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find_map(|index| match self.atoms.get(index) {
|
||||
Some(Atom::AttachmentStage {
|
||||
id, failed: true, ..
|
||||
}) => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn finish_attachment_stage(
|
||||
&mut self,
|
||||
id: &str,
|
||||
file: Option<protocol::UploadedFileRef>,
|
||||
) -> bool {
|
||||
self.bump_revision();
|
||||
let Some(index) = self.atoms.iter().position(
|
||||
|atom| matches!(atom, Atom::AttachmentStage { id: stage_id, .. } if stage_id == id),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(file) = file {
|
||||
self.atoms[index] = Atom::UploadedFile(file);
|
||||
} else if let Atom::AttachmentStage { failed, .. } = &mut self.atoms[index] {
|
||||
*failed = true;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn take_removed_attachment_stages(&mut self) -> Vec<String> {
|
||||
std::mem::take(&mut self.removed_attachment_stages)
|
||||
}
|
||||
|
||||
/// Replace `atoms[start..self.cursor]` (the in-flight `@<typed>` /
|
||||
/// active `@<typed>` file token) with the corresponding chip atom
|
||||
/// and place the cursor right after the chip. Used by the completion
|
||||
/// confirm path.
|
||||
pub fn replace_with_file_ref(&mut self, start: usize, path: String) {
|
||||
self.atoms.drain(start..self.cursor);
|
||||
self.atoms
|
||||
.insert(start, Atom::FileRef(FileRefAtom { path }));
|
||||
self.replace_atoms(
|
||||
start,
|
||||
self.cursor,
|
||||
vec![Atom::FileRef(FileRefAtom { path })],
|
||||
);
|
||||
self.cursor = start + 1;
|
||||
}
|
||||
|
||||
@@ -363,14 +705,177 @@ impl InputBuffer {
|
||||
/// completion path: the popup-selected entry is inserted as raw
|
||||
/// text (not a chip) so the user can keep typing — e.g. drill into
|
||||
/// a directory whose value ends with `/`.
|
||||
pub fn replace_with_text_at(&mut self, start: usize, text: &str) {
|
||||
self.atoms.drain(start..self.cursor);
|
||||
let mut idx = start;
|
||||
for c in text.chars() {
|
||||
self.atoms.insert(idx, Atom::Char(c));
|
||||
idx += 1;
|
||||
pub fn char_at(&self, index: usize) -> Option<char> {
|
||||
match self.atoms.get(index) {
|
||||
Some(Atom::Char(ch)) => Some(*ch),
|
||||
_ => None,
|
||||
}
|
||||
self.cursor = idx;
|
||||
}
|
||||
|
||||
pub fn replace_with_text_at(&mut self, start: usize, text: &str) {
|
||||
self.replace_atoms(start, self.cursor, text.chars().map(Atom::Char).collect());
|
||||
self.cursor = start + text.chars().count();
|
||||
}
|
||||
|
||||
pub fn can_complete_argument_name(&self, prefix_start: usize) -> bool {
|
||||
let mut before = prefix_start;
|
||||
while self.char_at(before).is_some_and(char::is_whitespace) {
|
||||
let Some(previous) = before.checked_sub(1) else {
|
||||
return false;
|
||||
};
|
||||
before = previous;
|
||||
}
|
||||
matches!(self.char_at(before), Some('(' | ','))
|
||||
}
|
||||
|
||||
pub fn replace_feature_name_completion(&mut self, start: usize, text: &str) {
|
||||
let mut end = self.cursor;
|
||||
while self
|
||||
.char_at(end)
|
||||
.is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '-')
|
||||
{
|
||||
end += 1;
|
||||
}
|
||||
if self.char_at(end) == Some('(') {
|
||||
end += 1;
|
||||
}
|
||||
self.replace_atoms(start, end, text.chars().map(Atom::Char).collect());
|
||||
self.cursor = start + text.chars().count();
|
||||
}
|
||||
|
||||
pub fn replace_argument_completion(&mut self, start: usize, text: &str) {
|
||||
// Replace the whole value including an existing closing quote when editing its middle.
|
||||
let mut end = self.cursor;
|
||||
let quoted = self.char_at(start) == Some('"');
|
||||
let mut escaped = false;
|
||||
while let Some(ch) = self.char_at(end) {
|
||||
if !quoted && (ch == ',' || ch == ')' || ch.is_whitespace()) {
|
||||
break;
|
||||
}
|
||||
end += 1;
|
||||
if quoted && !escaped && ch == '"' {
|
||||
break;
|
||||
}
|
||||
escaped = ch == '\\' && !escaped;
|
||||
if ch != '\\' {
|
||||
escaped = false;
|
||||
}
|
||||
}
|
||||
if quoted && self.char_at(end.saturating_sub(1)) != Some('"') {
|
||||
end = self.cursor;
|
||||
}
|
||||
self.replace_atoms(start, end, text.chars().map(Atom::Char).collect());
|
||||
self.cursor = start + text.chars().count();
|
||||
}
|
||||
|
||||
pub fn pending_feature_argument_completion(
|
||||
&self,
|
||||
descriptors: &[protocol::FeatureInvocationDescriptor],
|
||||
) -> Option<(
|
||||
protocol::CompletionKind,
|
||||
usize,
|
||||
String,
|
||||
protocol::CompletionContext,
|
||||
)> {
|
||||
if self.cursor == 0 {
|
||||
return None;
|
||||
}
|
||||
let selection = self
|
||||
.selected_invocations
|
||||
.iter()
|
||||
.filter(|selected| selected.start < self.cursor)
|
||||
.max_by_key(|selected| selected.start)?;
|
||||
let run_start = selection.start;
|
||||
let descriptor = &selection.descriptor;
|
||||
if !descriptors
|
||||
.iter()
|
||||
.any(|candidate| candidate.identity == descriptor.identity)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let invocation = self.atoms[run_start..self.cursor]
|
||||
.iter()
|
||||
.map(|atom| match atom {
|
||||
Atom::Char(ch) => Some(*ch),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<String>>()?;
|
||||
let open = invocation.find('(')?;
|
||||
let arguments = &invocation[open + 1..];
|
||||
if first_unquoted_char(arguments, ')').is_some() {
|
||||
return None;
|
||||
}
|
||||
let current_start = last_unquoted_char(arguments, ',').map_or(0, |index| index + 1);
|
||||
let current = &arguments[current_start..];
|
||||
let leading = current.len() - current.trim_start().len();
|
||||
let current = ¤t[leading..];
|
||||
let (argument, prefix_offset, mut prefix) =
|
||||
if let Some(equal) = first_unquoted_char(current, '=') {
|
||||
let name = current[..equal].trim();
|
||||
let mut value = ¤t[equal + 1..];
|
||||
let mut offset = equal + 1;
|
||||
let value_leading = value.len() - value.trim_start().len();
|
||||
value = value.trim_start();
|
||||
offset += value_leading;
|
||||
if let Some(unquoted) = value.strip_prefix('"') {
|
||||
value = unquoted;
|
||||
offset += 1;
|
||||
}
|
||||
(Some(name.to_string()), offset, value.to_string())
|
||||
} else {
|
||||
let mut previous_start = 0;
|
||||
let mut position = 0;
|
||||
for comma in unquoted_char_indices(&arguments[..current_start])
|
||||
.filter_map(|(index, ch)| (ch == ',').then_some(index))
|
||||
{
|
||||
if first_unquoted_char(&arguments[previous_start..comma], '=').is_none() {
|
||||
position += 1;
|
||||
}
|
||||
previous_start = comma + 1;
|
||||
}
|
||||
let positional = descriptor
|
||||
.arguments
|
||||
.iter()
|
||||
.find(|argument| argument.position == Some(position));
|
||||
if (current.is_empty() && positional.is_none())
|
||||
|| (current.bytes().all(|byte| {
|
||||
byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'
|
||||
}) && positional.is_none())
|
||||
{
|
||||
(None, 0, current.to_string())
|
||||
} else {
|
||||
let mut value = current;
|
||||
let mut offset = 0;
|
||||
if let Some(unquoted) = value.strip_prefix('"') {
|
||||
value = unquoted;
|
||||
offset = 1;
|
||||
}
|
||||
(
|
||||
positional.map(|argument| argument.name.clone()),
|
||||
offset,
|
||||
value.to_string(),
|
||||
)
|
||||
}
|
||||
};
|
||||
if prefix_offset > 0 && current[..prefix_offset].ends_with('"') {
|
||||
if serde_json::from_str::<String>(&format!("\"{}", prefix)).is_ok() {
|
||||
return None;
|
||||
}
|
||||
prefix = serde_json::from_str::<String>(&format!("\"{}\"", prefix)).ok()?;
|
||||
}
|
||||
let prefix_start = run_start
|
||||
+ invocation[..open + 1].chars().count()
|
||||
+ arguments[..current_start + leading].chars().count()
|
||||
+ current[..prefix_offset].chars().count();
|
||||
Some((
|
||||
protocol::CompletionKind::FeatureArgument,
|
||||
prefix_start.saturating_sub(1),
|
||||
prefix,
|
||||
protocol::CompletionContext {
|
||||
invocation: descriptor.identity.clone(),
|
||||
argument,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// If the cursor is currently inside a `@<typed>` /
|
||||
@@ -398,7 +903,9 @@ impl InputBuffer {
|
||||
}
|
||||
let kind = match c {
|
||||
'@' => Some(protocol::CompletionKind::File),
|
||||
|
||||
'/' if !typed.contains(['(', ')', '=', ',']) => {
|
||||
Some(protocol::CompletionKind::Feature)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(k) = kind {
|
||||
@@ -422,17 +929,29 @@ impl InputBuffer {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn take_removed_uploaded_files(&mut self) -> Vec<protocol::UploadedFileRef> {
|
||||
std::mem::take(&mut self.removed_uploaded_files)
|
||||
}
|
||||
|
||||
fn record_removed_atom(&mut self, atom: Atom) {
|
||||
match atom {
|
||||
Atom::UploadedFile(file) => self.removed_uploaded_files.push(file),
|
||||
Atom::AttachmentStage { id, .. } => self.removed_attachment_stages.push(id),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_before(&mut self) {
|
||||
if self.cursor == 0 {
|
||||
return;
|
||||
}
|
||||
self.cursor -= 1;
|
||||
self.atoms.remove(self.cursor);
|
||||
self.replace_atoms(self.cursor, self.cursor + 1, Vec::new());
|
||||
}
|
||||
|
||||
pub fn delete_after(&mut self) {
|
||||
if self.cursor < self.atoms.len() {
|
||||
self.atoms.remove(self.cursor);
|
||||
self.replace_atoms(self.cursor, self.cursor + 1, Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,14 +961,16 @@ impl InputBuffer {
|
||||
let end = self.cursor;
|
||||
self.move_word_left();
|
||||
let start = self.cursor;
|
||||
self.atoms.drain(start..end);
|
||||
self.replace_atoms(start, end, Vec::new());
|
||||
}
|
||||
|
||||
pub fn move_left(&mut self) {
|
||||
self.bump_revision();
|
||||
self.cursor = self.cursor.saturating_sub(1);
|
||||
}
|
||||
|
||||
pub fn move_right(&mut self) {
|
||||
self.bump_revision();
|
||||
self.cursor = (self.cursor + 1).min(self.atoms.len());
|
||||
}
|
||||
|
||||
@@ -457,6 +978,7 @@ impl InputBuffer {
|
||||
/// atoms sharing the same [`AtomClass`] — so `Word(Hiragana)` next to
|
||||
/// `Word(Han)` are separate blocks, and a `Paste` atom is its own block.
|
||||
pub fn move_word_left(&mut self) {
|
||||
self.bump_revision();
|
||||
while self.cursor > 0 && atom_class(&self.atoms[self.cursor - 1]) == AtomClass::Sep {
|
||||
self.cursor -= 1;
|
||||
}
|
||||
@@ -471,6 +993,7 @@ impl InputBuffer {
|
||||
|
||||
/// Move forward by one word. Mirror of [`move_word_left`].
|
||||
pub fn move_word_right(&mut self) {
|
||||
self.bump_revision();
|
||||
while self.cursor < self.atoms.len()
|
||||
&& atom_class(&self.atoms[self.cursor]) == AtomClass::Sep
|
||||
{
|
||||
@@ -486,6 +1009,7 @@ impl InputBuffer {
|
||||
}
|
||||
|
||||
pub fn move_start(&mut self) {
|
||||
self.bump_revision();
|
||||
self.cursor = 0;
|
||||
}
|
||||
|
||||
@@ -534,11 +1058,13 @@ impl InputBuffer {
|
||||
}
|
||||
|
||||
pub fn move_home(&mut self) {
|
||||
self.bump_revision();
|
||||
let (ranges, line, _) = self.logical_line_and_col();
|
||||
self.cursor = ranges[line].0;
|
||||
}
|
||||
|
||||
pub fn move_end(&mut self) {
|
||||
self.bump_revision();
|
||||
let (ranges, line, _) = self.logical_line_and_col();
|
||||
self.cursor = ranges[line].1;
|
||||
}
|
||||
@@ -546,6 +1072,7 @@ impl InputBuffer {
|
||||
/// Move one logical line up, preserving column (atom count from
|
||||
/// current line start). No-op if already on the first line.
|
||||
pub fn move_up(&mut self) {
|
||||
self.bump_revision();
|
||||
let (ranges, line, col) = self.logical_line_and_col();
|
||||
if line == 0 {
|
||||
return;
|
||||
@@ -556,6 +1083,7 @@ impl InputBuffer {
|
||||
|
||||
/// Move one logical line down, preserving column.
|
||||
pub fn move_down(&mut self) {
|
||||
self.bump_revision();
|
||||
let (ranges, line, col) = self.logical_line_and_col();
|
||||
let Some(&(start, end)) = ranges.get(line + 1) else {
|
||||
return;
|
||||
@@ -580,6 +1108,12 @@ impl InputBuffer {
|
||||
for a in &self.atoms {
|
||||
match a {
|
||||
Atom::Char(c) => buf.push(*c),
|
||||
Atom::Unknown => {
|
||||
flush_text(&mut buf, &mut out);
|
||||
out.push(protocol::Segment::Unknown);
|
||||
}
|
||||
// Staging is client-owned and never a submit payload.
|
||||
Atom::AttachmentStage { .. } => {}
|
||||
Atom::Paste(p) => {
|
||||
flush_text(&mut buf, &mut out);
|
||||
out.push(protocol::Segment::Paste {
|
||||
@@ -595,12 +1129,22 @@ impl InputBuffer {
|
||||
artifact: artifact.clone(),
|
||||
});
|
||||
}
|
||||
Atom::UploadedFile(file) => {
|
||||
flush_text(&mut buf, &mut out);
|
||||
out.push(protocol::Segment::UploadedFile { file: file.clone() });
|
||||
}
|
||||
Atom::FileRef(r) => {
|
||||
flush_text(&mut buf, &mut out);
|
||||
out.push(protocol::Segment::FileRef {
|
||||
path: r.path.clone(),
|
||||
});
|
||||
}
|
||||
Atom::FeatureInvoke(invocation) => {
|
||||
flush_text(&mut buf, &mut out);
|
||||
out.push(protocol::Segment::FeatureInvoke {
|
||||
invocation: invocation.invocation.clone(),
|
||||
});
|
||||
}
|
||||
Atom::FlowRef(r) => {
|
||||
flush_text(&mut buf, &mut out);
|
||||
out.push(protocol::Segment::Flow {
|
||||
@@ -1531,9 +2075,14 @@ mod word_motion_tests {
|
||||
for a in &buf.atoms {
|
||||
match a {
|
||||
Atom::Char(c) => out.push(*c),
|
||||
Atom::Paste(_) | Atom::PasteArtifact(_) | Atom::FileRef(_) | Atom::FlowRef(_) => {
|
||||
out.push_str("<P>")
|
||||
}
|
||||
Atom::Unknown
|
||||
| Atom::AttachmentStage { .. }
|
||||
| Atom::Paste(_)
|
||||
| Atom::PasteArtifact(_)
|
||||
| Atom::UploadedFile(_)
|
||||
| Atom::FileRef(_)
|
||||
| Atom::FeatureInvoke(_)
|
||||
| Atom::FlowRef(_) => out.push_str("<P>"),
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -1621,3 +2170,81 @@ mod word_motion_tests {
|
||||
assert_eq!(cursor(&buf), 7); // after "った"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod feature_invocation_input_tests {
|
||||
use super::*;
|
||||
use protocol::{
|
||||
FeatureInvocationDescriptor, FeatureInvocationIdentity, FeatureInvocationSyntax,
|
||||
InvocationArgumentDescriptor, InvocationArgumentType, InvocationCompletion, Segment,
|
||||
UploadedFileAvailability, UploadedFileRef,
|
||||
};
|
||||
|
||||
fn descriptor() -> FeatureInvocationDescriptor {
|
||||
FeatureInvocationDescriptor {
|
||||
identity: FeatureInvocationIdentity("builtin:test/run".into()),
|
||||
name: "run".into(),
|
||||
aliases: Vec::new(),
|
||||
display_name: "Run".into(),
|
||||
description: "test".into(),
|
||||
syntax: FeatureInvocationSyntax::Parenthesized,
|
||||
arguments: vec![InvocationArgumentDescriptor {
|
||||
name: "path".into(),
|
||||
position: Some(0),
|
||||
required: true,
|
||||
value_type: InvocationArgumentType::String,
|
||||
completion: InvocationCompletion::None,
|
||||
description: None,
|
||||
}],
|
||||
client_adapter: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_invocations_become_typed_atoms_and_preserve_following_prose() {
|
||||
let mut input = InputBuffer::new();
|
||||
input.insert_str("before /run(\"資料/a b\") after /run(\"x/y\") done");
|
||||
input.select_feature_invocation(7, descriptor());
|
||||
input.select_feature_invocation(
|
||||
"before /run(\"資料/a b\") after ".chars().count(),
|
||||
descriptor(),
|
||||
);
|
||||
input.finalize_feature_invocations().unwrap();
|
||||
let segments = input.submit_segments();
|
||||
assert_eq!(
|
||||
segments
|
||||
.iter()
|
||||
.filter(|segment| matches!(segment, Segment::FeatureInvoke { .. }))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert!(matches!(segments.last(), Some(Segment::Text { content }) if content == " done"));
|
||||
|
||||
let mut unselected = InputBuffer::new();
|
||||
unselected.insert_str("/run(\"x\")");
|
||||
unselected.finalize_feature_invocations().unwrap();
|
||||
assert!(matches!(
|
||||
unselected.submit_segments().as_slice(),
|
||||
[Segment::Text { content }] if content == "/run(\"x\")"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_an_uploaded_file_chip_reports_exact_staged_resource() {
|
||||
let file = UploadedFileRef {
|
||||
artifact_id: "artifact-1".into(),
|
||||
file_name: "report.md".into(),
|
||||
media_type: "text/markdown".into(),
|
||||
created_at_ms: 1,
|
||||
availability: UploadedFileAvailability::Available,
|
||||
byte_len: 4,
|
||||
sha256: "a".repeat(64),
|
||||
source_entry_id: None,
|
||||
};
|
||||
let mut input = InputBuffer::new();
|
||||
input.insert_uploaded_file(file.clone());
|
||||
input.delete_before();
|
||||
assert_eq!(input.take_removed_uploaded_files(), vec![file]);
|
||||
assert!(input.take_removed_uploaded_files().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,653 @@
|
||||
//! T-696 client contract regressions, exercised through real composer selection.
|
||||
use crate::{app::App, input::InputBuffer};
|
||||
use protocol::*;
|
||||
|
||||
pub(crate) fn descriptor() -> FeatureInvocationDescriptor {
|
||||
FeatureInvocationDescriptor {
|
||||
identity: FeatureInvocationIdentity("plugin:test/run".into()),
|
||||
name: "run".into(),
|
||||
aliases: vec!["execute".into()],
|
||||
display_name: "Run".into(),
|
||||
description: "Run a declared action".into(),
|
||||
syntax: FeatureInvocationSyntax::Parenthesized,
|
||||
arguments: vec![
|
||||
InvocationArgumentDescriptor {
|
||||
name: "path".into(),
|
||||
position: Some(0),
|
||||
required: true,
|
||||
value_type: InvocationArgumentType::WorkerFile,
|
||||
completion: InvocationCompletion::WorkerFile,
|
||||
description: None,
|
||||
},
|
||||
InvocationArgumentDescriptor {
|
||||
name: "mode".into(),
|
||||
position: None,
|
||||
required: false,
|
||||
value_type: InvocationArgumentType::Enum {
|
||||
values: vec!["safe".into(), "fast".into()],
|
||||
},
|
||||
completion: InvocationCompletion::Static {
|
||||
values: vec!["safe".into(), "fast".into()],
|
||||
},
|
||||
description: None,
|
||||
},
|
||||
InvocationArgumentDescriptor {
|
||||
name: "count".into(),
|
||||
position: None,
|
||||
required: false,
|
||||
value_type: InvocationArgumentType::Integer,
|
||||
completion: InvocationCompletion::None,
|
||||
description: None,
|
||||
},
|
||||
InvocationArgumentDescriptor {
|
||||
name: "force".into(),
|
||||
position: None,
|
||||
required: false,
|
||||
value_type: InvocationArgumentType::Boolean,
|
||||
completion: InvocationCompletion::None,
|
||||
description: None,
|
||||
},
|
||||
],
|
||||
client_adapter: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn attachment_descriptor() -> FeatureInvocationDescriptor {
|
||||
let mut descriptor = descriptor();
|
||||
// No hardcoded builtin identity, public name, or argument name in adapter dispatch.
|
||||
descriptor.identity = FeatureInvocationIdentity("plugin:files/add".into());
|
||||
descriptor.name = "add-file".into();
|
||||
descriptor.aliases.clear();
|
||||
descriptor.arguments.truncate(1);
|
||||
descriptor.arguments[0].name = "resource".into();
|
||||
descriptor.arguments[0].value_type = InvocationArgumentType::ClientFile;
|
||||
descriptor.arguments[0].completion = InvocationCompletion::ClientFile;
|
||||
descriptor.client_adapter = Some(InvocationClientAdapter::Attachment);
|
||||
descriptor
|
||||
}
|
||||
|
||||
pub(crate) fn uploaded_file() -> UploadedFileRef {
|
||||
UploadedFileRef {
|
||||
artifact_id: "artifact-owned".into(),
|
||||
file_name: "資料 a.txt".into(),
|
||||
media_type: "text/plain".into(),
|
||||
created_at_ms: 1,
|
||||
availability: UploadedFileAvailability::Available,
|
||||
byte_len: 4,
|
||||
sha256: "a".repeat(64),
|
||||
source_entry_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn select(
|
||||
app: &mut App,
|
||||
descriptor: FeatureInvocationDescriptor,
|
||||
name: &str,
|
||||
) -> Option<Method> {
|
||||
app.insert_char('/');
|
||||
assert!(matches!(
|
||||
app.refresh_completion(),
|
||||
Some(Method::ListCompletions {
|
||||
kind: CompletionKind::Feature,
|
||||
..
|
||||
})
|
||||
));
|
||||
app.handle_worker_event(Event::Completions {
|
||||
request_id: app.completion_request_id(),
|
||||
kind: CompletionKind::Feature,
|
||||
prefix: String::new(),
|
||||
context: None,
|
||||
entries: vec![CompletionEntry {
|
||||
value: name.into(),
|
||||
invocation: Some(descriptor),
|
||||
..Default::default()
|
||||
}],
|
||||
});
|
||||
app.apply_completion_text()
|
||||
}
|
||||
|
||||
fn complete(app: &mut App, value: &str, is_dir: bool) -> Option<Method> {
|
||||
let state = app.completion.as_ref().expect("argument completion");
|
||||
app.handle_worker_event(Event::Completions {
|
||||
request_id: app.completion_request_id(),
|
||||
kind: state.kind,
|
||||
prefix: state.prefix.clone(),
|
||||
context: state.context.clone(),
|
||||
entries: vec![CompletionEntry {
|
||||
value: value.into(),
|
||||
is_dir,
|
||||
..Default::default()
|
||||
}],
|
||||
});
|
||||
app.apply_completion_text()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_invocation_discovery_alias_selection_and_confirmation_are_occurrence_scoped() {
|
||||
let mut app = App::new("test".into());
|
||||
app.input.insert_str("説明 ");
|
||||
assert!(
|
||||
matches!(select(&mut app, descriptor(), "execute"), Some(Method::ListCompletions {
|
||||
kind: CompletionKind::FeatureArgument, context: Some(CompletionContext { argument: Some(argument), .. }), ..
|
||||
}) if argument == "path")
|
||||
);
|
||||
app.input
|
||||
.insert_str("\"資料/a b\") after /execute(\"not selected\")");
|
||||
assert!(
|
||||
app.submit_input().is_none(),
|
||||
"first Enter confirms typed chips, not Send"
|
||||
);
|
||||
let segments = app.input.submit_segments();
|
||||
assert!(
|
||||
matches!(&segments[1], Segment::FeatureInvoke { invocation } if invocation.identity == descriptor().identity && invocation.name == "execute")
|
||||
);
|
||||
assert!(
|
||||
matches!(segments.last(), Some(Segment::Text { content }) if content == " after /execute(\"not selected\")")
|
||||
);
|
||||
assert!(matches!(app.submit_input(), Some(Method::Submit { input, .. }) if input == segments));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_invocation_raw_text_paste_and_unknown_payloads_do_not_dispatch() {
|
||||
let mut app = App::new("test".into());
|
||||
app.input
|
||||
.insert_str("/run(x) /attach(/tmp/a.txt) /clear-attachments");
|
||||
assert!(
|
||||
matches!(app.submit_input(), Some(Method::Submit { input, .. }) if matches!(input.as_slice(), [Segment::Text { .. }]))
|
||||
);
|
||||
let unknown: Segment =
|
||||
serde_json::from_str(r#"{"kind":"future_invocation","identity":"plugin:test/run"}"#)
|
||||
.unwrap();
|
||||
app.input.replace_with_segments(&[unknown]);
|
||||
assert_eq!(app.input.submit_segments(), vec![Segment::Unknown]);
|
||||
let mut input = InputBuffer::new();
|
||||
input.insert_paste("/run(x) ".repeat(20));
|
||||
input.finalize_feature_invocations().unwrap();
|
||||
assert!(matches!(
|
||||
input.submit_segments().as_slice(),
|
||||
[Segment::Paste { .. }]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_invocation_argument_insertion_quotes_unicode_spaces_escapes_and_keeps_prose() {
|
||||
let mut app = App::new("test".into());
|
||||
app.input.insert_str("界 ");
|
||||
select(&mut app, descriptor(), "run");
|
||||
let value = "資料/a b/quote\"slash\\end";
|
||||
complete(&mut app, value, false);
|
||||
app.input.insert_str(", mode=sa");
|
||||
app.refresh_completion();
|
||||
complete(&mut app, "safe", false);
|
||||
app.input.insert_str(", count=1");
|
||||
app.refresh_completion();
|
||||
complete(&mut app, "12", false);
|
||||
app.input.insert_str(", force=f");
|
||||
app.refresh_completion();
|
||||
complete(&mut app, "false", false);
|
||||
app.input.insert_str(") trailing prose");
|
||||
assert!(app.submit_input().is_none());
|
||||
let segments = app.input.submit_segments();
|
||||
let Segment::FeatureInvoke { invocation } = &segments[1] else {
|
||||
panic!("typed chip expected")
|
||||
};
|
||||
assert_eq!(
|
||||
invocation.arguments[0].value,
|
||||
InvocationValue::String(value.into())
|
||||
);
|
||||
assert_eq!(invocation.arguments[2].value, InvocationValue::Integer(12));
|
||||
assert_eq!(
|
||||
invocation.arguments[3].value,
|
||||
InvocationValue::Boolean(false)
|
||||
);
|
||||
assert!(
|
||||
matches!(segments.last(), Some(Segment::Text { content }) if content == " trailing prose")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_invocation_argument_names_and_directory_drill_in_are_safe() {
|
||||
let mut app = App::new("test".into());
|
||||
select(&mut app, descriptor(), "run");
|
||||
assert!(
|
||||
matches!(complete(&mut app, "資料 dir", true), Some(Method::ListCompletions { prefix, .. }) if prefix == "資料 dir/")
|
||||
);
|
||||
complete(&mut app, "資料 dir/file name.txt", false);
|
||||
app.input.insert_str(", mo");
|
||||
app.refresh_completion();
|
||||
assert!(
|
||||
app.completion
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.context
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.argument
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
matches!(complete(&mut app, "mode=", false), Some(Method::ListCompletions { context: Some(CompletionContext { argument: Some(argument), .. }), .. }) if argument == "mode")
|
||||
);
|
||||
complete(&mut app, "fast", false);
|
||||
app.input.insert_char(')');
|
||||
assert!(app.submit_input().is_none());
|
||||
assert!(matches!(
|
||||
app.input.submit_segments().as_slice(),
|
||||
[Segment::FeatureInvoke { .. }]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_invocation_completion_replaces_middle_value_without_suffix_corruption() {
|
||||
let mut input = InputBuffer::new();
|
||||
input.insert_str("界 /run(path= \"資料/old name\", mode=safe) prose");
|
||||
input.select_feature_invocation(2, descriptor());
|
||||
input.move_home();
|
||||
for _ in 0.."界 /run(path= \"資料/ol".chars().count() {
|
||||
input.move_right();
|
||||
}
|
||||
let (_, before, prefix, context) = input
|
||||
.pending_feature_argument_completion(&[descriptor()])
|
||||
.unwrap();
|
||||
assert_eq!(prefix, "資料/ol");
|
||||
assert_eq!(context.argument.as_deref(), Some("path"));
|
||||
let start = before; // preceding atom is the opening quote
|
||||
input.replace_argument_completion(start, "e_invocation_string("資料/new name"));
|
||||
assert_eq!(
|
||||
input.plain_text(),
|
||||
"界 /run(path= \"資料/new name\", mode=safe) prose"
|
||||
);
|
||||
input.finalize_feature_invocations().unwrap();
|
||||
assert!(
|
||||
matches!(input.submit_segments().last(), Some(Segment::Text { content }) if content == " prose")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_invocation_validation_is_atomic_for_multiple_selected_calls() {
|
||||
for invalid in [
|
||||
"/run()",
|
||||
"/run(x, nope=y)",
|
||||
"/run(x, mode=unknown)",
|
||||
"/run(x, count=oops)",
|
||||
"/run(x, force=yes)",
|
||||
"/run(x, path=y)",
|
||||
"/run(\"unfinished",
|
||||
] {
|
||||
let mut input = InputBuffer::new();
|
||||
input.insert_str("/run(ok) then ");
|
||||
let second = input.plain_text().chars().count();
|
||||
input.insert_str(invalid);
|
||||
input.select_feature_invocation(0, descriptor());
|
||||
input.select_feature_invocation(second, descriptor());
|
||||
let before = input.submit_segments();
|
||||
assert!(input.finalize_feature_invocations().is_err(), "{invalid}");
|
||||
assert_eq!(input.submit_segments(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_invocation_selection_tracks_cursor_edits_and_does_not_survive_deleting_its_sigil() {
|
||||
let mut input = InputBuffer::new();
|
||||
input.insert_str("/run(x)");
|
||||
input.select_feature_invocation(0, descriptor());
|
||||
input.move_home();
|
||||
input.insert_str("prefix ");
|
||||
input.finalize_feature_invocations().unwrap();
|
||||
assert!(matches!(
|
||||
&input.submit_segments()[1],
|
||||
Segment::FeatureInvoke { .. }
|
||||
));
|
||||
input.replace_with_segments(&[Segment::text("/run(y)")]);
|
||||
input.select_feature_invocation(0, descriptor());
|
||||
input.move_home();
|
||||
input.delete_after();
|
||||
input.insert_char('/');
|
||||
input.finalize_feature_invocations().unwrap();
|
||||
assert_eq!(input.submit_segments(), vec![Segment::text("/run(y)")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_invocation_chip_reedit_preserves_identity_id_and_typed_arguments() {
|
||||
let parsed = parse_feature_invocation(
|
||||
"/execute(\"a b\", mode=safe, count=2)",
|
||||
0,
|
||||
&descriptor(),
|
||||
"stable-id",
|
||||
)
|
||||
.unwrap()
|
||||
.invocation;
|
||||
let segments = vec![
|
||||
Segment::FeatureInvoke {
|
||||
invocation: parsed.clone(),
|
||||
},
|
||||
Segment::UploadedFile {
|
||||
file: uploaded_file(),
|
||||
},
|
||||
Segment::Flow {
|
||||
selector: "review".into(),
|
||||
},
|
||||
Segment::FileRef {
|
||||
path: "src/".into(),
|
||||
},
|
||||
];
|
||||
let mut input = InputBuffer::new();
|
||||
input.replace_with_segments(&segments);
|
||||
assert_eq!(input.submit_segments(), segments);
|
||||
assert!(input.edit_feature_invocation("stable-id", descriptor()));
|
||||
input.finalize_feature_invocations().unwrap();
|
||||
assert_eq!(input.submit_segments(), segments);
|
||||
assert!(!input.edit_feature_invocation("stable-id", attachment_descriptor()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_invocation_reedit_unknown_disabled_or_stale_keeps_original_chip() {
|
||||
let invocation = parse_feature_invocation("/run(x)", 0, &descriptor(), "id")
|
||||
.unwrap()
|
||||
.invocation;
|
||||
let mut app = App::new("test".into());
|
||||
let segments = vec![Segment::FeatureInvoke { invocation }];
|
||||
app.input.replace_with_segments(&segments);
|
||||
assert!(matches!(
|
||||
app.edit_adjacent_feature_invocation(),
|
||||
Some(Method::ListCompletions { .. })
|
||||
));
|
||||
app.handle_worker_event(Event::Completions {
|
||||
request_id: app.completion_request_id(),
|
||||
kind: CompletionKind::Feature,
|
||||
prefix: "run".into(),
|
||||
context: None,
|
||||
entries: Vec::new(),
|
||||
});
|
||||
assert_eq!(app.input.submit_segments(), segments);
|
||||
app.edit_adjacent_feature_invocation();
|
||||
app.input.move_home();
|
||||
app.refresh_completion();
|
||||
app.handle_worker_event(Event::Completions {
|
||||
request_id: app.completion_request_id(),
|
||||
kind: CompletionKind::Feature,
|
||||
prefix: "run".into(),
|
||||
context: None,
|
||||
entries: vec![CompletionEntry {
|
||||
value: "run".into(),
|
||||
invocation: Some(descriptor()),
|
||||
..Default::default()
|
||||
}],
|
||||
});
|
||||
assert_eq!(app.input.submit_segments(), segments);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_invocation_generic_attachment_adapter_never_records_or_sends_local_path() {
|
||||
let mut app = App::new("test".into());
|
||||
select(&mut app, attachment_descriptor(), "add-file");
|
||||
app.input.insert_str("\"/tmp/資料 a.txt\") keep this prose");
|
||||
assert!(app.submit_input().is_none());
|
||||
assert_eq!(app.input_history_len(), 0);
|
||||
let adapters = app.attachment_invocations();
|
||||
assert_eq!(adapters.len(), 1);
|
||||
assert_eq!(adapters[0].1.to_str().unwrap(), "/tmp/資料 a.txt");
|
||||
app.input
|
||||
.stage_attachment(&adapters[0].0, "staging-id".into(), "資料 a.txt".into());
|
||||
assert!(app.submit_input().is_none());
|
||||
assert!(app.submit_notify_input().is_none());
|
||||
app.input
|
||||
.finish_attachment_stage("staging-id", Some(uploaded_file()));
|
||||
let Method::Submit { input, .. } = app.submit_input().unwrap() else {
|
||||
panic!("submit expected")
|
||||
};
|
||||
assert!(matches!(&input[0], Segment::UploadedFile { .. }));
|
||||
assert!(!serde_json::to_string(&input).unwrap().contains("/tmp/"));
|
||||
assert!(
|
||||
matches!(input.last(), Some(Segment::Text { content }) if content == " keep this prose")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_invocation_staging_deletion_and_late_completion_do_not_resurrect_chips() {
|
||||
for delete in [0, 1, 2] {
|
||||
let invocation = parse_feature_invocation("/run(x)", 0, &descriptor(), "invoke")
|
||||
.unwrap()
|
||||
.invocation;
|
||||
let mut input = InputBuffer::new();
|
||||
input.replace_with_segments(&[Segment::FeatureInvoke { invocation }]);
|
||||
input.stage_attachment("invoke", "stage".into(), "a.txt".into());
|
||||
match delete {
|
||||
0 => input.delete_before(),
|
||||
1 => {
|
||||
input.move_left();
|
||||
input.delete_after();
|
||||
}
|
||||
_ => input.delete_word_before(),
|
||||
}
|
||||
assert_eq!(input.take_removed_attachment_stages(), vec!["stage"]);
|
||||
assert!(!input.finish_attachment_stage("stage", Some(uploaded_file())));
|
||||
assert!(input.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_invocation_typed_history_draft_transport_and_rewind_restore_without_flattening() {
|
||||
let invocation = parse_feature_invocation(
|
||||
"/run(\"資料/a b\", mode=safe)",
|
||||
0,
|
||||
&descriptor(),
|
||||
"stable-id",
|
||||
)
|
||||
.unwrap()
|
||||
.invocation;
|
||||
let segments = vec![
|
||||
Segment::text("before "),
|
||||
Segment::FeatureInvoke { invocation },
|
||||
Segment::UploadedFile {
|
||||
file: uploaded_file(),
|
||||
},
|
||||
Segment::Paste {
|
||||
id: 2,
|
||||
chars: 4,
|
||||
lines: 1,
|
||||
content: "data".into(),
|
||||
},
|
||||
Segment::FileRef {
|
||||
path: "src/".into(),
|
||||
},
|
||||
Segment::Flow {
|
||||
selector: "review".into(),
|
||||
},
|
||||
];
|
||||
let mut app = App::new("test".into());
|
||||
app.input.replace_with_segments(&segments);
|
||||
let method = app.submit_input().unwrap();
|
||||
app.restore_unsent_run(&method);
|
||||
assert_eq!(app.input.submit_segments(), segments);
|
||||
app.input.clear();
|
||||
let draft = vec![
|
||||
Segment::text("draft "),
|
||||
Segment::UploadedFile {
|
||||
file: uploaded_file(),
|
||||
},
|
||||
];
|
||||
app.input.replace_with_segments(&draft);
|
||||
app.input.move_home();
|
||||
assert!(app.browse_input_history_older());
|
||||
assert_eq!(app.input.submit_segments(), segments);
|
||||
assert!(app.browse_input_history_newer());
|
||||
assert_eq!(app.input.submit_segments(), draft);
|
||||
app.input.clear();
|
||||
app.handle_worker_event(Event::RewindApplied {
|
||||
session: SessionSnapshot {
|
||||
entries: vec![],
|
||||
pending_submissions: PendingSubmissionsSnapshot::default(),
|
||||
},
|
||||
input: segments.clone(),
|
||||
summary: RewindSummary {
|
||||
truncated_to_entries: 1,
|
||||
discarded_entries: 2,
|
||||
tool_side_effect_warning: false,
|
||||
},
|
||||
});
|
||||
assert_eq!(app.input.submit_segments(), segments);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_invocation_stale_argument_context_and_unvalidated_discovery_entries_are_ignored() {
|
||||
let mut app = App::new("test".into());
|
||||
select(&mut app, descriptor(), "run");
|
||||
let state = app.completion.as_ref().unwrap();
|
||||
let prefix = state.prefix.clone();
|
||||
let mut context = state.context.clone().unwrap();
|
||||
context.invocation = FeatureInvocationIdentity("plugin:other/run".into());
|
||||
app.handle_worker_event(Event::Completions {
|
||||
request_id: app.completion_request_id(),
|
||||
kind: CompletionKind::FeatureArgument,
|
||||
prefix,
|
||||
context: Some(context),
|
||||
entries: vec![CompletionEntry {
|
||||
value: "unsafe".into(),
|
||||
..Default::default()
|
||||
}],
|
||||
});
|
||||
assert!(app.completion.as_ref().unwrap().entries.is_empty());
|
||||
let mut app = App::new("test".into());
|
||||
app.insert_char('/');
|
||||
app.refresh_completion();
|
||||
let mut invalid = descriptor();
|
||||
invalid.identity = FeatureInvocationIdentity("unqualified".into());
|
||||
app.handle_worker_event(Event::Completions {
|
||||
request_id: app.completion_request_id(),
|
||||
kind: CompletionKind::Feature,
|
||||
prefix: String::new(),
|
||||
context: None,
|
||||
entries: vec![CompletionEntry {
|
||||
value: "run".into(),
|
||||
invocation: Some(invalid),
|
||||
..Default::default()
|
||||
}],
|
||||
});
|
||||
assert!(app.apply_completion_text().is_none());
|
||||
assert_eq!(app.input.plain_text(), "/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_invocation_multiple_selected_calls_preserve_prose_and_source_qualified_identity() {
|
||||
let mut app = App::new("test".into());
|
||||
select(&mut app, descriptor(), "run");
|
||||
app.input.insert_str("x) prose ");
|
||||
let mut other = descriptor();
|
||||
other.identity = FeatureInvocationIdentity("plugin:other/second".into());
|
||||
other.name = "second".into();
|
||||
other.aliases.clear();
|
||||
select(&mut app, other.clone(), "second");
|
||||
app.input.insert_str("\"y z\") done");
|
||||
assert!(app.submit_input().is_none());
|
||||
let segments = app.input.submit_segments();
|
||||
assert!(
|
||||
matches!(&segments[0], Segment::FeatureInvoke { invocation } if invocation.identity == descriptor().identity)
|
||||
);
|
||||
assert!(matches!(&segments[1], Segment::Text { content } if content == " prose "));
|
||||
assert!(
|
||||
matches!(&segments[2], Segment::FeatureInvoke { invocation } if invocation.identity == other.identity)
|
||||
);
|
||||
assert!(matches!(&segments[3], Segment::Text { content } if content == " done"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_invocation_range_replacement_reports_each_removed_upload_and_stage() {
|
||||
let invocation = parse_feature_invocation("/run(x)", 0, &descriptor(), "id")
|
||||
.unwrap()
|
||||
.invocation;
|
||||
let mut input = InputBuffer::new();
|
||||
input.replace_with_segments(&[
|
||||
Segment::UploadedFile {
|
||||
file: uploaded_file(),
|
||||
},
|
||||
Segment::text(" "),
|
||||
Segment::FeatureInvoke { invocation },
|
||||
]);
|
||||
input.stage_attachment("id", "stage".into(), "a.txt".into());
|
||||
input.replace_with_text_at(0, "replacement");
|
||||
assert_eq!(input.take_removed_uploaded_files(), vec![uploaded_file()]);
|
||||
assert_eq!(input.take_removed_attachment_stages(), vec!["stage"]);
|
||||
assert_eq!(input.plain_text(), "replacement");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_invocation_argument_positions_ignore_previous_named_arguments() {
|
||||
let mut input = InputBuffer::new();
|
||||
input.insert_str("/run(mode=safe, \"資料/a");
|
||||
input.select_feature_invocation(0, descriptor());
|
||||
let (_, _, prefix, context) = input
|
||||
.pending_feature_argument_completion(&[descriptor()])
|
||||
.unwrap();
|
||||
assert_eq!(context.argument.as_deref(), Some("path"));
|
||||
assert_eq!(prefix, "資料/a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_invocation_name_completion_replaces_suffix_at_middle_cursor() {
|
||||
let mut app = App::new("test".into());
|
||||
app.input.insert_str("/run() prose");
|
||||
app.input.move_home();
|
||||
app.input.move_right();
|
||||
app.input.move_right();
|
||||
app.refresh_completion();
|
||||
app.handle_worker_event(Event::Completions {
|
||||
request_id: app.completion_request_id(),
|
||||
kind: CompletionKind::Feature,
|
||||
prefix: "r".into(),
|
||||
context: None,
|
||||
entries: vec![CompletionEntry {
|
||||
value: "run".into(),
|
||||
invocation: Some(descriptor()),
|
||||
..Default::default()
|
||||
}],
|
||||
});
|
||||
app.apply_completion_text();
|
||||
assert_eq!(app.input.plain_text(), "/run() prose");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_invocation_discovery_expands_aliases_from_host_metadata() {
|
||||
let mut app = App::new("test".into());
|
||||
app.input.insert_str("/exe");
|
||||
app.refresh_completion();
|
||||
app.handle_worker_event(Event::Completions {
|
||||
request_id: app.completion_request_id(),
|
||||
kind: CompletionKind::Feature,
|
||||
prefix: "exe".into(),
|
||||
context: None,
|
||||
entries: vec![CompletionEntry {
|
||||
value: "run".into(),
|
||||
invocation: Some(descriptor()),
|
||||
..Default::default()
|
||||
}],
|
||||
});
|
||||
assert_eq!(app.completion.as_ref().unwrap().entries[0].value, "execute");
|
||||
app.apply_completion_text();
|
||||
assert_eq!(app.input.plain_text(), "/execute(");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_invocation_named_arguments_are_suggested_alongside_positional_values() {
|
||||
let mut app = App::new("test".into());
|
||||
select(&mut app, descriptor(), "run");
|
||||
app.input.insert_str("pa");
|
||||
app.refresh_completion();
|
||||
let state = app.completion.as_ref().unwrap();
|
||||
app.handle_worker_event(Event::Completions {
|
||||
request_id: app.completion_request_id(),
|
||||
kind: state.kind,
|
||||
prefix: state.prefix.clone(),
|
||||
context: state.context.clone(),
|
||||
entries: Vec::new(),
|
||||
});
|
||||
assert_eq!(app.completion.as_ref().unwrap().entries[0].value, "path=");
|
||||
app.apply_completion_text();
|
||||
assert_eq!(app.input.plain_text(), "/run(path=");
|
||||
app.input.insert_str("\"資料/path\")");
|
||||
assert!(app.submit_input().is_none());
|
||||
assert!(matches!(
|
||||
app.input.submit_segments().as_slice(),
|
||||
[Segment::FeatureInvoke { .. }]
|
||||
));
|
||||
}
|
||||
@@ -13,6 +13,8 @@ mod console;
|
||||
mod e2e_observer;
|
||||
mod inline_terminal;
|
||||
mod input;
|
||||
#[cfg(test)]
|
||||
mod invocation_tests;
|
||||
pub mod keys;
|
||||
mod markdown;
|
||||
mod scroll;
|
||||
|
||||
+46
-8
@@ -366,6 +366,23 @@ fn task_status_mark(status: TaskStatus) -> (&'static str, Style) {
|
||||
/// taller than that single row); `Clear` blanks the cells first so
|
||||
/// underlying text doesn't bleed through. The popup width matches the
|
||||
/// widest visible label, capped at the input-area width.
|
||||
fn completion_entry_label(entry: &CompletionEntry) -> String {
|
||||
if let Some(invocation) = &entry.invocation {
|
||||
format!(
|
||||
"/{} — {} — {}",
|
||||
entry.value,
|
||||
invocation.description,
|
||||
invocation.usage()
|
||||
)
|
||||
} else {
|
||||
let suffix = if entry.is_dir { "/" } else { "" };
|
||||
match &entry.description {
|
||||
Some(description) => format!("{}{suffix} — {description}", entry.value),
|
||||
None => format!("{}{suffix}", entry.value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_completion_popup(frame: &mut Frame, state: &CompletionState, input_area: Rect) {
|
||||
let entries = &state.entries;
|
||||
if entries.is_empty() || input_area.y == 0 {
|
||||
@@ -380,16 +397,10 @@ fn draw_completion_popup(frame: &mut Frame, state: &CompletionState, input_area:
|
||||
};
|
||||
let view_end = (view_start + visible).min(entries.len());
|
||||
|
||||
let label_for = |entry: &CompletionEntry| {
|
||||
let mut s = entry.value.clone();
|
||||
if entry.is_dir {
|
||||
s.push('/');
|
||||
}
|
||||
s
|
||||
};
|
||||
let label_for = |entry: &CompletionEntry| completion_entry_label(entry);
|
||||
let max_label = entries[view_start..view_end]
|
||||
.iter()
|
||||
.map(|e| label_for(e).chars().count() as u16)
|
||||
.map(|e| label_for(e).width().min(u16::MAX as usize) as u16)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let popup_w = max_label.saturating_add(2).min(input_area.width).max(1);
|
||||
@@ -1341,6 +1352,10 @@ fn chip_span_for(seg: &Segment, fallback: Style) -> (Style, String) {
|
||||
),
|
||||
),
|
||||
Segment::FileRef { path } => (Style::default().fg(Color::Cyan), format!("@{path}")),
|
||||
Segment::FeatureInvoke { invocation } => (
|
||||
Style::default().fg(Color::Yellow),
|
||||
invocation.display_input(),
|
||||
),
|
||||
Segment::Flow { selector } => (
|
||||
Style::default().fg(Color::Yellow),
|
||||
format!("[Flow: {selector}]"),
|
||||
@@ -1375,6 +1390,7 @@ fn segment_display_text(seg: &Segment) -> String {
|
||||
file.availability.as_str()
|
||||
),
|
||||
Segment::FileRef { path } => format!("@{path}"),
|
||||
Segment::FeatureInvoke { invocation } => invocation.display_input(),
|
||||
Segment::Flow { selector } => format!("[Flow: {selector}]"),
|
||||
Segment::Unknown => "[unknown segment]".to_owned(),
|
||||
}
|
||||
@@ -2186,6 +2202,28 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_invocation_completion_labels_include_alias_description_usage_and_argument_help() {
|
||||
let descriptor = crate::invocation_tests::descriptor();
|
||||
let entry = CompletionEntry {
|
||||
value: "execute".into(),
|
||||
invocation: Some(descriptor.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
let label = completion_entry_label(&entry);
|
||||
assert!(label.starts_with("/execute"));
|
||||
assert!(label.contains(&descriptor.description));
|
||||
assert!(label.contains(&descriptor.usage()));
|
||||
assert_eq!(
|
||||
completion_entry_label(&CompletionEntry {
|
||||
value: "mode=".into(),
|
||||
description: Some("execution mode".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
"mode= — execution mode"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_status_line_matches_console_metrics_and_spinner_frame() {
|
||||
let now = Instant::now();
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# Request-time checkout provider boundary
|
||||
|
||||
`workdir::checkout` (also crate-root reexports) exposes `CheckoutObservation`,
|
||||
`CheckoutRequest`, `CheckoutOperation`, `CheckoutOutput`, and `CheckoutResult`.
|
||||
`WorkdirSession::checkout_observe(path)` defaults to unsupported; execution also
|
||||
fails closed by default. Observation is metadata-only, with no recursive tree
|
||||
walk. Directory discovery/search remains the ordinary bounded List/Glob/Grep
|
||||
provider engine. GrepResult.paths carries exact typed provider paths appearing
|
||||
in the returned report, respecting its offset/limit/truncation; filenames are
|
||||
never parsed from rendered text. List/Glob/Grep skip unrepresentable non-UTF8
|
||||
paths, preserving valid Unicode exactly without normalization. Native callers supply their own attachment generation/namespace
|
||||
fence; this provider validator is not a substitute for that fence.
|
||||
|
||||
* Read: line `offset`, `limit`, `max_bytes`; same `ReadResult` (bytes, start line,
|
||||
total lines, full-source content hash, truncation) as normal Tools. Formatting
|
||||
line numbers and recording the returned hash in caller read-history are caller
|
||||
responsibilities. The validator is **not** permission to bypass read-before-write.
|
||||
* Write: replace existing file with `content` and required `expected_hash`.
|
||||
* Edit: existing UTF-8 file, unique `old_string` unless `replace_all`, required
|
||||
`expected_hash`; same match/replacement/result-size checks as Tools.
|
||||
* Create: target must be an observed directory; `path` is **checkout-root
|
||||
relative**, strictly below target, not target-relative. No existing file can
|
||||
be overwritten. Intermediate directories and destination must be writable.
|
||||
* Every result observation describes the bound request target. Create returns
|
||||
the updated **parent directory** observation from the retained pinned target,
|
||||
not the created destination. Read returns the read state's observation.
|
||||
|
||||
## Shared implementation
|
||||
|
||||
`fs_operation::CheckedTarget` is an `FsAccessPolicy` adapter around pinned target,
|
||||
save-parent, and provider-root descriptors. It calls the ordinary
|
||||
`run_read_bounded`, `run_edit`, and `run_write` functions, including full-source
|
||||
hashing, UTF-8/string matching, bounds, and atomic temp-save primitive; there is
|
||||
no second content-operation engine. Shared text reads now check descriptor
|
||||
metadata before/after reading. Validator bytes encode device/inode/size/mode/
|
||||
mtime/ctime with nanoseconds, excluding atime and host paths; same-content inode
|
||||
replacement is distinguishable. Successful native Read rechecks the named target
|
||||
and pinned descriptor before returning hash and observation.
|
||||
|
||||
Local sessions serialize checked observe/execute and ordinary trait Read/Write/
|
||||
Edit using one nonqueueing session lock. Expected validator comparison happens
|
||||
inside that boundary. The adapter retains handles across reading and save,
|
||||
rechecks named target + save-parent identity at save boundary, and writes a new
|
||||
temporary inode then atomically renames it, isolating existing hardlink aliases.
|
||||
External sessions retain the approved descriptor root after pathname rename or
|
||||
replacement. Checkout resolution rejects symlinks, special files, mount crossings,
|
||||
absolute/traversal paths and unavailable descriptor primitives, rather than
|
||||
weakening confinement. This currently requires Linux `openat2` and `/proc/self/fd`.
|
||||
Ordinary Tools preserve their existing explicit symlink-policy behavior.
|
||||
|
||||
Scoped sessions use their existing scope mutex, current validity/source
|
||||
capability checks, provider authorization and write leases; Create also checks
|
||||
intermediate paths. Observation capabilities are attenuated per path and live
|
||||
write leases, never broadened to the whole attachment. ReadOnly removes mutation
|
||||
capabilities and rejects mutations. Routed operations use existing active-op
|
||||
accounting and detach fencing. Remote HTTP forwards exact operation/result types
|
||||
and validates returned operation/path pairing. External DTOs validate checkout
|
||||
path/validator/content/result bounds. No provider endpoint or host root is added
|
||||
to the observation.
|
||||
|
||||
## Effects and limits
|
||||
|
||||
Absent paths during checkout observation return `NotFound` (including a missing
|
||||
ancestor or a non-directory ancestor); native discovery can return no route.
|
||||
An execution against a previously observed target that disappeared instead
|
||||
returns `Conflict`, consistently at pinning, shared-engine, and final-check
|
||||
boundaries. Conflict means stale validator/hash/name or existing Create destination before
|
||||
save effects. `WorkdirError::OutcomeUnknown` has a separate sanitized wire code
|
||||
`outcome_unknown` (HTTP 500), never an automatic retry. Partial parent creation,
|
||||
possible commit failure, or failed post-save observation is outcome unknown.
|
||||
Remote lost/malformed/mismatched mutation responses are also outcome unknown.
|
||||
Create failures conservatively report unknown once parent creation may begin,
|
||||
even if a particular failure happened without a persisted effect.
|
||||
|
||||
Bounds: path <=4096 UTF-8 bytes/128 components, validator <=256 bytes; Read source
|
||||
64 MiB, retained response <=1 MiB (or stricter provider read limits), Write/Edit
|
||||
preimage and result <=8 MiB (or stricter provider limit), Edit replacements
|
||||
<=10,000 (or stricter provider limit). One checked operation per Local session,
|
||||
with bounded retained handles and no waiting queue. Cancellation/deadline checks
|
||||
use the existing provider guard, or a 30-second cooperative guard for Local
|
||||
checkout operations. Blocking kernel/filesystem calls are not preempted; this
|
||||
is **not** a hard wall-time guarantee for hung storage. Transport request-body
|
||||
bounds before serde allocation remain the hosting transport's responsibility.
|
||||
|
||||
### External-editor limitations (not a magic advisory-lock guarantee)
|
||||
|
||||
No advisory `flock` is claimed to exclude arbitrary editors. Provider-session
|
||||
serialization does not lock other sessions/processes. Metadata checks detect
|
||||
concurrent reads/edits up to each check, but are not a transactional snapshot
|
||||
against privileged actors forging metadata. Linux offers no atomic
|
||||
compare-identity-and-replace-by-name primitive here: an uncooperative external
|
||||
rename/change between the final validator check and rename can still race the
|
||||
save. The pinned handles prevent following replacements/symlinks into arbitrary
|
||||
objects, and deterministic substitution before the save boundary is rejected,
|
||||
but the final syscall gap is an explicit limitation. Callers must not interpret
|
||||
this as linearizability against all external filesystem mutations. Likewise,
|
||||
post-save observation is the then-current named state, not an exclusion of an
|
||||
external edit after save. Crash durability of parent directories is not claimed.
|
||||
|
||||
Host generation reuse, native permission identity, read-history updates, model
|
||||
Operation publication, transport hosting (Runtime/Backend/External), and durable
|
||||
Ticket/MR review remain the parent integration's responsibilities.
|
||||
|
||||
## Scoped checkout search boundary
|
||||
|
||||
`CheckoutSearchRequest::new(CheckoutSearchOperation::{List, Glob, Grep}(request))`
|
||||
uses the current session's logical root as its result frame. The typed result is
|
||||
`CheckoutSearchResult::{List, Glob, Grep}`. The request's internal context fields
|
||||
are `scope_layers: Vec<Vec<WorkdirToolScopeRule>>` and `output_root: WorkdirPath`.
|
||||
Rules union within each layer; layers intersect with each other, the current
|
||||
provider authority, and the output-root boundary. Wrappers contribute their own
|
||||
scope; caller-supplied layers can only narrow, never replace it. Each wrapper
|
||||
translates all incoming coordinates once and forwards the composed output root.
|
||||
Provider-rendered Grep text and every typed result path are already relative to
|
||||
that final root; wrappers must not prefix cwd onto returned paths again.
|
||||
|
||||
Local/External use the same shared `run_list`, `run_glob`, `run_grep` engines.
|
||||
`SearchAccess` gates metadata/content opens and enumeration. Shared List checks
|
||||
`can_enumerate_directory`; Glob/Grep use the shared bounded descriptor walk and
|
||||
owned `traversal_filter` policy to prune before descending/opening directories.
|
||||
The walk anchors at the actual search directory, not at a broader provider
|
||||
root. It counts rejected entries too, checks cancellation while scanning them,
|
||||
and limits descent to 128 levels (bounded retained directory handles). Existing
|
||||
provider-owned ignore loading, glob/type selection, grep context/offset/limit,
|
||||
rendering, and source/result limits remain in their shared engines. Unreadable
|
||||
ignore files are not opened to discover out-of-scope contents.
|
||||
|
||||
Descriptor search loads `.ignore`, `.gitignore` and an authorized directory's
|
||||
`.git/info/exclude` through the same `FsAccessPolicy` opens as other sources.
|
||||
It preserves anchored rules, negation, nearest-directory precedence within a
|
||||
source class, `.ignore` precedence over Git rules, and nested repository
|
||||
boundaries. Ignored directories are pruned before enumeration or loading their
|
||||
own rules; explicitly selected ignored search roots are still traversable.
|
||||
Only the current ancestor matchers are retained, never a second tree/store.
|
||||
Glob requires an authorized `.git` directory/file marker for Git rules. Grep
|
||||
retains the existing descriptor-provider contract of applying `.gitignore`
|
||||
without a marker and rejecting unavailable/symlinked ignore opens; Glob skips
|
||||
unavailable ignore sources like its ordinary walker. Scope-denied sources are
|
||||
skipped by both before open. Bounds/cancellation failures are never retried.
|
||||
|
||||
Configuration discovery cannot expand provider authority. Ambient ancestors
|
||||
outside the search result/provider root, global Git configuration/ignore files,
|
||||
and linked-worktree `.git`/`commondir` administration pointers are not followed.
|
||||
A `.git` file activates repository matching without treating it as a directory
|
||||
or opening the host path it contains. In-root readable ignore rules remain
|
||||
active, including inherited rules above a nested base. Descriptor ignore text
|
||||
must be UTF-8, bounded to 1 MiB/file, 16 KiB/line and 8 MiB aggregate per search;
|
||||
limit violations fail rather than returning silently incomplete filtering.
|
||||
Grep's separate candidate-content budget remains 64 MiB. Ordinary unscoped
|
||||
path-backed Tools keep their original discovery policy.
|
||||
|
||||
Scoped ordinary trait List/Glob/Grep use this boundary as well, returning
|
||||
session-logical coordinates. Unscoped normal Tools continue using their original
|
||||
trait/provider behavior. A provider without checkout search support returns
|
||||
UnsupportedOperation; there is no broad traversal fallback. Native/scoped
|
||||
searches intentionally fail closed on symlink paths, including logical-policy
|
||||
aliases which normal unscoped Tools may follow. A visible symlink may therefore
|
||||
cause a native List failure rather than expanding authority. Descriptor support
|
||||
is currently Linux-only. Search remains request-time traversal, not a snapshot
|
||||
or transactional exclusion of external editors.
|
||||
|
||||
HTTP operation/result variant: `CheckoutSearch`. External DTO validation rejects
|
||||
absolute/out-of-root paths, empty scope layers, oversized paths/patterns/context/
|
||||
results and invalid result paths. Generic request ceilings are 16 layers and 256
|
||||
aggregate rules; External transport further limits aggregate rules to 64. Search
|
||||
result limits follow the existing shared List/Glob/Grep wire bounds. Runtime and
|
||||
Backend forwarding outside this crate remain parent integration work.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,817 @@
|
||||
#![cfg(target_os = "linux")]
|
||||
use crate::*;
|
||||
use std::sync::Arc;
|
||||
fn path(value: &str) -> WorkdirPath {
|
||||
WorkdirPath::new(value).unwrap()
|
||||
}
|
||||
fn rule(value: &str, recursive: bool) -> WorkdirToolScopeRule {
|
||||
WorkdirToolScopeRule {
|
||||
target: path(value),
|
||||
recursive,
|
||||
permission: WorkdirToolScopePermission::Read,
|
||||
symlink_policy: Default::default(),
|
||||
}
|
||||
}
|
||||
fn scope(rules: Vec<WorkdirToolScopeRule>, cwd: &str) -> WorkdirToolScope {
|
||||
WorkdirToolScope {
|
||||
rules,
|
||||
cwd: path(cwd),
|
||||
command: false,
|
||||
}
|
||||
}
|
||||
fn grep(mode: GrepOutputMode) -> GrepRequest {
|
||||
GrepRequest {
|
||||
path: WorkdirPath::root(),
|
||||
pattern: "needle".into(),
|
||||
glob: None,
|
||||
file_type: None,
|
||||
case_insensitive: false,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
multiline: false,
|
||||
output_mode: mode,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
}
|
||||
}
|
||||
fn fixture() -> tempfile::TempDir {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(root.path().join("child/keep")).unwrap();
|
||||
std::fs::create_dir_all(root.path().join("child/denied/deep")).unwrap();
|
||||
std::fs::write(root.path().join("child/a.txt"), "needle allowed direct").unwrap();
|
||||
std::fs::write(
|
||||
root.path().join("child/keep/yes.txt"),
|
||||
"needle allowed subtree",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.path().join("child/denied/deep/secret.txt"),
|
||||
"needle secret",
|
||||
)
|
||||
.unwrap();
|
||||
// Opening this out-of-scope source would exhaust the shared grep source bound.
|
||||
std::fs::File::create(root.path().join("child/denied/huge.txt"))
|
||||
.unwrap()
|
||||
.set_len(65 * 1024 * 1024)
|
||||
.unwrap();
|
||||
std::fs::write(root.path().join("outside.txt"), "needle outside").unwrap();
|
||||
root
|
||||
}
|
||||
async fn verify_sparse(session: &dyn WorkdirSession) {
|
||||
let listed = session
|
||||
.list(ListRequest {
|
||||
path: WorkdirPath::root(),
|
||||
limit: 100,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
listed
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| entry.path.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![path("denied"), path("keep"), path("a.txt")]
|
||||
);
|
||||
let empty = session
|
||||
.list(ListRequest {
|
||||
path: path("denied"),
|
||||
limit: 100,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(empty.entries.is_empty());
|
||||
assert_eq!(empty.total_entries, 0);
|
||||
let globbed = session
|
||||
.glob(GlobRequest {
|
||||
path: WorkdirPath::root(),
|
||||
pattern: "**/*.txt".into(),
|
||||
limit: 100,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(globbed.paths, vec![path("a.txt"), path("keep/yes.txt")]);
|
||||
for mode in [
|
||||
GrepOutputMode::Content,
|
||||
GrepOutputMode::Count,
|
||||
GrepOutputMode::FilesWithMatches,
|
||||
] {
|
||||
let result = session.grep(grep(mode)).await.unwrap();
|
||||
let mut paths = result.paths;
|
||||
paths.sort_by(|a, b| a.as_str().cmp(b.as_str()));
|
||||
assert_eq!(paths, vec![path("a.txt"), path("keep/yes.txt")]);
|
||||
assert!(!result.output.contains("child/"));
|
||||
assert!(!result.output.contains("secret"));
|
||||
assert!(!result.output.contains("outside"));
|
||||
assert_eq!(result.matched_files, 2);
|
||||
}
|
||||
let CheckoutSearchResult::List(native) = session
|
||||
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List(
|
||||
ListRequest {
|
||||
path: WorkdirPath::root(),
|
||||
limit: 100,
|
||||
},
|
||||
)))
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(listed, native);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn checkout_search_nested_sparse_nonrecursive_scopes_rebase_and_exclude_content() {
|
||||
let root = fixture();
|
||||
let source = Arc::new(LocalWorkdirSession::new(
|
||||
manifest::Scope::writable(root.path()).unwrap(),
|
||||
root.path().to_path_buf(),
|
||||
));
|
||||
let parent = WorkdirToolBroker::new(source);
|
||||
let first = parent
|
||||
.scope(scope(vec![rule("child", true)], "child"))
|
||||
.await
|
||||
.unwrap();
|
||||
let nested = first
|
||||
.scope(scope(
|
||||
vec![rule("child", false), rule("child/keep", true)],
|
||||
"child",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
verify_sparse(&*nested.tool_session()).await;
|
||||
// Nested incoming layers cannot replace the session's own sparse layer.
|
||||
let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::Glob(GlobRequest {
|
||||
path: WorkdirPath::root(),
|
||||
pattern: "**/*.txt".into(),
|
||||
limit: 100,
|
||||
}));
|
||||
request.scope_layers.push(vec![rule("", true)]);
|
||||
let CheckoutSearchResult::Glob(result) = nested
|
||||
.tool_session()
|
||||
.checkout_search(request)
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(result.paths, vec![path("a.txt"), path("keep/yes.txt")]);
|
||||
nested.close().await.unwrap();
|
||||
assert!(matches!(
|
||||
nested
|
||||
.tool_session()
|
||||
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List(
|
||||
ListRequest {
|
||||
path: WorkdirPath::root(),
|
||||
limit: 100
|
||||
}
|
||||
)))
|
||||
.await,
|
||||
Err(WorkdirError::SessionClosed)
|
||||
));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn checkout_search_composes_actual_nested_wrappers_and_output_root() {
|
||||
let root = fixture();
|
||||
let parent = WorkdirToolBroker::new(Arc::new(LocalWorkdirSession::new(
|
||||
manifest::Scope::writable(root.path()).unwrap(),
|
||||
root.path().to_path_buf(),
|
||||
)));
|
||||
let first = parent
|
||||
.scope(scope(vec![rule("child", true)], "child"))
|
||||
.await
|
||||
.unwrap();
|
||||
// Two broker wrappers, not only leases that share a source.
|
||||
let stacked = WorkdirToolBroker::new(first.tool_session());
|
||||
let mut request =
|
||||
CheckoutSearchRequest::new(CheckoutSearchOperation::Grep(grep(GrepOutputMode::Content)));
|
||||
*request.operation.path_mut() = path("keep");
|
||||
request.output_root = path("keep");
|
||||
request.scope_layers = vec![vec![rule("keep", false)]];
|
||||
let CheckoutSearchResult::Grep(result) = stacked.checkout_search(request).await.unwrap() else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(result.paths, vec![path("yes.txt")]);
|
||||
assert!(result.output.starts_with("yes.txt\n"));
|
||||
// The outer context is relative to the inner session; no duplicated child/.
|
||||
let CheckoutSearchResult::List(result) = stacked
|
||||
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List(
|
||||
ListRequest {
|
||||
path: path("keep"),
|
||||
limit: 100,
|
||||
},
|
||||
)))
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(result.entries[0].path, path("keep/yes.txt"));
|
||||
// A second actual scoped wrapper has its own cwd and nonrecursive layer.
|
||||
// Logical rules avoid requesting resolved authorization from a wrapper
|
||||
// that conservatively does not expose host-side resolution authority.
|
||||
std::fs::create_dir(root.path().join("child/keep/deep")).unwrap();
|
||||
std::fs::write(
|
||||
root.path().join("child/keep/deep/secret"),
|
||||
"needle hidden nested",
|
||||
)
|
||||
.unwrap();
|
||||
let mut own_rule = rule("keep", false);
|
||||
own_rule.symlink_policy = manifest::SymlinkPolicy::Logical;
|
||||
let second = stacked.scope(scope(vec![own_rule], "keep")).await.unwrap();
|
||||
let result = second
|
||||
.tool_session()
|
||||
.grep(grep(GrepOutputMode::Content))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.paths, vec![path("yes.txt")]);
|
||||
assert!(result.output.starts_with("yes.txt\n"));
|
||||
assert!(!result.output.contains("hidden nested"));
|
||||
let entries = second
|
||||
.tool_session()
|
||||
.list(ListRequest {
|
||||
path: WorkdirPath::root(),
|
||||
limit: 100,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.entries;
|
||||
assert_eq!(
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| entry.path.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![path("deep"), path("yes.txt")]
|
||||
);
|
||||
assert!(
|
||||
second
|
||||
.tool_session()
|
||||
.list(ListRequest {
|
||||
path: path("deep"),
|
||||
limit: 100
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.entries
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn checkout_search_external_keeps_approved_root_and_sparse_authority() {
|
||||
let root = fixture();
|
||||
let external = LocalWorkdirSession::external_read_only(
|
||||
Workdir::new("external-search"),
|
||||
root.path(),
|
||||
BoundedReadLimits::EXTERNAL_DEFAULT,
|
||||
)
|
||||
.unwrap();
|
||||
let broker = WorkdirToolBroker::new(Arc::new(external));
|
||||
let lease = broker
|
||||
.scope(scope(
|
||||
vec![rule("child", false), rule("child/keep", true)],
|
||||
"child",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let parent = tempfile::tempdir().unwrap();
|
||||
let approved = parent.path().join("approved");
|
||||
std::fs::rename(root.path(), &approved).unwrap();
|
||||
std::fs::create_dir(root.path()).unwrap();
|
||||
std::fs::create_dir(root.path().join("child")).unwrap();
|
||||
std::fs::write(
|
||||
root.path().join("child/replacement.txt"),
|
||||
"needle replacement",
|
||||
)
|
||||
.unwrap();
|
||||
verify_sparse(&*lease.tool_session()).await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn checkout_search_scope_layers_intersect_before_list_and_grep() {
|
||||
let root = fixture();
|
||||
let source = LocalWorkdirSession::new(
|
||||
manifest::Scope::writable(root.path()).unwrap(),
|
||||
root.path().to_path_buf(),
|
||||
);
|
||||
let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::List(ListRequest {
|
||||
path: path("child"),
|
||||
limit: 100,
|
||||
}));
|
||||
request.output_root = path("child");
|
||||
request.scope_layers = vec![
|
||||
vec![rule("child", false), rule("child/keep", true)],
|
||||
vec![rule("child", false)],
|
||||
];
|
||||
let CheckoutSearchResult::List(result) = source.checkout_search(request.clone()).await.unwrap()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(result.entries.len(), 3);
|
||||
request.operation = CheckoutSearchOperation::Grep(GrepRequest {
|
||||
path: path("child"),
|
||||
..grep(GrepOutputMode::Content)
|
||||
});
|
||||
let CheckoutSearchResult::Grep(result) = source.checkout_search(request).await.unwrap() else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(result.paths, vec![path("a.txt")]);
|
||||
assert!(!result.output.contains("subtree"));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn checkout_search_wire_dto_bounds_and_provider_capabilities() {
|
||||
use crate::external::{ExternalWorkdirOperation, ExternalWorkdirOperationResult};
|
||||
use crate::http::{WorkdirSessionOperation as Op, WorkdirSessionOperationResult as Output};
|
||||
let root = fixture();
|
||||
let source = LocalWorkdirSession::new(
|
||||
manifest::Scope::writable(root.path()).unwrap(),
|
||||
root.path().to_path_buf(),
|
||||
);
|
||||
let request = CheckoutSearchRequest::new(CheckoutSearchOperation::List(ListRequest {
|
||||
path: path("child/keep"),
|
||||
limit: 100,
|
||||
}));
|
||||
let wire = Op::CheckoutSearch(request.clone());
|
||||
assert!(ExternalWorkdirOperation::try_from(wire.clone()).is_ok());
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Op>(serde_json::to_value(&wire).unwrap()).unwrap(),
|
||||
wire
|
||||
);
|
||||
let result = crate::http::dispatch_workdir_session_operation(&source, wire)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
&result,
|
||||
Output::CheckoutSearch(CheckoutSearchResult::List(_))
|
||||
));
|
||||
assert!(ExternalWorkdirOperationResult::try_from(result.clone()).is_ok());
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Output>(serde_json::to_value(&result).unwrap()).unwrap(),
|
||||
result
|
||||
);
|
||||
let mut bad = request.clone();
|
||||
bad.output_root = WorkdirPath::new_scoped("/host").unwrap();
|
||||
assert!(ExternalWorkdirOperation::try_from(Op::CheckoutSearch(bad)).is_err());
|
||||
let mut bad = request.clone();
|
||||
bad.scope_layers = vec![vec![rule("", true)]; 17];
|
||||
assert!(ExternalWorkdirOperation::try_from(Op::CheckoutSearch(bad)).is_err());
|
||||
let mut bad = request.clone();
|
||||
bad.scope_layers = vec![vec![rule("", true); 257]];
|
||||
assert!(ExternalWorkdirOperation::try_from(Op::CheckoutSearch(bad)).is_err());
|
||||
let mut bad = request.clone();
|
||||
bad.scope_layers = vec![vec![rule("", true); 65]];
|
||||
assert!(ExternalWorkdirOperation::try_from(Op::CheckoutSearch(bad)).is_err());
|
||||
let mut bad = request;
|
||||
bad.scope_layers = vec![Vec::new()];
|
||||
assert!(ExternalWorkdirOperation::try_from(Op::CheckoutSearch(bad)).is_err());
|
||||
let bad_result = Output::CheckoutSearch(CheckoutSearchResult::Glob(GlobResult {
|
||||
paths: vec![WorkdirPath::new_scoped("/host").unwrap()],
|
||||
truncated: false,
|
||||
}));
|
||||
assert!(ExternalWorkdirOperationResult::try_from(bad_result).is_err());
|
||||
}
|
||||
|
||||
fn write_ignore_fixture(root: &std::path::Path, relative: &str, contents: &str) {
|
||||
let file = root.join(relative);
|
||||
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
std::fs::write(file, contents).unwrap();
|
||||
}
|
||||
|
||||
async fn verify_ignore_search_parity(session: &dyn WorkdirSession, base: &str, expected: &[&str]) {
|
||||
let request = GlobRequest {
|
||||
path: path(base),
|
||||
pattern: "**/*.txt".into(),
|
||||
limit: 100,
|
||||
};
|
||||
// On a Local session this is the actual ordinary WalkBuilder-backed path,
|
||||
// not a broker wrapper that would route both calls through checkout_search.
|
||||
let ordinary = session.glob(request.clone()).await.unwrap();
|
||||
assert_eq!(
|
||||
ordinary.paths,
|
||||
expected.iter().map(|value| path(value)).collect::<Vec<_>>(),
|
||||
"ordinary Glob fixture semantics at {base:?}"
|
||||
);
|
||||
assert!(!ordinary.truncated);
|
||||
let CheckoutSearchResult::Glob(native) = session
|
||||
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::Glob(
|
||||
request,
|
||||
)))
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("expected native Glob result")
|
||||
};
|
||||
assert_eq!(native, ordinary, "native Glob parity at {base:?}");
|
||||
|
||||
// Content mode renders paths in sorted order, avoiding dependence on the
|
||||
// different walkers' visitation order. All fixture content has one match.
|
||||
let request = GrepRequest {
|
||||
path: path(base),
|
||||
..grep(GrepOutputMode::Content)
|
||||
};
|
||||
let ordinary = session.grep(request.clone()).await.unwrap();
|
||||
assert_eq!(
|
||||
ordinary.paths,
|
||||
expected.iter().map(|value| path(value)).collect::<Vec<_>>(),
|
||||
"ordinary Grep fixture semantics at {base:?}"
|
||||
);
|
||||
assert_eq!(ordinary.matched_files, expected.len());
|
||||
assert_eq!(ordinary.match_count, expected.len());
|
||||
assert!(!ordinary.truncated);
|
||||
let CheckoutSearchResult::Grep(native) = session
|
||||
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::Grep(
|
||||
request,
|
||||
)))
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("expected native Grep result")
|
||||
};
|
||||
assert_eq!(native, ordinary, "native Grep parity at {base:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_search_local_glob_inherits_root_and_nested_ignore_rules() {
|
||||
// Exercise the two ignore sources independently so rule precedence cannot
|
||||
// conceal either missing source. A .git marker activates .gitignore rules.
|
||||
for ignore_name in [".ignore", ".gitignore"] {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(root.path().join(".git")).unwrap();
|
||||
write_ignore_fixture(
|
||||
root.path(),
|
||||
ignore_name,
|
||||
"root-only.txt\n/root-anchored.txt\n*.drop.txt\n!keep.drop.txt\ninherited.txt\npruned/\n",
|
||||
);
|
||||
write_ignore_fixture(
|
||||
root.path(),
|
||||
&format!("nested/{ignore_name}"),
|
||||
"/nested-anchored.txt\nnested-only.txt\n!inherited.txt\nignored-dir/\n!ignored-dir/rescue.txt\n",
|
||||
);
|
||||
for relative in [
|
||||
"visible.txt",
|
||||
"root-only.txt",
|
||||
"root-anchored.txt",
|
||||
"discard.drop.txt",
|
||||
"keep.drop.txt",
|
||||
"inherited.txt",
|
||||
"pruned/rescue.txt",
|
||||
"nested/visible.txt",
|
||||
"nested/root-only.txt",
|
||||
"nested/root-anchored.txt",
|
||||
"nested/nested-only.txt",
|
||||
"nested/nested-anchored.txt",
|
||||
"nested/discard.drop.txt",
|
||||
"nested/keep.drop.txt",
|
||||
"nested/inherited.txt",
|
||||
"nested/pruned/rescue.txt",
|
||||
"nested/ignored-dir/rescue.txt",
|
||||
"nested/deep/nested-anchored.txt",
|
||||
] {
|
||||
write_ignore_fixture(root.path(), relative, "needle fixture\n");
|
||||
}
|
||||
let source = LocalWorkdirSession::new(
|
||||
manifest::Scope::writable(root.path()).unwrap(),
|
||||
root.path().to_path_buf(),
|
||||
);
|
||||
verify_ignore_search_parity(
|
||||
&source,
|
||||
"",
|
||||
&[
|
||||
"keep.drop.txt",
|
||||
"nested/deep/nested-anchored.txt",
|
||||
"nested/inherited.txt",
|
||||
"nested/keep.drop.txt",
|
||||
"nested/root-anchored.txt",
|
||||
"nested/visible.txt",
|
||||
"visible.txt",
|
||||
],
|
||||
)
|
||||
.await;
|
||||
// Starting explicitly below the provider root must still inherit the
|
||||
// root's rules and retain provider-relative (not base-relative) paths.
|
||||
verify_ignore_search_parity(
|
||||
&source,
|
||||
"nested",
|
||||
&[
|
||||
"nested/deep/nested-anchored.txt",
|
||||
"nested/inherited.txt",
|
||||
"nested/keep.drop.txt",
|
||||
"nested/root-anchored.txt",
|
||||
"nested/visible.txt",
|
||||
],
|
||||
)
|
||||
.await;
|
||||
verify_ignore_search_parity(&source, "nested/deep", &["nested/deep/nested-anchored.txt"])
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_search_local_glob_preserves_ignore_source_precedence_across_levels() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(root.path().join(".git")).unwrap();
|
||||
write_ignore_fixture(
|
||||
root.path(),
|
||||
".ignore",
|
||||
"priority.txt\n!ignore-wins.txt\nroot-ignore.txt\n",
|
||||
);
|
||||
write_ignore_fixture(
|
||||
root.path(),
|
||||
".gitignore",
|
||||
"ignore-wins.txt\nrevived-git.txt\n",
|
||||
);
|
||||
write_ignore_fixture(root.path(), "nested/.ignore", "!root-ignore.txt\n");
|
||||
write_ignore_fixture(
|
||||
root.path(),
|
||||
"nested/.gitignore",
|
||||
"!priority.txt\n!revived-git.txt\n",
|
||||
);
|
||||
for relative in [
|
||||
"priority.txt",
|
||||
"ignore-wins.txt",
|
||||
"root-ignore.txt",
|
||||
"revived-git.txt",
|
||||
"nested/priority.txt",
|
||||
"nested/ignore-wins.txt",
|
||||
"nested/root-ignore.txt",
|
||||
"nested/revived-git.txt",
|
||||
] {
|
||||
write_ignore_fixture(root.path(), relative, "needle fixture\n");
|
||||
}
|
||||
let source = LocalWorkdirSession::new(
|
||||
manifest::Scope::writable(root.path()).unwrap(),
|
||||
root.path().to_path_buf(),
|
||||
);
|
||||
// .ignore outranks .gitignore even when the .gitignore is deeper. Within
|
||||
// the same source kind, a deeper negation can override an ancestor rule.
|
||||
verify_ignore_search_parity(
|
||||
&source,
|
||||
"",
|
||||
&[
|
||||
"ignore-wins.txt",
|
||||
"nested/ignore-wins.txt",
|
||||
"nested/revived-git.txt",
|
||||
"nested/root-ignore.txt",
|
||||
],
|
||||
)
|
||||
.await;
|
||||
verify_ignore_search_parity(
|
||||
&source,
|
||||
"nested",
|
||||
&[
|
||||
"nested/ignore-wins.txt",
|
||||
"nested/revived-git.txt",
|
||||
"nested/root-ignore.txt",
|
||||
],
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_search_scoped_broker_skips_denied_ancestor_ignore_files() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(root.path().join(".git")).unwrap();
|
||||
for (relative, contents) in [
|
||||
(".ignore", "root-ignore-visible.txt\n"),
|
||||
(".gitignore", "root-git-visible.txt\n"),
|
||||
("parent/.ignore", "parent-ignore-visible.txt\n"),
|
||||
("parent/.gitignore", "parent-git-visible.txt\n"),
|
||||
("parent/nested/.ignore", "allowed-ignore.txt\n"),
|
||||
] {
|
||||
write_ignore_fixture(root.path(), relative, contents);
|
||||
}
|
||||
for name in [
|
||||
"root-ignore-visible.txt",
|
||||
"root-git-visible.txt",
|
||||
"parent-ignore-visible.txt",
|
||||
"parent-git-visible.txt",
|
||||
"allowed-ignore.txt",
|
||||
] {
|
||||
write_ignore_fixture(
|
||||
root.path(),
|
||||
&format!("parent/nested/{name}"),
|
||||
"needle fixture\n",
|
||||
);
|
||||
}
|
||||
let source = Arc::new(LocalWorkdirSession::new(
|
||||
manifest::Scope::writable(root.path()).unwrap(),
|
||||
root.path().to_path_buf(),
|
||||
));
|
||||
// Without a scope layer, ordinary Local traversal really does inherit
|
||||
// these ancestor rules. Each denied source suppresses a different file.
|
||||
assert!(
|
||||
source
|
||||
.glob(GlobRequest {
|
||||
path: path("parent/nested"),
|
||||
pattern: "**/*.txt".into(),
|
||||
limit: 100,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.paths
|
||||
.is_empty()
|
||||
);
|
||||
// Make accidental consumption observable even if a matcher were to drop
|
||||
// the valid patterns: these denied sparse sources exceed the shared Grep
|
||||
// source budget. The allowed subtree remains tiny.
|
||||
for relative in [
|
||||
".ignore",
|
||||
".gitignore",
|
||||
"parent/.ignore",
|
||||
"parent/.gitignore",
|
||||
] {
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(root.path().join(relative))
|
||||
.unwrap()
|
||||
.set_len(65 * 1024 * 1024)
|
||||
.unwrap();
|
||||
}
|
||||
let broker = WorkdirToolBroker::new(source.clone());
|
||||
let lease = broker
|
||||
.scope(scope(vec![rule("parent/nested", true)], "parent/nested"))
|
||||
.await
|
||||
.unwrap();
|
||||
// Broker .glob/.grep use the native path under a scope. Compare those
|
||||
// public entry points with explicit checkout_search and assert rebasing.
|
||||
verify_ignore_search_parity(
|
||||
&*lease.tool_session(),
|
||||
"",
|
||||
&[
|
||||
"parent-git-visible.txt",
|
||||
"parent-ignore-visible.txt",
|
||||
"root-git-visible.txt",
|
||||
"root-ignore-visible.txt",
|
||||
],
|
||||
)
|
||||
.await;
|
||||
// Also exercise a Local native request with an unre-based provider root:
|
||||
// allowed descendants do not grant authority to ancestor ignore sources.
|
||||
let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::Glob(GlobRequest {
|
||||
path: path("parent/nested"),
|
||||
pattern: "**/*.txt".into(),
|
||||
limit: 100,
|
||||
}));
|
||||
request.scope_layers = vec![vec![rule("parent/nested", true)]];
|
||||
let CheckoutSearchResult::Glob(result) = source.checkout_search(request).await.unwrap() else {
|
||||
panic!("expected native Glob result")
|
||||
};
|
||||
assert_eq!(
|
||||
result.paths,
|
||||
vec![
|
||||
path("parent/nested/parent-git-visible.txt"),
|
||||
path("parent/nested/parent-ignore-visible.txt"),
|
||||
path("parent/nested/root-git-visible.txt"),
|
||||
path("parent/nested/root-ignore-visible.txt"),
|
||||
]
|
||||
);
|
||||
assert!(!result.truncated);
|
||||
lease.close().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_search_native_skips_symlinked_ignore_sources_without_outside_effects() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(root.path().join(".git")).unwrap();
|
||||
std::fs::create_dir(root.path().join("nested")).unwrap();
|
||||
// Following any of these sources would hide an otherwise visible file.
|
||||
// The native descriptor provider must safely skip rejected ignore opens,
|
||||
// including when the source belongs to an explicitly selected base.
|
||||
for (relative, hidden) in [
|
||||
(".ignore", "root-ignore-visible.txt"),
|
||||
(".gitignore", "root-git-visible.txt"),
|
||||
("nested/.ignore", "nested-ignore-visible.txt"),
|
||||
("nested/.gitignore", "nested-git-visible.txt"),
|
||||
] {
|
||||
let target = outside.path().join(relative.replace('/', "-"));
|
||||
let contents = format!("{hidden}\n");
|
||||
std::fs::write(&target, &contents).unwrap();
|
||||
std::os::unix::fs::symlink(&target, root.path().join(relative)).unwrap();
|
||||
let visible = if relative.starts_with("nested/") {
|
||||
format!("nested/{hidden}")
|
||||
} else {
|
||||
hidden.to_string()
|
||||
};
|
||||
write_ignore_fixture(root.path(), &visible, "needle fixture\n");
|
||||
}
|
||||
write_ignore_fixture(outside.path(), "secret.txt", "needle outside\n");
|
||||
let source = LocalWorkdirSession::new(
|
||||
manifest::Scope::writable(root.path()).unwrap(),
|
||||
root.path().to_path_buf(),
|
||||
);
|
||||
for (base, expected) in [
|
||||
(
|
||||
"",
|
||||
vec![
|
||||
path("nested/nested-git-visible.txt"),
|
||||
path("nested/nested-ignore-visible.txt"),
|
||||
path("root-git-visible.txt"),
|
||||
path("root-ignore-visible.txt"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"nested",
|
||||
vec![
|
||||
path("nested/nested-git-visible.txt"),
|
||||
path("nested/nested-ignore-visible.txt"),
|
||||
],
|
||||
),
|
||||
] {
|
||||
let CheckoutSearchResult::Glob(result) = source
|
||||
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::Glob(
|
||||
GlobRequest {
|
||||
path: path(base),
|
||||
pattern: "**/*.txt".into(),
|
||||
limit: 100,
|
||||
},
|
||||
)))
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("expected native Glob result")
|
||||
};
|
||||
assert_eq!(result.paths, expected);
|
||||
assert!(!result.truncated);
|
||||
// Grep's existing descriptor contract rejects an unauthorized ignore
|
||||
// open, rather than ignoring its error as Glob does. Both behaviors
|
||||
// preserve confinement; do not relax Grep merely to match Glob.
|
||||
let error = source
|
||||
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::Grep(
|
||||
GrepRequest {
|
||||
path: path(base),
|
||||
..grep(GrepOutputMode::Content)
|
||||
},
|
||||
)))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, WorkdirError::OutOfScope(_)));
|
||||
}
|
||||
for (relative, hidden) in [
|
||||
(".ignore", "root-ignore-visible.txt"),
|
||||
(".gitignore", "root-git-visible.txt"),
|
||||
("nested/.ignore", "nested-ignore-visible.txt"),
|
||||
("nested/.gitignore", "nested-git-visible.txt"),
|
||||
] {
|
||||
assert!(
|
||||
std::fs::symlink_metadata(root.path().join(relative))
|
||||
.unwrap()
|
||||
.file_type()
|
||||
.is_symlink()
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(outside.path().join(relative.replace('/', "-"))).unwrap(),
|
||||
format!("{hidden}\n")
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(outside.path().join("secret.txt")).unwrap(),
|
||||
"needle outside\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_search_preserves_current_provider_scope_and_rejects_symlink_escape() {
|
||||
let root = fixture();
|
||||
let current = manifest::Scope::from_config(&manifest::ScopeConfig {
|
||||
allow: vec![manifest::ScopeRule {
|
||||
target: root.path().join("child"),
|
||||
permission: manifest::Permission::Read,
|
||||
recursive: false,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
})
|
||||
.unwrap();
|
||||
let source = LocalWorkdirSession::new(current, root.path().to_path_buf());
|
||||
let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::Grep(GrepRequest {
|
||||
path: path("child"),
|
||||
..grep(GrepOutputMode::Content)
|
||||
}));
|
||||
request.output_root = path("child");
|
||||
// A broad caller layer cannot enlarge the provider's nonrecursive authority.
|
||||
request.scope_layers = vec![vec![rule("", true)]];
|
||||
let CheckoutSearchResult::Grep(result) = source.checkout_search(request).await.unwrap() else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(result.paths, vec![path("a.txt")]);
|
||||
let CheckoutSearchResult::List(result) = source
|
||||
.checkout_search(CheckoutSearchRequest {
|
||||
operation: CheckoutSearchOperation::List(ListRequest {
|
||||
path: path("child/denied"),
|
||||
limit: 100,
|
||||
}),
|
||||
output_root: path("child"),
|
||||
scope_layers: Vec::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert!(result.entries.is_empty());
|
||||
std::os::unix::fs::symlink("../outside.txt", root.path().join("child/link.txt")).unwrap();
|
||||
let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::Grep(GrepRequest {
|
||||
path: path("child/link.txt"),
|
||||
..grep(GrepOutputMode::Content)
|
||||
}));
|
||||
request.output_root = path("child");
|
||||
assert!(source.checkout_search(request).await.is_err());
|
||||
}
|
||||
@@ -149,6 +149,56 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<()
|
||||
WorkdirSessionOperation::ScopeRulesOverlap(request) => {
|
||||
is_root_relative(&request.left.target) && is_root_relative(&request.right.target)
|
||||
}
|
||||
WorkdirSessionOperation::CheckoutSearch(request) => {
|
||||
request.validate().is_ok()
|
||||
&& request.scope_layers.iter().map(Vec::len).sum::<usize>()
|
||||
<= MAX_EXTERNAL_SCOPE_RULES
|
||||
&& validate_external_operation(&match &request.operation {
|
||||
crate::CheckoutSearchOperation::List(r) => {
|
||||
WorkdirSessionOperation::List(r.clone())
|
||||
}
|
||||
crate::CheckoutSearchOperation::Glob(r) => {
|
||||
WorkdirSessionOperation::Glob(r.clone())
|
||||
}
|
||||
crate::CheckoutSearchOperation::Grep(r) => {
|
||||
WorkdirSessionOperation::Grep(r.clone())
|
||||
}
|
||||
})
|
||||
.is_ok()
|
||||
}
|
||||
WorkdirSessionOperation::CheckoutObserve(path) => {
|
||||
is_root_relative(path) && crate::checkout::validate_path(path).is_ok()
|
||||
}
|
||||
WorkdirSessionOperation::CheckoutExecute(request) => {
|
||||
is_root_relative(&request.target)
|
||||
&& crate::checkout::validate_path(&request.target).is_ok()
|
||||
&& !request.validator.is_empty()
|
||||
&& request.validator.len() <= 256
|
||||
&& match &request.operation {
|
||||
crate::CheckoutOperation::Read {
|
||||
limit, max_bytes, ..
|
||||
} => {
|
||||
*limit <= 1_000_000
|
||||
&& *max_bytes <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
|
||||
}
|
||||
crate::CheckoutOperation::Write { content, .. } => {
|
||||
content.len() <= MAX_EXTERNAL_WRITE_BYTES
|
||||
}
|
||||
crate::CheckoutOperation::Edit {
|
||||
old_string,
|
||||
new_string,
|
||||
..
|
||||
} => old_string
|
||||
.len()
|
||||
.checked_add(new_string.len())
|
||||
.is_some_and(|size| size <= MAX_EXTERNAL_WRITE_BYTES),
|
||||
crate::CheckoutOperation::Create { path, content } => {
|
||||
is_root_relative(path)
|
||||
&& crate::checkout::create_relative_path(&request.target, path).is_ok()
|
||||
&& content.len() <= MAX_EXTERNAL_WRITE_BYTES
|
||||
}
|
||||
}
|
||||
}
|
||||
WorkdirSessionOperation::Stat(request) => is_root_relative(&request.path),
|
||||
WorkdirSessionOperation::Read(request) => {
|
||||
is_root_relative(&request.path)
|
||||
@@ -272,10 +322,59 @@ fn result_paths_fit<'a>(paths: impl IntoIterator<Item = &'a WorkdirPath>) -> boo
|
||||
.is_some_and(|retained| retained <= fs_operation::MAX_RESULT_PATH_BYTES)
|
||||
}
|
||||
|
||||
fn valid_checkout_observation(observation: &crate::CheckoutObservation) -> bool {
|
||||
is_root_relative(&observation.path)
|
||||
&& !observation.validator.is_empty()
|
||||
&& observation.validator.len() <= 256
|
||||
&& matches!(
|
||||
observation.kind,
|
||||
crate::EntryKind::File | crate::EntryKind::Directory
|
||||
)
|
||||
&& (observation.kind == crate::EntryKind::Directory
|
||||
|| observation.size <= BoundedReadLimits::EXTERNAL_DEFAULT.max_source_bytes)
|
||||
}
|
||||
|
||||
fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<(), String> {
|
||||
let result_is_valid = match result {
|
||||
WorkdirSessionOperationResult::AuthorizeScope
|
||||
| WorkdirSessionOperationResult::ScopeRulesOverlap { .. } => true,
|
||||
WorkdirSessionOperationResult::CheckoutSearch(result) => {
|
||||
validate_external_result(&match result {
|
||||
crate::CheckoutSearchResult::List(r) => {
|
||||
WorkdirSessionOperationResult::List(r.clone())
|
||||
}
|
||||
crate::CheckoutSearchResult::Glob(r) => {
|
||||
WorkdirSessionOperationResult::Glob(r.clone())
|
||||
}
|
||||
crate::CheckoutSearchResult::Grep(r) => {
|
||||
WorkdirSessionOperationResult::Grep(r.clone())
|
||||
}
|
||||
})
|
||||
.is_ok()
|
||||
}
|
||||
WorkdirSessionOperationResult::CheckoutObserve(observation) => {
|
||||
valid_checkout_observation(observation)
|
||||
}
|
||||
WorkdirSessionOperationResult::CheckoutExecute(result) => {
|
||||
valid_checkout_observation(&result.observation)
|
||||
&& match &result.output {
|
||||
crate::CheckoutOutput::Read(read) => {
|
||||
read.path == result.observation.path
|
||||
&& validate_external_result(&WorkdirSessionOperationResult::Read(
|
||||
read.clone(),
|
||||
))
|
||||
.is_ok()
|
||||
}
|
||||
crate::CheckoutOutput::Write(write) => validate_external_result(
|
||||
&WorkdirSessionOperationResult::Write(write.clone()),
|
||||
)
|
||||
.is_ok(),
|
||||
crate::CheckoutOutput::Edit(edit) => {
|
||||
validate_external_result(&WorkdirSessionOperationResult::Edit(edit.clone()))
|
||||
.is_ok()
|
||||
}
|
||||
}
|
||||
}
|
||||
WorkdirSessionOperationResult::Stat(result) => is_root_relative(&result.path),
|
||||
WorkdirSessionOperationResult::Read(result) => {
|
||||
is_root_relative(&result.path)
|
||||
@@ -307,6 +406,9 @@ fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<()
|
||||
result.output.len() <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
|
||||
&& result.match_count <= MAX_EXTERNAL_RESULT_ITEMS
|
||||
&& result.matched_files <= MAX_EXTERNAL_RESULT_ITEMS
|
||||
&& result.paths.len() <= MAX_EXTERNAL_RESULT_ITEMS
|
||||
&& result.paths.iter().all(is_root_relative)
|
||||
&& result_paths_fit(&result.paths)
|
||||
}
|
||||
WorkdirSessionOperationResult::Write(result) => {
|
||||
result.bytes_written <= MAX_EXTERNAL_WRITE_BYTES
|
||||
@@ -821,6 +923,7 @@ mod tests {
|
||||
assert!(ExternalWorkdirOperation::try_from(unbounded).is_err());
|
||||
|
||||
let oversized_result = WorkdirSessionOperationResult::Grep(GrepResult {
|
||||
paths: Vec::new(),
|
||||
output: "x".repeat(BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes + 1),
|
||||
match_count: 1,
|
||||
matched_files: 1,
|
||||
|
||||
+136
-1
@@ -64,6 +64,9 @@ pub struct OpenWorkdirSessionResponse {
|
||||
pub enum WorkdirSessionOperation {
|
||||
AuthorizeScope(WorkdirScopeAuthorizationRequest),
|
||||
ScopeRulesOverlap(WorkdirScopeOverlapRequest),
|
||||
CheckoutSearch(crate::CheckoutSearchRequest),
|
||||
CheckoutObserve(crate::WorkdirPath),
|
||||
CheckoutExecute(crate::CheckoutRequest),
|
||||
Stat(StatRequest),
|
||||
Read(ReadRequest),
|
||||
ReadBytes(ReadBytesRequest),
|
||||
@@ -96,6 +99,9 @@ pub struct WorkdirSessionOperationRequest {
|
||||
pub enum WorkdirSessionOperationResult {
|
||||
AuthorizeScope,
|
||||
ScopeRulesOverlap { overlaps: bool },
|
||||
CheckoutSearch(crate::CheckoutSearchResult),
|
||||
CheckoutObserve(crate::CheckoutObservation),
|
||||
CheckoutExecute(crate::CheckoutResult),
|
||||
Stat(StatResult),
|
||||
Read(ReadResult),
|
||||
ReadBytes(ReadBytesResult),
|
||||
@@ -129,6 +135,18 @@ pub async fn dispatch_workdir_session_operation(
|
||||
.scope_rules_overlap(request)
|
||||
.await
|
||||
.map(|overlaps| WorkdirSessionOperationResult::ScopeRulesOverlap { overlaps }),
|
||||
WorkdirSessionOperation::CheckoutSearch(request) => session
|
||||
.checkout_search(request)
|
||||
.await
|
||||
.map(WorkdirSessionOperationResult::CheckoutSearch),
|
||||
WorkdirSessionOperation::CheckoutObserve(path) => session
|
||||
.checkout_observe(path)
|
||||
.await
|
||||
.map(WorkdirSessionOperationResult::CheckoutObserve),
|
||||
WorkdirSessionOperation::CheckoutExecute(request) => session
|
||||
.checkout_execute(request)
|
||||
.await
|
||||
.map(WorkdirSessionOperationResult::CheckoutExecute),
|
||||
WorkdirSessionOperation::Stat(request) => session
|
||||
.stat(request)
|
||||
.await
|
||||
@@ -186,6 +204,7 @@ pub async fn dispatch_workdir_session_operation(
|
||||
pub enum WorkdirTransportErrorCode {
|
||||
NotFound,
|
||||
Conflict,
|
||||
OutcomeUnknown,
|
||||
Unsupported,
|
||||
InvalidRequest,
|
||||
Denied,
|
||||
@@ -208,6 +227,7 @@ impl WorkdirTransportErrorCode {
|
||||
match self {
|
||||
Self::NotFound => "not_found",
|
||||
Self::Conflict => "conflict",
|
||||
Self::OutcomeUnknown => "outcome_unknown",
|
||||
Self::Unsupported => "unsupported",
|
||||
Self::InvalidRequest => "invalid_request",
|
||||
Self::Denied => "denied",
|
||||
@@ -239,7 +259,7 @@ impl WorkdirTransportErrorCode {
|
||||
| Self::IsDirectory
|
||||
| Self::SymlinkDirectoryNotTraversed => 400,
|
||||
Self::Unavailable => 503,
|
||||
Self::Io | Self::Internal => 500,
|
||||
Self::Io | Self::Internal | Self::OutcomeUnknown => 500,
|
||||
Self::Transport => 502,
|
||||
}
|
||||
}
|
||||
@@ -256,6 +276,10 @@ impl WorkdirTransportError {
|
||||
pub fn from_workdir_error(error: &WorkdirError) -> Self {
|
||||
use WorkdirTransportErrorCode as Code;
|
||||
let (code, message) = match error {
|
||||
WorkdirError::OutcomeUnknown(_) => (
|
||||
Code::OutcomeUnknown,
|
||||
"Workdir operation may have effects; inspect before retrying",
|
||||
),
|
||||
WorkdirError::NotFound(_) => (Code::NotFound, "Workdir path was not found"),
|
||||
WorkdirError::Conflict(_) => (
|
||||
Code::Conflict,
|
||||
@@ -330,6 +354,9 @@ impl WorkdirTransportError {
|
||||
match self.code {
|
||||
Code::NotFound => WorkdirError::NotFound("<remote>".into()),
|
||||
Code::Conflict => WorkdirError::Conflict(self.message),
|
||||
Code::OutcomeUnknown => WorkdirError::OutcomeUnknown(
|
||||
"Workdir operation may have effects; inspect before retrying".into(),
|
||||
),
|
||||
Code::Unsupported => WorkdirError::UnsupportedOperation(self.message),
|
||||
Code::InvalidRequest => WorkdirError::InvalidArgument(self.message),
|
||||
Code::Denied => WorkdirError::Denied(self.message),
|
||||
@@ -564,6 +591,114 @@ mod client {
|
||||
}
|
||||
}
|
||||
|
||||
async fn checkout_search(
|
||||
&self,
|
||||
request: crate::CheckoutSearchRequest,
|
||||
) -> Result<crate::CheckoutSearchResult, WorkdirError> {
|
||||
request.validate()?;
|
||||
let expected = request.operation.capability();
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::CheckoutSearch(request))
|
||||
.await?
|
||||
{
|
||||
WorkdirSessionOperationResult::CheckoutSearch(result)
|
||||
if matches!(
|
||||
(&result, expected),
|
||||
(
|
||||
crate::CheckoutSearchResult::List(_),
|
||||
crate::WorkdirSessionCapability::Read
|
||||
) | (
|
||||
crate::CheckoutSearchResult::Glob(_),
|
||||
crate::WorkdirSessionCapability::Glob
|
||||
) | (
|
||||
crate::CheckoutSearchResult::Grep(_),
|
||||
crate::WorkdirSessionCapability::Grep
|
||||
)
|
||||
) =>
|
||||
{
|
||||
crate::external::ExternalWorkdirOperationResult::try_from(
|
||||
WorkdirSessionOperationResult::CheckoutSearch(result.clone()),
|
||||
)
|
||||
.map_err(|_| Self::mismatch("bounded checkout_search"))?;
|
||||
Ok(result)
|
||||
}
|
||||
_ => Err(Self::mismatch("checkout_search")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn checkout_observe(
|
||||
&self,
|
||||
path: crate::WorkdirPath,
|
||||
) -> Result<crate::CheckoutObservation, WorkdirError> {
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::CheckoutObserve(path.clone()))
|
||||
.await?
|
||||
{
|
||||
WorkdirSessionOperationResult::CheckoutObserve(mut result)
|
||||
if result.path == path
|
||||
&& !result.validator.is_empty()
|
||||
&& result.validator.len() <= 256 =>
|
||||
{
|
||||
result.capabilities = result.capabilities.intersection(self.capabilities());
|
||||
Ok(result)
|
||||
}
|
||||
_ => Err(Self::mismatch("checkout_observe")),
|
||||
}
|
||||
}
|
||||
async fn checkout_execute(
|
||||
&self,
|
||||
request: crate::CheckoutRequest,
|
||||
) -> Result<crate::CheckoutResult, WorkdirError> {
|
||||
let mutation = request.operation.capability() != crate::WorkdirSessionCapability::Read;
|
||||
let expected_path = request.target.clone();
|
||||
let expected_output = request.operation.capability();
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::CheckoutExecute(request))
|
||||
.await
|
||||
{
|
||||
Ok(WorkdirSessionOperationResult::CheckoutExecute(mut result))
|
||||
if result.observation.path == expected_path
|
||||
&& !result.observation.validator.is_empty()
|
||||
&& result.observation.validator.len() <= 256
|
||||
&& match (&result.output, expected_output) {
|
||||
(
|
||||
crate::CheckoutOutput::Read(read),
|
||||
crate::WorkdirSessionCapability::Read,
|
||||
) => {
|
||||
read.path == expected_path
|
||||
&& read.bytes.len()
|
||||
<= crate::BoundedReadLimits::EXTERNAL_DEFAULT
|
||||
.max_response_bytes
|
||||
}
|
||||
(
|
||||
crate::CheckoutOutput::Write(_),
|
||||
crate::WorkdirSessionCapability::Write,
|
||||
)
|
||||
| (
|
||||
crate::CheckoutOutput::Edit(_),
|
||||
crate::WorkdirSessionCapability::Edit,
|
||||
) => true,
|
||||
_ => false,
|
||||
} =>
|
||||
{
|
||||
result.observation.capabilities = result
|
||||
.observation
|
||||
.capabilities
|
||||
.intersection(self.capabilities());
|
||||
Ok(result)
|
||||
}
|
||||
Ok(_) if mutation => Err(WorkdirError::OutcomeUnknown(
|
||||
"mismatched response after checkout mutation".into(),
|
||||
)),
|
||||
Ok(_) => Err(Self::mismatch("checkout_execute")),
|
||||
Err(WorkdirError::Unavailable(_) | WorkdirError::Transport(_)) if mutation => {
|
||||
Err(WorkdirError::OutcomeUnknown(
|
||||
"checkout transport failed; effects may have occurred".into(),
|
||||
))
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||
match self.operate(WorkdirSessionOperation::Stat(request)).await? {
|
||||
WorkdirSessionOperationResult::Stat(result) => Ok(result),
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
//! bound to one Worker. Tools consume sessions; they do not own Workdir
|
||||
//! materialization or cleanup.
|
||||
|
||||
pub mod checkout;
|
||||
pub use checkout::{
|
||||
CheckoutObservation, CheckoutOperation, CheckoutOutput, CheckoutRequest, CheckoutResult,
|
||||
CheckoutSearchOperation, CheckoutSearchRequest, CheckoutSearchResult,
|
||||
};
|
||||
pub mod external;
|
||||
pub mod http;
|
||||
mod local;
|
||||
@@ -13,6 +18,8 @@ mod router;
|
||||
mod scope;
|
||||
pub mod workspace;
|
||||
|
||||
#[cfg(test)]
|
||||
mod checkout_search_tests;
|
||||
#[cfg(test)]
|
||||
mod external_local_tests;
|
||||
|
||||
@@ -219,6 +226,31 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
|
||||
))
|
||||
}
|
||||
|
||||
async fn checkout_search(
|
||||
&self,
|
||||
_request: CheckoutSearchRequest,
|
||||
) -> Result<CheckoutSearchResult, WorkdirError> {
|
||||
Err(WorkdirError::UnsupportedOperation(
|
||||
"scoped checkout traversal".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn checkout_observe(
|
||||
&self,
|
||||
_path: WorkdirPath,
|
||||
) -> Result<CheckoutObservation, WorkdirError> {
|
||||
Err(WorkdirError::UnsupportedOperation(
|
||||
"checkout observation".into(),
|
||||
))
|
||||
}
|
||||
async fn checkout_execute(
|
||||
&self,
|
||||
_request: CheckoutRequest,
|
||||
) -> Result<CheckoutResult, WorkdirError> {
|
||||
Err(WorkdirError::UnsupportedOperation(
|
||||
"checked checkout execution".into(),
|
||||
))
|
||||
}
|
||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError>;
|
||||
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError>;
|
||||
async fn read_bytes(
|
||||
@@ -289,6 +321,10 @@ pub enum WorkdirError {
|
||||
#[error("{0}")]
|
||||
Conflict(String),
|
||||
|
||||
/// Effects may have happened. Do not automatically retry the operation.
|
||||
#[error("Workdir operation outcome is unknown: {0}")]
|
||||
OutcomeUnknown(String),
|
||||
|
||||
#[error("unknown Workdir session command: {0}")]
|
||||
UnknownCommand(String),
|
||||
|
||||
|
||||
+527
-8
@@ -204,7 +204,7 @@ impl CommandTelemetry {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
struct ScopeAccess {
|
||||
scope: Arc<Scope>,
|
||||
root: PathBuf,
|
||||
@@ -314,13 +314,12 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
|
||||
"path is not a directory",
|
||||
));
|
||||
}
|
||||
let root = self
|
||||
.pinned_root
|
||||
.as_ref()
|
||||
.expect("confined traversal requires a pinned root")
|
||||
.try_clone()?;
|
||||
let path = PathBuf::from(format!("/proc/self/fd/{}", root.as_raw_fd()));
|
||||
return Ok(Some(fs_operation::FsTraversalRoot::new(path, root)));
|
||||
let path = PathBuf::from(format!("/proc/self/fd/{}", directory.as_raw_fd()));
|
||||
return Ok(Some(fs_operation::FsTraversalRoot::new_at(
|
||||
path,
|
||||
directory,
|
||||
resolved.to_path_buf(),
|
||||
)));
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
Err(std::io::Error::new(
|
||||
@@ -443,6 +442,118 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
|
||||
}
|
||||
}
|
||||
|
||||
/// A read-only, conjunctively narrowed descriptor policy for native searches.
|
||||
#[derive(Debug, Clone)]
|
||||
struct SearchAccess {
|
||||
inner: ScopeAccess,
|
||||
layers: Vec<Vec<crate::WorkdirToolScopeRule>>,
|
||||
output_root: PathBuf,
|
||||
source_allows: Arc<Vec<manifest::ScopeRule>>,
|
||||
}
|
||||
impl SearchAccess {
|
||||
fn logical_path(&self, path: &Path) -> Option<WorkdirPath> {
|
||||
WorkdirPath::new(path.strip_prefix(&self.inner.root).ok()?.to_str()?).ok()
|
||||
}
|
||||
fn read_allowed(&self, logical: &Path, resolved: &Path) -> bool {
|
||||
use fs_operation::FsAccessPolicy;
|
||||
let Some(path) = self.logical_path(logical) else {
|
||||
return false;
|
||||
};
|
||||
logical.starts_with(&self.output_root)
|
||||
&& self.inner.is_readable_paths(logical, resolved)
|
||||
&& self.layers.iter().all(|layer| {
|
||||
layer.iter().any(|rule| {
|
||||
crate::scope::rule_allows_path(rule, &path, WorkdirToolScopePermission::Read)
|
||||
})
|
||||
})
|
||||
}
|
||||
fn enumeration_allowed(&self, logical: &Path, resolved: &Path) -> bool {
|
||||
self.read_allowed(logical, resolved)
|
||||
&& self.source_allows.iter().any(|rule| {
|
||||
if rule.recursive {
|
||||
logical.starts_with(&rule.target) || rule.target.starts_with(logical)
|
||||
} else {
|
||||
logical == rule.target || rule.target.starts_with(logical)
|
||||
}
|
||||
})
|
||||
&& self.layers.iter().all(|layer| {
|
||||
layer.iter().any(|rule| {
|
||||
let target = self.inner.root.join(rule.target.as_str());
|
||||
if rule.recursive {
|
||||
logical.starts_with(&target) || target.starts_with(logical)
|
||||
} else {
|
||||
logical == target || target.starts_with(logical)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
fn require_read(&self, logical: &Path, resolved: &Path) -> std::io::Result<()> {
|
||||
if self.read_allowed(logical, resolved) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"checkout search path denied",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
impl fs_operation::FsAccessPolicy for SearchAccess {
|
||||
fn is_readable(&self, path: &Path) -> bool {
|
||||
self.read_allowed(path, path)
|
||||
}
|
||||
fn is_writable(&self, _: &Path) -> bool {
|
||||
false
|
||||
}
|
||||
fn is_readable_paths(&self, logical: &Path, resolved: &Path) -> bool {
|
||||
self.read_allowed(logical, resolved)
|
||||
}
|
||||
fn can_enumerate_directory(&self, logical: &Path, resolved: &Path) -> bool {
|
||||
self.enumeration_allowed(logical, resolved)
|
||||
}
|
||||
fn traversal_filter(&self) -> Option<fs_operation::FsTraversalFilter> {
|
||||
let policy = self.clone();
|
||||
Some(Arc::new(move |path, directory| {
|
||||
if directory {
|
||||
policy.enumeration_allowed(path, path)
|
||||
} else {
|
||||
policy.read_allowed(path, path)
|
||||
}
|
||||
}))
|
||||
}
|
||||
fn check_cancelled(&self) -> std::io::Result<()> {
|
||||
self.inner.check_cancelled()
|
||||
}
|
||||
fn resolve_access_path(&self, logical: &Path) -> std::io::Result<PathBuf> {
|
||||
self.inner.resolve_access_path(logical)
|
||||
}
|
||||
fn open_traversal_root(
|
||||
&self,
|
||||
logical: &Path,
|
||||
resolved: &Path,
|
||||
) -> std::io::Result<Option<fs_operation::FsTraversalRoot>> {
|
||||
self.require_read(logical, resolved)?;
|
||||
self.inner.open_traversal_root(logical, resolved)
|
||||
}
|
||||
fn open_read_file(&self, logical: &Path, resolved: &Path) -> std::io::Result<std::fs::File> {
|
||||
self.require_read(logical, resolved)?;
|
||||
self.inner.open_read_file(logical, resolved)
|
||||
}
|
||||
fn read_metadata(&self, logical: &Path, resolved: &Path) -> std::io::Result<std::fs::Metadata> {
|
||||
self.require_read(logical, resolved)?;
|
||||
self.inner.read_metadata(logical, resolved)
|
||||
}
|
||||
fn open_read_dir(&self, logical: &Path, resolved: &Path) -> std::io::Result<std::fs::ReadDir> {
|
||||
if !self.enumeration_allowed(logical, resolved) {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"checkout directory enumeration denied",
|
||||
));
|
||||
}
|
||||
self.inner.open_read_dir(logical, resolved)
|
||||
}
|
||||
}
|
||||
|
||||
fn path_sets_overlap(
|
||||
left: &Path,
|
||||
left_recursive: bool,
|
||||
@@ -491,6 +602,7 @@ struct LocalWorkdirSessionInner {
|
||||
reject_symlinks: bool,
|
||||
closed: AtomicBool,
|
||||
close_lock: Mutex<()>,
|
||||
checkout_lock: StdMutex<()>,
|
||||
next_command_id: AtomicU64,
|
||||
commands: Mutex<HashMap<String, LocalCommand>>,
|
||||
command_telemetry: CommandTelemetry,
|
||||
@@ -911,6 +1023,7 @@ impl LocalWorkdirSession {
|
||||
reject_symlinks,
|
||||
closed: AtomicBool::new(false),
|
||||
close_lock: Mutex::new(()),
|
||||
checkout_lock: StdMutex::new(()),
|
||||
next_command_id: AtomicU64::new(1),
|
||||
commands: Mutex::new(HashMap::new()),
|
||||
command_telemetry: CommandTelemetry::new(),
|
||||
@@ -1129,6 +1242,69 @@ impl LocalWorkdirSession {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn checkout_access(&self) -> ScopeAccess {
|
||||
let guard = self
|
||||
.operation_guard
|
||||
.clone()
|
||||
.unwrap_or_else(|| OperationGuard {
|
||||
cancelled: Arc::new(AtomicBool::new(false)),
|
||||
deadline: Instant::now() + Duration::from_secs(30),
|
||||
});
|
||||
ScopeAccess::new(
|
||||
self.inner.scope.snapshot(),
|
||||
&self.inner.root,
|
||||
self.inner.pinned_root.clone(),
|
||||
self.inner.reject_symlinks,
|
||||
Some(guard),
|
||||
)
|
||||
}
|
||||
|
||||
fn checkout_root(&self) -> Result<Arc<std::fs::File>, WorkdirError> {
|
||||
match &self.inner.pinned_root {
|
||||
Some(root) => Ok(root.clone()),
|
||||
None => fs_operation::open_root_no_symlinks(&self.inner.root)
|
||||
.map(Arc::new)
|
||||
.map_err(|e| WorkdirError::io(Path::new("<checkout-root>"), e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn checkout_observation(
|
||||
&self,
|
||||
path: WorkdirPath,
|
||||
pinned: &fs_operation::CheckedTarget<'_>,
|
||||
) -> Result<crate::CheckoutObservation, WorkdirError> {
|
||||
let metadata = pinned
|
||||
.metadata()
|
||||
.map_err(|e| WorkdirError::io(Path::new(path.as_str()), e))?;
|
||||
if fs_operation::identity_validator(&metadata).map_err(|e| checkout_io(&path, e))?
|
||||
!= pinned.validator()
|
||||
{
|
||||
return Err(WorkdirError::Conflict(
|
||||
"checkout changed during observation".into(),
|
||||
));
|
||||
}
|
||||
let access = self.scope_access();
|
||||
let absolute = self.resolve(&path);
|
||||
let capabilities = if manifest::Scope::is_writable(&access.scope, &absolute) {
|
||||
self.capabilities()
|
||||
.intersection(WorkdirSessionCapabilities::READ_WRITE)
|
||||
} else {
|
||||
self.capabilities()
|
||||
.intersection(WorkdirSessionCapabilities::READ_ONLY)
|
||||
};
|
||||
Ok(crate::CheckoutObservation {
|
||||
path,
|
||||
kind: if metadata.is_dir() {
|
||||
crate::EntryKind::Directory
|
||||
} else {
|
||||
crate::EntryKind::File
|
||||
},
|
||||
size: metadata.len(),
|
||||
validator: pinned.validator().to_vec(),
|
||||
capabilities,
|
||||
})
|
||||
}
|
||||
|
||||
fn scope_access(&self) -> ScopeAccess {
|
||||
ScopeAccess::new(
|
||||
self.inner.scope.snapshot(),
|
||||
@@ -1236,6 +1412,255 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
))
|
||||
}
|
||||
|
||||
async fn checkout_search(
|
||||
&self,
|
||||
mut request: crate::CheckoutSearchRequest,
|
||||
) -> Result<crate::CheckoutSearchResult, WorkdirError> {
|
||||
use crate::{CheckoutSearchOperation as Op, CheckoutSearchResult as Output};
|
||||
self.ensure_capability(request.operation.capability())?;
|
||||
request.validate()?;
|
||||
let _serial = self
|
||||
.inner
|
||||
.checkout_lock
|
||||
.try_lock()
|
||||
.map_err(|_| WorkdirError::Unavailable("checkout provider busy".into()))?;
|
||||
let provider_root = self.checkout_root()?;
|
||||
let mut inner = self.checkout_access();
|
||||
inner.pinned_root = Some(provider_root);
|
||||
inner.reject_symlinks = true;
|
||||
let output_root = self.resolve(&request.output_root);
|
||||
let logical = request.operation.path().clone();
|
||||
let relative = Path::new(logical.as_str())
|
||||
.strip_prefix(request.output_root.as_str())
|
||||
.map_err(|_| WorkdirError::Denied("checkout search escaped output root".into()))?;
|
||||
*request.operation.path_mut() = WorkdirPath::new(relative.to_str().ok_or_else(|| {
|
||||
WorkdirError::InvalidPath("checkout search path is not UTF8".into())
|
||||
})?)?;
|
||||
let source_allows = Arc::new(inner.scope.allow_rules());
|
||||
let access = SearchAccess {
|
||||
inner,
|
||||
layers: request.scope_layers,
|
||||
output_root: output_root.clone(),
|
||||
source_allows,
|
||||
};
|
||||
let result = match request.operation {
|
||||
Op::List(r) => fs_operation::run_list(&output_root, r, &access).map(Output::List),
|
||||
Op::Glob(r) => {
|
||||
fs_operation::run_glob(&output_root, &self.resolve(&logical), r, &access)
|
||||
.map(Output::Glob)
|
||||
}
|
||||
Op::Grep(r) => fs_operation::run_grep(&output_root, self.resolve(&logical), r, &access)
|
||||
.map(Output::Grep),
|
||||
};
|
||||
result
|
||||
.map_err(WorkdirError::from)
|
||||
.map_err(|error| sanitize_error(error, &logical))
|
||||
}
|
||||
|
||||
async fn checkout_observe(
|
||||
&self,
|
||||
path: WorkdirPath,
|
||||
) -> Result<crate::CheckoutObservation, WorkdirError> {
|
||||
self.ensure_capability(WorkdirSessionCapability::Read)?;
|
||||
crate::checkout::validate_path(&path)?;
|
||||
let _serial = self
|
||||
.inner
|
||||
.checkout_lock
|
||||
.try_lock()
|
||||
.map_err(|_| WorkdirError::Unavailable("checkout provider busy".into()))?;
|
||||
let root = self.checkout_root().map_err(|error| match error {
|
||||
WorkdirError::Io { source, .. } => checkout_observe_io(&path, source),
|
||||
error => error,
|
||||
})?;
|
||||
let access = self.checkout_access();
|
||||
let pinned = fs_operation::CheckedTarget::pin(
|
||||
&root,
|
||||
Path::new(path.as_str()),
|
||||
&self.resolve(&path),
|
||||
&access,
|
||||
false,
|
||||
)
|
||||
.map_err(|e| checkout_observe_io(&path, e))?;
|
||||
self.checkout_observation(path.clone(), &pinned)
|
||||
.map_err(|error| match error {
|
||||
WorkdirError::Io { source, .. } => checkout_observe_io(&path, source),
|
||||
error => error,
|
||||
})
|
||||
}
|
||||
|
||||
async fn checkout_execute(
|
||||
&self,
|
||||
request: crate::CheckoutRequest,
|
||||
) -> Result<crate::CheckoutResult, WorkdirError> {
|
||||
use crate::{CheckoutOperation as Op, CheckoutOutput as Output};
|
||||
self.ensure_capability(request.operation.capability())?;
|
||||
crate::checkout::validate_path(&request.target)?;
|
||||
if request.validator.is_empty() || request.validator.len() > 256 {
|
||||
return Err(WorkdirError::InvalidArgument(
|
||||
"invalid checkout validator".into(),
|
||||
));
|
||||
}
|
||||
let _serial = self
|
||||
.inner
|
||||
.checkout_lock
|
||||
.try_lock()
|
||||
.map_err(|_| WorkdirError::Unavailable("checkout provider busy".into()))?;
|
||||
let path = request.target;
|
||||
let root = self
|
||||
.checkout_root()
|
||||
.map_err(|error| checkout_execute_error(&path, error))?;
|
||||
let access = self.checkout_access();
|
||||
let mut pinned = fs_operation::CheckedTarget::pin(
|
||||
&root,
|
||||
Path::new(path.as_str()),
|
||||
&self.resolve(&path),
|
||||
&access,
|
||||
request.operation.capability() != WorkdirSessionCapability::Read,
|
||||
)
|
||||
.map_err(|e| checkout_io(&path, e))?;
|
||||
if pinned.validator() != request.validator {
|
||||
return Err(WorkdirError::Conflict(
|
||||
"checkout observation changed; observe and read again".into(),
|
||||
));
|
||||
}
|
||||
let is_create = matches!(&request.operation, Op::Create { .. });
|
||||
let output = match request.operation {
|
||||
Op::Read {
|
||||
offset,
|
||||
limit,
|
||||
max_bytes,
|
||||
} => {
|
||||
if limit > 1_000_000
|
||||
|| max_bytes > BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
|
||||
{
|
||||
return Err(WorkdirError::InvalidArgument(
|
||||
"checkout Read exceeds provider bounds".into(),
|
||||
));
|
||||
}
|
||||
fs_operation::run_read_bounded(
|
||||
&self.inner.root,
|
||||
ReadRequest {
|
||||
path: path.clone(),
|
||||
offset,
|
||||
limit,
|
||||
max_bytes,
|
||||
},
|
||||
&pinned,
|
||||
self.inner
|
||||
.read_limits
|
||||
.unwrap_or(BoundedReadLimits::EXTERNAL_DEFAULT),
|
||||
)
|
||||
.map(Output::Read)
|
||||
}
|
||||
Op::Write {
|
||||
content,
|
||||
expected_hash,
|
||||
} => fs_operation::run_write(
|
||||
&self.inner.root,
|
||||
WriteRequest {
|
||||
path: path.clone(),
|
||||
content,
|
||||
expected_hash: Some(expected_hash),
|
||||
},
|
||||
&pinned,
|
||||
)
|
||||
.map(Output::Write),
|
||||
Op::Edit {
|
||||
old_string,
|
||||
new_string,
|
||||
replace_all,
|
||||
expected_hash,
|
||||
} => {
|
||||
if old_string.len().saturating_add(new_string.len()) > 8 * 1024 * 1024 {
|
||||
return Err(WorkdirError::InvalidArgument(
|
||||
"checkout Edit exceeds provider bounds".into(),
|
||||
));
|
||||
}
|
||||
fs_operation::run_edit(
|
||||
&self.inner.root,
|
||||
EditRequest {
|
||||
path: path.clone(),
|
||||
old_string,
|
||||
new_string,
|
||||
replace_all,
|
||||
expected_hash,
|
||||
},
|
||||
&pinned,
|
||||
)
|
||||
.map(Output::Edit)
|
||||
}
|
||||
Op::Create {
|
||||
path: destination,
|
||||
content,
|
||||
} => {
|
||||
let suffix = crate::checkout::create_relative_path(&path, &destination)?;
|
||||
let create = pinned.create_access(suffix, self.resolve(&destination));
|
||||
fs_operation::run_write(
|
||||
&self.inner.root,
|
||||
WriteRequest {
|
||||
path: destination,
|
||||
content,
|
||||
expected_hash: None,
|
||||
},
|
||||
&create,
|
||||
)
|
||||
.map(Output::Write)
|
||||
}
|
||||
};
|
||||
let output = output.map_err(|error| {
|
||||
if pinned.effects_possible() {
|
||||
WorkdirError::OutcomeUnknown(
|
||||
"checkout save or parent creation may have effects; inspect before retry"
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
checkout_execute_error(&path, WorkdirError::from(error))
|
||||
}
|
||||
})?;
|
||||
if matches!(&output, Output::Read(_)) {
|
||||
pinned.confirm().map_err(|e| checkout_io(&path, e))?;
|
||||
return Ok(crate::CheckoutResult {
|
||||
observation: self
|
||||
.checkout_observation(path.clone(), &pinned)
|
||||
.map_err(|error| checkout_execute_error(&path, error))?,
|
||||
output,
|
||||
});
|
||||
}
|
||||
if is_create {
|
||||
pinned.refresh_directory_observation().map_err(|_| {
|
||||
WorkdirError::OutcomeUnknown(
|
||||
"checkout Create may have effects but parent observation failed".into(),
|
||||
)
|
||||
})?;
|
||||
let observation = self.checkout_observation(path, &pinned).map_err(|_| {
|
||||
WorkdirError::OutcomeUnknown(
|
||||
"checkout Create may have effects but parent observation failed".into(),
|
||||
)
|
||||
})?;
|
||||
return Ok(crate::CheckoutResult {
|
||||
observation,
|
||||
output,
|
||||
});
|
||||
}
|
||||
let after = fs_operation::CheckedTarget::pin(
|
||||
&root,
|
||||
Path::new(path.as_str()),
|
||||
&self.resolve(&path),
|
||||
&access,
|
||||
true,
|
||||
)
|
||||
.map_err(|_| {
|
||||
WorkdirError::OutcomeUnknown("checkout saved but post-save observation failed".into())
|
||||
})?;
|
||||
let observation = self.checkout_observation(path, &after).map_err(|_| {
|
||||
WorkdirError::OutcomeUnknown("checkout saved but post-save observation failed".into())
|
||||
})?;
|
||||
Ok(crate::CheckoutResult {
|
||||
observation,
|
||||
output,
|
||||
})
|
||||
}
|
||||
|
||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||
self.ensure_capability(WorkdirSessionCapability::Read)?;
|
||||
let logical = request.path.clone();
|
||||
@@ -1247,6 +1672,11 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
}
|
||||
|
||||
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError> {
|
||||
let _serial = self
|
||||
.inner
|
||||
.checkout_lock
|
||||
.try_lock()
|
||||
.map_err(|_| WorkdirError::Unavailable("filesystem provider busy".into()))?;
|
||||
self.ensure_capability(WorkdirSessionCapability::Read)?;
|
||||
let logical = request.path.clone();
|
||||
self.validate_operation_path(&request.path)?;
|
||||
@@ -1277,6 +1707,11 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
}
|
||||
|
||||
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
||||
let _serial = self
|
||||
.inner
|
||||
.checkout_lock
|
||||
.try_lock()
|
||||
.map_err(|_| WorkdirError::Unavailable("filesystem provider busy".into()))?;
|
||||
self.ensure_capability(WorkdirSessionCapability::Write)?;
|
||||
let logical = request.path.clone();
|
||||
self.validate_operation_path(&request.path)?;
|
||||
@@ -1287,6 +1722,11 @@ impl WorkdirSession for LocalWorkdirSession {
|
||||
}
|
||||
|
||||
async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError> {
|
||||
let _serial = self
|
||||
.inner
|
||||
.checkout_lock
|
||||
.try_lock()
|
||||
.map_err(|_| WorkdirError::Unavailable("filesystem provider busy".into()))?;
|
||||
self.ensure_capability(WorkdirSessionCapability::Edit)?;
|
||||
let logical = request.path.clone();
|
||||
self.validate_operation_path(&request.path)?;
|
||||
@@ -1562,6 +2002,44 @@ fn command_output_page(output: &CommandOutput, cursor: usize, limit: usize) -> C
|
||||
}
|
||||
}
|
||||
|
||||
// Discovery of an absent route is not a stale-validator execution attempt.
|
||||
fn checkout_observe_io(path: &WorkdirPath, error: std::io::Error) -> WorkdirError {
|
||||
if matches!(
|
||||
error.kind(),
|
||||
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
|
||||
) {
|
||||
WorkdirError::NotFound(PathBuf::from(path.as_str()))
|
||||
} else {
|
||||
checkout_io(path, error)
|
||||
}
|
||||
}
|
||||
|
||||
// All pre-effect execution stages preserve the same stale-name classification,
|
||||
// including FsError::NotFound produced by the shared engine after pinning.
|
||||
fn checkout_execute_error(path: &WorkdirPath, error: WorkdirError) -> WorkdirError {
|
||||
match error {
|
||||
WorkdirError::NotFound(_) => {
|
||||
WorkdirError::Conflict("checkout observation changed; observe and read again".into())
|
||||
}
|
||||
WorkdirError::Io { source, .. } => checkout_io(path, source),
|
||||
error => sanitize_error(error, path),
|
||||
}
|
||||
}
|
||||
|
||||
fn checkout_io(path: &WorkdirPath, error: std::io::Error) -> WorkdirError {
|
||||
if matches!(
|
||||
error.kind(),
|
||||
std::io::ErrorKind::WouldBlock
|
||||
| std::io::ErrorKind::NotFound
|
||||
| std::io::ErrorKind::NotADirectory
|
||||
| std::io::ErrorKind::AlreadyExists
|
||||
) {
|
||||
WorkdirError::Conflict("checkout observation changed; observe and read again".into())
|
||||
} else {
|
||||
sanitize_error(WorkdirError::io(Path::new(path.as_str()), error), path)
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_error(error: WorkdirError, logical: &WorkdirPath) -> WorkdirError {
|
||||
let path = PathBuf::from(logical.as_str());
|
||||
match error {
|
||||
@@ -2083,6 +2561,47 @@ mod tests {
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn checkout_error_mapping_distinguishes_absence_from_stale_execution_at_all_boundaries() {
|
||||
let path = WorkdirPath::new("missing").unwrap();
|
||||
for kind in [
|
||||
std::io::ErrorKind::NotFound,
|
||||
std::io::ErrorKind::NotADirectory,
|
||||
] {
|
||||
assert!(
|
||||
matches!(checkout_observe_io(&path, std::io::Error::from(kind)), WorkdirError::NotFound(p) if p == Path::new("missing"))
|
||||
);
|
||||
assert!(matches!(
|
||||
checkout_io(&path, std::io::Error::from(kind)),
|
||||
WorkdirError::Conflict(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
checkout_execute_error(
|
||||
&path,
|
||||
WorkdirError::io(Path::new("<provider-root>"), std::io::Error::from(kind))
|
||||
),
|
||||
WorkdirError::Conflict(_)
|
||||
));
|
||||
}
|
||||
let shared_error =
|
||||
WorkdirError::from(fs_operation::FsError::NotFound(PathBuf::from("missing")));
|
||||
assert!(matches!(
|
||||
checkout_execute_error(&path, shared_error),
|
||||
WorkdirError::Conflict(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
checkout_observe_io(&path, std::io::Error::from(std::io::ErrorKind::WouldBlock)),
|
||||
WorkdirError::Conflict(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
checkout_execute_error(
|
||||
&path,
|
||||
WorkdirError::OutcomeUnknown("possible save effects".into())
|
||||
),
|
||||
WorkdirError::OutcomeUnknown(_)
|
||||
));
|
||||
}
|
||||
|
||||
fn make_fs(dir: &TempDir) -> LocalWorkdirSession {
|
||||
LocalWorkdirSession::new(
|
||||
Scope::writable(dir.path()).unwrap(),
|
||||
|
||||
@@ -248,6 +248,29 @@ impl WorkdirSession for RoutedWorkdirSession {
|
||||
self.state.session.scope_rules_overlap(request).await
|
||||
}
|
||||
|
||||
async fn checkout_search(
|
||||
&self,
|
||||
request: crate::CheckoutSearchRequest,
|
||||
) -> Result<crate::CheckoutSearchResult, WorkdirError> {
|
||||
let _active = self.state.enter()?;
|
||||
self.state.session.checkout_search(request).await
|
||||
}
|
||||
|
||||
async fn checkout_observe(
|
||||
&self,
|
||||
path: crate::WorkdirPath,
|
||||
) -> Result<crate::CheckoutObservation, WorkdirError> {
|
||||
let _active = self.state.enter()?;
|
||||
self.state.session.checkout_observe(path).await
|
||||
}
|
||||
async fn checkout_execute(
|
||||
&self,
|
||||
request: crate::CheckoutRequest,
|
||||
) -> Result<crate::CheckoutResult, WorkdirError> {
|
||||
let _active = self.state.enter()?;
|
||||
self.state.session.checkout_execute(request).await
|
||||
}
|
||||
|
||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||
let _active = self.state.enter()?;
|
||||
self.state.session.stat(request).await
|
||||
@@ -650,6 +673,42 @@ mod tests {
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_routes_use_active_operation_detach_fence_and_old_handles_stay_closed() {
|
||||
let root = TempDir::new().unwrap();
|
||||
std::fs::write(root.path().join("a"), "contents").unwrap();
|
||||
let router = WorkdirSessionRouter::new();
|
||||
let alias = WorkdirAttachmentAlias::new("checkout").unwrap();
|
||||
router.attach(alias.clone(), session("wd", &root)).unwrap();
|
||||
let old = router.resolve(Some("checkout")).unwrap().session;
|
||||
old.checkout_observe(crate::WorkdirPath::new("a").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let state = router.sessions.read().unwrap().get(&alias).unwrap().clone();
|
||||
let active = state.enter().unwrap();
|
||||
assert!(router.begin_detach(&alias).await.is_err());
|
||||
drop(active);
|
||||
router.detach(&alias).await.unwrap();
|
||||
assert!(
|
||||
old.checkout_observe(crate::WorkdirPath::new("a").unwrap())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
router.attach(alias, session("wd-new", &root)).unwrap();
|
||||
router
|
||||
.resolve(Some("checkout"))
|
||||
.unwrap()
|
||||
.session
|
||||
.checkout_observe(crate::WorkdirPath::new("a").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
old.checkout_observe(crate::WorkdirPath::new("a").unwrap())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn router_holds_multiple_aliases_without_implicit_primary() {
|
||||
let left = TempDir::new().unwrap();
|
||||
|
||||
@@ -994,6 +994,138 @@ impl WorkdirSession for ScopedWorkdirSession {
|
||||
self.capabilities
|
||||
}
|
||||
|
||||
async fn checkout_search(
|
||||
&self,
|
||||
mut request: crate::CheckoutSearchRequest,
|
||||
) -> Result<crate::CheckoutSearchResult, WorkdirError> {
|
||||
let _scope_guard = self.scope_lock.lock().await;
|
||||
request.validate()?;
|
||||
let path = self
|
||||
.resolve_operation_path(request.operation.path(), WorkdirToolScopePermission::Read)
|
||||
.await?;
|
||||
self.ensure_read(&path, request.operation.capability())?;
|
||||
*request.operation.path_mut() = path;
|
||||
request.output_root = self.resolve_path(&request.output_root)?;
|
||||
for layer in &mut request.scope_layers {
|
||||
for rule in layer {
|
||||
rule.target = self.resolve_path(&rule.target)?;
|
||||
}
|
||||
}
|
||||
if let Some(rules) = &self.scope {
|
||||
request.scope_layers.push(rules.clone());
|
||||
}
|
||||
request.validate()?;
|
||||
// Source results already use the composed output_root, not this source's
|
||||
// cwd. Rewriting them here would duplicate cwd through nested wrappers.
|
||||
let result = self.source.checkout_search(request).await?;
|
||||
self.ensure_active()?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn checkout_observe(
|
||||
&self,
|
||||
caller_path: crate::WorkdirPath,
|
||||
) -> Result<crate::CheckoutObservation, WorkdirError> {
|
||||
let _scope_guard = self.scope_lock.lock().await;
|
||||
let path = self
|
||||
.resolve_operation_path(&caller_path, WorkdirToolScopePermission::Read)
|
||||
.await?;
|
||||
self.ensure_read(&path, WorkdirSessionCapability::Read)?;
|
||||
let mut observation = self.source.checkout_observe(path.clone()).await?;
|
||||
observation.capabilities = observation.capabilities.intersection(self.capabilities());
|
||||
// Capability bits must not advertise authority over a read-only path or
|
||||
// a path whose write authority is currently leased to a child.
|
||||
if self
|
||||
.resolve_operation_path(&caller_path, WorkdirToolScopePermission::Write)
|
||||
.await
|
||||
.is_err()
|
||||
|| self
|
||||
.ensure_write(&path, WorkdirSessionCapability::Write)
|
||||
.is_err()
|
||||
{
|
||||
observation.capabilities = observation
|
||||
.capabilities
|
||||
.intersection(WorkdirSessionCapabilities::READ_ONLY);
|
||||
}
|
||||
observation.path = caller_path;
|
||||
Ok(observation)
|
||||
}
|
||||
async fn checkout_execute(
|
||||
&self,
|
||||
mut request: crate::CheckoutRequest,
|
||||
) -> Result<crate::CheckoutResult, WorkdirError> {
|
||||
let _scope_guard = self.scope_lock.lock().await;
|
||||
let caller_path = request.target.clone();
|
||||
let capability = request.operation.capability();
|
||||
let permission = if capability == WorkdirSessionCapability::Read {
|
||||
WorkdirToolScopePermission::Read
|
||||
} else {
|
||||
WorkdirToolScopePermission::Write
|
||||
};
|
||||
let path = self
|
||||
.resolve_operation_path(&request.target, permission)
|
||||
.await?;
|
||||
if permission == WorkdirToolScopePermission::Read {
|
||||
self.ensure_read(&path, capability)?;
|
||||
} else {
|
||||
self.ensure_write(&path, capability)?;
|
||||
}
|
||||
let result_path = caller_path;
|
||||
if let crate::CheckoutOperation::Create {
|
||||
path: destination, ..
|
||||
} = &mut request.operation
|
||||
{
|
||||
let suffix = crate::checkout::create_relative_path(&request.target, destination)?;
|
||||
let mut intermediate = std::path::PathBuf::from(request.target.as_str());
|
||||
let mut components = suffix.components().peekable();
|
||||
while let Some(component) = components.next() {
|
||||
intermediate.push(component.as_os_str());
|
||||
if components.peek().is_some() {
|
||||
let intermediate =
|
||||
crate::WorkdirPath::new(intermediate.to_str().ok_or_else(|| {
|
||||
WorkdirError::InvalidPath("non-UTF8 Create path".into())
|
||||
})?)?;
|
||||
let resolved = self
|
||||
.resolve_operation_path(&intermediate, WorkdirToolScopePermission::Write)
|
||||
.await?;
|
||||
self.ensure_write(&resolved, WorkdirSessionCapability::Write)?;
|
||||
}
|
||||
}
|
||||
let resolved = self
|
||||
.resolve_operation_path(destination, WorkdirToolScopePermission::Write)
|
||||
.await?;
|
||||
self.ensure_write(&resolved, WorkdirSessionCapability::Write)?;
|
||||
*destination = resolved;
|
||||
}
|
||||
request.target = path;
|
||||
let mut result = self.source.checkout_execute(request).await?;
|
||||
result.observation.path = result_path.clone();
|
||||
result.observation.capabilities = result
|
||||
.observation
|
||||
.capabilities
|
||||
.intersection(self.capabilities());
|
||||
if self
|
||||
.resolve_operation_path(&result_path, WorkdirToolScopePermission::Write)
|
||||
.await
|
||||
.is_err()
|
||||
|| self
|
||||
.ensure_write(
|
||||
&self.resolve_path(&result_path)?,
|
||||
WorkdirSessionCapability::Write,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
result.observation.capabilities = result
|
||||
.observation
|
||||
.capabilities
|
||||
.intersection(WorkdirSessionCapabilities::READ_ONLY);
|
||||
}
|
||||
if let crate::CheckoutOutput::Read(read) = &mut result.output {
|
||||
read.path = result_path;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn stat(&self, mut request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||
let path = self
|
||||
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
||||
@@ -1048,6 +1180,19 @@ impl WorkdirSession for ScopedWorkdirSession {
|
||||
}
|
||||
|
||||
async fn list(&self, mut request: ListRequest) -> Result<ListResult, WorkdirError> {
|
||||
if self.scope.is_some() || !self.cwd.is_root() {
|
||||
return match self
|
||||
.checkout_search(crate::CheckoutSearchRequest::new(
|
||||
crate::CheckoutSearchOperation::List(request),
|
||||
))
|
||||
.await?
|
||||
{
|
||||
crate::CheckoutSearchResult::List(result) => Ok(result),
|
||||
_ => Err(WorkdirError::Unavailable(
|
||||
"mismatched checkout search result".into(),
|
||||
)),
|
||||
};
|
||||
}
|
||||
let path = self
|
||||
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
||||
.await?;
|
||||
@@ -1057,6 +1202,19 @@ impl WorkdirSession for ScopedWorkdirSession {
|
||||
}
|
||||
|
||||
async fn glob(&self, mut request: GlobRequest) -> Result<GlobResult, WorkdirError> {
|
||||
if self.scope.is_some() || !self.cwd.is_root() {
|
||||
return match self
|
||||
.checkout_search(crate::CheckoutSearchRequest::new(
|
||||
crate::CheckoutSearchOperation::Glob(request),
|
||||
))
|
||||
.await?
|
||||
{
|
||||
crate::CheckoutSearchResult::Glob(result) => Ok(result),
|
||||
_ => Err(WorkdirError::Unavailable(
|
||||
"mismatched checkout search result".into(),
|
||||
)),
|
||||
};
|
||||
}
|
||||
let path = self
|
||||
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
||||
.await?;
|
||||
@@ -1066,6 +1224,19 @@ impl WorkdirSession for ScopedWorkdirSession {
|
||||
}
|
||||
|
||||
async fn grep(&self, mut request: GrepRequest) -> Result<GrepResult, WorkdirError> {
|
||||
if self.scope.is_some() || !self.cwd.is_root() {
|
||||
return match self
|
||||
.checkout_search(crate::CheckoutSearchRequest::new(
|
||||
crate::CheckoutSearchOperation::Grep(request),
|
||||
))
|
||||
.await?
|
||||
{
|
||||
crate::CheckoutSearchResult::Grep(result) => Ok(result),
|
||||
_ => Err(WorkdirError::Unavailable(
|
||||
"mismatched checkout search result".into(),
|
||||
)),
|
||||
};
|
||||
}
|
||||
let path = self
|
||||
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
||||
.await?;
|
||||
@@ -1284,6 +1455,44 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
|
||||
self.inner.scope_rules_overlap(request).await
|
||||
}
|
||||
|
||||
async fn checkout_search(
|
||||
&self,
|
||||
request: crate::CheckoutSearchRequest,
|
||||
) -> Result<crate::CheckoutSearchResult, WorkdirError> {
|
||||
if !self
|
||||
.capabilities()
|
||||
.intersection(self.inner.capabilities())
|
||||
.supports(request.operation.capability())
|
||||
{
|
||||
return Err(WorkdirError::Unsupported(request.operation.capability()));
|
||||
}
|
||||
self.inner.checkout_search(request).await
|
||||
}
|
||||
|
||||
async fn checkout_observe(
|
||||
&self,
|
||||
path: crate::WorkdirPath,
|
||||
) -> Result<crate::CheckoutObservation, WorkdirError> {
|
||||
let mut observation = self.inner.checkout_observe(path).await?;
|
||||
observation.capabilities = observation
|
||||
.capabilities
|
||||
.intersection(WorkdirSessionCapabilities::READ_ONLY);
|
||||
Ok(observation)
|
||||
}
|
||||
async fn checkout_execute(
|
||||
&self,
|
||||
request: crate::CheckoutRequest,
|
||||
) -> Result<crate::CheckoutResult, WorkdirError> {
|
||||
if request.operation.capability() != WorkdirSessionCapability::Read {
|
||||
return Err(WorkdirError::Unsupported(request.operation.capability()));
|
||||
}
|
||||
let mut result = self.inner.checkout_execute(request).await?;
|
||||
result.observation.capabilities = result
|
||||
.observation
|
||||
.capabilities
|
||||
.intersection(WorkdirSessionCapabilities::READ_ONLY);
|
||||
Ok(result)
|
||||
}
|
||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||
self.inner.stat(request).await
|
||||
}
|
||||
@@ -1654,6 +1863,53 @@ mod tests {
|
||||
(WorkdirToolBroker::new(source), receiver, release)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_search_unsupported_source_never_falls_back_to_broad_traversal() {
|
||||
let root = TempDir::new().unwrap();
|
||||
fs::create_dir(root.path().join("child")).unwrap();
|
||||
fs::write(root.path().join("child/a"), "needle").unwrap();
|
||||
let (parent, _, _) = blocking_session(root.path());
|
||||
// Unscoped normal Tools still use their established provider contract.
|
||||
assert_eq!(
|
||||
parent
|
||||
.list(ListRequest {
|
||||
path: fs_path("child"),
|
||||
limit: 100
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.entries
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let child = parent
|
||||
.scope(request("child", WorkdirToolScopePermission::Read))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
child
|
||||
.tool_session()
|
||||
.list(ListRequest {
|
||||
path: FsPath::root(),
|
||||
limit: 100
|
||||
})
|
||||
.await,
|
||||
Err(WorkdirError::UnsupportedOperation(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
child
|
||||
.tool_session()
|
||||
.checkout_search(crate::CheckoutSearchRequest::new(
|
||||
crate::CheckoutSearchOperation::List(ListRequest {
|
||||
path: FsPath::root(),
|
||||
limit: 100
|
||||
})
|
||||
))
|
||||
.await,
|
||||
Err(WorkdirError::UnsupportedOperation(_))
|
||||
));
|
||||
}
|
||||
|
||||
fn request(path: &str, permission: WorkdirToolScopePermission) -> WorkdirToolScope {
|
||||
WorkdirToolScope {
|
||||
rules: vec![WorkdirToolScopeRule {
|
||||
|
||||
@@ -258,6 +258,8 @@ impl std::fmt::Debug for WorkspaceApiRef {
|
||||
}
|
||||
}
|
||||
|
||||
pub use worker::BackendJobExecutionBinding;
|
||||
|
||||
/// Canonical Runtime Worker creation request.
|
||||
///
|
||||
/// Browser/product launch semantics are resolved by a backend before this
|
||||
@@ -302,6 +304,9 @@ pub struct CreateWorkerRequest {
|
||||
/// may enable subjektiv policy, but only this host-owned bit activates it.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub subjektiv_attached: bool,
|
||||
/// Backend-only immutable Job/attempt capability. Never inferred from Profile policy.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub backend_job: Option<BackendJobExecutionBinding>,
|
||||
}
|
||||
|
||||
/// Last persisted Worker lifecycle status.
|
||||
@@ -334,6 +339,8 @@ pub(crate) enum WorkerRestoreIntent {
|
||||
/// Lightweight persisted Worker identity projection.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkerSummary {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub restore_observation_token: Option<String>,
|
||||
pub worker_ref: WorkerRef,
|
||||
pub worker_id: WorkerId,
|
||||
pub status: WorkerStatus,
|
||||
@@ -359,6 +366,8 @@ pub struct WorkerSummary {
|
||||
/// Full persisted Worker identity and lifecycle detail.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkerDetail {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub restore_observation_token: Option<String>,
|
||||
pub worker_ref: WorkerRef,
|
||||
pub worker_id: WorkerId,
|
||||
pub status: WorkerStatus,
|
||||
|
||||
@@ -12,6 +12,9 @@ pub enum RuntimeError {
|
||||
#[error("initial worker input must be user input, got {kind}")]
|
||||
InvalidInitialInputKind { kind: String },
|
||||
|
||||
#[error("worker {worker_id} Restore observation conflicts with the current operation")]
|
||||
RestoreObservationConflict { worker_id: WorkerId },
|
||||
|
||||
#[error("worker {worker_id} was not found")]
|
||||
WorkerNotFound { worker_id: WorkerId },
|
||||
|
||||
|
||||
@@ -207,34 +207,6 @@ impl WorkerExecutionResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque per-Worker execution handle returned by a backend.
|
||||
///
|
||||
/// The handle is a typed token for routing calls back into the same backend. It
|
||||
/// intentionally contains no socket path, process id, credential, manifest path,
|
||||
/// or session path.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkerExecutionHandle {
|
||||
worker_ref: WorkerRef,
|
||||
backend_id: String,
|
||||
}
|
||||
|
||||
impl WorkerExecutionHandle {
|
||||
pub fn new(worker_ref: WorkerRef, backend_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
worker_ref,
|
||||
backend_id: backend_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn worker_ref(&self) -> &WorkerRef {
|
||||
&self.worker_ref
|
||||
}
|
||||
|
||||
pub fn backend_id(&self) -> &str {
|
||||
&self.backend_id
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime hooks available to an execution backend for one Worker.
|
||||
#[cfg(feature = "ws-server")]
|
||||
type WorkerObservationPublisher = Arc<
|
||||
@@ -348,7 +320,17 @@ impl WorkerExecutionContext {
|
||||
let Some(event) = event else {
|
||||
return Ok(());
|
||||
};
|
||||
(self.observation_publisher)(self.worker_ref.clone(), event)?;
|
||||
if let Err(error) = (self.observation_publisher)(self.worker_ref.clone(), event.clone())
|
||||
{
|
||||
let mut state = self
|
||||
.publication_state
|
||||
.lock()
|
||||
.map_err(|_| RuntimeError::StatePoisoned)?;
|
||||
if let WorkerExecutionPublicationState::Activating(events) = &mut *state {
|
||||
events.push_front(event);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,7 +425,6 @@ pub struct WorkerExecutionRestoreRequest {
|
||||
pub struct WorkerExecutionStopRequest {
|
||||
pub operation_id: WorkerLifecycleOperationId,
|
||||
pub worker_ref: WorkerRef,
|
||||
pub handle: Option<WorkerExecutionHandle>,
|
||||
}
|
||||
|
||||
/// Runtime-side request to refresh the latest Workspace Config before Worker creation.
|
||||
@@ -466,7 +447,6 @@ pub enum WorkspaceConfigFetchResult {
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum WorkerExecutionSpawnResult {
|
||||
Connected {
|
||||
handle: WorkerExecutionHandle,
|
||||
worker_state: protocol::WorkerStateSnapshot,
|
||||
workdir_attachments: Vec<crate::catalog::WorkingDirectoryAttachmentStatus>,
|
||||
},
|
||||
@@ -475,11 +455,10 @@ pub enum WorkerExecutionSpawnResult {
|
||||
/// Live work started, failed, and all operation-owned resources were joined/released.
|
||||
RolledBack(WorkerExecutionResult),
|
||||
/// The operation crossed the live side-effect boundary and could not prove
|
||||
/// either commit or complete cleanup. Optional handle/state fields preserve
|
||||
/// either commit or complete cleanup. Optional state fields preserve
|
||||
/// concrete execution evidence for retry reconciliation.
|
||||
ReconciliationRequired {
|
||||
result: WorkerExecutionResult,
|
||||
handle: Option<WorkerExecutionHandle>,
|
||||
worker_state: Option<protocol::WorkerStateSnapshot>,
|
||||
workdir_attachments: Vec<crate::catalog::WorkingDirectoryAttachmentStatus>,
|
||||
},
|
||||
@@ -490,12 +469,10 @@ pub enum WorkerExecutionSpawnResult {
|
||||
|
||||
impl WorkerExecutionSpawnResult {
|
||||
pub fn connected(
|
||||
handle: WorkerExecutionHandle,
|
||||
worker_state: protocol::WorkerStateSnapshot,
|
||||
workdir_attachments: Vec<crate::catalog::WorkingDirectoryAttachmentStatus>,
|
||||
) -> Self {
|
||||
Self::Connected {
|
||||
handle,
|
||||
worker_state,
|
||||
workdir_attachments,
|
||||
}
|
||||
@@ -521,6 +498,57 @@ pub struct WorkerSessionAttachmentRequest {
|
||||
pub attachment_id: String,
|
||||
}
|
||||
|
||||
/// One real protocol connection, bound to the execution that was attached.
|
||||
/// This is not a routing token and is never persisted as Worker authority.
|
||||
#[cfg(feature = "ws-server")]
|
||||
pub struct WorkerProtocolTransport {
|
||||
pub(crate) worker_ref: WorkerRef,
|
||||
pub snapshot: protocol::Event,
|
||||
pub events: tokio::sync::mpsc::Receiver<protocol::Event>,
|
||||
dispatch:
|
||||
Arc<dyn Fn(Method) -> Result<Vec<protocol::Event>, WorkerExecutionResult> + Send + Sync>,
|
||||
validate: Arc<dyn Fn() -> Result<(), WorkerExecutionResult> + Send + Sync>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "ws-server")]
|
||||
impl WorkerProtocolTransport {
|
||||
pub fn new(
|
||||
worker_ref: WorkerRef,
|
||||
snapshot: protocol::Event,
|
||||
events: tokio::sync::mpsc::Receiver<protocol::Event>,
|
||||
dispatch: Arc<
|
||||
dyn Fn(Method) -> Result<Vec<protocol::Event>, WorkerExecutionResult> + Send + Sync,
|
||||
>,
|
||||
validate: Arc<dyn Fn() -> Result<(), WorkerExecutionResult> + Send + Sync>,
|
||||
) -> Self {
|
||||
Self {
|
||||
worker_ref,
|
||||
snapshot,
|
||||
events,
|
||||
dispatch,
|
||||
validate,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dispatch(&self, method: Method) -> Result<Vec<protocol::Event>, WorkerExecutionResult> {
|
||||
(self.dispatch)(method)
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), WorkerExecutionResult> {
|
||||
(self.validate)()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ws-server")]
|
||||
impl fmt::Debug for WorkerProtocolTransport {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("WorkerProtocolTransport")
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// The connection selects this backend; WorkerRef identifies an execution,
|
||||
/// never a capability or authorization token.
|
||||
pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
||||
fn backend_id(&self) -> &str;
|
||||
|
||||
@@ -580,8 +608,9 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
||||
fn activate_restored_worker(
|
||||
&self,
|
||||
_operation_id: WorkerLifecycleOperationId,
|
||||
_handle: &WorkerExecutionHandle,
|
||||
worker_ref: &WorkerRef,
|
||||
) -> Result<(), String> {
|
||||
let _ = worker_ref;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -673,20 +702,17 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dispatch_input(
|
||||
&self,
|
||||
handle: &WorkerExecutionHandle,
|
||||
input: WorkerInput,
|
||||
) -> WorkerExecutionResult;
|
||||
fn dispatch_input(&self, worker_ref: &WorkerRef, input: WorkerInput) -> WorkerExecutionResult;
|
||||
|
||||
fn upload_file(
|
||||
&self,
|
||||
_handle: &WorkerExecutionHandle,
|
||||
worker_ref: &WorkerRef,
|
||||
_file_name: &str,
|
||||
_media_type: &str,
|
||||
_content: &[u8],
|
||||
_context: Option<&session_store::UploadedFileUploadContext>,
|
||||
) -> Result<UploadedFileRef, WorkerExecutionResult> {
|
||||
let _ = worker_ref;
|
||||
Err(WorkerExecutionResult::unsupported(
|
||||
WorkerExecutionOperation::UploadFile,
|
||||
"execution backend does not support file upload",
|
||||
@@ -695,20 +721,18 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
||||
|
||||
fn delete_uploaded_file(
|
||||
&self,
|
||||
_handle: &WorkerExecutionHandle,
|
||||
worker_ref: &WorkerRef,
|
||||
_artifact_id: &str,
|
||||
) -> WorkerExecutionResult {
|
||||
let _ = worker_ref;
|
||||
WorkerExecutionResult::unsupported(
|
||||
WorkerExecutionOperation::DeleteUploadedFile,
|
||||
"execution backend does not support uploaded-file deletion",
|
||||
)
|
||||
}
|
||||
|
||||
fn dispatch_method(
|
||||
&self,
|
||||
_handle: &WorkerExecutionHandle,
|
||||
_method: Method,
|
||||
) -> WorkerExecutionResult {
|
||||
fn dispatch_method(&self, worker_ref: &WorkerRef, _method: Method) -> WorkerExecutionResult {
|
||||
let _ = worker_ref;
|
||||
WorkerExecutionResult::unsupported(
|
||||
WorkerExecutionOperation::ProtocolMethod,
|
||||
"execution backend does not support direct Worker protocol methods",
|
||||
@@ -717,31 +741,29 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
||||
|
||||
fn worker_completions(
|
||||
&self,
|
||||
_handle: &WorkerExecutionHandle,
|
||||
worker_ref: &WorkerRef,
|
||||
_kind: protocol::CompletionKind,
|
||||
_prefix: &str,
|
||||
_context: Option<&protocol::CompletionContext>,
|
||||
) -> Vec<protocol::CompletionEntry> {
|
||||
let _ = worker_ref;
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn stop_worker_operation(&self, request: WorkerExecutionStopRequest) -> WorkerExecutionResult {
|
||||
let Some(handle) = request.handle.as_ref() else {
|
||||
return WorkerExecutionResult::unsupported(
|
||||
WorkerExecutionOperation::Stop,
|
||||
"execution backend cannot reconcile a stopped Worker without a live handle",
|
||||
);
|
||||
};
|
||||
self.stop_worker(handle)
|
||||
self.stop_worker(&request.worker_ref)
|
||||
}
|
||||
|
||||
fn stop_worker(&self, _handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
|
||||
fn stop_worker(&self, worker_ref: &WorkerRef) -> WorkerExecutionResult {
|
||||
let _ = worker_ref;
|
||||
WorkerExecutionResult::unsupported(
|
||||
WorkerExecutionOperation::Stop,
|
||||
"execution backend does not support stopping workers",
|
||||
)
|
||||
}
|
||||
|
||||
fn cancel_worker(&self, _handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
|
||||
fn cancel_worker(&self, worker_ref: &WorkerRef) -> WorkerExecutionResult {
|
||||
let _ = worker_ref;
|
||||
WorkerExecutionResult::unsupported(
|
||||
WorkerExecutionOperation::Cancel,
|
||||
"execution backend does not support cancelling workers",
|
||||
@@ -749,7 +771,20 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
||||
}
|
||||
|
||||
#[cfg(feature = "ws-server")]
|
||||
fn worker_snapshot(&self, _handle: &WorkerExecutionHandle) -> Option<protocol::Event> {
|
||||
fn attach_worker_protocol(
|
||||
self: Arc<Self>,
|
||||
worker_ref: &WorkerRef,
|
||||
) -> Result<WorkerProtocolTransport, WorkerExecutionResult> {
|
||||
let _ = worker_ref;
|
||||
Err(WorkerExecutionResult::unsupported(
|
||||
WorkerExecutionOperation::ProtocolMethod,
|
||||
"execution backend does not support bound Worker protocol connections",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(feature = "ws-server")]
|
||||
fn worker_snapshot(&self, worker_ref: &WorkerRef) -> Option<protocol::Event> {
|
||||
let _ = worker_ref;
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -827,9 +862,10 @@ impl WorkerExecutionBackendRef {
|
||||
pub(crate) fn activate_restored_worker(
|
||||
&self,
|
||||
operation_id: WorkerLifecycleOperationId,
|
||||
handle: &WorkerExecutionHandle,
|
||||
worker_ref: &WorkerRef,
|
||||
) -> Result<(), String> {
|
||||
self.backend.activate_restored_worker(operation_id, handle)
|
||||
self.backend
|
||||
.activate_restored_worker(operation_id, worker_ref)
|
||||
}
|
||||
|
||||
pub(crate) fn restore_worker(
|
||||
@@ -895,55 +931,62 @@ impl WorkerExecutionBackendRef {
|
||||
|
||||
pub(crate) fn dispatch_input(
|
||||
&self,
|
||||
handle: &WorkerExecutionHandle,
|
||||
worker_ref: &WorkerRef,
|
||||
input: WorkerInput,
|
||||
) -> WorkerExecutionResult {
|
||||
self.backend.dispatch_input(handle, input)
|
||||
self.backend.dispatch_input(worker_ref, input)
|
||||
}
|
||||
|
||||
pub(crate) fn upload_file(
|
||||
&self,
|
||||
handle: &WorkerExecutionHandle,
|
||||
worker_ref: &WorkerRef,
|
||||
file_name: &str,
|
||||
media_type: &str,
|
||||
content: &[u8],
|
||||
context: Option<&session_store::UploadedFileUploadContext>,
|
||||
) -> Result<UploadedFileRef, WorkerExecutionResult> {
|
||||
self.backend
|
||||
.upload_file(handle, file_name, media_type, content, context)
|
||||
.upload_file(worker_ref, file_name, media_type, content, context)
|
||||
}
|
||||
|
||||
pub(crate) fn delete_uploaded_file(
|
||||
&self,
|
||||
handle: &WorkerExecutionHandle,
|
||||
worker_ref: &WorkerRef,
|
||||
artifact_id: &str,
|
||||
) -> WorkerExecutionResult {
|
||||
self.backend.delete_uploaded_file(handle, artifact_id)
|
||||
self.backend.delete_uploaded_file(worker_ref, artifact_id)
|
||||
}
|
||||
|
||||
pub(crate) fn dispatch_method(
|
||||
&self,
|
||||
handle: &WorkerExecutionHandle,
|
||||
worker_ref: &WorkerRef,
|
||||
method: Method,
|
||||
) -> WorkerExecutionResult {
|
||||
self.backend.dispatch_method(handle, method)
|
||||
self.backend.dispatch_method(worker_ref, method)
|
||||
}
|
||||
|
||||
#[cfg(feature = "ws-server")]
|
||||
pub(crate) fn worker_snapshot(
|
||||
pub(crate) fn attach_worker_protocol(
|
||||
&self,
|
||||
handle: &WorkerExecutionHandle,
|
||||
) -> Option<protocol::Event> {
|
||||
self.backend.worker_snapshot(handle)
|
||||
worker_ref: &WorkerRef,
|
||||
) -> Result<WorkerProtocolTransport, WorkerExecutionResult> {
|
||||
Arc::clone(&self.backend).attach_worker_protocol(worker_ref)
|
||||
}
|
||||
|
||||
#[cfg(feature = "ws-server")]
|
||||
pub(crate) fn worker_snapshot(&self, worker_ref: &WorkerRef) -> Option<protocol::Event> {
|
||||
self.backend.worker_snapshot(worker_ref)
|
||||
}
|
||||
|
||||
pub(crate) fn worker_completions(
|
||||
&self,
|
||||
handle: &WorkerExecutionHandle,
|
||||
worker_ref: &WorkerRef,
|
||||
kind: protocol::CompletionKind,
|
||||
prefix: &str,
|
||||
context: Option<&protocol::CompletionContext>,
|
||||
) -> Vec<protocol::CompletionEntry> {
|
||||
self.backend.worker_completions(handle, kind, prefix)
|
||||
self.backend
|
||||
.worker_completions(worker_ref, kind, prefix, context)
|
||||
}
|
||||
|
||||
pub(crate) fn stop_worker_operation(
|
||||
@@ -953,12 +996,12 @@ impl WorkerExecutionBackendRef {
|
||||
self.backend.stop_worker_operation(request)
|
||||
}
|
||||
|
||||
pub(crate) fn stop_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
|
||||
self.backend.stop_worker(handle)
|
||||
pub(crate) fn stop_worker(&self, worker_ref: &WorkerRef) -> WorkerExecutionResult {
|
||||
self.backend.stop_worker(worker_ref)
|
||||
}
|
||||
|
||||
pub(crate) fn cancel_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
|
||||
self.backend.cancel_worker(handle)
|
||||
pub(crate) fn cancel_worker(&self, worker_ref: &WorkerRef) -> WorkerExecutionResult {
|
||||
self.backend.cancel_worker(worker_ref)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -493,7 +493,12 @@ pub(crate) struct PersistedWorkerRestoreOperation {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct PersistedWorkerStopOperation {
|
||||
pub(crate) operation_id: WorkerLifecycleOperationId,
|
||||
pub(crate) request: CreateWorkerRequest,
|
||||
/// Restore resources remain operation-owned until stop confirms cleanup.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) pending_restore: Option<PersistedWorkerRestoreOperation>,
|
||||
/// Stop can reconcile an identity whose execution metadata is unavailable.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) request: Option<CreateWorkerRequest>,
|
||||
pub(crate) binding: Option<PersistedWorkerExecutionBinding>,
|
||||
pub(crate) restore_intent: WorkerRestoreIntent,
|
||||
pub(crate) last_settled_status: WorkerStatus,
|
||||
@@ -515,6 +520,7 @@ pub(crate) enum PersistedWorkerExecutionState {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct PersistedWorkerRecord {
|
||||
pub(crate) restore_guard: crate::runtime::RestoreGuard,
|
||||
pub(crate) worker_ref: WorkerRef,
|
||||
pub(crate) worker_id: WorkerId,
|
||||
pub(crate) profile: ProfileSelector,
|
||||
@@ -1814,6 +1820,8 @@ impl RuntimeSnapshot {
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WorkerAggregateRecord {
|
||||
#[serde(default)]
|
||||
restore_guard: crate::runtime::RestoreGuard,
|
||||
schema_version: u32,
|
||||
worker_ref: WorkerRef,
|
||||
worker_id: WorkerId,
|
||||
@@ -1869,6 +1877,7 @@ impl WorkerAggregateRecord {
|
||||
};
|
||||
Self {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
restore_guard: worker.restore_guard.clone(),
|
||||
worker_ref: worker.worker_ref.clone(),
|
||||
worker_id: worker.worker_id,
|
||||
profile: worker.profile.clone(),
|
||||
@@ -1897,6 +1906,7 @@ impl WorkerAggregateRecord {
|
||||
}
|
||||
|
||||
fn validate(self, path: &Path) -> Result<PersistedWorkerRecord, RuntimeError> {
|
||||
let restore_guard = self.restore_guard;
|
||||
let last_finished_submission_request_id = self.last_finished_submission_request_id;
|
||||
let identity = WorkerIdentityRecord {
|
||||
schema_version: self.schema_version,
|
||||
@@ -1942,7 +1952,7 @@ impl WorkerAggregateRecord {
|
||||
});
|
||||
}
|
||||
(
|
||||
&restore.request,
|
||||
Some(&restore.request),
|
||||
&restore.binding,
|
||||
if restore.last_settled_status.is_active() {
|
||||
WorkerRestoreIntent::Automatic
|
||||
@@ -1953,7 +1963,7 @@ impl WorkerAggregateRecord {
|
||||
)
|
||||
}
|
||||
PersistedWorkerLifecycleOperation::Stop(stop) => {
|
||||
if !stop.last_settled_status.is_active() {
|
||||
if !stop.last_settled_status.is_active() && stop.pending_restore.is_none() {
|
||||
return Err(RuntimeError::StoreCorrupt {
|
||||
operation: "read Worker stop reconciliation",
|
||||
path: path.to_path_buf(),
|
||||
@@ -1961,8 +1971,18 @@ impl WorkerAggregateRecord {
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
if stop.request.is_none()
|
||||
&& (stop.binding.is_some() || stop.pending_restore.is_some())
|
||||
{
|
||||
return Err(RuntimeError::StoreCorrupt {
|
||||
operation: "read Worker stop reconciliation",
|
||||
path: path.to_path_buf(),
|
||||
message: "metadata-free stop cannot carry restore authority"
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
(
|
||||
&stop.request,
|
||||
stop.request.as_ref(),
|
||||
&stop.binding,
|
||||
stop.restore_intent,
|
||||
stop.last_settled_status,
|
||||
@@ -1978,6 +1998,7 @@ impl WorkerAggregateRecord {
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
if let Some(request) = request {
|
||||
WorkerExecutionRecord {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
request: request.clone(),
|
||||
@@ -1985,11 +2006,13 @@ impl WorkerAggregateRecord {
|
||||
restore_intent,
|
||||
}
|
||||
.validate_for_schema(&identity, path, SCHEMA_VERSION)?;
|
||||
}
|
||||
PersistedWorkerExecutionState::ReconciliationRequired(operation)
|
||||
}
|
||||
WorkerExecutionStateRecord::Unavailable => PersistedWorkerExecutionState::Unavailable,
|
||||
};
|
||||
let mut persisted = identity.into_persisted(execution_state);
|
||||
persisted.restore_guard = restore_guard;
|
||||
persisted.last_finished_submission_request_id = last_finished_submission_request_id;
|
||||
Ok(persisted)
|
||||
}
|
||||
@@ -2163,6 +2186,7 @@ impl WorkerIdentityRecord {
|
||||
self.workdir_attachments
|
||||
};
|
||||
PersistedWorkerRecord {
|
||||
restore_guard: crate::runtime::RestoreGuard::default(),
|
||||
worker_ref: self.worker_ref,
|
||||
worker_id: self.worker_id,
|
||||
profile: self.profile,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -462,21 +462,74 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
|
||||
Ok(runtime_api::WorkerLifecycleResponse { ack })
|
||||
}
|
||||
|
||||
async fn coordinate_worker_restore(
|
||||
&self,
|
||||
worker_id: String,
|
||||
request: runtime_api::WorkerRestoreCoordinationRequest,
|
||||
) -> Result<runtime_api::WorkerRestoreCoordinationResponse, runtime_api::RuntimeApiError> {
|
||||
let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?;
|
||||
let scope =
|
||||
auth_workspace_scope(&self.state, auth_extension().as_ref()).map_err(api_error)?;
|
||||
let runtime = self.state.runtime.clone();
|
||||
let (result, preparation) = tokio::task::spawn_blocking(move || {
|
||||
runtime.coordinate_worker_restore_operation(&worker_ref, request, scope.as_ref())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_api::RuntimeApiError::new(500, "runtime_restore_task_failed", error.to_string())
|
||||
})?
|
||||
.map_err(RuntimeHttpRestError::runtime)
|
||||
.map_err(api_error)?;
|
||||
let result = result
|
||||
.map(|result| -> Result<_, runtime_api::RuntimeApiError> {
|
||||
Ok(runtime_api::WorkerRestoreResponse {
|
||||
state: match result.state {
|
||||
server_api::WorkerRestoreState::Accepted => {
|
||||
runtime_api::WorkerRestoreState::Accepted
|
||||
}
|
||||
server_api::WorkerRestoreState::Rejected => {
|
||||
runtime_api::WorkerRestoreState::Rejected
|
||||
}
|
||||
server_api::WorkerRestoreState::RolledBack => {
|
||||
runtime_api::WorkerRestoreState::RolledBack
|
||||
}
|
||||
server_api::WorkerRestoreState::ReconciliationRequired => {
|
||||
runtime_api::WorkerRestoreState::ReconciliationRequired
|
||||
}
|
||||
},
|
||||
worker: result.worker.map(response).transpose()?,
|
||||
reason_code: result.reason_code,
|
||||
message: result.message,
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(runtime_api::WorkerRestoreCoordinationResponse {
|
||||
result,
|
||||
preparation,
|
||||
})
|
||||
}
|
||||
|
||||
async fn restore_worker(
|
||||
&self,
|
||||
worker_id: String,
|
||||
_request: runtime_api::EmptyObjectRequest,
|
||||
request: runtime_api::WorkerRestoreRequest,
|
||||
) -> Result<runtime_api::WorkerRestoreResponse, runtime_api::RuntimeApiError> {
|
||||
let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?;
|
||||
let result = match auth_workspace_scope(&self.state, auth_extension().as_ref())
|
||||
.map_err(api_error)?
|
||||
{
|
||||
Some(scope) => self
|
||||
.state
|
||||
.runtime
|
||||
.restore_worker_operation_scoped(&scope, &worker_ref),
|
||||
None => self.state.runtime.restore_worker_operation(&worker_ref),
|
||||
}
|
||||
let scope =
|
||||
auth_workspace_scope(&self.state, auth_extension().as_ref()).map_err(api_error)?;
|
||||
let runtime = self.state.runtime.clone();
|
||||
let result = tokio::task::spawn_blocking(move || match scope {
|
||||
Some(scope) => runtime.restore_worker_operation_scoped(&scope, &worker_ref, request),
|
||||
None => runtime.restore_worker_operation(&worker_ref, request),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_api::RuntimeApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR.as_u16(),
|
||||
"runtime_restore_task_failed",
|
||||
error.to_string(),
|
||||
)
|
||||
})?
|
||||
.map_err(RuntimeHttpRestError::runtime)
|
||||
.map_err(api_error)?;
|
||||
Ok(runtime_api::WorkerRestoreResponse {
|
||||
@@ -562,15 +615,19 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
|
||||
let entries = match auth_workspace_scope(&self.state, auth_extension().as_ref())
|
||||
.map_err(api_error)?
|
||||
{
|
||||
Some(scope) => {
|
||||
self.state
|
||||
.runtime
|
||||
.worker_completions_scoped(&scope, &worker_ref, kind, &prefix)
|
||||
}
|
||||
None => self
|
||||
.state
|
||||
.runtime
|
||||
.worker_completions(&worker_ref, kind, &prefix),
|
||||
Some(scope) => self.state.runtime.worker_completions_scoped(
|
||||
&scope,
|
||||
&worker_ref,
|
||||
kind,
|
||||
&prefix,
|
||||
value.context.as_ref(),
|
||||
),
|
||||
None => self.state.runtime.worker_completions(
|
||||
&worker_ref,
|
||||
kind,
|
||||
&prefix,
|
||||
value.context.as_ref(),
|
||||
),
|
||||
}
|
||||
.map_err(RuntimeHttpRestError::runtime)
|
||||
.map_err(api_error)?;
|
||||
@@ -578,6 +635,8 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
|
||||
Ok(runtime_api::CompletionResponse {
|
||||
kind,
|
||||
prefix,
|
||||
context: value.context,
|
||||
request_id: value.request_id,
|
||||
entries,
|
||||
})
|
||||
}
|
||||
|
||||
+3605
-426
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -555,9 +555,15 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
||||
WorkspaceServerOperation::WorkerControlRestore {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
request,
|
||||
} => generated_workspace_response(
|
||||
client
|
||||
.worker_control_restore(workspace_id, runtime_id, worker_id)
|
||||
.worker_control_restore(
|
||||
workspace_id,
|
||||
runtime_id,
|
||||
worker_id,
|
||||
request,
|
||||
)
|
||||
.await,
|
||||
),
|
||||
WorkspaceServerOperation::WorkerObservationSessions => {
|
||||
|
||||
@@ -55,6 +55,8 @@ wip-http = { workspace = true }
|
||||
wip-protocol = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
axum = { workspace = true }
|
||||
workdir = { workspace = true, features = ["http-client"] }
|
||||
dotenv = "0.15.0"
|
||||
futures = { workspace = true }
|
||||
serial_test = "3.4.0"
|
||||
|
||||
@@ -25,6 +25,80 @@ Does not own:
|
||||
|
||||
A Worker is runtime authority, not UI state. It should commit model-visible events through history/session paths and keep current Worker-name state in Worker metadata rather than in transient runtime files.
|
||||
|
||||
## Workspace config WIP Feature
|
||||
|
||||
Enable the opt-in surface in a Worker profile/manifest:
|
||||
|
||||
```toml
|
||||
[feature.workspace_config]
|
||||
enabled = true
|
||||
```
|
||||
|
||||
This setting accepts **only** `enabled` and defaults to false. Controller
|
||||
registration additionally requires Workspace identity, an available injected
|
||||
Workspace Client and WIP mode. It creates no grant. An owner must explicitly
|
||||
create a Backend grant bound to Workspace/Runtime/Worker and read-only or
|
||||
read-write access; standalone/Tools-mode config compatibility tools do not exist.
|
||||
|
||||
`feature::builtin::workspace_config` registers `/workspace-config` through the
|
||||
common structured invocation registry (T696). Its optional `access` argument is
|
||||
`effective` (default), `read_only`, or `read_write`; explicit write never silently
|
||||
downgrades. Feature preparation attaches before the request reaches the assistant
|
||||
and reports the logical target and effective access. Native self-attach uses the
|
||||
same `WorkspaceConfigFeature::attach` path. Backend alias attachment is idempotent
|
||||
under the session lock; neither entry point blindly retries uncertain outcomes.
|
||||
|
||||
The canonical alias is `workspace-config`; the content entrance is WIP
|
||||
`/workspace-config`. Attachment identity and conditional detach use the existing
|
||||
T697 Backend ledger. No OS mount, Workdir session, Bash capability, local config
|
||||
copy or second attachment inventory is created. The registry source is logical,
|
||||
not a Runtime filesystem target.
|
||||
|
||||
The request-time provider maps `/workspace-config` to the Backend's empty root
|
||||
path and descendants to canonical relative source paths, for example
|
||||
`/workspace-config/main.dcdl` -> `main.dcdl`. Observations return structure,
|
||||
Backend validators and current operations, **not bodies**. Files publish `read`;
|
||||
read-write grants additionally publish `write`, unique-substring `edit`, and
|
||||
`delete`. Directories and explicit absence observations publish `create` where
|
||||
Backend metadata permits it. The root publishes `apply_changes` for atomic
|
||||
create/update/delete/rename sets, allowing new imported
|
||||
sources and their references to be saved together. Empty directory creation,
|
||||
commands and generic POSIX operations are not exposed. The adapter uses the typed
|
||||
`workers/self/workspace-config` GET/attach/observe/read/commit API; it never calls
|
||||
an owner config API as a bypass.
|
||||
|
||||
Client-managed PATH validators are compared against freshly resolved Host
|
||||
metadata. Each mutation captures the ROOT validator, whole-tree revision/digest,
|
||||
canonical entrypoints and node state from that same observation. Backend resolves
|
||||
the live grant/link and validates canonical changes/CAS under its execution lock.
|
||||
No operation accepts LLM-supplied validators or `expected_digest` fields. Returned
|
||||
post-call path validators are refreshed from the exact committed revision, not
|
||||
substituted with root validators or later unrelated state. UI/Worker edits and
|
||||
same-bytes replacement revisions reject stale calls. Old descriptors cannot
|
||||
bypass read-only restrictions, detach, revocation or Workspace boundaries.
|
||||
|
||||
Bounds: depth 0..8, 256 observed nodes/changes, 512-byte relative paths, 256 KiB
|
||||
aggregate changed text/read content, and 30-second retrieval/operation deadlines.
|
||||
Path traversal and malformed parameters fail before content calls. Typed
|
||||
`NotCommitted` failures remain distinct from `Unknown`; timeouts/lost mutation
|
||||
responses are unknown and never automatically retried. Raw transport/provider
|
||||
errors are not copied into model/UI output. Retrieval protocol failures are
|
||||
reported as failures after recording their Client cache state, not as fresh
|
||||
successful observations. Saving commits the canonical Workspace configuration
|
||||
and existing projection/activation pipeline; it does **not** rewrite the resolved
|
||||
manifests of already-running Workers.
|
||||
|
||||
Public integration seams: `WorkspaceConfigFeature::for_workspace`,
|
||||
`workspace_config::wip::mount_workspace_config_wip`, and
|
||||
`WipRuntime::from_mounts` plus `discover`/`inspect`/`call`. Worker tests exercise the
|
||||
real Feature registry, native mount and WIP Client/Host through a typed in-process
|
||||
contract router: deep discovery, slash/native attach, read/edit/write,
|
||||
create/delete, atomic multi-source updates, grant restrictions/revocation,
|
||||
detach, cross-Workspace isolation, concurrent/same-bytes stale observations,
|
||||
invalid/oversize/save/unknown failures and no retries. Those contract-router tests
|
||||
are not a substitute for Backend canonical persistence, semantic validation,
|
||||
projection activation or full Web-to-Backend acceptance tests.
|
||||
|
||||
## See also
|
||||
|
||||
- [`../../docs/design/worker-session-state.md`](../../docs/design/worker-session-state.md)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Host-authored Job authority, separate from reusable Profile policy.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Immutable execution capability delivered only by a trusted Backend host.
|
||||
/// Names, instructions and Profiles never confer this capability.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BackendJobExecutionBinding {
|
||||
pub job_id: String,
|
||||
pub attempt_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub input_revision: Option<String>,
|
||||
#[serde(default)]
|
||||
pub subjektiv_consolidation: bool,
|
||||
}
|
||||
|
||||
impl BackendJobExecutionBinding {
|
||||
pub fn validate(&self) -> Result<(), &'static str> {
|
||||
if [&self.job_id, &self.attempt_id]
|
||||
.into_iter()
|
||||
.chain(self.input_revision.iter())
|
||||
.any(|value| value.trim().is_empty() || value.chars().any(char::is_control))
|
||||
{
|
||||
return Err("Backend Job binding contains an invalid identity or input revision");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,757 @@
|
||||
//! Provider-owned request-time checkout projection. No filesystem path is opened
|
||||
//! by the Worker: every observation and operation goes through the live router.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use agen::tool::ToolError;
|
||||
use async_trait::async_trait;
|
||||
use manifest::{ToolPermissionAction, ToolPermissionConfig};
|
||||
use serde_json::{Value as Json, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use wip_protocol::{
|
||||
Documentation, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, OperationDeclaration,
|
||||
ParameterDeclaration, ProtocolError, ProtocolErrorCode, ReturnDeclaration, TypeExpr, Value,
|
||||
};
|
||||
use workdir::{
|
||||
CheckoutObservation, EntryKind, ListRequest, WorkdirPath, WorkdirSessionCapability as Cap,
|
||||
WorkdirSessionRouter,
|
||||
};
|
||||
|
||||
use crate::feature::builtin::manage_workdir::wip::{decode_identity, encode_identity};
|
||||
use crate::permission::permission_action_for;
|
||||
use crate::wip::{
|
||||
WipCallContext, WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler,
|
||||
WipOperationOutput, WipProjection, WipProjectionKind, WipSubtreeMount, WipSubtreeProvider,
|
||||
json_to_wip, wip_to_json,
|
||||
};
|
||||
|
||||
const ROOT: &str = "/checkouts";
|
||||
const MAX_NODES: usize = 1024;
|
||||
const DEADLINE: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Return a stable, injective attachment-local entrance, never a host path.
|
||||
pub fn checkout_root(alias: &str) -> String {
|
||||
format!("{ROOT}/{}", encode_identity(alias))
|
||||
}
|
||||
|
||||
/// Register the native content namespace independently of inventory/lifecycle
|
||||
/// Features. Its capabilities come from current attachments, not Feature flags.
|
||||
pub fn mount_checkouts(
|
||||
registry: &mut WipMountRegistry,
|
||||
router: Arc<WorkdirSessionRouter>,
|
||||
tracker: tools::Tracker,
|
||||
permissions: Option<ToolPermissionConfig>,
|
||||
) -> Result<(), WipMountError> {
|
||||
registry.allocate_namespace("checkout", "checkouts")?;
|
||||
let provider = Arc::new(Provider {
|
||||
router,
|
||||
tracker,
|
||||
permissions,
|
||||
incarnation: uuid::Uuid::now_v7().to_string(),
|
||||
permits: Arc::new(tokio::sync::Semaphore::new(16)),
|
||||
deadline: DEADLINE,
|
||||
});
|
||||
registry.mount(provider.collection())?;
|
||||
registry.replace_compatibility_tools(ROOT, ["Read", "Edit", "Write", "Glob", "Grep"])?;
|
||||
registry.mount_subtree(WipSubtreeMount {
|
||||
root: ROOT.into(),
|
||||
provider: Arc::new(provider),
|
||||
})
|
||||
}
|
||||
|
||||
struct Provider {
|
||||
router: Arc<WorkdirSessionRouter>,
|
||||
tracker: tools::Tracker,
|
||||
permissions: Option<ToolPermissionConfig>,
|
||||
incarnation: String,
|
||||
permits: Arc<tokio::sync::Semaphore>,
|
||||
deadline: Duration,
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
fn allowed(&self, tool: &str, input: &Json) -> bool {
|
||||
self.permissions
|
||||
.as_ref()
|
||||
.is_none_or(|p| permission_action_for(p, tool, input) == ToolPermissionAction::Allow)
|
||||
}
|
||||
fn may_allow(&self, tool: &str) -> bool {
|
||||
let Some(p) = &self.permissions else {
|
||||
return true;
|
||||
};
|
||||
for rule in p.rules.iter().filter(|r| r.tool.eq_ignore_ascii_case(tool)) {
|
||||
if rule.action == ToolPermissionAction::Allow {
|
||||
return true;
|
||||
}
|
||||
if rule.pattern == "*" {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
p.default_action == ToolPermissionAction::Allow
|
||||
}
|
||||
fn parse(&self, path: &str) -> Option<(String, WorkdirPath)> {
|
||||
wip_protocol::validate_path(path).ok()?;
|
||||
let rest = path.strip_prefix("/checkouts/")?;
|
||||
let (slug, tail) = rest.split_once('/').unwrap_or((rest, ""));
|
||||
if tail.contains('\0') {
|
||||
return None;
|
||||
}
|
||||
Some((decode_identity(slug)?, WorkdirPath::new(tail).ok()?))
|
||||
}
|
||||
fn wrap_validator(&self, alias: &str, generation: u64, workdir: &str, raw: &[u8]) -> Vec<u8> {
|
||||
let mut hash = Sha256::new();
|
||||
for value in [
|
||||
self.incarnation.as_bytes(),
|
||||
alias.as_bytes(),
|
||||
&generation.to_be_bytes(),
|
||||
workdir.as_bytes(),
|
||||
raw,
|
||||
] {
|
||||
hash.update(value.len().to_be_bytes());
|
||||
hash.update(value);
|
||||
}
|
||||
hash.finalize().to_vec()
|
||||
}
|
||||
fn collection(self: &Arc<Self>) -> WipProjection {
|
||||
let mut state = Sha256::new();
|
||||
state.update(&self.incarnation);
|
||||
for alias in self.router.aliases() {
|
||||
if let Ok(s) = self.router.resolve(Some(alias.as_str())) {
|
||||
state.update(alias.as_str());
|
||||
state.update(s.generation.to_be_bytes());
|
||||
state.update(s.session.workdir().id().as_str());
|
||||
state.update(format!("{:?}", s.session.capabilities()));
|
||||
}
|
||||
}
|
||||
self.project(
|
||||
ROOT,
|
||||
descriptor(vec![operation(
|
||||
"list",
|
||||
"List currently accessible checkouts",
|
||||
vec![],
|
||||
)]),
|
||||
state.finalize().to_vec(),
|
||||
"collection",
|
||||
Arc::new(Collection {
|
||||
provider: self.clone(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
fn project(
|
||||
&self,
|
||||
path: &str,
|
||||
descriptor: InterfaceDescriptor,
|
||||
validator: Vec<u8>,
|
||||
kind: &str,
|
||||
handler: Arc<dyn WipOperationHandler>,
|
||||
) -> WipProjection {
|
||||
let encoded: String = path.as_bytes().iter().map(|b| format!("{b:02x}")).collect();
|
||||
let interface = format!("yoi.checkout/{}/{kind}/v1/@/{encoded}", self.incarnation);
|
||||
let interface_validator = Sha256::digest(format!("{descriptor:?}")).to_vec();
|
||||
WipProjection {
|
||||
route: path.into(),
|
||||
capability: format!("checkout:{kind}"),
|
||||
kind: WipProjectionKind::Native,
|
||||
object: Object {
|
||||
name: path.rsplit('/').next().unwrap_or_default().into(),
|
||||
description: Some(format!("Attached Workdir {kind}")),
|
||||
interfaces: vec![interface.clone()],
|
||||
r#ref: None,
|
||||
validator: Some(validator),
|
||||
},
|
||||
interface,
|
||||
descriptor,
|
||||
interface_validator: Some(interface_validator),
|
||||
handler,
|
||||
}
|
||||
}
|
||||
async fn node(self: &Arc<Self>, path: &str) -> Result<Option<WipProjection>, ProtocolError> {
|
||||
if path == ROOT {
|
||||
return Ok(Some(self.collection()));
|
||||
}
|
||||
let Some((alias, target)) = self.parse(path) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Ok(selected) = self.router.resolve(Some(&alias)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let observation = match selected.session.checkout_observe(target.clone()).await {
|
||||
Ok(o) => o,
|
||||
Err(
|
||||
workdir::WorkdirError::Denied(_)
|
||||
| workdir::WorkdirError::OutOfScope(_)
|
||||
| workdir::WorkdirError::NotFound(_)
|
||||
| workdir::WorkdirError::SessionClosed
|
||||
| workdir::WorkdirError::UnsupportedOperation(_)
|
||||
| workdir::WorkdirError::Unsupported(_),
|
||||
) => return Ok(None),
|
||||
Err(_) => {
|
||||
return Err(error(
|
||||
ProtocolErrorCode::Internal,
|
||||
"checkout observation unavailable",
|
||||
));
|
||||
}
|
||||
};
|
||||
if !matches!(observation.kind, EntryKind::File | EntryKind::Directory) {
|
||||
return Ok(None);
|
||||
}
|
||||
let permission_input = json!({"target_workdir":alias, "file_path":target.as_str()});
|
||||
if observation.kind == EntryKind::File && !self.allowed("Read", &permission_input) {
|
||||
return Ok(None);
|
||||
}
|
||||
let ops = self.operations(&observation);
|
||||
if ops.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let kind = if observation.kind == EntryKind::File {
|
||||
"file"
|
||||
} else {
|
||||
"directory"
|
||||
};
|
||||
let workdir = selected.session.workdir().id().as_str().to_owned();
|
||||
let validator = self.wrap_validator(
|
||||
&alias,
|
||||
selected.generation,
|
||||
&workdir,
|
||||
&observation.validator,
|
||||
);
|
||||
let handler = Arc::new(FileHandler {
|
||||
provider: self.clone(),
|
||||
alias,
|
||||
generation: selected.generation,
|
||||
workdir,
|
||||
target,
|
||||
observation,
|
||||
});
|
||||
let family = format!("{kind}-g{}", selected.generation);
|
||||
Ok(Some(self.project(
|
||||
path,
|
||||
descriptor(ops),
|
||||
validator,
|
||||
&family,
|
||||
handler,
|
||||
)))
|
||||
}
|
||||
fn operations(&self, o: &CheckoutObservation) -> Vec<OperationDeclaration> {
|
||||
let mut ops = Vec::new();
|
||||
let has = |cap, name| o.capabilities.supports(cap) && self.may_allow(name);
|
||||
if o.kind == EntryKind::File {
|
||||
if has(Cap::Read, "Read") {
|
||||
ops.push(operation(
|
||||
"read",
|
||||
"Read text with line offsets and numbered output",
|
||||
crate::file_operation::parameters("read", true).unwrap(),
|
||||
));
|
||||
}
|
||||
if has(Cap::Edit, "Edit") {
|
||||
ops.push(operation(
|
||||
"edit",
|
||||
"Replace a unique string (requires prior Read)",
|
||||
crate::file_operation::parameters("edit", true).unwrap(),
|
||||
));
|
||||
}
|
||||
if has(Cap::Write, "Write") {
|
||||
ops.push(operation(
|
||||
"write",
|
||||
"Overwrite this existing file (requires prior Read)",
|
||||
crate::file_operation::parameters("write", true).unwrap(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
if has(Cap::Glob, "Glob") {
|
||||
ops.push(operation(
|
||||
"glob",
|
||||
"Glob within this directory",
|
||||
vec![
|
||||
parameter("pattern", true, TypeExpr::String),
|
||||
parameter("path", false, TypeExpr::String),
|
||||
],
|
||||
));
|
||||
}
|
||||
if has(Cap::Grep, "Grep") {
|
||||
ops.push(operation(
|
||||
"grep",
|
||||
"Search within this directory with the existing Grep contract",
|
||||
grep_parameters(),
|
||||
));
|
||||
}
|
||||
if has(Cap::Write, "Write") {
|
||||
ops.push(operation("create_file", "Create a new relative file, creating permitted missing parents; never overwrite", vec![parameter("path",true,TypeExpr::String),parameter("content",true,TypeExpr::String)]));
|
||||
}
|
||||
}
|
||||
ops
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WipSubtreeProvider for Arc<Provider> {
|
||||
async fn projection(&self, path: &str) -> Result<Option<WipProjection>, ProtocolError> {
|
||||
let _permit = tokio::time::timeout(self.deadline, self.permits.acquire())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
error(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"checkout concurrency deadline",
|
||||
)
|
||||
})?
|
||||
.map_err(|_| error(ProtocolErrorCode::Internal, "checkout unavailable"))?;
|
||||
tokio::time::timeout(self.deadline, self.node(path))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
error(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"checkout observation deadline",
|
||||
)
|
||||
})?
|
||||
}
|
||||
async fn children(&self, path: &str) -> Result<Vec<String>, ProtocolError> {
|
||||
let _permit = tokio::time::timeout(self.deadline, self.permits.acquire())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
error(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"checkout capacity deadline",
|
||||
)
|
||||
})?
|
||||
.map_err(|_| error(ProtocolErrorCode::Internal, "checkout unavailable"))?;
|
||||
tokio::time::timeout(self.deadline, self.children_inner(path))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
error(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"checkout enumeration deadline",
|
||||
)
|
||||
})?
|
||||
}
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
async fn children_inner(self: &Arc<Self>, path: &str) -> Result<Vec<String>, ProtocolError> {
|
||||
if path == ROOT {
|
||||
let before = self.collection().object.validator.unwrap();
|
||||
if self.router.aliases().len() > MAX_NODES {
|
||||
return Err(error(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"too many checkouts",
|
||||
));
|
||||
}
|
||||
let mut paths = Vec::new();
|
||||
for alias in self.router.aliases() {
|
||||
let p = checkout_root(alias.as_str());
|
||||
if self.node(&p).await?.is_some() {
|
||||
paths.push(p);
|
||||
}
|
||||
}
|
||||
if before != self.collection().object.validator.unwrap() {
|
||||
return Err(error(
|
||||
ProtocolErrorCode::ValidatorMismatch,
|
||||
"attachment collection changed",
|
||||
));
|
||||
}
|
||||
return Ok(paths);
|
||||
}
|
||||
let Some((alias, target)) = self.parse(path) else {
|
||||
return Err(error(ProtocolErrorCode::NotFound, "checkout not published"));
|
||||
};
|
||||
let selected = self
|
||||
.router
|
||||
.resolve(Some(&alias))
|
||||
.map_err(|_| error(ProtocolErrorCode::NotFound, "attachment expired"))?;
|
||||
let o = selected
|
||||
.session
|
||||
.checkout_observe(target.clone())
|
||||
.await
|
||||
.map_err(|_| error(ProtocolErrorCode::NotFound, "checkout not published"))?;
|
||||
if o.kind != EntryKind::Directory {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if !self.allowed(
|
||||
"Glob",
|
||||
&json!({"target_workdir":alias,"path":target.as_str(),"pattern":"*"}),
|
||||
) && !self.allowed(
|
||||
"Grep",
|
||||
&json!({"target_workdir":alias,"path":target.as_str(),"pattern":""}),
|
||||
) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let listing = selected
|
||||
.session
|
||||
.checkout_search(workdir::CheckoutSearchRequest::new(
|
||||
workdir::CheckoutSearchOperation::List(ListRequest {
|
||||
path: target,
|
||||
limit: MAX_NODES + 1,
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.map_err(|_| error(ProtocolErrorCode::Internal, "checkout listing unavailable"))?;
|
||||
let workdir::CheckoutSearchResult::List(listing) = listing else {
|
||||
return Err(error(
|
||||
ProtocolErrorCode::Internal,
|
||||
"mismatched checkout listing",
|
||||
));
|
||||
};
|
||||
if listing.truncated || listing.entries.len() > MAX_NODES {
|
||||
return Err(error(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"directory observation exceeds node bound",
|
||||
));
|
||||
}
|
||||
let mut paths = Vec::new();
|
||||
for entry in listing.entries {
|
||||
let p = object_path(&alias, &entry.path);
|
||||
if self.node(&p).await?.is_some() {
|
||||
paths.push(p);
|
||||
}
|
||||
}
|
||||
if !self
|
||||
.router
|
||||
.resolve(Some(&alias))
|
||||
.is_ok_and(|current| current.generation == selected.generation)
|
||||
{
|
||||
return Err(error(
|
||||
ProtocolErrorCode::ValidatorMismatch,
|
||||
"attachment changed",
|
||||
));
|
||||
}
|
||||
Ok(paths)
|
||||
}
|
||||
}
|
||||
|
||||
struct Collection {
|
||||
provider: Arc<Provider>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for Collection {
|
||||
async fn call(
|
||||
&self,
|
||||
operation: &str,
|
||||
arguments: &BTreeMap<String, Value>,
|
||||
_context: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
if operation != "list" || !arguments.is_empty() {
|
||||
return Err(protocol(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"list accepts no arguments",
|
||||
));
|
||||
}
|
||||
let before = self.provider.collection().object.validator.unwrap();
|
||||
let paths = Arc::new(self.provider.clone())
|
||||
.children(ROOT)
|
||||
.await
|
||||
.map_err(WipOperationError::Protocol)?;
|
||||
let mut items = Vec::new();
|
||||
for path in paths {
|
||||
let (alias, _) = self.provider.parse(&path).expect("generated checkout path");
|
||||
let s = self.provider.router.resolve(Some(&alias)).map_err(|_| {
|
||||
protocol(ProtocolErrorCode::ValidatorMismatch, "attachment changed")
|
||||
})?;
|
||||
items.push(json!({"path":path,"slug":encode_identity(&alias),"alias":alias,
|
||||
"working_directory_id":s.session.workdir().id().as_str(),"generation":s.generation}));
|
||||
}
|
||||
let value = json_to_wip(&json!({"items":items}))
|
||||
.map_err(|_| protocol(ProtocolErrorCode::Internal, "invalid checkout result"))?;
|
||||
let after = self.provider.collection().object.validator.unwrap();
|
||||
if before != after {
|
||||
return Err(protocol(
|
||||
ProtocolErrorCode::ValidatorMismatch,
|
||||
"attachment collection changed",
|
||||
));
|
||||
}
|
||||
Ok(WipOperationOutput::native_with_validator(value, after))
|
||||
}
|
||||
}
|
||||
|
||||
struct FileHandler {
|
||||
provider: Arc<Provider>,
|
||||
alias: String,
|
||||
generation: u64,
|
||||
workdir: String,
|
||||
target: WorkdirPath,
|
||||
observation: CheckoutObservation,
|
||||
}
|
||||
impl FileHandler {
|
||||
fn check_connection(&self) -> Result<(), WipOperationError> {
|
||||
if self
|
||||
.provider
|
||||
.router
|
||||
.resolve(Some(&self.alias))
|
||||
.is_ok_and(|s| {
|
||||
s.generation == self.generation && s.session.workdir().id().as_str() == self.workdir
|
||||
})
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(protocol(
|
||||
ProtocolErrorCode::ValidatorMismatch,
|
||||
"attachment changed while publishing result",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for FileHandler {
|
||||
async fn call(
|
||||
&self,
|
||||
operation: &str,
|
||||
arguments: &BTreeMap<String, Value>,
|
||||
context: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
let tool = match operation {
|
||||
"read" => "Read",
|
||||
"edit" => "Edit",
|
||||
"write" => "Write",
|
||||
"create_file" => "Create",
|
||||
"glob" => "Glob",
|
||||
"grep" => "Grep",
|
||||
_ => {
|
||||
return Err(protocol(
|
||||
ProtocolErrorCode::OperationNotFound,
|
||||
"unknown checkout operation",
|
||||
));
|
||||
}
|
||||
};
|
||||
let mut args = serde_json::Map::new();
|
||||
for (k, v) in arguments {
|
||||
args.insert(
|
||||
k.clone(),
|
||||
wip_to_json(v).map_err(|_| {
|
||||
protocol(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"invalid checkout argument",
|
||||
)
|
||||
})?,
|
||||
);
|
||||
}
|
||||
let mut permission_input = args.clone();
|
||||
permission_input.insert("target_workdir".into(), json!(self.alias));
|
||||
if tool == "Glob" || tool == "Grep" {
|
||||
let relative = args.get("path").and_then(Json::as_str).unwrap_or("");
|
||||
permission_input.insert("path".into(), json!(join_under(&self.target, relative)?));
|
||||
} else if tool == "Create" {
|
||||
let relative = args.get("path").and_then(Json::as_str).ok_or_else(|| {
|
||||
protocol(ProtocolErrorCode::InvalidArguments, "create requires path")
|
||||
})?;
|
||||
permission_input.remove("path");
|
||||
permission_input.insert(
|
||||
"file_path".into(),
|
||||
json!(join_under(&self.target, relative)?),
|
||||
);
|
||||
} else {
|
||||
permission_input.insert("file_path".into(), json!(self.target.as_str()));
|
||||
}
|
||||
if !self.provider.allowed(
|
||||
if tool == "Create" { "Write" } else { tool },
|
||||
&Json::Object(permission_input),
|
||||
) {
|
||||
return Err(protocol(
|
||||
ProtocolErrorCode::PermissionDenied,
|
||||
"file operation denied; approval-required policy fails closed",
|
||||
));
|
||||
}
|
||||
let _permit = tokio::time::timeout(self.provider.deadline, self.provider.permits.acquire())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
protocol(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"checkout operation capacity deadline",
|
||||
)
|
||||
})?
|
||||
.map_err(|_| protocol(ProtocolErrorCode::Internal, "checkout unavailable"))?;
|
||||
let deadline = tokio::time::Instant::now() + self.provider.deadline;
|
||||
let result = tokio::time::timeout_at(
|
||||
deadline,
|
||||
tools::execute_checkout_tool(
|
||||
self.provider.router.clone(),
|
||||
self.provider.tracker.clone(),
|
||||
&self.alias,
|
||||
self.generation,
|
||||
self.target.clone(),
|
||||
self.observation.validator.clone(),
|
||||
tool,
|
||||
Json::Object(args),
|
||||
context.execution,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
if matches!(tool, "Write" | "Edit" | "Create") {
|
||||
WipOperationError::OutcomeUnknown("file operation deadline after dispatch".into())
|
||||
} else {
|
||||
protocol(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"file operation deadline",
|
||||
)
|
||||
}
|
||||
})?
|
||||
.map_err(map_tool_error)?;
|
||||
let links = tokio::time::timeout_at(deadline, async {
|
||||
let mut links = Vec::new();
|
||||
self.check_connection()?;
|
||||
for path in &result.paths {
|
||||
let route = object_path(&self.alias, path);
|
||||
// References are validated only against the captured live connection.
|
||||
if self
|
||||
.provider
|
||||
.node(&route)
|
||||
.await
|
||||
.map_err(WipOperationError::Protocol)?
|
||||
.is_some()
|
||||
{
|
||||
links.push(json!({"path":route}));
|
||||
}
|
||||
self.check_connection()?;
|
||||
}
|
||||
Ok::<_, WipOperationError>(links)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
if matches!(tool, "Write" | "Edit" | "Create") {
|
||||
WipOperationError::OutcomeUnknown("completed operation response deadline".into())
|
||||
} else {
|
||||
protocol(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"checkout result deadline",
|
||||
)
|
||||
}
|
||||
})?
|
||||
.map_err(|e| {
|
||||
if matches!(tool, "Write" | "Edit" | "Create") {
|
||||
WipOperationError::OutcomeUnknown("completed operation response unavailable".into())
|
||||
} else {
|
||||
e
|
||||
}
|
||||
})?;
|
||||
let value = json_to_wip(
|
||||
&json!({"summary":result.output.summary,"content":result.output.content,"items":links}),
|
||||
)
|
||||
.map_err(|_| {
|
||||
WipOperationError::OutcomeUnknown("invalid completed file operation result".into())
|
||||
})?;
|
||||
if let Some(raw) = result.validator {
|
||||
Ok(WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
self.provider
|
||||
.wrap_validator(&self.alias, self.generation, &self.workdir, &raw),
|
||||
))
|
||||
} else {
|
||||
Ok(WipOperationOutput::native(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn object_path(alias: &str, path: &WorkdirPath) -> String {
|
||||
if path.is_root() {
|
||||
checkout_root(alias)
|
||||
} else {
|
||||
format!("{}/{}", checkout_root(alias), path.as_str())
|
||||
}
|
||||
}
|
||||
fn join_under(parent: &WorkdirPath, relative: &str) -> Result<String, WipOperationError> {
|
||||
let child = WorkdirPath::new(relative).map_err(|_| {
|
||||
protocol(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"path must stay relative to this directory",
|
||||
)
|
||||
})?;
|
||||
if relative.starts_with('/') {
|
||||
return Err(protocol(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"absolute paths are not permitted",
|
||||
));
|
||||
}
|
||||
Ok(if parent.is_root() {
|
||||
child.as_str().into()
|
||||
} else if child.is_root() {
|
||||
parent.as_str().into()
|
||||
} else {
|
||||
format!("{}/{}", parent.as_str(), child.as_str())
|
||||
})
|
||||
}
|
||||
fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclaration {
|
||||
ParameterDeclaration {
|
||||
name: name.into(),
|
||||
required,
|
||||
documentation: None,
|
||||
r#type,
|
||||
}
|
||||
}
|
||||
fn operation(
|
||||
name: &str,
|
||||
summary: &str,
|
||||
parameters: Vec<ParameterDeclaration>,
|
||||
) -> OperationDeclaration {
|
||||
OperationDeclaration {
|
||||
name: name.into(),
|
||||
documentation: Some(Documentation {
|
||||
summary: summary.into(),
|
||||
details: None,
|
||||
}),
|
||||
parameters,
|
||||
returns: ReturnDeclaration {
|
||||
documentation: None,
|
||||
r#type: TypeExpr::Json,
|
||||
},
|
||||
}
|
||||
}
|
||||
fn descriptor(operations: Vec<OperationDeclaration>) -> InterfaceDescriptor {
|
||||
InterfaceDescriptor{format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation{summary:"AI-oriented attached Workdir operations".into(),details:Some("The route binds attachment and target. Line offsets are zero-based; text output numbers lines from one. Read-before-write and provider validators are both required. Paths are Worldspace references, never host paths.".into())}),types:Vec::new(),operations}
|
||||
}
|
||||
fn grep_parameters() -> Vec<ParameterDeclaration> {
|
||||
let mut p = vec![parameter("pattern", true, TypeExpr::String)];
|
||||
for name in ["path", "glob", "type", "output_mode"] {
|
||||
p.push(parameter(name, false, TypeExpr::String));
|
||||
}
|
||||
for name in ["-A", "-B", "-C", "head_limit", "offset"] {
|
||||
p.push(parameter(name, false, TypeExpr::Integer));
|
||||
}
|
||||
for name in ["case_insensitive", "multiline"] {
|
||||
p.push(parameter(name, false, TypeExpr::Boolean));
|
||||
}
|
||||
p
|
||||
}
|
||||
fn error(code: ProtocolErrorCode, message: &str) -> ProtocolError {
|
||||
ProtocolError {
|
||||
code,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
fn protocol(code: ProtocolErrorCode, message: &str) -> WipOperationError {
|
||||
WipOperationError::Protocol(error(code, message))
|
||||
}
|
||||
fn map_tool_error(e: ToolError) -> WipOperationError {
|
||||
match e {
|
||||
ToolError::InvalidArgument(_) => protocol(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"invalid file arguments or read-before-write requirement",
|
||||
),
|
||||
ToolError::StructuredConflict { code, .. } => match code.as_str() {
|
||||
"checkout_denied" => protocol(
|
||||
ProtocolErrorCode::PermissionDenied,
|
||||
"file operation refused before effects",
|
||||
),
|
||||
"checkout_invalid" => protocol(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"invalid checkout operation or target",
|
||||
),
|
||||
"checkout_unavailable" => protocol(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"checkout provider declined before effects",
|
||||
),
|
||||
"checkout_stale" => protocol(
|
||||
ProtocolErrorCode::ValidatorMismatch,
|
||||
"file or attachment observation is stale; rediscover and reread",
|
||||
),
|
||||
_ => WipOperationError::OutcomeUnknown("unclassified file operation failure".into()),
|
||||
},
|
||||
ToolError::Cancelled(o) => WipOperationError::Cancelled(o),
|
||||
ToolError::Interrupted(o) => WipOperationError::Interrupted(o),
|
||||
_ => WipOperationError::OutcomeUnknown(
|
||||
"file operation failed; inspect current state before retry".into(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "checkout_race_tests.rs"]
|
||||
mod race_tests;
|
||||
@@ -0,0 +1,671 @@
|
||||
//! Real provider HTTP -> RemoteWorkdirSession -> router -> native WIP Client.
|
||||
//! This deliberately does not exercise the production Backend grant network.
|
||||
use super::*;
|
||||
use axum::{
|
||||
Json as AxumJson, Router,
|
||||
body::Body,
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode, Uri},
|
||||
response::{IntoResponse, Response as AxumResponse},
|
||||
routing::{delete, post},
|
||||
};
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
use workdir::http::{
|
||||
OpenWorkdirSessionRequest, OpenWorkdirSessionResponse, RemoteWorkdirSession, WorkdirSessionId,
|
||||
WorkdirSessionOperation, WorkdirSessionOperationRequest, WorkdirTransportError,
|
||||
WorkdirTransportErrorCode,
|
||||
};
|
||||
use workdir::{
|
||||
CheckoutOperation, CheckoutRequest, CheckoutSearchOperation, LocalWorkdirSession,
|
||||
WorkdirAttachmentAlias, WorkdirError, WorkdirPath, WorkdirSession, WorkdirSessionCapabilities,
|
||||
WorkdirSessionHandle, WorkdirSessionRouter, dispatch_workdir_session_operation,
|
||||
};
|
||||
|
||||
const TOKEN: &str = "checkout-http-test-private-bearer";
|
||||
const SESSION: &str = "http-test-session";
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum WriteResponseFault {
|
||||
Malformed,
|
||||
LostBody,
|
||||
}
|
||||
|
||||
struct ProviderState {
|
||||
session: WorkdirSessionHandle,
|
||||
requests: Mutex<Vec<(String, String)>>,
|
||||
operations: Mutex<Vec<WorkdirSessionOperation>>,
|
||||
write_fault: Mutex<Option<WriteResponseFault>>,
|
||||
}
|
||||
|
||||
impl ProviderState {
|
||||
fn authorize(&self, headers: &HeaderMap, method: &str, uri: &Uri) -> Result<(), AxumResponse> {
|
||||
self.requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((method.into(), uri.path().into()));
|
||||
let expected = format!("Bearer {TOKEN}");
|
||||
if headers.get("authorization").and_then(|h| h.to_str().ok()) != Some(expected.as_str()) {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
AxumJson(WorkdirTransportError {
|
||||
code: WorkdirTransportErrorCode::Denied,
|
||||
message: "provider authorization denied".into(),
|
||||
}),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn execute_calls(&self) -> usize {
|
||||
self.operations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|op| matches!(op, WorkdirSessionOperation::CheckoutExecute(_)))
|
||||
.count()
|
||||
}
|
||||
|
||||
fn write_calls(&self) -> usize {
|
||||
self.operations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|op| {
|
||||
matches!(
|
||||
op,
|
||||
WorkdirSessionOperation::CheckoutExecute(CheckoutRequest {
|
||||
operation: CheckoutOperation::Write { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
})
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_error(error: WorkdirError) -> AxumResponse {
|
||||
let error = WorkdirTransportError::from_workdir_error(&error);
|
||||
(
|
||||
StatusCode::from_u16(error.code.http_status()).unwrap(),
|
||||
AxumJson(error),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn open_session(
|
||||
State(state): State<Arc<ProviderState>>,
|
||||
Path(id): Path<String>,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
AxumJson(_request): AxumJson<OpenWorkdirSessionRequest>,
|
||||
) -> AxumResponse {
|
||||
if let Err(response) = state.authorize(&headers, "POST", &uri) {
|
||||
return response;
|
||||
}
|
||||
if id != state.session.workdir().id().as_str() {
|
||||
return provider_error(WorkdirError::NotFound("unknown workdir".into()));
|
||||
}
|
||||
AxumJson(OpenWorkdirSessionResponse {
|
||||
session_id: WorkdirSessionId::new(SESSION).unwrap(),
|
||||
workdir_id: state.session.workdir().id().clone(),
|
||||
capabilities: state.session.capabilities(),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn operate(
|
||||
State(state): State<Arc<ProviderState>>,
|
||||
Path(id): Path<String>,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
AxumJson(request): AxumJson<WorkdirSessionOperationRequest>,
|
||||
) -> AxumResponse {
|
||||
if let Err(response) = state.authorize(&headers, "POST", &uri) {
|
||||
return response;
|
||||
}
|
||||
if id != SESSION {
|
||||
return provider_error(WorkdirError::Unavailable("unknown session".into()));
|
||||
}
|
||||
let is_write = matches!(
|
||||
&request.operation,
|
||||
WorkdirSessionOperation::CheckoutExecute(CheckoutRequest {
|
||||
operation: CheckoutOperation::Write { .. },
|
||||
..
|
||||
})
|
||||
);
|
||||
state
|
||||
.operations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(request.operation.clone());
|
||||
let result =
|
||||
dispatch_workdir_session_operation(state.session.as_ref(), request.operation).await;
|
||||
match result {
|
||||
// Inject only AFTER the real LocalWorkdirSession successfully committed.
|
||||
Ok(result) => {
|
||||
let fault = if is_write {
|
||||
state.write_fault.lock().unwrap().take()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match fault {
|
||||
Some(WriteResponseFault::Malformed) => (
|
||||
StatusCode::OK,
|
||||
[("content-type", "application/json")],
|
||||
"{broken-response",
|
||||
)
|
||||
.into_response(),
|
||||
Some(WriteResponseFault::LostBody) => {
|
||||
let body = Body::from_stream(futures::stream::once(async {
|
||||
Err::<Vec<u8>, _>(std::io::Error::other("response lost after commit"))
|
||||
}));
|
||||
AxumResponse::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("content-type", "application/json")
|
||||
.body(body)
|
||||
.unwrap()
|
||||
}
|
||||
None => AxumJson(result).into_response(),
|
||||
}
|
||||
}
|
||||
Err(error) => provider_error(error),
|
||||
}
|
||||
}
|
||||
|
||||
async fn close_session(
|
||||
State(state): State<Arc<ProviderState>>,
|
||||
Path(id): Path<String>,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
) -> AxumResponse {
|
||||
if let Err(response) = state.authorize(&headers, "DELETE", &uri) {
|
||||
return response;
|
||||
}
|
||||
if id != SESSION {
|
||||
return provider_error(WorkdirError::Unavailable("unknown session".into()));
|
||||
}
|
||||
match state.session.close().await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(error) => provider_error(error),
|
||||
}
|
||||
}
|
||||
|
||||
struct HttpFixture {
|
||||
dir: TempDir,
|
||||
state: Arc<ProviderState>,
|
||||
base_url: reqwest::Url,
|
||||
server: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl Drop for HttpFixture {
|
||||
fn drop(&mut self) {
|
||||
self.server.abort();
|
||||
}
|
||||
}
|
||||
|
||||
impl HttpFixture {
|
||||
async fn new(capabilities: WorkdirSessionCapabilities) -> Self {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("src/deep")).unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("src/deep/a.txt"),
|
||||
"first\nneedle needle\nlast\n",
|
||||
)
|
||||
.unwrap();
|
||||
let session = Arc::new(LocalWorkdirSession::materialized(
|
||||
dir.path().to_path_buf(),
|
||||
dir.path().to_path_buf(),
|
||||
manifest::SharedScope::new(manifest::Scope::writable(dir.path()).unwrap()),
|
||||
capabilities,
|
||||
));
|
||||
let state = Arc::new(ProviderState {
|
||||
session,
|
||||
requests: Mutex::new(Vec::new()),
|
||||
operations: Mutex::new(Vec::new()),
|
||||
write_fault: Mutex::new(None),
|
||||
});
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/provider/v1/working-directories/{id}/sessions",
|
||||
post(open_session),
|
||||
)
|
||||
.route(
|
||||
"/provider/v1/workdir-sessions/{id}/operations",
|
||||
post(operate),
|
||||
)
|
||||
.route("/provider/v1/workdir-sessions/{id}", delete(close_session))
|
||||
.with_state(state.clone());
|
||||
// Binding before spawn makes readiness deterministic; no sleeps or polling.
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base_url = format!("http://{}/provider/", listener.local_addr().unwrap())
|
||||
.parse()
|
||||
.unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
Self {
|
||||
dir,
|
||||
state,
|
||||
base_url,
|
||||
server,
|
||||
}
|
||||
}
|
||||
|
||||
async fn open(&self, token: &str) -> Result<RemoteWorkdirSession, WorkdirError> {
|
||||
RemoteWorkdirSession::open(
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap(),
|
||||
self.base_url.clone(),
|
||||
token.to_owned(),
|
||||
self.state.session.workdir().id().clone(),
|
||||
OpenWorkdirSessionRequest::default(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn public_output(&self, text: &str) {
|
||||
assert!(!text.contains(TOKEN), "bearer token leaked");
|
||||
assert!(
|
||||
!text.contains(self.dir.path().to_str().unwrap()),
|
||||
"host path leaked"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("/provider/"),
|
||||
"transport prefix leaked into Worldspace"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_endpoints(&self) {
|
||||
let open = format!(
|
||||
"/provider/v1/working-directories/{}/sessions",
|
||||
self.state.session.workdir().id()
|
||||
);
|
||||
let operations = format!("/provider/v1/workdir-sessions/{SESSION}/operations");
|
||||
let close = format!("/provider/v1/workdir-sessions/{SESSION}");
|
||||
let requests = self.state.requests.lock().unwrap();
|
||||
assert!(requests.contains(&("POST".into(), open.clone())));
|
||||
assert!(requests.contains(&("POST".into(), operations.clone())));
|
||||
assert_eq!(
|
||||
requests
|
||||
.iter()
|
||||
.filter(|(method, path)| method == "DELETE" && path == &close)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(requests.iter().all(|(method, path)| (method == "POST"
|
||||
&& (path == &open || path == &operations))
|
||||
|| (method == "DELETE" && path == &close)));
|
||||
}
|
||||
}
|
||||
|
||||
fn native_runtime(remote: Arc<RemoteWorkdirSession>) -> (WipRuntime, Arc<WorkdirSessionRouter>) {
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(WorkdirAttachmentAlias::new("main").unwrap(), remote)
|
||||
.unwrap();
|
||||
let mut registry = WipMountRegistry::new();
|
||||
crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None)
|
||||
.unwrap();
|
||||
let runtime = WipRuntime::new(
|
||||
WipHost::new(registry),
|
||||
SecurityContext::new("http-checkout-worker"),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
(runtime, router)
|
||||
}
|
||||
|
||||
async fn observe_interface(runtime: &WipRuntime, path: &str) -> String {
|
||||
runtime.discover(path.into(), 0, true).await.unwrap();
|
||||
// Select from the real Client's Known Space, not a direct handler invocation.
|
||||
let interface = {
|
||||
let state = runtime.state.lock().unwrap();
|
||||
state
|
||||
.client
|
||||
.known_space(&state.session)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|o| o.path == path)
|
||||
.unwrap()
|
||||
.object
|
||||
.unwrap()
|
||||
.interfaces[0]
|
||||
.clone()
|
||||
};
|
||||
runtime.inspect(interface.clone(), true).await.unwrap();
|
||||
interface
|
||||
}
|
||||
|
||||
async fn native_call(runtime: &WipRuntime, path: &str, operation: &str, args: Json) -> Value {
|
||||
let interface = observe_interface(runtime, path).await;
|
||||
runtime
|
||||
.call(
|
||||
path.into(),
|
||||
interface,
|
||||
operation.into(),
|
||||
args,
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let state = runtime.state.lock().unwrap();
|
||||
match &state
|
||||
.client
|
||||
.call_history(&state.session)
|
||||
.unwrap()
|
||||
.back()
|
||||
.unwrap()
|
||||
.outcome
|
||||
{
|
||||
CallOutcome::Success(response) => response.result.clone(),
|
||||
outcome => panic!("expected native Client success, got {outcome:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn field<'a>(value: &'a Value, name: &str) -> &'a Value {
|
||||
let Value::Record(fields) = value else {
|
||||
panic!("expected typed Record")
|
||||
};
|
||||
fields.get(name).unwrap()
|
||||
}
|
||||
|
||||
fn first_link(value: &Value) -> &str {
|
||||
let Value::List(items) = field(value, "items") else {
|
||||
panic!("expected typed link List")
|
||||
};
|
||||
assert_eq!(items.len(), 1);
|
||||
let Value::String(path) = field(&items[0], "path") else {
|
||||
panic!("expected typed link path")
|
||||
};
|
||||
path
|
||||
}
|
||||
|
||||
async fn watchdog(future: impl std::future::Future<Output = ()>) {
|
||||
tokio::time::timeout(Duration::from_secs(20), future)
|
||||
.await
|
||||
.expect("HTTP checkout test watchdog");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_http_native_search_links_read_edit_write_create_and_close() {
|
||||
watchdog(async {
|
||||
let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_WRITE).await;
|
||||
let remote = Arc::new(fixture.open(TOKEN).await.unwrap());
|
||||
let (runtime, router) = native_runtime(remote);
|
||||
let glob = native_call(
|
||||
&runtime,
|
||||
"/checkouts/main",
|
||||
"glob",
|
||||
json!({"pattern":"**/*.txt"}),
|
||||
)
|
||||
.await;
|
||||
let file = first_link(&glob).to_owned();
|
||||
assert_eq!(file, "/checkouts/main/src/deep/a.txt");
|
||||
let grep = native_call(
|
||||
&runtime,
|
||||
"/checkouts/main/src",
|
||||
"grep",
|
||||
json!({
|
||||
"pattern":"needle", "output_mode":"files_with_matches"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(first_link(&grep), file);
|
||||
let read = native_call(
|
||||
&runtime,
|
||||
first_link(&grep),
|
||||
"read",
|
||||
json!({"offset":1,"limit":1}),
|
||||
)
|
||||
.await;
|
||||
let Value::String(content) = field(&read, "content") else {
|
||||
panic!("expected line Read text")
|
||||
};
|
||||
assert_eq!(content, " 2\tneedle needle\n");
|
||||
let edit = native_call(
|
||||
&runtime,
|
||||
&file,
|
||||
"edit",
|
||||
json!({
|
||||
"old_string":"needle", "new_string":"done", "replace_all":true
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(fixture.dir.path().join("src/deep/a.txt")).unwrap(),
|
||||
"first\ndone done\nlast\n"
|
||||
);
|
||||
let write = native_call(&runtime, &file, "write", json!({"content":"saved\n"})).await;
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(fixture.dir.path().join("src/deep/a.txt")).unwrap(),
|
||||
"saved\n"
|
||||
);
|
||||
let create = native_call(
|
||||
&runtime,
|
||||
"/checkouts/main/src",
|
||||
"create_file",
|
||||
json!({
|
||||
"path":"new/deeper.txt", "content":"created\n"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(fixture.dir.path().join("src/new/deeper.txt")).unwrap(),
|
||||
"created\n"
|
||||
);
|
||||
let created = native_call(
|
||||
&runtime,
|
||||
"/checkouts/main/src/new/deeper.txt",
|
||||
"read",
|
||||
json!({}),
|
||||
)
|
||||
.await;
|
||||
for value in [&glob, &grep, &read, &edit, &write, &create, &created] {
|
||||
fixture.public_output(&serde_json::to_string(&wip_to_json(value).unwrap()).unwrap());
|
||||
}
|
||||
let operations = fixture.state.operations.lock().unwrap();
|
||||
assert!(
|
||||
operations
|
||||
.iter()
|
||||
.any(|op| matches!(op, WorkdirSessionOperation::CheckoutSearch(r)
|
||||
if matches!(r.operation, CheckoutSearchOperation::Glob(_))))
|
||||
);
|
||||
assert!(
|
||||
operations
|
||||
.iter()
|
||||
.any(|op| matches!(op, WorkdirSessionOperation::CheckoutSearch(r)
|
||||
if matches!(r.operation, CheckoutSearchOperation::Grep(_))))
|
||||
);
|
||||
assert!(operations.iter().any(
|
||||
|op| matches!(op, WorkdirSessionOperation::CheckoutExecute(r)
|
||||
if matches!(r.operation, CheckoutOperation::Edit { .. }))
|
||||
));
|
||||
assert!(operations.iter().any(
|
||||
|op| matches!(op, WorkdirSessionOperation::CheckoutExecute(r)
|
||||
if matches!(r.operation, CheckoutOperation::Create { .. }))
|
||||
));
|
||||
drop(operations);
|
||||
assert_eq!(fixture.state.write_calls(), 1);
|
||||
router
|
||||
.detach(&WorkdirAttachmentAlias::new("main").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
fixture.assert_endpoints();
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_http_wrong_bearer_and_readonly_caps_fail_closed() {
|
||||
watchdog(async {
|
||||
let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_ONLY).await;
|
||||
let error = fixture.open("wrong-bearer").await.unwrap_err();
|
||||
assert!(matches!(error, WorkdirError::Denied(_)));
|
||||
fixture.public_output(&error.to_string());
|
||||
assert!(fixture.state.operations.lock().unwrap().is_empty());
|
||||
let remote = Arc::new(fixture.open(TOKEN).await.unwrap());
|
||||
assert_eq!(remote.capabilities(), WorkdirSessionCapabilities::READ_ONLY);
|
||||
let (runtime, router) = native_runtime(remote.clone());
|
||||
let file = "/checkouts/main/src/deep/a.txt";
|
||||
native_call(&runtime, file, "read", json!({})).await;
|
||||
let interface = observe_interface(&runtime, file).await;
|
||||
{
|
||||
let state = runtime.state.lock().unwrap();
|
||||
let observed = state.client.interface(&state.session, &interface).unwrap();
|
||||
let descriptor = observed.descriptor.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
descriptor
|
||||
.operations
|
||||
.iter()
|
||||
.map(|op| op.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["read"]
|
||||
);
|
||||
}
|
||||
let directory_interface = observe_interface(&runtime, "/checkouts/main").await;
|
||||
let before = fixture.state.execute_calls();
|
||||
for (path, interface, operation, args) in [
|
||||
(
|
||||
file,
|
||||
interface.clone(),
|
||||
"write",
|
||||
json!({"content":"forbidden"}),
|
||||
),
|
||||
(
|
||||
file,
|
||||
interface,
|
||||
"edit",
|
||||
json!({"old_string":"needle", "new_string":"forbidden"}),
|
||||
),
|
||||
(
|
||||
"/checkouts/main",
|
||||
directory_interface,
|
||||
"create_file",
|
||||
json!({"path":"forbidden.txt","content":"bad"}),
|
||||
),
|
||||
] {
|
||||
let error = runtime
|
||||
.call(
|
||||
path.into(),
|
||||
interface,
|
||||
operation.into(),
|
||||
args,
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
fixture.public_output(&error.to_string());
|
||||
}
|
||||
// Preflight may observe objects, but no rejected mutation reaches execution.
|
||||
assert_eq!(fixture.state.execute_calls(), before);
|
||||
// A caller bypassing WIP publication still cannot mutate at the provider.
|
||||
let parent = remote.checkout_observe(WorkdirPath::root()).await.unwrap();
|
||||
let error = remote
|
||||
.checkout_execute(CheckoutRequest {
|
||||
target: parent.path,
|
||||
validator: parent.validator,
|
||||
operation: CheckoutOperation::Create {
|
||||
path: WorkdirPath::new("forbidden.txt").unwrap(),
|
||||
content: b"bad".to_vec(),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
WorkdirError::Unsupported(_)
|
||||
| WorkdirError::UnsupportedOperation(_)
|
||||
| WorkdirError::ReadOnly(_)
|
||||
| WorkdirError::Denied(_)
|
||||
));
|
||||
fixture.public_output(&error.to_string());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(fixture.dir.path().join("src/deep/a.txt")).unwrap(),
|
||||
"first\nneedle needle\nlast\n"
|
||||
);
|
||||
assert!(!fixture.dir.path().join("forbidden.txt").exists());
|
||||
router
|
||||
.detach(&WorkdirAttachmentAlias::new("main").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
fixture.assert_endpoints();
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn committed_write_unknown(fault: WriteResponseFault) {
|
||||
let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_WRITE).await;
|
||||
let remote = Arc::new(fixture.open(TOKEN).await.unwrap());
|
||||
let (runtime, router) = native_runtime(remote);
|
||||
let file = "/checkouts/main/src/deep/a.txt";
|
||||
native_call(&runtime, file, "read", json!({})).await;
|
||||
let interface = observe_interface(&runtime, file).await;
|
||||
*fixture.state.write_fault.lock().unwrap() = Some(fault);
|
||||
let error = runtime
|
||||
.call(
|
||||
file.into(),
|
||||
interface,
|
||||
"write".into(),
|
||||
json!({"content":"committed\n"}),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("outcome unknown"));
|
||||
assert!(error.to_string().contains("do not retry automatically"));
|
||||
fixture.public_output(&error.to_string());
|
||||
assert_eq!(
|
||||
runtime.audit().last().unwrap().outcome,
|
||||
WipAuditOutcome::OutcomeUnknown
|
||||
);
|
||||
{
|
||||
let state = runtime.state.lock().unwrap();
|
||||
assert!(matches!(
|
||||
state
|
||||
.client
|
||||
.call_history(&state.session)
|
||||
.unwrap()
|
||||
.back()
|
||||
.unwrap()
|
||||
.outcome,
|
||||
CallOutcome::Unknown { .. }
|
||||
));
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(fixture.dir.path().join("src/deep/a.txt")).unwrap(),
|
||||
"committed\n"
|
||||
);
|
||||
assert_eq!(
|
||||
fixture.state.write_calls(),
|
||||
1,
|
||||
"committed Write must not be automatically retried"
|
||||
);
|
||||
// Explicit inspection of the effect is safe; it must not replay the mutation.
|
||||
let read = native_call(&runtime, file, "read", json!({})).await;
|
||||
fixture.public_output(&serde_json::to_string(&wip_to_json(&read).unwrap()).unwrap());
|
||||
let Value::String(content) = field(&read, "content") else {
|
||||
panic!("expected Read text")
|
||||
};
|
||||
assert!(content.contains("committed"));
|
||||
assert_eq!(fixture.state.write_calls(), 1);
|
||||
router
|
||||
.detach(&WorkdirAttachmentAlias::new("main").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
fixture.assert_endpoints();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_http_malformed_response_after_write_is_unknown_without_retry() {
|
||||
watchdog(committed_write_unknown(WriteResponseFault::Malformed)).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_http_lost_response_after_write_is_unknown_without_retry() {
|
||||
watchdog(committed_write_unknown(WriteResponseFault::LostBody)).await;
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
//! Deterministic attachment races at provider observation boundaries.
|
||||
//!
|
||||
//! These tests deliberately avoid spawned tasks, sleeps, and detach attempts
|
||||
//! inside a routed operation (which is busy and cannot be detached). Deadline
|
||||
//! tests use the provider's configurable deadline and a permanently pending
|
||||
//! observation, without Tokio's test-util clock or the production 30-second wait.
|
||||
use super::*;
|
||||
|
||||
use std::future::pending;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tempfile::TempDir;
|
||||
use workdir::{
|
||||
CheckoutRequest, CheckoutResult, CommandHandle, CommandOutput, CommandOutputRequest,
|
||||
CommandRequest, CommandStatus, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest,
|
||||
GrepResult, ListResult, LocalWorkdirSession, ReadBytesRequest, ReadBytesResult, ReadRequest,
|
||||
ReadResult, StatRequest, StatResult, Workdir, WorkdirAttachmentAlias, WorkdirError,
|
||||
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
||||
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
|
||||
};
|
||||
|
||||
type ObserveHook = Box<dyn FnOnce(&WorkdirPath) + Send>;
|
||||
|
||||
/// Shared local provider with a one-shot, synchronous observation boundary.
|
||||
/// Take the callback out of the lock before invoking it: it may call the router.
|
||||
struct HookedSession {
|
||||
local: LocalWorkdirSession,
|
||||
observe_hook: Mutex<Option<ObserveHook>>,
|
||||
stall_path: Mutex<Option<WorkdirPath>>,
|
||||
stalled_observations: AtomicUsize,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HookedSession {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("HookedSession")
|
||||
.field("local", &self.local)
|
||||
.field("stall_path", &self.stall_path)
|
||||
.field("stalled_observations", &self.stalled_observations)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl HookedSession {
|
||||
fn new(dir: &TempDir) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
local: LocalWorkdirSession::materialized(
|
||||
dir.path().to_path_buf(),
|
||||
dir.path().to_path_buf(),
|
||||
manifest::SharedScope::new(manifest::Scope::writable(dir.path()).unwrap()),
|
||||
WorkdirSessionCapabilities::READ_WRITE,
|
||||
),
|
||||
observe_hook: Mutex::new(None),
|
||||
stall_path: Mutex::new(None),
|
||||
stalled_observations: AtomicUsize::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
fn on_next_observe(&self, hook: impl FnOnce(&WorkdirPath) + Send + 'static) {
|
||||
let mut slot = self.observe_hook.lock().unwrap();
|
||||
assert!(slot.is_none(), "an observation hook is already armed");
|
||||
*slot = Some(Box::new(hook));
|
||||
}
|
||||
|
||||
fn stall_observation(&self, path: Option<WorkdirPath>) {
|
||||
*self.stall_path.lock().unwrap() = path;
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WorkdirSession for HookedSession {
|
||||
fn workdir(&self) -> &Workdir {
|
||||
self.local.workdir()
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> WorkdirSessionCapabilities {
|
||||
self.local.capabilities()
|
||||
}
|
||||
|
||||
async fn authorize_scope_path(
|
||||
&self,
|
||||
request: WorkdirScopeAuthorizationRequest,
|
||||
) -> Result<(), WorkdirError> {
|
||||
self.local.authorize_scope_path(request).await
|
||||
}
|
||||
|
||||
async fn scope_rules_overlap(
|
||||
&self,
|
||||
request: WorkdirScopeOverlapRequest,
|
||||
) -> Result<bool, WorkdirError> {
|
||||
self.local.scope_rules_overlap(request).await
|
||||
}
|
||||
|
||||
async fn checkout_observe(
|
||||
&self,
|
||||
path: WorkdirPath,
|
||||
) -> Result<CheckoutObservation, WorkdirError> {
|
||||
let hook = { self.observe_hook.lock().unwrap().take() };
|
||||
if let Some(hook) = hook {
|
||||
hook(&path);
|
||||
}
|
||||
let should_stall = {
|
||||
self.stall_path
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.is_some_and(|stall| stall.as_str() == path.as_str())
|
||||
};
|
||||
if should_stall {
|
||||
self.stalled_observations.fetch_add(1, Ordering::SeqCst);
|
||||
return pending().await;
|
||||
}
|
||||
self.local.checkout_observe(path).await
|
||||
}
|
||||
|
||||
async fn checkout_execute(
|
||||
&self,
|
||||
request: CheckoutRequest,
|
||||
) -> Result<CheckoutResult, WorkdirError> {
|
||||
self.local.checkout_execute(request).await
|
||||
}
|
||||
|
||||
async fn checkout_search(
|
||||
&self,
|
||||
request: workdir::CheckoutSearchRequest,
|
||||
) -> Result<workdir::CheckoutSearchResult, WorkdirError> {
|
||||
self.local.checkout_search(request).await
|
||||
}
|
||||
|
||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||
self.local.stat(request).await
|
||||
}
|
||||
|
||||
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError> {
|
||||
self.local.read(request).await
|
||||
}
|
||||
|
||||
async fn read_bytes(&self, request: ReadBytesRequest) -> Result<ReadBytesResult, WorkdirError> {
|
||||
self.local.read_bytes(request).await
|
||||
}
|
||||
|
||||
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
||||
self.local.write(request).await
|
||||
}
|
||||
|
||||
async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError> {
|
||||
self.local.edit(request).await
|
||||
}
|
||||
|
||||
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError> {
|
||||
self.local.list(request).await
|
||||
}
|
||||
|
||||
async fn glob(&self, request: GlobRequest) -> Result<GlobResult, WorkdirError> {
|
||||
self.local.glob(request).await
|
||||
}
|
||||
|
||||
async fn grep(&self, request: GrepRequest) -> Result<GrepResult, WorkdirError> {
|
||||
self.local.grep(request).await
|
||||
}
|
||||
|
||||
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
|
||||
self.local.start_command(request).await
|
||||
}
|
||||
|
||||
async fn command_status(&self, handle: CommandHandle) -> Result<CommandStatus, WorkdirError> {
|
||||
self.local.command_status(handle).await
|
||||
}
|
||||
|
||||
async fn command_output(
|
||||
&self,
|
||||
request: CommandOutputRequest,
|
||||
) -> Result<CommandOutput, WorkdirError> {
|
||||
self.local.command_output(request).await
|
||||
}
|
||||
|
||||
async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> {
|
||||
self.local.cancel_command(handle).await
|
||||
}
|
||||
|
||||
async fn close(&self) -> Result<(), WorkdirError> {
|
||||
self.local.close().await
|
||||
}
|
||||
}
|
||||
|
||||
fn provider(router: Arc<WorkdirSessionRouter>) -> Arc<Provider> {
|
||||
Arc::new(Provider {
|
||||
router,
|
||||
tracker: tools::Tracker::new(),
|
||||
permissions: None,
|
||||
incarnation: "checkout-race-tests".into(),
|
||||
permits: Arc::new(tokio::sync::Semaphore::new(16)),
|
||||
deadline: DEADLINE,
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_wip_file_operations_share_core_and_keep_prior_read_boundary() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("text.txt"), "旧 旧\n").unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||
HookedSession::new(&dir),
|
||||
)
|
||||
.unwrap();
|
||||
let provider = provider(router);
|
||||
let route = format!("{}/text.txt", checkout_root("main"));
|
||||
let projection = provider.node(&route).await.unwrap().unwrap();
|
||||
let invalid = BTreeMap::from([
|
||||
("old_string".into(), Value::String("旧".into())),
|
||||
("new_string".into(), Value::String("旧".into())),
|
||||
]);
|
||||
assert!(
|
||||
matches!(projection.handler.call("edit", &invalid, context()).await,
|
||||
Err(WipOperationError::Protocol(error)) if error.code == ProtocolErrorCode::InvalidArguments)
|
||||
);
|
||||
let edit = BTreeMap::from([
|
||||
("old_string".into(), Value::String("旧".into())),
|
||||
("new_string".into(), Value::String("新".into())),
|
||||
("replace_all".into(), Value::Boolean(true)),
|
||||
]);
|
||||
// Valid shared arguments do not bypass the provider's Read requirement.
|
||||
assert!(
|
||||
projection
|
||||
.handler
|
||||
.call("edit", &edit, context())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join("text.txt")).unwrap(),
|
||||
"旧 旧\n"
|
||||
);
|
||||
projection
|
||||
.handler
|
||||
.call("read", &BTreeMap::new(), context())
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("shared read failed"));
|
||||
projection
|
||||
.handler
|
||||
.call("edit", &edit, context())
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("shared edit failed after read"));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join("text.txt")).unwrap(),
|
||||
"新 新\n"
|
||||
);
|
||||
let projection = provider.node(&route).await.unwrap().unwrap();
|
||||
projection
|
||||
.handler
|
||||
.call(
|
||||
"write",
|
||||
&BTreeMap::from([("content".into(), Value::String("written\n".into()))]),
|
||||
context(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("shared write failed after read/edit"));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join("text.txt")).unwrap(),
|
||||
"written\n"
|
||||
);
|
||||
}
|
||||
|
||||
fn deadline_provider(router: Arc<WorkdirSessionRouter>) -> Arc<Provider> {
|
||||
let mut p = provider(router);
|
||||
let settings = Arc::get_mut(&mut p).unwrap();
|
||||
// Allow ample time for the tiny local search before stalling post-processing.
|
||||
// The outer test watchdog is intentionally much more generous than this.
|
||||
settings.deadline = Duration::from_millis(100);
|
||||
settings.permits = Arc::new(tokio::sync::Semaphore::new(1));
|
||||
p
|
||||
}
|
||||
|
||||
fn context() -> WipCallContext {
|
||||
WipCallContext {
|
||||
execution: Default::default(),
|
||||
security_context: "checkout-race-tests".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_validator_mismatch<T>(result: Result<T, WipOperationError>) {
|
||||
match result {
|
||||
Err(WipOperationError::Protocol(e)) => {
|
||||
assert_eq!(e.code, ProtocolErrorCode::ValidatorMismatch);
|
||||
}
|
||||
Err(_) => panic!("expected ValidatorMismatch, received a non-protocol error"),
|
||||
Ok(_) => panic!("a changed attachment snapshot must not be published"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collection_list_rejects_alias_attached_during_observation_and_retries_coherently() {
|
||||
let main_dir = TempDir::new().unwrap();
|
||||
let extra_dir = TempDir::new().unwrap();
|
||||
let main = HookedSession::new(&main_dir);
|
||||
let extra: WorkdirSessionHandle = HookedSession::new(&extra_dir);
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(WorkdirAttachmentAlias::new("main").unwrap(), main.clone())
|
||||
.unwrap();
|
||||
let p = provider(router.clone());
|
||||
let before = p.collection().object.validator;
|
||||
let hook_router = router.clone();
|
||||
main.on_next_observe(move |path| {
|
||||
assert!(path.is_root(), "collection enumeration observes the root");
|
||||
hook_router
|
||||
.attach(WorkdirAttachmentAlias::new("extra").unwrap(), extra)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let collection = Collection {
|
||||
provider: p.clone(),
|
||||
};
|
||||
assert_validator_mismatch(collection.call("list", &BTreeMap::new(), context()).await);
|
||||
assert_ne!(before, p.collection().object.validator);
|
||||
assert!(router.resolve(Some("extra")).is_ok(), "the hook must run");
|
||||
|
||||
// The callback was consumed. A fresh snapshot includes both aliases, with
|
||||
// identity metadata from exactly the router state being enumerated.
|
||||
let output = match collection.call("list", &BTreeMap::new(), context()).await {
|
||||
Ok(output) => output,
|
||||
Err(_) => panic!("the stable retry must succeed"),
|
||||
};
|
||||
let value = wip_to_json(&output.value).unwrap();
|
||||
let items = value["items"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 2);
|
||||
for alias in ["extra", "main"] {
|
||||
let item = items
|
||||
.iter()
|
||||
.find(|item| item["alias"] == json!(alias))
|
||||
.expect("each attached alias must appear in the stable snapshot");
|
||||
let selected = router.resolve(Some(alias)).unwrap();
|
||||
assert_eq!(item["alias"], json!(alias));
|
||||
assert_eq!(item["path"], json!(checkout_root(alias)));
|
||||
assert_eq!(item["slug"], json!(encode_identity(alias)));
|
||||
assert_eq!(item["generation"], json!(selected.generation));
|
||||
assert_eq!(
|
||||
item["working_directory_id"],
|
||||
json!(selected.session.workdir().id().as_str())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_enumeration_rejects_alias_attached_during_observation() {
|
||||
let main_dir = TempDir::new().unwrap();
|
||||
let extra_dir = TempDir::new().unwrap();
|
||||
let main = HookedSession::new(&main_dir);
|
||||
let extra: WorkdirSessionHandle = HookedSession::new(&extra_dir);
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(WorkdirAttachmentAlias::new("main").unwrap(), main.clone())
|
||||
.unwrap();
|
||||
let hook_router = router.clone();
|
||||
main.on_next_observe(move |_| {
|
||||
hook_router
|
||||
.attach(WorkdirAttachmentAlias::new("extra").unwrap(), extra)
|
||||
.unwrap();
|
||||
});
|
||||
let subtree = Arc::new(provider(router.clone()));
|
||||
|
||||
match subtree.children(ROOT).await {
|
||||
Err(e) => assert_eq!(e.code, ProtocolErrorCode::ValidatorMismatch),
|
||||
Ok(_) => panic!("enumeration must reject a mixed attachment snapshot"),
|
||||
}
|
||||
assert!(router.resolve(Some("extra")).is_ok(), "the hook must run");
|
||||
let mut paths = subtree.children(ROOT).await.unwrap();
|
||||
paths.sort();
|
||||
assert_eq!(paths, vec![checkout_root("extra"), checkout_root("main")]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_handler_connection_fences_detach_and_same_session_alias_reuse() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("file.txt"), "unchanged\n").unwrap();
|
||||
let session: WorkdirSessionHandle = HookedSession::new(&dir);
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
let alias = WorkdirAttachmentAlias::new("main").unwrap();
|
||||
router.attach(alias.clone(), session.clone()).unwrap();
|
||||
let selected = router.resolve(Some("main")).unwrap();
|
||||
let target = WorkdirPath::new("file.txt").unwrap();
|
||||
let observation = selected
|
||||
.session
|
||||
.checkout_observe(target.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut handler = FileHandler {
|
||||
provider: provider(router.clone()),
|
||||
alias: "main".into(),
|
||||
generation: selected.generation,
|
||||
workdir: selected.session.workdir().id().as_str().into(),
|
||||
target,
|
||||
observation,
|
||||
};
|
||||
drop(selected);
|
||||
assert!(handler.check_connection().is_ok());
|
||||
|
||||
// No routed operation is active here. Reuse the exact same provider identity
|
||||
// to ensure generation, not changed workdir ID/content, causes rejection.
|
||||
// Detach may close the local session; no subsequent filesystem operation is
|
||||
// made on it. This test only exercises the router's connection identity.
|
||||
router.detach(&alias).await.unwrap();
|
||||
assert_validator_mismatch(handler.check_connection());
|
||||
router.attach(alias, session).unwrap();
|
||||
let replacement = router.resolve(Some("main")).unwrap();
|
||||
assert_ne!(replacement.generation, handler.generation);
|
||||
assert_eq!(replacement.session.workdir().id().as_str(), handler.workdir);
|
||||
assert_validator_mismatch(handler.check_connection());
|
||||
|
||||
// Positive control: the new generation is accepted with the same identity.
|
||||
handler.generation = replacement.generation;
|
||||
assert!(handler.check_connection().is_ok());
|
||||
}
|
||||
|
||||
fn assert_deadline<T>(result: Result<T, WipOperationError>, message: &str) {
|
||||
match result {
|
||||
Err(WipOperationError::Protocol(e)) => {
|
||||
assert_eq!(e.code, ProtocolErrorCode::ResourceLimitExceeded);
|
||||
assert_eq!(e.message, message);
|
||||
}
|
||||
Err(_) => panic!("expected a protocol deadline error"),
|
||||
Ok(_) => panic!("a stalled provider observation must time out"),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_permit_released(p: &Provider) {
|
||||
assert_eq!(p.permits.available_permits(), 1);
|
||||
let permit = p
|
||||
.permits
|
||||
.try_acquire()
|
||||
.expect("the timed-out operation must release its permit");
|
||||
drop(permit);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collection_list_and_root_enumeration_bound_stalled_observation_and_release_permit() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let session = HookedSession::new(&dir);
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||
session.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
session.stall_observation(Some(WorkdirPath::new("").unwrap()));
|
||||
let p = deadline_provider(router);
|
||||
let collection = Collection {
|
||||
provider: p.clone(),
|
||||
};
|
||||
|
||||
// The outer watchdog prevents a missing production timeout from hanging the
|
||||
// suite. The exact protocol message proves the inner enumeration timed out.
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
collection.call("list", &BTreeMap::new(), context()),
|
||||
)
|
||||
.await
|
||||
.expect("Collection.list must be bounded by the provider deadline");
|
||||
assert_deadline(result, "checkout enumeration deadline");
|
||||
assert_eq!(session.stalled_observations.load(Ordering::SeqCst), 1);
|
||||
assert_permit_released(&p);
|
||||
|
||||
// Exercise the subtree entry point independently with the stall still armed.
|
||||
let subtree = Arc::new(p.clone());
|
||||
let result = tokio::time::timeout(Duration::from_secs(2), subtree.children(ROOT))
|
||||
.await
|
||||
.expect("root enumeration must be bounded by the provider deadline")
|
||||
.map_err(WipOperationError::Protocol);
|
||||
assert_deadline(result, "checkout enumeration deadline");
|
||||
assert_eq!(session.stalled_observations.load(Ordering::SeqCst), 2);
|
||||
assert_permit_released(&p);
|
||||
|
||||
// Removing the stall makes the same provider usable immediately, without
|
||||
// resetting its semaphore or waiting for any background task to finish.
|
||||
session.stall_observation(None);
|
||||
let paths = tokio::time::timeout(Duration::from_secs(2), subtree.children(ROOT))
|
||||
.await
|
||||
.expect("enumeration must remain usable after cancellation")
|
||||
.unwrap();
|
||||
assert_eq!(paths, vec![checkout_root("main")]);
|
||||
assert_permit_released(&p);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_post_search_link_observation_is_deadline_bounded_and_releases_permit() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("a.txt"), "needle\n").unwrap();
|
||||
let session = HookedSession::new(&dir);
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||
session.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let p = deadline_provider(router);
|
||||
let root = checkout_root("main");
|
||||
let projection = p.node(&root).await.unwrap().unwrap();
|
||||
|
||||
// Directory observations and the normal local grep/checkout execution can
|
||||
// complete. Only publishing the typed search result path gets stuck.
|
||||
session.stall_observation(Some(WorkdirPath::new("a.txt").unwrap()));
|
||||
let arguments = BTreeMap::from([
|
||||
("pattern".into(), Value::String("needle".into())),
|
||||
(
|
||||
"output_mode".into(),
|
||||
Value::String("files_with_matches".into()),
|
||||
),
|
||||
]);
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
projection.handler.call("grep", &arguments, context()),
|
||||
)
|
||||
.await
|
||||
.expect("post-search link observations must share the operation deadline");
|
||||
assert_deadline(result, "checkout result deadline");
|
||||
assert_eq!(session.stalled_observations.load(Ordering::SeqCst), 1);
|
||||
assert_permit_released(&p);
|
||||
|
||||
// A successful retry both checks cancellation cleanup and proves that the
|
||||
// delegated search produces the typed path consumed by post-processing.
|
||||
session.stall_observation(None);
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
projection.handler.call("grep", &arguments, context()),
|
||||
)
|
||||
.await
|
||||
.expect("grep must remain usable after cancelling result publication");
|
||||
let output = match result {
|
||||
Ok(output) => output,
|
||||
Err(_) => panic!("grep must succeed once the link observation is unstalled"),
|
||||
};
|
||||
let value = wip_to_json(&output.value).unwrap();
|
||||
assert_eq!(value["items"], json!([{"path": format!("{root}/a.txt")}]));
|
||||
assert_permit_released(&p);
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
//! Exercises native checkout interactions through the real WIP Client/HTTP codec.
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
use workdir::{
|
||||
LocalWorkdirSession, WorkdirAttachmentAlias, WorkdirSessionCapabilities, WorkdirSessionHandle,
|
||||
WorkdirSessionRouter,
|
||||
};
|
||||
|
||||
fn session(dir: &TempDir, read_only: bool) -> WorkdirSessionHandle {
|
||||
let caps = if read_only {
|
||||
WorkdirSessionCapabilities::READ_ONLY
|
||||
} else {
|
||||
WorkdirSessionCapabilities::READ_WRITE
|
||||
};
|
||||
Arc::new(LocalWorkdirSession::materialized(
|
||||
dir.path().to_path_buf(),
|
||||
dir.path().to_path_buf(),
|
||||
manifest::SharedScope::new(manifest::Scope::writable(dir.path()).unwrap()),
|
||||
caps,
|
||||
))
|
||||
}
|
||||
fn runtime(
|
||||
router: Arc<WorkdirSessionRouter>,
|
||||
permissions: Option<ToolPermissionConfig>,
|
||||
) -> WipRuntime {
|
||||
let mut registry = WipMountRegistry::new();
|
||||
crate::checkout::mount_checkouts(&mut registry, router, tools::Tracker::new(), permissions)
|
||||
.unwrap();
|
||||
WipRuntime::new(
|
||||
WipHost::new(registry),
|
||||
SecurityContext::new("checkout-test-worker"),
|
||||
0,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
async fn interface(r: &WipRuntime, path: &str) -> String {
|
||||
r.discover(path.into(), 0, true).await.unwrap();
|
||||
let p = r.host.projection_live(path).await.unwrap().unwrap();
|
||||
r.inspect(p.interface.clone(), true).await.unwrap();
|
||||
p.interface
|
||||
}
|
||||
async fn call(
|
||||
r: &WipRuntime,
|
||||
path: &str,
|
||||
operation: &str,
|
||||
args: Json,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let i = interface(r, path).await;
|
||||
r.call(path.into(), i, operation.into(), args, Default::default())
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_wip_search_preserves_root_and_nested_ignore_rules_and_links() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir(dir.path().join(".git")).unwrap();
|
||||
std::fs::create_dir(dir.path().join("nested")).unwrap();
|
||||
for (name, content) in [
|
||||
(".ignore", "root-denied.txt\npriority.txt\n"),
|
||||
(".gitignore", "git-denied.txt\n"),
|
||||
("nested/.gitignore", "!priority.txt\nnested-denied.txt\n"),
|
||||
] {
|
||||
std::fs::write(dir.path().join(name), content).unwrap();
|
||||
}
|
||||
for name in [
|
||||
"visible.txt",
|
||||
"root-denied.txt",
|
||||
"git-denied.txt",
|
||||
"nested/visible.txt",
|
||||
"nested/root-denied.txt",
|
||||
"nested/git-denied.txt",
|
||||
"nested/priority.txt",
|
||||
"nested/nested-denied.txt",
|
||||
] {
|
||||
std::fs::write(dir.path().join(name), "needle fixture\n").unwrap();
|
||||
}
|
||||
let source = session(&dir, false);
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(WorkdirAttachmentAlias::new("main").unwrap(), source.clone())
|
||||
.unwrap();
|
||||
let r = runtime(router, None);
|
||||
for base in ["", "nested"] {
|
||||
let ordinary = source
|
||||
.glob(workdir::GlobRequest {
|
||||
path: workdir::WorkdirPath::new(base).unwrap(),
|
||||
pattern: "**/*.txt".into(),
|
||||
limit: 100,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let expected = ordinary
|
||||
.paths
|
||||
.iter()
|
||||
.map(|path| format!("/checkouts/main/{}", path.as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(expected.len(), if base.is_empty() { 2 } else { 1 });
|
||||
let route = if base.is_empty() {
|
||||
"/checkouts/main"
|
||||
} else {
|
||||
"/checkouts/main/nested"
|
||||
};
|
||||
for (operation, args) in [
|
||||
("glob", json!({"pattern":"**/*.txt"})),
|
||||
(
|
||||
"grep",
|
||||
json!({"pattern":"needle","output_mode":"files_with_matches"}),
|
||||
),
|
||||
] {
|
||||
let output = call(&r, route, operation, args).await.unwrap();
|
||||
let result: Json = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||
let mut paths = result["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|item| item["path"].as_str().unwrap().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
// Grep retains traversal order; Glob sorts its result paths.
|
||||
paths.sort();
|
||||
assert_eq!(paths, expected, "{operation} at {route}");
|
||||
for path in paths {
|
||||
assert!(r.host.projection_live(&path).await.unwrap().is_some());
|
||||
}
|
||||
assert!(!output.content.unwrap().contains("denied.txt"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_wip_native_roundtrip_search_read_edit_write_create() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("src/deep")).unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("src/deep/a.txt"),
|
||||
"first\nneedle needle\nlast\n",
|
||||
)
|
||||
.unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||
session(&dir, false),
|
||||
)
|
||||
.unwrap();
|
||||
let r = runtime(router, None);
|
||||
let root = r.host.observe_live("/", 2).await.unwrap();
|
||||
assert_eq!(root.children.unwrap()[0].object.name, "checkouts");
|
||||
let file = "/checkouts/main/src/deep/a.txt";
|
||||
let glob = call(&r, "/checkouts/main", "glob", json!({"pattern":"**/*.txt"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(glob.content.unwrap().contains(file));
|
||||
let grep = call(
|
||||
&r,
|
||||
"/checkouts/main/src",
|
||||
"grep",
|
||||
json!({"pattern":"needle","output_mode":"files_with_matches"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let grep_result: Json = serde_json::from_str(grep.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(grep_result["items"][0]["path"], file);
|
||||
let followed = grep_result["items"][0]["path"].as_str().unwrap();
|
||||
assert_eq!(followed, file);
|
||||
assert!(
|
||||
call(
|
||||
&r,
|
||||
file,
|
||||
"edit",
|
||||
json!({"old_string":"needle","new_string":"done"})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let read = call(&r, file, "read", json!({"offset":1,"limit":1}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(read.content.unwrap().contains("needle needle"));
|
||||
assert!(
|
||||
call(
|
||||
&r,
|
||||
file,
|
||||
"edit",
|
||||
json!({"old_string":"needle","new_string":"done"})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
call(
|
||||
&r,
|
||||
file,
|
||||
"edit",
|
||||
json!({"old_string":"needle","new_string":"done","replace_all":true}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join("src/deep/a.txt")).unwrap(),
|
||||
"first\ndone done\nlast\n"
|
||||
);
|
||||
call(&r, file, "write", json!({"content":"saved\n"}))
|
||||
.await
|
||||
.unwrap();
|
||||
let reread = call(&r, file, "read", json!({})).await.unwrap();
|
||||
assert!(reread.content.unwrap().contains("saved"));
|
||||
call(
|
||||
&r,
|
||||
"/checkouts/main/src",
|
||||
"create_file",
|
||||
json!({"path":"new/deeper.txt","content":"created\n"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join("src/new/deeper.txt")).unwrap(),
|
||||
"created\n"
|
||||
);
|
||||
assert!(
|
||||
call(
|
||||
&r,
|
||||
"/checkouts/main/src",
|
||||
"create_file",
|
||||
json!({"path":"new/deeper.txt","content":"overwrite"})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
call(&r, "/checkouts/main/src/new/deeper.txt", "read", json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_wip_multiple_aliases_read_only_and_escape_rejection() {
|
||||
let a = TempDir::new().unwrap();
|
||||
let b = TempDir::new().unwrap();
|
||||
std::fs::write(a.path().join("a.txt"), "A").unwrap();
|
||||
std::fs::write(b.path().join("b.txt"), "B").unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("a").unwrap(),
|
||||
session(&a, false),
|
||||
)
|
||||
.unwrap();
|
||||
router
|
||||
.attach(WorkdirAttachmentAlias::new("b").unwrap(), session(&b, true))
|
||||
.unwrap();
|
||||
let r = runtime(router, None);
|
||||
let observed = r.host.observe_live("/checkouts", 2).await.unwrap();
|
||||
assert_eq!(observed.children.unwrap().len(), 2);
|
||||
let bfile = r
|
||||
.host
|
||||
.projection_live("/checkouts/b/b.txt")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
bfile
|
||||
.descriptor
|
||||
.operations
|
||||
.iter()
|
||||
.map(|o| o.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["read"]
|
||||
);
|
||||
assert!(
|
||||
r.host
|
||||
.projection_live("/checkouts/b/a.txt")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
r.host
|
||||
.projection_live("/checkouts/a/../b/b.txt")
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
call(
|
||||
&r,
|
||||
"/checkouts/a",
|
||||
"glob",
|
||||
json!({"pattern":"*","path":"../b"})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
call(
|
||||
&r,
|
||||
"/checkouts/a",
|
||||
"create_file",
|
||||
json!({"path":"/outside.txt","content":"bad"})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
call(
|
||||
&r,
|
||||
"/checkouts/a/a.txt",
|
||||
"read",
|
||||
json!({"target_workdir":"b"})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
call(
|
||||
&r,
|
||||
"/checkouts/a/a.txt",
|
||||
"read",
|
||||
json!({"file_path":"b.txt"})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_wip_detach_alias_reuse_and_restore_fence_old_references() {
|
||||
let a = TempDir::new().unwrap();
|
||||
let b = TempDir::new().unwrap();
|
||||
std::fs::write(a.path().join("file.txt"), "same").unwrap();
|
||||
std::fs::write(b.path().join("file.txt"), "same").unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
let alias = WorkdirAttachmentAlias::new("main").unwrap();
|
||||
router.attach(alias.clone(), session(&a, false)).unwrap();
|
||||
let r = runtime(router.clone(), None);
|
||||
let path = "/checkouts/main/file.txt";
|
||||
call(&r, path, "read", json!({})).await.unwrap();
|
||||
let old = r.host.projection_live(path).await.unwrap().unwrap();
|
||||
router.detach(&alias).await.unwrap();
|
||||
assert!(r.host.projection_live(path).await.unwrap().is_none());
|
||||
assert!(r.host.fetch_interface_live(&old.interface).await.is_err());
|
||||
router.attach(alias, session(&b, false)).unwrap();
|
||||
let new = r.host.projection_live(path).await.unwrap().unwrap();
|
||||
assert_ne!(old.interface, new.interface);
|
||||
assert_ne!(old.object.validator, new.object.validator);
|
||||
assert!(r.host.fetch_interface_live(&old.interface).await.is_err());
|
||||
assert!(
|
||||
call(
|
||||
&r,
|
||||
path,
|
||||
"write",
|
||||
json!({"content":"must read new attachment"})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let restored = runtime(router, None);
|
||||
let next = restored.host.projection_live(path).await.unwrap().unwrap();
|
||||
assert_ne!(new.interface, next.interface);
|
||||
assert_ne!(new.object.validator, next.object.validator);
|
||||
assert!(
|
||||
restored
|
||||
.host
|
||||
.fetch_interface_live(&new.interface)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(b.path().join("file.txt")).unwrap(),
|
||||
"same"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_wip_provider_validator_rejects_same_content_inode_substitution() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("file.txt"), "same").unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||
session(&dir, false),
|
||||
)
|
||||
.unwrap();
|
||||
let r = runtime(router, None);
|
||||
let path = "/checkouts/main/file.txt";
|
||||
call(&r, path, "read", json!({})).await.unwrap();
|
||||
let p = r.host.projection_live(path).await.unwrap().unwrap();
|
||||
std::fs::write(dir.path().join("replacement"), "same").unwrap();
|
||||
std::fs::rename(dir.path().join("replacement"), dir.path().join("file.txt")).unwrap();
|
||||
// Call the captured handler directly: provider must reject even if Host validation was earlier.
|
||||
let out = p
|
||||
.handler
|
||||
.call(
|
||||
"write",
|
||||
&BTreeMap::from([("content".into(), Value::String("bad".into()))]),
|
||||
WipCallContext {
|
||||
execution: Default::default(),
|
||||
security_context: "checkout-test-worker".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(
|
||||
out,
|
||||
Err(WipOperationError::Protocol(ProtocolError {
|
||||
code: ProtocolErrorCode::ValidatorMismatch,
|
||||
..
|
||||
}))
|
||||
));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join("file.txt")).unwrap(),
|
||||
"same"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_wip_policy_is_not_feature_authority_and_compatibility_is_replaced() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("file.txt"), "secret").unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||
session(&dir, false),
|
||||
)
|
||||
.unwrap();
|
||||
let permissions = ToolPermissionConfig {
|
||||
default_action: ToolPermissionAction::Deny,
|
||||
rules: Vec::new(),
|
||||
};
|
||||
let r = runtime(router.clone(), Some(permissions));
|
||||
assert!(
|
||||
r.host
|
||||
.projection_live("/checkouts/main/file.txt")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
r.host
|
||||
.observe_live("/checkouts", 1)
|
||||
.await
|
||||
.unwrap()
|
||||
.children
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let mut registry = WipMountRegistry::new();
|
||||
crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None)
|
||||
.unwrap();
|
||||
for def in tools::routed_builtin_tools(router, tools::Tracker::new(), dir.path().join("bash")) {
|
||||
let (meta, tool) = def();
|
||||
let p = compatibility_projection(meta, tool, None).unwrap();
|
||||
registry.mount(p).unwrap();
|
||||
}
|
||||
for tool in ["Read", "Edit", "Write", "Glob", "Grep"] {
|
||||
assert!(!registry.routes().any(|p| p == format!("/tools/{tool}")));
|
||||
}
|
||||
assert!(registry.routes().any(|p| p == "/tools/Bash"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_wip_bounded_observation_and_deep_subtree_collision() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||
session(&dir, false),
|
||||
)
|
||||
.unwrap();
|
||||
let mut registry = WipMountRegistry::new();
|
||||
crate::checkout::mount_checkouts(&mut registry, router, tools::Tracker::new(), None).unwrap();
|
||||
let placeholder = registry.mounts["/checkouts"].resolved();
|
||||
let mut collision = placeholder.clone();
|
||||
collision.route = "/checkouts/main/a".into();
|
||||
collision.object.name = "a".into();
|
||||
assert!(matches!(
|
||||
registry.mount(collision),
|
||||
Err(WipMountError::RouteCollision { .. })
|
||||
));
|
||||
let host = WipHost::new(registry);
|
||||
assert_eq!(
|
||||
host.observe_live("/checkouts", 33).await.unwrap_err().code,
|
||||
ProtocolErrorCode::ResourceLimitExceeded
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_wip_post_operation_validators_support_cached_chains() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("file.txt"), "old\n").unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||
session(&dir, false),
|
||||
)
|
||||
.unwrap();
|
||||
let r = runtime(router, None);
|
||||
let path = "/checkouts/main/file.txt";
|
||||
let i = interface(&r, path).await;
|
||||
// No rediscovery or Interface refresh between any of these operations.
|
||||
for (op, args) in [
|
||||
("read", json!({})),
|
||||
("edit", json!({"old_string":"old","new_string":"edited"})),
|
||||
("write", json!({"content":"saved\n"})),
|
||||
("read", json!({})),
|
||||
] {
|
||||
r.call(path.into(), i.clone(), op.into(), args, Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join("file.txt")).unwrap(),
|
||||
"saved\n"
|
||||
);
|
||||
let parent = "/checkouts/main";
|
||||
let i = interface(&r, parent).await;
|
||||
for name in ["one.txt", "nested/two.txt"] {
|
||||
r.call(
|
||||
parent.into(),
|
||||
i.clone(),
|
||||
"create_file".into(),
|
||||
json!({"path":name,"content":"new"}),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join("nested/two.txt")).unwrap(),
|
||||
"new"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_wip_scoped_child_search_and_revoke_cannot_expose_siblings() {
|
||||
use workdir::{
|
||||
WorkdirPath, WorkdirToolBroker, WorkdirToolScope, WorkdirToolScopePermission as Permission,
|
||||
WorkdirToolScopeRule,
|
||||
};
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir(dir.path().join("child")).unwrap();
|
||||
std::fs::write(dir.path().join("child/a.txt"), "visible needle").unwrap();
|
||||
std::fs::write(dir.path().join("private-sibling-name.txt"), "hidden needle").unwrap();
|
||||
let broker = Arc::new(WorkdirToolBroker::new(session(&dir, false)));
|
||||
let lease = broker
|
||||
.scope(WorkdirToolScope {
|
||||
rules: vec![WorkdirToolScopeRule {
|
||||
target: WorkdirPath::new("child").unwrap(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
cwd: WorkdirPath::new("child").unwrap(),
|
||||
command: false,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let parent_router = Arc::new(WorkdirSessionRouter::new());
|
||||
parent_router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("parent").unwrap(),
|
||||
broker.tool_session(),
|
||||
)
|
||||
.unwrap();
|
||||
let parent = runtime(parent_router, None);
|
||||
let parent_file = parent
|
||||
.host
|
||||
.projection_live("/checkouts/parent/child/a.txt")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!parent_file
|
||||
.descriptor
|
||||
.operations
|
||||
.iter()
|
||||
.any(|op| op.name == "write" || op.name == "edit")
|
||||
);
|
||||
let child_router = Arc::new(WorkdirSessionRouter::new());
|
||||
child_router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("child").unwrap(),
|
||||
lease.tool_session(),
|
||||
)
|
||||
.unwrap();
|
||||
let child = runtime(child_router, None);
|
||||
let observed = child
|
||||
.host
|
||||
.observe_live("/checkouts/child", 2)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(observed.children.unwrap().len(), 1);
|
||||
for (op, args) in [
|
||||
("glob", json!({"pattern":"**/*"})),
|
||||
(
|
||||
"grep",
|
||||
json!({"pattern":"needle","output_mode":"files_with_matches"}),
|
||||
),
|
||||
] {
|
||||
let output = call(&child, "/checkouts/child", op, args)
|
||||
.await
|
||||
.unwrap()
|
||||
.content
|
||||
.unwrap();
|
||||
assert!(output.contains("/checkouts/child/a.txt"));
|
||||
assert!(!output.contains("private-sibling-name"));
|
||||
assert!(!output.contains("hidden needle"));
|
||||
}
|
||||
assert!(
|
||||
child
|
||||
.host
|
||||
.projection_live("/checkouts/child/private-sibling-name.txt")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let file = "/checkouts/child/a.txt";
|
||||
let i = interface(&child, file).await;
|
||||
child
|
||||
.call(
|
||||
file.into(),
|
||||
i.clone(),
|
||||
"read".into(),
|
||||
json!({}),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
child
|
||||
.call(
|
||||
file.into(),
|
||||
i.clone(),
|
||||
"write".into(),
|
||||
json!({"content":"changed"}),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
lease.close().await.unwrap();
|
||||
assert!(child.host.projection_live(file).await.unwrap().is_none());
|
||||
assert!(
|
||||
child
|
||||
.call(
|
||||
file.into(),
|
||||
i,
|
||||
"write".into(),
|
||||
json!({"content":"after revoke"}),
|
||||
Default::default()
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join("child/a.txt")).unwrap(),
|
||||
"changed"
|
||||
);
|
||||
}
|
||||
+267
-41
@@ -64,6 +64,20 @@ pub struct WorkerHandle {
|
||||
}
|
||||
|
||||
impl WorkerHandle {
|
||||
/// Transport closure belongs to this Controller endpoint, not a WorkerRef.
|
||||
pub fn protocol_is_closed(&self) -> bool {
|
||||
self.method_tx.is_closed()
|
||||
}
|
||||
|
||||
pub async fn protocol_closed(&self) {
|
||||
self.method_tx.closed().await;
|
||||
}
|
||||
|
||||
/// Compare actual endpoints when an execution-bound transport is dispatched.
|
||||
pub fn same_controller(&self, other: &Self) -> bool {
|
||||
self.method_tx.same_channel(&other.method_tx)
|
||||
}
|
||||
|
||||
pub async fn send(&self, method: Method) -> Result<(), mpsc::error::SendError<Method>> {
|
||||
// Reject known-busy sends before channel dispatch, including maintenance
|
||||
// that cannot receive methods until it finishes. The controller checks
|
||||
@@ -184,6 +198,7 @@ impl WorkerHandle {
|
||||
&self,
|
||||
kind: protocol::CompletionKind,
|
||||
prefix: &str,
|
||||
context: Option<&protocol::CompletionContext>,
|
||||
) -> Vec<protocol::CompletionEntry> {
|
||||
match kind {
|
||||
protocol::CompletionKind::File => {
|
||||
@@ -196,9 +211,62 @@ impl WorkerHandle {
|
||||
.map(|candidate| protocol::CompletionEntry {
|
||||
value: candidate.path,
|
||||
is_dir: candidate.is_dir,
|
||||
description: None,
|
||||
usage: None,
|
||||
invocation: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
protocol::CompletionKind::Feature => self
|
||||
.shared_state
|
||||
.feature_invocations()
|
||||
.feature_completions(prefix),
|
||||
protocol::CompletionKind::FeatureArgument => {
|
||||
let Some(context) = context else {
|
||||
return Vec::new();
|
||||
};
|
||||
let registry = self.shared_state.feature_invocations();
|
||||
let Some(argument) = context.argument.as_deref() else {
|
||||
return registry.argument_name_completions(&context.invocation, prefix);
|
||||
};
|
||||
let worker_file = registry
|
||||
.descriptor(&context.invocation)
|
||||
.and_then(|descriptor| {
|
||||
descriptor
|
||||
.arguments
|
||||
.iter()
|
||||
.find(|candidate| candidate.name == argument)
|
||||
})
|
||||
.is_some_and(|descriptor| {
|
||||
matches!(
|
||||
descriptor.completion,
|
||||
protocol::InvocationCompletion::WorkerFile
|
||||
)
|
||||
});
|
||||
if worker_file {
|
||||
let Some(view) = self.shared_state.fs_view() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let candidates = view.list_file_completions(prefix).await;
|
||||
if registry.descriptor(&context.invocation).is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
return crate::feature::invocation::bounded_completion_entries(
|
||||
candidates
|
||||
.into_iter()
|
||||
.map(|candidate| protocol::CompletionEntry {
|
||||
value: candidate.path,
|
||||
is_dir: candidate.is_dir,
|
||||
description: None,
|
||||
usage: None,
|
||||
invocation: None,
|
||||
}),
|
||||
);
|
||||
}
|
||||
registry
|
||||
.argument_completions(&context.invocation, argument, prefix)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,11 +503,29 @@ fn stage_pending_notifications<St: Store + Clone>(
|
||||
count
|
||||
}
|
||||
|
||||
/// The durable input append is the activation boundary. Clear live state in
|
||||
/// the same poll, before invocation preparation can checkpoint it again; the
|
||||
/// oneshot only publishes the already-completed transition to the Controller.
|
||||
pub(crate) fn input_committed_hook<St: Store + Clone>(
|
||||
pending: crate::worker::PendingSubmissionHandle<St>,
|
||||
submission_id: String,
|
||||
sender: oneshot::Sender<()>,
|
||||
) -> impl FnOnce() {
|
||||
move || {
|
||||
pending.finish_activation(&submission_id);
|
||||
let _ = sender.send(());
|
||||
}
|
||||
}
|
||||
|
||||
fn notification_coalesce_remaining<St: Store + Clone>(
|
||||
pending_submissions: &crate::worker::PendingSubmissionHandle<St>,
|
||||
notify_buffer: &NotifyBuffer,
|
||||
delay: Duration,
|
||||
can_schedule_run: bool,
|
||||
) -> Option<Duration> {
|
||||
if !can_schedule_run {
|
||||
return None;
|
||||
}
|
||||
let Some(accepted_at_ms) = pending_submissions.oldest_pending_notification_accepted_at_ms()
|
||||
else {
|
||||
return notify_buffer
|
||||
@@ -801,9 +887,6 @@ impl WorkerController {
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let command_observer = fs_for_view
|
||||
.as_ref()
|
||||
.and_then(|session| wire_workdir_command_events(session, &in_flight));
|
||||
|
||||
// Intake role Workers self-terminate only after a successful
|
||||
// TicketIntakeReady turn has fully settled back to Idle. The request
|
||||
@@ -846,6 +929,7 @@ impl WorkerController {
|
||||
manifest_toml.clone(),
|
||||
greeting,
|
||||
));
|
||||
shared_state.set_feature_invocations(worker.feature_invocations());
|
||||
let usage_state = shared_state.clone();
|
||||
let usage_events = working_event_tx.clone();
|
||||
worker.engine_mut().on_usage(move |event| {
|
||||
@@ -856,8 +940,8 @@ impl WorkerController {
|
||||
protocol::ContextTokenSource::Measured,
|
||||
);
|
||||
});
|
||||
if let Some(fs_for_view) = fs_for_view {
|
||||
shared_state.set_fs_view(crate::fs_view::WorkerFsView::new(fs_for_view));
|
||||
if let Some(fs_for_view) = fs_for_view.as_ref() {
|
||||
shared_state.set_fs_view(crate::fs_view::WorkerFsView::new(fs_for_view.clone()));
|
||||
}
|
||||
runtime_dir.write_manifest(&manifest_toml).await?;
|
||||
runtime_dir.write_status(&shared_state).await?;
|
||||
@@ -879,8 +963,23 @@ impl WorkerController {
|
||||
pending_activations,
|
||||
};
|
||||
|
||||
// Do not start an observer before fallible head/recovery/runtime-file
|
||||
// initialization. The only remaining fallible step is socket startup,
|
||||
// whose error path must abort AND join this task before returning.
|
||||
let command_observer = fs_for_view
|
||||
.as_ref()
|
||||
.and_then(|session| wire_workdir_command_events(session, &in_flight));
|
||||
let socket_server = match transport {
|
||||
WorkerControllerTransport::UnixSocket => Some(SocketServer::start(&handle).await?),
|
||||
WorkerControllerTransport::UnixSocket => match SocketServer::start(&handle).await {
|
||||
Ok(server) => Some(server),
|
||||
Err(error) => {
|
||||
if let Some(observer) = command_observer {
|
||||
observer.abort();
|
||||
let _ = observer.await;
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
},
|
||||
WorkerControllerTransport::InProcess => None,
|
||||
};
|
||||
|
||||
@@ -1277,10 +1376,12 @@ where
|
||||
),
|
||||
None => durable_parent_notifications,
|
||||
};
|
||||
let backend_job_profile = worker.manifest().engine.instruction == "internal.backend_job_system";
|
||||
let backend_job = worker.backend_job().cloned();
|
||||
// Bounded Jobs complete their lifecycle synchronously, never by post-run background work.
|
||||
let lifecycle_enabled = worker.manifest_lifecycle_features_enabled() && backend_job.is_none();
|
||||
let prompts = worker.prompts().clone();
|
||||
let tracker = tools::Tracker::new();
|
||||
if !backend_job_profile {
|
||||
{
|
||||
let paste_store = worker.store().clone();
|
||||
let paste_session_id = worker.session_id();
|
||||
worker
|
||||
@@ -1327,7 +1428,9 @@ where
|
||||
let worker_enabled = feature_config.worker.enabled;
|
||||
let sub_worker_enabled = feature_config.sub_worker.enabled;
|
||||
let mut feature_registry = FeatureRegistryBuilder::new();
|
||||
if backend_job_profile {
|
||||
feature_registry
|
||||
.add_module(crate::feature::builtin::chat_invocation::AttachmentInvocationFeature);
|
||||
if let Some(binding) = backend_job.as_ref() {
|
||||
let workspace_client = worker.workspace_client_handle();
|
||||
if !workspace_client.is_available() || workspace_client.workspace_id().is_none() {
|
||||
return Err(std::io::Error::new(
|
||||
@@ -1335,11 +1438,18 @@ where
|
||||
"Backend Job result capability requires Backend Workspace API authority",
|
||||
));
|
||||
}
|
||||
feature_registry.add_module(
|
||||
let mut result_feature =
|
||||
crate::feature::builtin::backend_job_result::BackendJobResultFeature::new(
|
||||
workspace_client,
|
||||
),
|
||||
workspace_client.clone(),
|
||||
);
|
||||
if binding.subjektiv_consolidation {
|
||||
let surface = crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature::for_job(
|
||||
workspace_client, spawner_manifest.clone(), worker.llm_client_handle(),
|
||||
prompts.clone(), spawner_workspace_context.clone(),
|
||||
)?;
|
||||
result_feature = result_feature.with_surface(surface);
|
||||
}
|
||||
feature_registry.add_module(result_feature);
|
||||
}
|
||||
let memory_profile = &worker.manifest().feature.memory.profile;
|
||||
let subjektiv_profile = &worker.manifest().feature.subjektiv.profile;
|
||||
@@ -1352,7 +1462,11 @@ where
|
||||
let mut feature_prompt_contribution = memory_install_plan.as_ref().map(|plan| {
|
||||
(
|
||||
plan.resident_summary_source.clone(),
|
||||
plan.system_prompt_override.clone(),
|
||||
if backend_job.is_none() {
|
||||
plan.system_prompt_override.clone()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
});
|
||||
let memory_lifecycle_config = worker.manifest().feature.memory.clone();
|
||||
@@ -1366,7 +1480,9 @@ where
|
||||
worker.prompts().load_full(),
|
||||
)?;
|
||||
if let Some(plan) = subjektiv_consolidation_plan {
|
||||
if backend_job.is_none() {
|
||||
feature_prompt_contribution = Some((None, Some(plan.system_prompt_override)));
|
||||
}
|
||||
feature_registry.add_module(plan.module);
|
||||
}
|
||||
let ordinary_subjektiv_features_enabled =
|
||||
@@ -1375,6 +1491,7 @@ where
|
||||
crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source(
|
||||
worker.manifest(),
|
||||
worker.workspace_client_handle(),
|
||||
worker.prompts(),
|
||||
)?
|
||||
{
|
||||
let workspace_id = worker
|
||||
@@ -1384,7 +1501,7 @@ where
|
||||
.to_string();
|
||||
if let Some(refresh_feature) =
|
||||
crate::feature::builtin::memory::SubjektivResidentRestoreRefreshFeature::for_host(
|
||||
worker.manifest_lifecycle_features_enabled(),
|
||||
lifecycle_enabled,
|
||||
Arc::clone(&resident_summary_source),
|
||||
worker.prompts(),
|
||||
workspace_id,
|
||||
@@ -1415,7 +1532,7 @@ where
|
||||
}
|
||||
if let Some(memory_lifecycle) =
|
||||
crate::feature::builtin::memory_lifecycle::MemoryLifecycleFeature::from_resolved_config(
|
||||
worker.manifest_lifecycle_features_enabled(),
|
||||
lifecycle_enabled,
|
||||
memory_lifecycle_config,
|
||||
worker.committed_session_capture_handle(),
|
||||
worker.session_extension_handle(),
|
||||
@@ -1429,8 +1546,8 @@ where
|
||||
{
|
||||
feature_registry.add_module(memory_lifecycle);
|
||||
}
|
||||
if let Some(surface_lifecycle) = crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature::from_manifest(
|
||||
worker.manifest_lifecycle_features_enabled(),
|
||||
if backend_job.is_none() && let Some(surface_lifecycle) = crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature::from_manifest(
|
||||
lifecycle_enabled,
|
||||
worker.workspace_client_handle(),
|
||||
spawner_manifest.clone(),
|
||||
worker.llm_client_handle(),
|
||||
@@ -1442,7 +1559,7 @@ where
|
||||
if ordinary_subjektiv_features_enabled
|
||||
&& let Some(subjektiv_lifecycle) =
|
||||
crate::feature::builtin::memory_lifecycle::SubjektivLifecycleFeature::from_resolved_config(
|
||||
worker.manifest_lifecycle_features_enabled(),
|
||||
lifecycle_enabled,
|
||||
worker.manifest().feature.subjektiv.clone(),
|
||||
worker.committed_session_capture_handle(),
|
||||
worker.session_extension_handle(),
|
||||
@@ -1504,7 +1621,22 @@ where
|
||||
),
|
||||
);
|
||||
}
|
||||
if feature_config.manage_workdir.enabled {
|
||||
let wip_mode = worker.manifest().worker.mode == manifest::WorkerMode::Wip;
|
||||
let workspace_config_feature =
|
||||
crate::feature::builtin::workspace_config::WorkspaceConfigFeature::configured(
|
||||
worker.workspace_client_handle(),
|
||||
feature_config.workspace_config.enabled,
|
||||
wip_mode,
|
||||
);
|
||||
if let Some(module) = &workspace_config_feature {
|
||||
feature_registry.add_module(module.clone());
|
||||
}
|
||||
let mut workdir_wip_feature = None;
|
||||
if feature_config.manage_workdir.enabled
|
||||
|| (wip_mode
|
||||
&& feature_config.workdir_catalog.enabled
|
||||
&& worker.workspace_client_handle().is_available())
|
||||
{
|
||||
// Workdir lifecycle is Workspace control-plane authority. The Worker
|
||||
// receives only the injected WorkspaceClient and never Runtime URLs,
|
||||
// repository paths, materializer handles, or cleanup sessions.
|
||||
@@ -1520,7 +1652,7 @@ where
|
||||
}
|
||||
let shutdown_registry = spawned_registry.clone();
|
||||
let reopen_registry = spawned_registry.clone();
|
||||
feature_registry.add_module(
|
||||
let module =
|
||||
crate::feature::builtin::manage_workdir::ManageWorkdirFeature::with_child_lifecycle(
|
||||
workspace_client,
|
||||
worker.workdir_sessions(),
|
||||
@@ -1529,8 +1661,11 @@ where
|
||||
Box::pin(async move { child_registry.shutdown_internal().await })
|
||||
}),
|
||||
Arc::new(move || reopen_registry.reopen_internal()),
|
||||
),
|
||||
);
|
||||
if feature_config.manage_workdir.enabled {
|
||||
feature_registry.add_module(module.clone());
|
||||
}
|
||||
workdir_wip_feature = Some(module);
|
||||
}
|
||||
if feature_config.workspace_worker_discovery.enabled {
|
||||
let workspace_client = worker.workspace_client_handle();
|
||||
@@ -1585,20 +1720,60 @@ where
|
||||
}
|
||||
|
||||
let host_worker_observation_provider = worker.worker_observation_provider();
|
||||
let wip_mode = worker.manifest().worker.mode == manifest::WorkerMode::Wip;
|
||||
let wip_permissions = worker.manifest().permissions.clone();
|
||||
let mut wip_mount_registry = crate::wip::WipMountRegistry::new();
|
||||
if let Some(feature) = &workspace_config_feature {
|
||||
crate::feature::builtin::workspace_config::wip::mount_workspace_config_wip(
|
||||
&mut wip_mount_registry,
|
||||
feature,
|
||||
)
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("mount Workspace config WIP projection: {error}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
if wip_mode {
|
||||
crate::checkout::mount_checkouts(
|
||||
&mut wip_mount_registry,
|
||||
worker.workdir_sessions(),
|
||||
tracker.clone(),
|
||||
wip_permissions.clone(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("mount checkout WIP projection: {error}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
if wip_mode && let Some(feature) = &workdir_wip_feature {
|
||||
crate::feature::builtin::manage_workdir::wip::mount_workspace_workdir_wip(
|
||||
&mut wip_mount_registry,
|
||||
feature,
|
||||
feature_config.workdir_catalog.enabled,
|
||||
feature_config.manage_workdir.enabled,
|
||||
wip_permissions.clone(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("mount Workdir WIP projection: {error}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
{
|
||||
let workspace_client = worker.workspace_client_handle();
|
||||
let engine = worker.engine_mut();
|
||||
|
||||
if feature_config.merge_request.any() && wip_mode {
|
||||
let feature_route = wip_mount_registry
|
||||
.allocate_feature_route("merge-request")
|
||||
let namespace_route = wip_mount_registry
|
||||
.allocate_namespace("merge-request", "merge-requests")
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("allocate Merge Request WIP route: {error}"),
|
||||
format!("allocate Merge Request WIP namespace: {error}"),
|
||||
)
|
||||
})?;
|
||||
crate::feature::builtin::merge_request::mount_workspace_http_merge_request_wip(
|
||||
@@ -1606,7 +1781,7 @@ where
|
||||
workspace_client.clone(),
|
||||
feature_config.merge_request,
|
||||
wip_permissions.clone(),
|
||||
&feature_route,
|
||||
&namespace_route,
|
||||
)
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
@@ -1623,12 +1798,12 @@ where
|
||||
intake: feature_config.ticket.intake,
|
||||
workflow: feature_config.ticket.workflow,
|
||||
};
|
||||
let feature_route = wip_mount_registry
|
||||
.allocate_feature_route("ticket")
|
||||
let namespace_route = wip_mount_registry
|
||||
.allocate_namespace("ticket", "tickets")
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("allocate Ticket WIP route: {error}"),
|
||||
format!("allocate Ticket WIP namespace: {error}"),
|
||||
)
|
||||
})?;
|
||||
crate::feature::builtin::ticket::mount_workspace_http_ticket_wip(
|
||||
@@ -1636,7 +1811,7 @@ where
|
||||
workspace_client.clone(),
|
||||
ticket_access,
|
||||
wip_permissions.clone(),
|
||||
&feature_route,
|
||||
&namespace_route,
|
||||
)
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
@@ -1657,19 +1832,19 @@ where
|
||||
engine.register_tool(definition);
|
||||
}
|
||||
if wip_mode {
|
||||
let feature_route = wip_mount_registry
|
||||
.allocate_feature_route("objective")
|
||||
let namespace_route = wip_mount_registry
|
||||
.allocate_namespace("objective", "objectives")
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("allocate Objective WIP route: {error}"),
|
||||
format!("allocate Objective WIP namespace: {error}"),
|
||||
)
|
||||
})?;
|
||||
crate::feature::builtin::objective::mount_workspace_http_objective_wip(
|
||||
&mut wip_mount_registry,
|
||||
workspace_client.clone(),
|
||||
wip_permissions.clone(),
|
||||
&feature_route,
|
||||
&namespace_route,
|
||||
)
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
@@ -2081,9 +2256,11 @@ async fn controller_loop<C, St>(
|
||||
submission.input,
|
||||
vec![extension],
|
||||
submission.provenance,
|
||||
move || {
|
||||
let _ = input_commit_tx.send(());
|
||||
},
|
||||
input_committed_hook(
|
||||
pending_submissions.clone(),
|
||||
committed_submission.submission_id.clone(),
|
||||
input_commit_tx,
|
||||
),
|
||||
),
|
||||
&mut method_rx,
|
||||
&working_event_tx,
|
||||
@@ -2140,10 +2317,6 @@ async fn controller_loop<C, St>(
|
||||
.await
|
||||
}
|
||||
};
|
||||
if notify_buffer.is_empty() {
|
||||
pending_submissions.finish_notification_batch();
|
||||
}
|
||||
|
||||
if !shutdown && may_drain_pending && new_status == WorkerStatus::Idle {
|
||||
match prepare_pending_run(&pending_submissions, ¬ify_buffer, None, false) {
|
||||
Ok(Some(next)) => {
|
||||
@@ -2192,12 +2365,16 @@ async fn controller_loop<C, St>(
|
||||
continue;
|
||||
}
|
||||
|
||||
// An expired Notify deadline must not repeatedly schedule a run that
|
||||
// cannot cross the invocation fence. Keep the accepted notification for
|
||||
// a fresh input/recovery boundary instead of retrying business effects.
|
||||
let notification_delay = (shared_state.catalog_status() == WorkerStatus::Idle)
|
||||
.then(|| {
|
||||
notification_coalesce_remaining(
|
||||
&pending_submissions,
|
||||
¬ify_buffer,
|
||||
notification_coalesce_delay,
|
||||
worker.can_schedule_notification_run(),
|
||||
)
|
||||
})
|
||||
.flatten();
|
||||
@@ -3754,6 +3931,21 @@ mod tests {
|
||||
assert!(feature_install < materialize_gate);
|
||||
assert!(materialize_gate < materialize);
|
||||
assert!(materialize < exposure);
|
||||
let observer = startup.find("let command_observer = fs_for_view").unwrap();
|
||||
let recover = startup.find(".recover_unfinished_compaction()").unwrap();
|
||||
let files = startup
|
||||
.find("runtime_dir.write_status(&shared_state).await?")
|
||||
.unwrap();
|
||||
let loop_start = startup.find("tokio::spawn(controller_loop(").unwrap();
|
||||
assert!(recover < observer && files < observer && observer < loop_start);
|
||||
let socket_startup = &startup[observer..loop_start];
|
||||
let abort = socket_startup.find("observer.abort()").unwrap();
|
||||
let join = socket_startup.find("observer.await").unwrap();
|
||||
let failure = socket_startup.find("return Err(error)").unwrap();
|
||||
assert!(
|
||||
abort < join && join < failure,
|
||||
"startup cannot detach its observer on error"
|
||||
);
|
||||
assert!(
|
||||
startup[materialize_gate..materialize]
|
||||
.contains("needs_initial_session_head_materialization")
|
||||
@@ -3871,6 +4063,40 @@ mod tests {
|
||||
assert!(notify_buffer.has_notification_pending());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invocation_fence_suspends_expired_notification_deadline_without_dropping_input() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let pending = crate::worker::PendingSubmissionHandle::for_test(temp.path());
|
||||
let buffer = NotifyBuffer::new();
|
||||
pending
|
||||
.accept_notification("held".into(), "held".into())
|
||||
.unwrap();
|
||||
let before = pending.persisted_entries_for_test().len();
|
||||
assert_eq!(
|
||||
notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, true),
|
||||
Some(Duration::ZERO)
|
||||
);
|
||||
for _ in 0..4 {
|
||||
assert_eq!(
|
||||
notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, false),
|
||||
None
|
||||
);
|
||||
}
|
||||
assert_eq!(pending.persisted_entries_for_test().len(), before);
|
||||
assert_eq!(pending.snapshot().notification_count, 1);
|
||||
// A busy-time staged notification is equally retained behind the fence.
|
||||
stage_pending_notifications(&pending, &buffer);
|
||||
assert_eq!(
|
||||
notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, false),
|
||||
None
|
||||
);
|
||||
assert!(buffer.has_notification_pending());
|
||||
assert_eq!(
|
||||
notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, true),
|
||||
Some(Duration::ZERO)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restored_mixed_activations_preserve_global_fifo_order() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
||||
@@ -20,6 +20,7 @@ use agen::Engine;
|
||||
use agen::llm_client::client::LlmClient;
|
||||
use agen::state::Mutable;
|
||||
use agen::tool::ToolDefinition;
|
||||
use protocol::FeatureInvocationDescriptor;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
@@ -623,6 +624,9 @@ pub struct FeatureDescriptor {
|
||||
pub provides_services: Vec<ServiceDeclaration>,
|
||||
pub requires_services: Vec<ServiceRequirement>,
|
||||
pub protocol_providers: Vec<ProtocolProviderDeclaration>,
|
||||
/// Declarative, user-selected chat invocations contributed by this Feature.
|
||||
/// Metadata is public; handlers are registered separately during install.
|
||||
pub chat_invocations: Vec<FeatureInvocationDescriptor>,
|
||||
}
|
||||
|
||||
impl FeatureDescriptor {
|
||||
@@ -640,6 +644,7 @@ impl FeatureDescriptor {
|
||||
provides_services: Vec::new(),
|
||||
requires_services: Vec::new(),
|
||||
protocol_providers: Vec::new(),
|
||||
chat_invocations: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,6 +687,11 @@ impl FeatureDescriptor {
|
||||
self.protocol_providers.push(provider);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_chat_invocation(mut self, invocation: FeatureInvocationDescriptor) -> Self {
|
||||
self.chat_invocations.push(invocation);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature module contribution boundary.
|
||||
@@ -739,6 +749,7 @@ pub enum FeatureContributionKind {
|
||||
BackgroundTask,
|
||||
Service,
|
||||
ProtocolProvider,
|
||||
ChatInvocation,
|
||||
Notification,
|
||||
Alert,
|
||||
Diagnostic,
|
||||
@@ -765,6 +776,7 @@ pub struct FeatureInstallReport {
|
||||
pub provided_services: Vec<ServiceDeclaration>,
|
||||
pub resolved_service_requirements: Vec<ServiceRequirement>,
|
||||
pub protocol_providers: Vec<ProtocolProviderLifecycleDiagnostic>,
|
||||
pub installed_chat_invocations: Vec<FeatureInvocationDescriptor>,
|
||||
pub skipped: Vec<SkippedContribution>,
|
||||
pub diagnostics: Vec<FeatureDiagnostic>,
|
||||
}
|
||||
@@ -782,6 +794,7 @@ impl FeatureInstallReport {
|
||||
provided_services: Vec::new(),
|
||||
resolved_service_requirements: Vec::new(),
|
||||
protocol_providers: Vec::new(),
|
||||
installed_chat_invocations: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
}
|
||||
@@ -794,6 +807,7 @@ impl FeatureInstallReport {
|
||||
self.installed_instructions.clear();
|
||||
self.declared_background_tasks.clear();
|
||||
self.provided_services.clear();
|
||||
self.installed_chat_invocations.clear();
|
||||
}
|
||||
|
||||
fn mark_skipped(
|
||||
@@ -819,6 +833,7 @@ struct FeatureContributionDeclarations {
|
||||
provided_services: HashSet<(ServiceId, String)>,
|
||||
required_services: HashSet<ServiceId>,
|
||||
protocol_providers: HashSet<ProviderId>,
|
||||
chat_invocations: Vec<FeatureInvocationDescriptor>,
|
||||
}
|
||||
|
||||
impl FeatureContributionDeclarations {
|
||||
@@ -859,6 +874,7 @@ impl FeatureContributionDeclarations {
|
||||
.iter()
|
||||
.map(|provider| provider.id.clone())
|
||||
.collect(),
|
||||
chat_invocations: descriptor.chat_invocations.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1527,6 +1543,7 @@ pub struct FeatureInstallContext<'a> {
|
||||
hook_builder: &'a mut HookRegistryBuilder,
|
||||
background_task_builder: &'a mut FeatureBackgroundTaskRegistryBuilder,
|
||||
service_registry: &'a mut FeatureServiceRegistry,
|
||||
invocation_registry: &'a mut FeatureInvocationRegistry,
|
||||
report: &'a mut FeatureInstallReport,
|
||||
}
|
||||
|
||||
@@ -1591,6 +1608,15 @@ impl FeatureInstallContext<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn chat_invocations(&mut self) -> InvocationContributionRegistrar<'_> {
|
||||
InvocationContributionRegistrar {
|
||||
feature_id: self.feature_id,
|
||||
declarations: &self.declarations.chat_invocations,
|
||||
registry: self.invocation_registry,
|
||||
installed: &mut self.report.installed_chat_invocations,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn diagnostics(&mut self) -> FeatureDiagnosticSink<'_> {
|
||||
FeatureDiagnosticSink {
|
||||
report: self.report,
|
||||
@@ -1604,6 +1630,7 @@ pub struct FeatureRegistryInstallReport {
|
||||
pub reports: Vec<FeatureInstallReport>,
|
||||
pub services: FeatureServiceRegistry,
|
||||
pub background_tasks: FeatureBackgroundTaskRegistry,
|
||||
pub chat_invocations: FeatureInvocationRegistry,
|
||||
pub plan_error: Option<FeaturePlanError>,
|
||||
}
|
||||
|
||||
@@ -2026,11 +2053,13 @@ impl FeatureRegistryBuilder {
|
||||
reports,
|
||||
services: FeatureServiceRegistry::default(),
|
||||
background_tasks: FeatureBackgroundTaskRegistry::default(),
|
||||
chat_invocations: FeatureInvocationRegistry::default(),
|
||||
plan_error: Some(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
let mut service_registry = FeatureServiceRegistry::default();
|
||||
let mut invocation_registry = FeatureInvocationRegistry::default();
|
||||
let mut background_task_builder = FeatureBackgroundTaskRegistryBuilder::default();
|
||||
let mut reports = Vec::with_capacity(plan.ordered_indices.len());
|
||||
let install_hook_checkpoint = hook_builder.checkpoint();
|
||||
@@ -2055,6 +2084,7 @@ impl FeatureRegistryBuilder {
|
||||
let hook_checkpoint = hook_builder.checkpoint();
|
||||
let background_checkpoint = background_task_builder.checkpoint();
|
||||
let service_checkpoint = service_registry.clone();
|
||||
let invocation_checkpoint = invocation_registry.clone();
|
||||
let tool_checkpoint = pending_tools.len();
|
||||
let installed_tool_checkpoint = installed_tool_names.clone();
|
||||
|
||||
@@ -2102,6 +2132,7 @@ impl FeatureRegistryBuilder {
|
||||
hook_builder,
|
||||
background_task_builder: &mut background_task_builder,
|
||||
service_registry: &mut service_registry,
|
||||
invocation_registry: &mut invocation_registry,
|
||||
report: &mut report,
|
||||
};
|
||||
module.install(&mut context)
|
||||
@@ -2113,6 +2144,7 @@ impl FeatureRegistryBuilder {
|
||||
hook_builder.rollback_to(hook_checkpoint);
|
||||
background_task_builder.rollback_to(&background_checkpoint);
|
||||
service_registry = service_checkpoint.clone();
|
||||
invocation_registry = invocation_checkpoint.clone();
|
||||
pending_tools.truncate(tool_checkpoint);
|
||||
installed_tool_names = installed_tool_checkpoint.clone();
|
||||
report.clear_installed_contributions();
|
||||
@@ -2138,6 +2170,14 @@ impl FeatureRegistryBuilder {
|
||||
)));
|
||||
}
|
||||
}
|
||||
for invocation in &descriptor.chat_invocations {
|
||||
if !report.installed_chat_invocations.contains(invocation) {
|
||||
report.diagnostics.push(FeatureDiagnostic::error(format!(
|
||||
"feature `{}` declared chat invocation `{}` but did not register an executable handler",
|
||||
descriptor.id, invocation.identity
|
||||
)));
|
||||
}
|
||||
}
|
||||
if report
|
||||
.diagnostics
|
||||
.iter()
|
||||
@@ -2146,6 +2186,7 @@ impl FeatureRegistryBuilder {
|
||||
hook_builder.rollback_to(hook_checkpoint);
|
||||
background_task_builder.rollback_to(&background_checkpoint);
|
||||
service_registry = service_checkpoint.clone();
|
||||
invocation_registry = invocation_checkpoint.clone();
|
||||
pending_tools.truncate(tool_checkpoint);
|
||||
installed_tool_names = installed_tool_checkpoint.clone();
|
||||
report.clear_installed_contributions();
|
||||
@@ -2176,6 +2217,7 @@ impl FeatureRegistryBuilder {
|
||||
reports,
|
||||
services: FeatureServiceRegistry::default(),
|
||||
background_tasks: FeatureBackgroundTaskRegistry::default(),
|
||||
chat_invocations: FeatureInvocationRegistry::default(),
|
||||
plan_error: None,
|
||||
}
|
||||
} else {
|
||||
@@ -2183,6 +2225,7 @@ impl FeatureRegistryBuilder {
|
||||
reports,
|
||||
services: service_registry,
|
||||
background_tasks: background_task_builder.build(),
|
||||
chat_invocations: invocation_registry,
|
||||
plan_error: None,
|
||||
}
|
||||
}
|
||||
@@ -2225,11 +2268,24 @@ pub enum FeatureInstallError {
|
||||
first_feature: String,
|
||||
duplicate_feature: String,
|
||||
},
|
||||
#[error("duplicate invocation identity `{identity}`")]
|
||||
DuplicateInvocationIdentity { identity: String },
|
||||
#[error("duplicate invocation name `{name}` from `{duplicate}`; first registered by `{first}`")]
|
||||
DuplicateInvocationName {
|
||||
name: String,
|
||||
first: String,
|
||||
duplicate: String,
|
||||
},
|
||||
#[error("feature install failed: {0}")]
|
||||
Install(String),
|
||||
}
|
||||
|
||||
pub mod background;
|
||||
pub mod invocation;
|
||||
pub use invocation::{
|
||||
FeatureInvocationCompletionProvider, FeatureInvocationContext, FeatureInvocationHandler,
|
||||
FeatureInvocationHandlerError, FeatureInvocationRegistry, InvocationContributionRegistrar,
|
||||
};
|
||||
pub mod builtin;
|
||||
pub mod mcp;
|
||||
pub(crate) mod session;
|
||||
|
||||
@@ -190,7 +190,7 @@ impl BackgroundTaskCancellation {
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel(&self) {
|
||||
pub(crate) fn cancel(&self) {
|
||||
self.sender.send_replace(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! an external plugin-loading surface.
|
||||
|
||||
pub mod backend_job_result;
|
||||
pub mod chat_invocation;
|
||||
pub mod flow_transition;
|
||||
pub mod manage_workdir;
|
||||
pub mod manage_worker;
|
||||
@@ -23,6 +24,7 @@ pub(crate) mod subjektiv_session;
|
||||
pub mod task;
|
||||
pub mod ticket;
|
||||
pub mod worker_observation;
|
||||
pub mod workspace_config;
|
||||
pub mod workspace_worker_discovery;
|
||||
|
||||
pub use task::{TaskFeature, task_tools_feature};
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
//! identity come from the authenticated Workspace client, while Job, attempt,
|
||||
//! and input revision are resolved from the Backend-owned binding.
|
||||
|
||||
use std::sync::Arc;
|
||||
use crate::feature::background::BackgroundTaskCancellation;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||
use async_trait::async_trait;
|
||||
@@ -12,6 +14,7 @@ use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature;
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
|
||||
ToolDeclaration, ToolDefinition,
|
||||
@@ -27,9 +30,61 @@ struct SubmitResultInput {
|
||||
result: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ConsolidationResult {
|
||||
subject_id: String,
|
||||
candidate_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SubmitConsolidationResultInput {
|
||||
/// Closed candidate ids only. Host awaits and adds the actual surface outcome.
|
||||
result: ConsolidationResult,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ResultSubmissionState {
|
||||
sealed: Option<SealedResult>,
|
||||
reusable_surface: Option<Value>,
|
||||
}
|
||||
|
||||
struct SealedResult {
|
||||
original: Value,
|
||||
completed: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SubmitResultTool {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
surface: Option<SubjektivSurfaceLifecycleFeature>,
|
||||
submission: Arc<tokio::sync::Mutex<ResultSubmissionState>>,
|
||||
active: Arc<Mutex<HashMap<String, BackgroundTaskCancellation>>>,
|
||||
}
|
||||
|
||||
struct ActiveResultGuard {
|
||||
id: String,
|
||||
active: Arc<Mutex<HashMap<String, BackgroundTaskCancellation>>>,
|
||||
cancellation: BackgroundTaskCancellation,
|
||||
}
|
||||
impl Drop for ActiveResultGuard {
|
||||
fn drop(&mut self) {
|
||||
self.cancellation.cancel();
|
||||
self.active
|
||||
.lock()
|
||||
.expect("Job result cancellation state poisoned")
|
||||
.remove(&self.id);
|
||||
}
|
||||
}
|
||||
|
||||
fn cancelled_output() -> ToolError {
|
||||
ToolError::Cancelled(ToolOutput {
|
||||
summary: "Job result submission cancelled; committed candidate decisions are preserved"
|
||||
.into(),
|
||||
content: None,
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -37,27 +92,128 @@ impl Tool for SubmitResultTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_context: ToolExecutionContext,
|
||||
context: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let input: SubmitResultInput = serde_json::from_str(input_json)
|
||||
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
||||
let id = context.execution_id();
|
||||
let cancellation = self
|
||||
.active
|
||||
.lock()
|
||||
.expect("Job result cancellation state poisoned")
|
||||
.entry(id.clone())
|
||||
.or_default()
|
||||
.clone();
|
||||
let _active = ActiveResultGuard {
|
||||
id,
|
||||
active: self.active.clone(),
|
||||
cancellation: cancellation.clone(),
|
||||
};
|
||||
// Serialize result calls and retain the exact lifecycle outcome for ambiguous retries.
|
||||
let mut submission = tokio::select! {
|
||||
guard = self.submission.lock() => guard,
|
||||
_ = cancellation.cancelled() => return Err(cancelled_output()),
|
||||
};
|
||||
// An existing seal may already have had an effect, even if this replay
|
||||
// is now definitively rejected. It must never be reopened.
|
||||
let was_previously_sealed = submission.sealed.is_some();
|
||||
let result = if let Some(sealed) = submission.sealed.as_ref() {
|
||||
if sealed.original != input.result {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"Job result is already sealed; retry the identical result".into(),
|
||||
));
|
||||
}
|
||||
sealed.completed.clone()
|
||||
} else {
|
||||
let mut result = input.result.clone();
|
||||
if let Some(surface) = &self.surface {
|
||||
let checked = serde_json::from_str::<SubmitConsolidationResultInput>(input_json)
|
||||
.map_err(|error| {
|
||||
ToolError::InvalidArgument(format!(
|
||||
"invalid consolidation result (surface is Host-generated): {error}"
|
||||
))
|
||||
})?
|
||||
.result;
|
||||
if checked.subject_id.trim().is_empty()
|
||||
|| checked.candidate_ids.iter().any(|id| id.trim().is_empty())
|
||||
{
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"subject_id and candidate ids must not be empty".into(),
|
||||
));
|
||||
}
|
||||
let object = result
|
||||
.as_object_mut()
|
||||
.expect("typed consolidation result is an object");
|
||||
let outcome = surface
|
||||
.complete_for_job(cancellation.clone(), submission.reusable_surface.as_ref())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if cancellation.is_cancelled() {
|
||||
cancelled_output()
|
||||
} else {
|
||||
ToolError::ExecutionFailed(error.to_string())
|
||||
}
|
||||
})?;
|
||||
submission.reusable_surface = Some(outcome.clone());
|
||||
object.insert("surface".into(), outcome);
|
||||
}
|
||||
result
|
||||
};
|
||||
if cancellation.is_cancelled() {
|
||||
return Err(cancelled_output());
|
||||
}
|
||||
let workspace_id = self.client.workspace_id().ok_or_else(|| {
|
||||
ToolError::ExecutionFailed(
|
||||
"Backend Job result submission requires Workspace identity".to_string(),
|
||||
)
|
||||
})?;
|
||||
let response = self
|
||||
.client
|
||||
.execute(WorkspaceRequest::json(
|
||||
let request = WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("/api/w/{workspace_id}/workers/self/backend-job-result"),
|
||||
serde_json::to_string(&serde_json::json!({ "result": input.result }))
|
||||
serde_json::to_string(&serde_json::json!({ "result": result }))
|
||||
.map_err(|error| ToolError::Internal(error.to_string()))?,
|
||||
))
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
if !response.is_success() {
|
||||
);
|
||||
// Seal before dispatch: a transport failure may follow durable acceptance.
|
||||
if submission.sealed.is_none() {
|
||||
submission.sealed = Some(SealedResult {
|
||||
original: input.result,
|
||||
completed: result,
|
||||
});
|
||||
submission.reusable_surface = None;
|
||||
}
|
||||
let response = match self.client.execute(request) {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"Backend Job result submission was rejected with HTTP {}: {}",
|
||||
"Backend Job result acceptance is unknown; retry the identical result: {error}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let definitive_rejection = matches!(response.status, 400 | 403 | 409 | 422);
|
||||
if !response.is_success() {
|
||||
let sealed = submission.sealed.as_mut().expect("sealed before dispatch");
|
||||
if definitive_rejection && !was_previously_sealed {
|
||||
// Only a definitively unaccepted request can be corrected. Reuse
|
||||
// a ready snapshot after a new preparation confirms its revision;
|
||||
// a conflict invalidates even that snapshot. Failed outcomes are
|
||||
// rebuilt, since another generation can replace their marker.
|
||||
submission.reusable_surface = if response.status != 409 {
|
||||
sealed
|
||||
.completed
|
||||
.get("surface")
|
||||
.filter(|surface| surface["availability"] == "ready")
|
||||
.cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
submission.sealed = None;
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"Backend Job result was definitively rejected with HTTP {}; correct result fields or retry to rebuild a stale surface: {}",
|
||||
response.status, response.body
|
||||
)));
|
||||
}
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"Backend Job result acceptance remains sealed; retry the identical result (HTTP {}): {}",
|
||||
response.status, response.body
|
||||
)));
|
||||
}
|
||||
@@ -67,10 +223,29 @@ impl Tool for SubmitResultTool {
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn cancel_execution(&self, context: &ToolExecutionContext) -> Result<(), ToolError> {
|
||||
self.active
|
||||
.lock()
|
||||
.expect("Job result cancellation state poisoned")
|
||||
.entry(context.execution_id())
|
||||
.or_default()
|
||||
.cancel();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn definition(client: Arc<dyn WorkspaceClient>) -> ToolDefinition {
|
||||
let schema = serde_json::to_value(schemars::schema_for!(SubmitResultInput))
|
||||
fn definition(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
surface: Option<SubjektivSurfaceLifecycleFeature>,
|
||||
) -> ToolDefinition {
|
||||
let submission = Arc::new(tokio::sync::Mutex::new(ResultSubmissionState::default()));
|
||||
let active = Arc::new(Mutex::new(HashMap::new()));
|
||||
let schema = if surface.is_some() {
|
||||
serde_json::to_value(schemars::schema_for!(SubmitConsolidationResultInput))
|
||||
} else {
|
||||
serde_json::to_value(schemars::schema_for!(SubmitResultInput))
|
||||
}
|
||||
.expect("Backend Job result schema must serialize");
|
||||
Arc::new(move || {
|
||||
(
|
||||
@@ -79,6 +254,9 @@ fn definition(client: Arc<dyn WorkspaceClient>) -> ToolDefinition {
|
||||
.input_schema(schema.clone()),
|
||||
Arc::new(SubmitResultTool {
|
||||
client: client.clone(),
|
||||
surface: surface.clone(),
|
||||
submission: submission.clone(),
|
||||
active: active.clone(),
|
||||
}) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
@@ -87,11 +265,22 @@ fn definition(client: Arc<dyn WorkspaceClient>) -> ToolDefinition {
|
||||
#[derive(Clone)]
|
||||
pub struct BackendJobResultFeature {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
surface: Option<SubjektivSurfaceLifecycleFeature>,
|
||||
}
|
||||
|
||||
impl BackendJobResultFeature {
|
||||
pub fn new(client: Arc<dyn WorkspaceClient>) -> Self {
|
||||
Self { client }
|
||||
Self {
|
||||
client,
|
||||
surface: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BackendJobResultFeature {
|
||||
pub(crate) fn with_surface(mut self, surface: SubjektivSurfaceLifecycleFeature) -> Self {
|
||||
self.surface = Some(surface);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +294,7 @@ impl FeatureModule for BackendJobResultFeature {
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
context.tools().register(ToolContribution::new(
|
||||
TOOL_NAME,
|
||||
definition(self.client.clone()),
|
||||
definition(self.client.clone(), self.surface.clone()),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -172,4 +361,14 @@ mod tests {
|
||||
.ends_with("/workers/self/backend-job-result")
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn cancellation_before_execution_never_submits_a_result() {
|
||||
let client = Arc::new(RecordingClient::default());
|
||||
let (_, tool) = definition(client.clone(), None)();
|
||||
let context = ToolExecutionContext::new("result-call", "batch", 0);
|
||||
tool.cancel_execution(&context).await.unwrap();
|
||||
let outcome = tool.execute(r#"{"result":{"valid":true}}"#, context).await;
|
||||
assert!(matches!(outcome, Err(ToolError::Cancelled(_))));
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Builtin metadata for client-local attachment staging.
|
||||
//!
|
||||
//! `/attach` uses the common invocation declaration and parser, but its path is
|
||||
//! intentionally consumed by the client adapter. The Worker accepts only the
|
||||
//! resulting immutable `UploadedFile` segment and never resolves a client path.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use protocol::{
|
||||
FeatureInvocation, FeatureInvocationDescriptor, FeatureInvocationIdentity,
|
||||
FeatureInvocationResult, FeatureInvocationSyntax, InvocationArgumentDescriptor,
|
||||
InvocationArgumentType, InvocationClientAdapter, InvocationCompletion,
|
||||
};
|
||||
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureInvocationContext,
|
||||
FeatureInvocationHandler, FeatureInvocationHandlerError, FeatureModule,
|
||||
};
|
||||
|
||||
pub const ATTACH_FEATURE_ID: &str = "attachments";
|
||||
pub const ATTACH_INVOCATION_ID: &str = "builtin:attachments/attach";
|
||||
|
||||
pub fn attach_invocation_descriptor() -> FeatureInvocationDescriptor {
|
||||
FeatureInvocationDescriptor {
|
||||
identity: FeatureInvocationIdentity(ATTACH_INVOCATION_ID.into()),
|
||||
name: "attach".into(),
|
||||
aliases: Vec::new(),
|
||||
display_name: "Attach file".into(),
|
||||
description: "Stage a client-local file as an editable attachment chip.".into(),
|
||||
syntax: FeatureInvocationSyntax::Parenthesized,
|
||||
arguments: vec![InvocationArgumentDescriptor {
|
||||
name: "file".into(),
|
||||
position: Some(0),
|
||||
required: true,
|
||||
value_type: InvocationArgumentType::ClientFile,
|
||||
completion: InvocationCompletion::ClientFile,
|
||||
description: Some("File available to this client".into()),
|
||||
}],
|
||||
client_adapter: Some(InvocationClientAdapter::Attachment),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AttachmentInvocationFeature;
|
||||
|
||||
impl FeatureModule for AttachmentInvocationFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
FeatureDescriptor::builtin(ATTACH_FEATURE_ID, "Attachments")
|
||||
.with_description("Client-local attachment staging and typed uploaded-file input")
|
||||
.with_chat_invocation(attach_invocation_descriptor())
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
context.chat_invocations().register(
|
||||
attach_invocation_descriptor(),
|
||||
AttachmentMustBeStagedByClient,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct AttachmentMustBeStagedByClient;
|
||||
|
||||
#[async_trait]
|
||||
impl FeatureInvocationHandler for AttachmentMustBeStagedByClient {
|
||||
async fn invoke(
|
||||
&self,
|
||||
_context: FeatureInvocationContext,
|
||||
_invocation: &FeatureInvocation,
|
||||
) -> Result<FeatureInvocationResult, FeatureInvocationHandlerError> {
|
||||
Err(FeatureInvocationHandlerError::failed(
|
||||
"attachment invocation reached the Worker without client-local upload staging",
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
//! endpoints, credentials, materializer handles, and operation sessions stay
|
||||
//! behind [`WorkspaceClient`].
|
||||
|
||||
pub mod wip;
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
@@ -67,6 +69,7 @@ pub struct ManageWorkdirFeature {
|
||||
session_router: Arc<workdir::WorkdirSessionRouter>,
|
||||
before_workdir_release: Option<BeforeWorkdirRelease>,
|
||||
after_workdir_attach: Option<AfterWorkdirAttach>,
|
||||
attachment_mutation_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ManageWorkdirFeature {
|
||||
@@ -86,6 +89,7 @@ impl ManageWorkdirFeature {
|
||||
session_router: Arc::new(workdir::WorkdirSessionRouter::new()),
|
||||
before_workdir_release: None,
|
||||
after_workdir_attach: None,
|
||||
attachment_mutation_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,8 +104,19 @@ impl ManageWorkdirFeature {
|
||||
session_router,
|
||||
before_workdir_release: Some(before_workdir_release),
|
||||
after_workdir_attach: Some(after_workdir_attach),
|
||||
attachment_mutation_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
}
|
||||
}
|
||||
fn backend(&self) -> WorkspaceHttpWorkdirBackend {
|
||||
let mut backend = WorkspaceHttpWorkdirBackend::new(self.client.clone())
|
||||
.with_session_router(self.session_router.clone())
|
||||
.with_child_lifecycle(
|
||||
self.before_workdir_release.clone(),
|
||||
self.after_workdir_attach.clone(),
|
||||
);
|
||||
backend.attachment_mutation_lock = self.attachment_mutation_lock.clone();
|
||||
backend
|
||||
}
|
||||
}
|
||||
|
||||
pub fn manage_workdir_feature(client: Arc<dyn WorkspaceClient>) -> ManageWorkdirFeature {
|
||||
@@ -120,12 +135,7 @@ impl FeatureModule for ManageWorkdirFeature {
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
let backend = WorkspaceHttpWorkdirBackend::new(self.client.clone())
|
||||
.with_session_router(self.session_router.clone())
|
||||
.with_child_lifecycle(
|
||||
self.before_workdir_release.clone(),
|
||||
self.after_workdir_attach.clone(),
|
||||
);
|
||||
let backend = self.backend();
|
||||
for (name, definition) in [
|
||||
(
|
||||
LIST_TOOL,
|
||||
@@ -343,6 +353,102 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
|
||||
}
|
||||
}
|
||||
|
||||
async fn checkout_observe(
|
||||
&self,
|
||||
path: workdir::WorkdirPath,
|
||||
) -> Result<workdir::CheckoutObservation, WorkdirError> {
|
||||
match self.operate(WorkdirSessionOperation::CheckoutObserve(path.clone()))? {
|
||||
WorkdirSessionOperationResult::CheckoutObserve(mut result)
|
||||
if result.path == path
|
||||
&& !result.validator.is_empty()
|
||||
&& result.validator.len() <= 256 =>
|
||||
{
|
||||
result.capabilities = result.capabilities.intersection(self.capabilities);
|
||||
Ok(result)
|
||||
}
|
||||
_ => Err(Self::mismatch("checkout_observe")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn checkout_execute(
|
||||
&self,
|
||||
request: workdir::CheckoutRequest,
|
||||
) -> Result<workdir::CheckoutResult, WorkdirError> {
|
||||
let cap = request.operation.capability();
|
||||
if !self.capabilities.supports(cap) {
|
||||
return Err(WorkdirError::Unsupported(cap));
|
||||
}
|
||||
let target = request.target.clone();
|
||||
let mutation = cap != workdir::WorkdirSessionCapability::Read;
|
||||
match self.operate(WorkdirSessionOperation::CheckoutExecute(request)) {
|
||||
Ok(WorkdirSessionOperationResult::CheckoutExecute(mut result))
|
||||
if result.observation.path == target
|
||||
&& !result.observation.validator.is_empty()
|
||||
&& result.observation.validator.len() <= 256
|
||||
&& matches!(
|
||||
(&result.output, cap),
|
||||
(
|
||||
workdir::CheckoutOutput::Read(_),
|
||||
workdir::WorkdirSessionCapability::Read
|
||||
) | (
|
||||
workdir::CheckoutOutput::Write(_),
|
||||
workdir::WorkdirSessionCapability::Write
|
||||
) | (
|
||||
workdir::CheckoutOutput::Edit(_),
|
||||
workdir::WorkdirSessionCapability::Edit
|
||||
)
|
||||
) =>
|
||||
{
|
||||
result.observation.capabilities = result
|
||||
.observation
|
||||
.capabilities
|
||||
.intersection(self.capabilities);
|
||||
Ok(result)
|
||||
}
|
||||
Ok(_) if mutation => Err(WorkdirError::OutcomeUnknown(
|
||||
"mismatched checkout mutation response".into(),
|
||||
)),
|
||||
Ok(_) => Err(Self::mismatch("checkout_execute")),
|
||||
Err(WorkdirError::Transport(_) | WorkdirError::Unavailable(_)) if mutation => {
|
||||
Err(WorkdirError::OutcomeUnknown(
|
||||
"checkout mutation response lost; inspect effects before retry".into(),
|
||||
))
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
async fn checkout_search(
|
||||
&self,
|
||||
request: workdir::CheckoutSearchRequest,
|
||||
) -> Result<workdir::CheckoutSearchResult, WorkdirError> {
|
||||
use workdir::{
|
||||
CheckoutSearchOperation as Op, CheckoutSearchResult as SearchResult,
|
||||
WorkdirSessionCapability as Cap,
|
||||
};
|
||||
let cap = match &request.operation {
|
||||
Op::List(_) => Cap::Read,
|
||||
Op::Glob(_) => Cap::Glob,
|
||||
Op::Grep(_) => Cap::Grep,
|
||||
};
|
||||
if !self.capabilities.supports(cap) {
|
||||
return Err(WorkdirError::Unsupported(cap));
|
||||
}
|
||||
match self.operate(WorkdirSessionOperation::CheckoutSearch(request))? {
|
||||
WorkdirSessionOperationResult::CheckoutSearch(result)
|
||||
if matches!(
|
||||
(&result, cap),
|
||||
(SearchResult::List(_), Cap::Read)
|
||||
| (SearchResult::Glob(_), Cap::Glob)
|
||||
| (SearchResult::Grep(_), Cap::Grep)
|
||||
) =>
|
||||
{
|
||||
Ok(result)
|
||||
}
|
||||
_ => Err(Self::mismatch("checkout_search")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||
match self.operate(WorkdirSessionOperation::Stat(request))? {
|
||||
WorkdirSessionOperationResult::Stat(result) => Ok(result),
|
||||
@@ -581,11 +687,24 @@ impl WorkspaceHttpWorkdirBackend {
|
||||
}
|
||||
|
||||
fn detach(&self, alias: &str) -> Result<ToolOutput, ToolError> {
|
||||
self.detach_expected(alias, None)
|
||||
}
|
||||
|
||||
fn detach_expected(
|
||||
&self,
|
||||
alias: &str,
|
||||
expected_connection_id: Option<&str>,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let workspace_id = encode_path_segment(self.workspace_id()?);
|
||||
let alias_path = encode_path_segment(alias);
|
||||
let suffix = expected_connection_id
|
||||
.map(|id| format!("?expected_connection_id={}", encode_path_segment(id)))
|
||||
.unwrap_or_default();
|
||||
let response = self.execute_json::<WorkdirAttachmentResponse>(WorkspaceRequest {
|
||||
method: WorkspaceRequestMethod::Delete,
|
||||
path: format!("/api/w/{workspace_id}/workers/self/workdir-attachments/{alias_path}"),
|
||||
path: format!(
|
||||
"/api/w/{workspace_id}/workers/self/workdir-attachments/{alias_path}{suffix}"
|
||||
),
|
||||
body: None,
|
||||
})?;
|
||||
workdir_output(
|
||||
@@ -622,6 +741,72 @@ impl WorkspaceHttpWorkdirBackend {
|
||||
)
|
||||
}
|
||||
|
||||
async fn detach_managed(
|
||||
&self,
|
||||
alias: &str,
|
||||
expected_connection_id: Option<&str>,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let _mutation_guard = self.attachment_mutation_lock.lock().await;
|
||||
let alias = validate_identity(alias, DETACH_TOOL, "alias")?;
|
||||
if let Some(expected) = expected_connection_id {
|
||||
// Under the shared local mutation lock, check lifetime before any
|
||||
// local route exclusion or child cleanup. Backend DELETE still performs
|
||||
// the authoritative conditional mutation under its lifecycle lock.
|
||||
let workspace = encode_path_segment(self.workspace_id()?);
|
||||
let page: serde_json::Value = self.execute_json(WorkspaceRequest::get(format!(
|
||||
"/api/w/{workspace}/workers/self/workdir-attachments?limit=1&connection_id={}",
|
||||
encode_path_segment(expected)
|
||||
)))?;
|
||||
let current = page
|
||||
.get("items")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|items| {
|
||||
items.iter().any(|item| {
|
||||
item.get("connection_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some(expected)
|
||||
&& item.get("alias").and_then(serde_json::Value::as_str) == Some(alias)
|
||||
})
|
||||
});
|
||||
if !current {
|
||||
return Err(ToolError::StructuredConflict {
|
||||
code: "workspace_http_409".into(),
|
||||
message: "attachment lifetime changed before local cleanup".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let alias_key = workdir::WorkdirAttachmentAlias::new(alias)
|
||||
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
||||
self.session_router
|
||||
.begin_detach(&alias_key)
|
||||
.await
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
if let Some(before_release) = &self.before_workdir_release
|
||||
&& let Err(error) = before_release().await
|
||||
{
|
||||
self.session_router.cancel_detach(&alias_key);
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"stop Internal SubWorkers before Workdir detach: {error}"
|
||||
)));
|
||||
}
|
||||
let result = match self.detach_expected(alias, expected_connection_id) {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
self.session_router.cancel_detach(&alias_key);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
self.session_router
|
||||
.finish_detach(&alias_key)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"detach Workdir session route after Backend release: {error}"
|
||||
))
|
||||
})?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn execute_json<T: for<'de> Deserialize<'de>>(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
@@ -697,42 +882,10 @@ impl Tool for WorkspaceHttpWorkdirTool {
|
||||
result
|
||||
}
|
||||
WorkdirOperation::Detach => {
|
||||
let _mutation_guard = self.backend.attachment_mutation_lock.lock().await;
|
||||
let input = parse_input::<WorkdirDetachInput>(input_json)?;
|
||||
let alias = validate_identity(&input.alias, DETACH_TOOL, "alias")?;
|
||||
let alias_key = workdir::WorkdirAttachmentAlias::new(alias)
|
||||
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
||||
self.backend
|
||||
.session_router
|
||||
.begin_detach(&alias_key)
|
||||
.await
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
if let Some(before_release) = &self.backend.before_workdir_release
|
||||
&& let Err(error) = before_release().await
|
||||
{
|
||||
self.backend.session_router.cancel_detach(&alias_key);
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"stop Internal SubWorkers before Workdir detach: {error}"
|
||||
)));
|
||||
}
|
||||
let result = match self.backend.detach(alias) {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
self.backend.session_router.cancel_detach(&alias_key);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
self.backend
|
||||
.session_router
|
||||
.finish_detach(&alias_key)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"detach Workdir session route after Backend release: {error}"
|
||||
))
|
||||
})?;
|
||||
Ok(result)
|
||||
self.backend.detach_managed(&input.alias, None).await
|
||||
}
|
||||
|
||||
WorkdirOperation::Delete => self
|
||||
.backend
|
||||
.delete(parse_input::<WorkdirDeleteInput>(input_json)?),
|
||||
@@ -747,6 +900,12 @@ fn parse_input<T: for<'de> Deserialize<'de>>(input: &str) -> Result<T, ToolError
|
||||
fn decode_response<T: for<'de> Deserialize<'de>>(
|
||||
response: WorkspaceResponse,
|
||||
) -> Result<T, ToolError> {
|
||||
if matches!(response.status, 401 | 403 | 404 | 409) {
|
||||
return Err(ToolError::StructuredConflict {
|
||||
code: format!("workspace_http_{}", response.status),
|
||||
message: "Workspace lifecycle authority rejected the request before execution".into(),
|
||||
});
|
||||
}
|
||||
if !response.is_success() {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"Workspace Workdir API returned HTTP {}: {}",
|
||||
@@ -1351,6 +1510,56 @@ mod tests {
|
||||
assert!(body.get("session_id").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn attached_checkout_mutations_preserve_known_rejection_and_lost_outcomes() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![
|
||||
response(json!({"operation":"write","result":{"bytes_written":3,"created":false}})),
|
||||
WorkspaceResponse {
|
||||
status: 200,
|
||||
body: "invalid payload".into(),
|
||||
},
|
||||
error_response(
|
||||
409,
|
||||
workdir::http::WorkdirTransportErrorCode::Conflict,
|
||||
"stale",
|
||||
),
|
||||
]));
|
||||
let session = WorkspaceAttachedWorkdirSession::handle(client.clone(), "checkout");
|
||||
let request = workdir::CheckoutRequest {
|
||||
target: workdir::WorkdirPath::new("file.txt").unwrap(),
|
||||
validator: vec![1],
|
||||
operation: workdir::CheckoutOperation::Write {
|
||||
content: b"new".to_vec(),
|
||||
expected_hash: [0; 32],
|
||||
},
|
||||
};
|
||||
for _ in 0..2 {
|
||||
assert!(matches!(
|
||||
session.checkout_execute(request.clone()).await,
|
||||
Err(WorkdirError::OutcomeUnknown(_))
|
||||
));
|
||||
}
|
||||
assert!(matches!(
|
||||
session.checkout_execute(request.clone()).await,
|
||||
Err(WorkdirError::Conflict(_))
|
||||
));
|
||||
// Missing response simulates the Workspace client losing the provider result.
|
||||
assert!(matches!(
|
||||
session.checkout_execute(request).await,
|
||||
Err(WorkdirError::OutcomeUnknown(_))
|
||||
));
|
||||
let calls = client.requests();
|
||||
assert_eq!(calls.len(), 4);
|
||||
for call in calls {
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(call.body.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(body["target_workdir"], "checkout");
|
||||
assert_eq!(body["operation"]["operation"], "checkout_execute");
|
||||
assert_eq!(body["operation"]["request"]["target"], "file.txt");
|
||||
assert!(body.get("runtime_id").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn attached_session_does_not_send_worker_local_bash_spill_path() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -77,8 +77,9 @@ pub trait WorkerControlService: Send + Sync {
|
||||
&self,
|
||||
runtime_id: String,
|
||||
worker_id: String,
|
||||
request: server_api::WorkerRestoreRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
let _ = (runtime_id, worker_id);
|
||||
let _ = (runtime_id, worker_id, request);
|
||||
Err(WorkspaceClientError::Unavailable(
|
||||
"Runtime Worker restore is unavailable".to_string(),
|
||||
))
|
||||
@@ -347,6 +348,7 @@ impl WorkerControlService for WorkspaceWorkerControlService {
|
||||
&self,
|
||||
runtime_id: String,
|
||||
worker_id: String,
|
||||
request: server_api::WorkerRestoreRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
if !self.runtime_worker_control {
|
||||
return Err(WorkspaceClientError::Unavailable(
|
||||
@@ -357,6 +359,7 @@ impl WorkerControlService for WorkspaceWorkerControlService {
|
||||
crate::worker::WorkspaceServerOperation::WorkerControlRestore {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
request,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -750,8 +753,13 @@ enum WorkerSubjectInput {
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WorkerTargetInput {
|
||||
struct WorkerRestoreInput {
|
||||
subject: WorkerSubjectInput,
|
||||
expected_observation_token: String,
|
||||
/// Reuse the reported ID for explicit unknown-outcome recovery. A new intent
|
||||
/// defaults to the durable Tool call identity.
|
||||
#[serde(default)]
|
||||
request_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
@@ -864,7 +872,7 @@ impl WorkerOperation {
|
||||
Self::Cancel => "Cancel the current turn of a known Runtime Worker when allowed.",
|
||||
Self::Stop => "Stop a known Runtime Worker when allowed.",
|
||||
Self::Restore => {
|
||||
"Restore a stopped Backend/Runtime Worker session in the current Workspace."
|
||||
"Restore a stopped Backend/Runtime Worker session in the current Workspace. Supply expected_observation_token from WorkerList; a conflict requires refreshing and deliberate new intent. New requests default to the durable Tool call ID. For explicit recovery of an unknown/pending outcome, supply the reported request_id and the original token unchanged; never create a new intent or automatically retry."
|
||||
}
|
||||
Self::Remove => {
|
||||
"Remove an eligible stopped, unassigned, non-internal Worker. Supply a bounded reason; Backend validation and retention are authoritative."
|
||||
@@ -981,12 +989,41 @@ impl Tool for WorkspaceWorkerTool {
|
||||
.map_err(control_tool_error)?
|
||||
}
|
||||
WorkerOperation::Restore => {
|
||||
let input = parse::<WorkerTargetInput>(input_json, "WorkerRestore")?;
|
||||
let input = parse::<WorkerRestoreInput>(input_json, "WorkerRestore")?;
|
||||
let (runtime_id, worker_id) = runtime_subject_ids(&input.subject, self.operation)?;
|
||||
self.control
|
||||
.restore_worker(runtime_id, worker_id)
|
||||
let request_id = match input.request_id {
|
||||
Some(id) => non_empty(id, "request_id")?,
|
||||
None => format!(
|
||||
"worker-restore:{}",
|
||||
non_empty(ctx.call_id.clone(), "tool call_id")?
|
||||
),
|
||||
};
|
||||
if request_id.len() > 512 {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"request_id must contain at most 512 bytes".into(),
|
||||
));
|
||||
}
|
||||
let recoverable_error = |error| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"WorkerRestore request_id={request_id}; reuse this ID with the original observation token only for explicit unknown/pending outcome recovery, not after a stale-intent conflict: {error}"
|
||||
))
|
||||
};
|
||||
let response = self
|
||||
.control
|
||||
.restore_worker(
|
||||
runtime_id,
|
||||
worker_id,
|
||||
server_api::WorkerRestoreRequest {
|
||||
expected_observation_token: non_empty(
|
||||
input.expected_observation_token,
|
||||
"expected_observation_token",
|
||||
)?,
|
||||
request_id: request_id.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(control_tool_error)?
|
||||
.map_err(|error| recoverable_error(control_tool_error(error)))?;
|
||||
return tool_output(self.operation, response).map_err(recoverable_error);
|
||||
}
|
||||
WorkerOperation::Remove => {
|
||||
let input = parse::<WorkerRemoveInput>(input_json, "WorkerRemove")?;
|
||||
@@ -1058,6 +1095,18 @@ fn tool_output(
|
||||
response.status, response.body
|
||||
)));
|
||||
}
|
||||
if operation == WorkerOperation::Restore {
|
||||
let restored: server_api::WorkerRestoreResponse = serde_json::from_str(&response.body)
|
||||
.map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!("invalid WorkerRestore response: {error}"))
|
||||
})?;
|
||||
if restored.result.state != server_api::WorkerRestoreState::Accepted {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"WorkerRestore returned {:?}: {}",
|
||||
restored.result.state, response.body
|
||||
)));
|
||||
}
|
||||
}
|
||||
if operation == WorkerOperation::Stop
|
||||
&& let Ok(summary) = serde_json::from_str::<SubWorkerStopSummary>(&response.body)
|
||||
{
|
||||
@@ -1150,7 +1199,7 @@ fn worker_tool_contribution(
|
||||
}
|
||||
}
|
||||
WorkerOperation::Restore => {
|
||||
definition::<WorkerTargetInput>(operation, control, runtime_worker_control)
|
||||
definition::<WorkerRestoreInput>(operation, control, runtime_worker_control)
|
||||
}
|
||||
WorkerOperation::Remove => {
|
||||
definition::<WorkerRemoveInput>(operation, control, runtime_worker_control)
|
||||
@@ -1410,6 +1459,95 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_restore_forwards_exact_observation_and_request_identity() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||
let control = test_control(client.clone());
|
||||
for _ in 0..2 {
|
||||
control
|
||||
.restore_worker(
|
||||
"runtime/a".into(),
|
||||
"worker/b".into(),
|
||||
server_api::WorkerRestoreRequest {
|
||||
expected_observation_token: "same-observation".into(),
|
||||
request_id: "same-request".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(
|
||||
requests[0].path,
|
||||
"/api/w/workspace%2Ftest/worker-control/workers/runtime%2Fa/worker%2Fb/restore"
|
||||
);
|
||||
assert_eq!(requests[0].body, requests[1].body);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
body,
|
||||
serde_json::json!({"expected_observation_token":"same-observation","request_id":"same-request"})
|
||||
);
|
||||
assert!(serde_json::from_str::<WorkerRestoreInput>(r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-a","worker_id":"worker-a"}}"#).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_restore_reports_retry_identity_and_reuses_it_across_explicit_tool_calls() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||
let tool = WorkspaceWorkerTool {
|
||||
operation: WorkerOperation::Restore,
|
||||
control: test_control(client.clone()),
|
||||
runtime_worker_control: true,
|
||||
};
|
||||
let mut input = serde_json::json!({
|
||||
"subject": {"kind":"runtime_worker", "runtime_id":"runtime-a", "worker_id":"worker-b"},
|
||||
"expected_observation_token":"original-observation"
|
||||
});
|
||||
// The fixture deliberately returns malformed JSON shape: delivery may
|
||||
// have succeeded, so retain the exact tuple rather than issuing new intent.
|
||||
let first = tool
|
||||
.execute(
|
||||
&input.to_string(),
|
||||
ToolExecutionContext::new("original-call", "batch", 0),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
first
|
||||
.to_string()
|
||||
.contains("request_id=worker-restore:original-call")
|
||||
);
|
||||
input["request_id"] = "worker-restore:original-call".into();
|
||||
let second = tool
|
||||
.execute(
|
||||
&input.to_string(),
|
||||
ToolExecutionContext::new("explicit-recovery-call", "batch", 1),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
second
|
||||
.to_string()
|
||||
.contains("request_id=worker-restore:original-call")
|
||||
);
|
||||
{
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(requests[0].body, requests[1].body);
|
||||
}
|
||||
input["request_id"] = "x".repeat(513).into();
|
||||
assert!(
|
||||
tool.execute(
|
||||
&input.to_string(),
|
||||
ToolExecutionContext::new("invalid", "batch", 2)
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_spawn_forwards_typed_initial_submit_to_workspace_api() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! authority. In that case model-visible Memory tools must go through the
|
||||
//! workspace backend instead of resolving `.yoi/memory` from a Worker workdir.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||
@@ -24,11 +25,15 @@ use crate::feature::{
|
||||
FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule,
|
||||
HookDeclaration, ToolContribution, ToolDeclaration,
|
||||
};
|
||||
use crate::hook::{Hook, HookError, HookErrorCategory, HookExecutionPolicy, WorkerRestored};
|
||||
use crate::hook::{
|
||||
BeforeSessionRewrite, BeforeSessionRewriteAction, BeforeSessionRewriteContext, Hook, HookError,
|
||||
HookErrorCategory, HookExecutionPolicy, HookPreRequestAction, PreLlmRequest, PreRequestContext,
|
||||
WorkerRestored,
|
||||
};
|
||||
use crate::prompt::catalog::{PromptCatalog, WorkerPrompt};
|
||||
use crate::worker::{
|
||||
SystemPromptContribution, SystemPromptContributionSource, WorkspaceClient,
|
||||
WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
|
||||
ResidentContextRefresh, SystemPromptContribution, SystemPromptContributionSource,
|
||||
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -74,6 +79,8 @@ pub enum WorkspaceMemoryBackendError {
|
||||
Decode(#[from] serde_json::Error),
|
||||
#[error("workspace memory backend rejected operation: {0}")]
|
||||
Backend(String),
|
||||
#[error("workspace memory backend returned an invalid response: {0}")]
|
||||
InvalidResponse(String),
|
||||
}
|
||||
|
||||
impl dyn WorkspaceClient + '_ {
|
||||
@@ -672,12 +679,17 @@ struct WorkspaceResidentSummarySource {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
}
|
||||
|
||||
struct WorkspaceSubjektivResidentSummarySource {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
#[derive(Default)]
|
||||
struct SubjectBehaviorRefreshState {
|
||||
applied: Option<(u64, String)>,
|
||||
pending: HashMap<u64, String>,
|
||||
}
|
||||
|
||||
const READY_EMPTY_RESIDENT_SURFACE: &str =
|
||||
"The current ready resident memory surface is intentionally empty.";
|
||||
struct WorkspaceSubjektivResidentSummarySource {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||
behavior_state: Mutex<SubjectBehaviorRefreshState>,
|
||||
}
|
||||
|
||||
fn resident_summary_contribution(
|
||||
output: memory::backend::MemoryResidentSummaryOutput,
|
||||
@@ -715,37 +727,133 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource {
|
||||
async fn load(&self) -> SystemPromptContribution {
|
||||
impl WorkspaceSubjektivResidentSummarySource {
|
||||
async fn fetch(
|
||||
&self,
|
||||
) -> Result<server_api::SubjektivResidentContextOutput, WorkspaceMemoryBackendError> {
|
||||
match execute_subjektiv_memory_operation_with(
|
||||
self.client.as_ref(),
|
||||
server_api::SubjektivMemoryBackendOperation::ResidentSummary(Default::default()),
|
||||
server_api::SubjektivMemoryBackendOperation::ResidentContext(Default::default()),
|
||||
|client, request| {
|
||||
client.execute_with_timeout(request, RESIDENT_SUMMARY_REQUEST_TIMEOUT)
|
||||
},
|
||||
) {
|
||||
Ok(server_api::SubjektivMemoryBackendResponse::ResidentSummary(output)) => {
|
||||
match resident_summary_contribution(output) {
|
||||
SystemPromptContribution::Ready(content) if content.trim().is_empty() => {
|
||||
SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.to_string())
|
||||
}
|
||||
contribution => contribution,
|
||||
)? {
|
||||
server_api::SubjektivMemoryBackendResponse::ResidentContext(output) => Ok(output),
|
||||
other => Err(WorkspaceMemoryBackendError::InvalidResponse(format!(
|
||||
"unexpected subject resident context response: {other:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
Ok(other) => {
|
||||
tracing::debug!(?other, "unexpected subject resident Memory response");
|
||||
SystemPromptContribution::Unavailable
|
||||
|
||||
fn render(
|
||||
&self,
|
||||
output: &server_api::SubjektivResidentContextOutput,
|
||||
) -> Result<String, String> {
|
||||
use memory::backend::MemoryResidentSummaryAvailability as Availability;
|
||||
let availability = match output.memory_surface.availability {
|
||||
Availability::Ready => "ready",
|
||||
Availability::Ungenerated => "ungenerated",
|
||||
Availability::Stale => "stale",
|
||||
Availability::Failed => "failed",
|
||||
Availability::Unavailable => "unavailable",
|
||||
};
|
||||
self.prompts
|
||||
.load()
|
||||
.subjektiv_resident_context(
|
||||
&output.behavior_md,
|
||||
availability,
|
||||
output.memory_surface.content.as_deref(),
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn remember_behavior(&self, output: &server_api::SubjektivResidentContextOutput) {
|
||||
let mut state = self
|
||||
.behavior_state
|
||||
.lock()
|
||||
.expect("subject behavior state poisoned");
|
||||
state.applied = Some((output.behavior_revision, output.behavior_md.clone()));
|
||||
state.pending.remove(&output.behavior_revision);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource {
|
||||
async fn load(&self) -> SystemPromptContribution {
|
||||
match self.fetch().await {
|
||||
Ok(output) => match self.render(&output) {
|
||||
Ok(rendered) => {
|
||||
self.remember_behavior(&output);
|
||||
SystemPromptContribution::Ready(rendered)
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::debug!(%error, "subject resident Memory unavailable");
|
||||
SystemPromptContribution::Unavailable
|
||||
tracing::error!(%error, "subject resident context render failed");
|
||||
SystemPromptContribution::Ready(
|
||||
"The Host could not render the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory."
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
tracing::error!(%error, "subject resident context fetch failed");
|
||||
SystemPromptContribution::Ready(
|
||||
"The Host could not fetch the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory."
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_changed_resident_context(
|
||||
&self,
|
||||
) -> Result<Option<ResidentContextRefresh>, String> {
|
||||
let output = self.fetch().await.map_err(|error| error.to_string())?;
|
||||
let changed = self
|
||||
.behavior_state
|
||||
.lock()
|
||||
.expect("subject behavior state poisoned")
|
||||
.applied
|
||||
.as_ref()
|
||||
.is_none_or(|(revision, body)| {
|
||||
*revision != output.behavior_revision || body != &output.behavior_md
|
||||
});
|
||||
if !changed {
|
||||
return Ok(None);
|
||||
}
|
||||
let rendered = self.render(&output)?;
|
||||
let revision = output.behavior_revision;
|
||||
self.behavior_state
|
||||
.lock()
|
||||
.expect("subject behavior state poisoned")
|
||||
.pending
|
||||
.insert(revision, output.behavior_md);
|
||||
Ok(Some(ResidentContextRefresh {
|
||||
body: rendered,
|
||||
revision,
|
||||
}))
|
||||
}
|
||||
|
||||
fn confirm_resident_context_revision(&self, revision: u64) {
|
||||
let mut state = self
|
||||
.behavior_state
|
||||
.lock()
|
||||
.expect("subject behavior state poisoned");
|
||||
if let Some(body) = state.pending.remove(&revision) {
|
||||
state.applied = Some((revision, body));
|
||||
}
|
||||
}
|
||||
|
||||
fn invalidate_resident_context_representation(&self) {
|
||||
self.behavior_state
|
||||
.lock()
|
||||
.expect("subject behavior state poisoned")
|
||||
.applied = None;
|
||||
}
|
||||
}
|
||||
|
||||
const SUBJEKTIV_RESTORE_REFRESH_HOOK: &str = "refresh-resident-surface-after-restore";
|
||||
const SUBJEKTIV_BEHAVIOR_REFRESH_HOOK: &str = "refresh-subject-behavior-before-request";
|
||||
const SUBJEKTIV_BEHAVIOR_REWRITE_HOOK: &str = "invalidate-subject-behavior-before-rewrite";
|
||||
|
||||
/// subjektiv-owned restore behavior. Initial prompt contribution remains a
|
||||
/// shared host facility, while loading and rendering a post-restore refresh is
|
||||
@@ -793,6 +901,14 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature {
|
||||
SUBJEKTIV_RESTORE_REFRESH_HOOK,
|
||||
FeatureHookPoint::WorkerRestored,
|
||||
))
|
||||
.with_hook(HookDeclaration::new(
|
||||
SUBJEKTIV_BEHAVIOR_REFRESH_HOOK,
|
||||
FeatureHookPoint::PreLlmRequest,
|
||||
))
|
||||
.with_hook(HookDeclaration::new(
|
||||
SUBJEKTIV_BEHAVIOR_REWRITE_HOOK,
|
||||
FeatureHookPoint::BeforeSessionRewrite,
|
||||
))
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
@@ -804,10 +920,64 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature {
|
||||
prompts: Arc::clone(&self.prompts),
|
||||
workspace_id: self.workspace_id.clone(),
|
||||
},
|
||||
)?;
|
||||
context.hooks().add_pre_llm_request(
|
||||
SUBJEKTIV_BEHAVIOR_REFRESH_HOOK,
|
||||
HookExecutionPolicy::fail_closed(),
|
||||
SubjektivBehaviorRefreshHook {
|
||||
source: Arc::clone(&self.source),
|
||||
},
|
||||
)?;
|
||||
context.hooks().add_before_session_rewrite(
|
||||
SUBJEKTIV_BEHAVIOR_REWRITE_HOOK,
|
||||
HookExecutionPolicy::fail_closed(),
|
||||
SubjektivBehaviorRewriteHook {
|
||||
source: Arc::clone(&self.source),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct SubjektivBehaviorRewriteHook {
|
||||
source: Arc<dyn SystemPromptContributionSource>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hook<BeforeSessionRewrite> for SubjektivBehaviorRewriteHook {
|
||||
async fn call(
|
||||
&self,
|
||||
_context: &BeforeSessionRewriteContext,
|
||||
) -> Result<BeforeSessionRewriteAction, HookError> {
|
||||
self.source.invalidate_resident_context_representation();
|
||||
Ok(BeforeSessionRewriteAction::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
struct SubjektivBehaviorRefreshHook {
|
||||
source: Arc<dyn SystemPromptContributionSource>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hook<PreLlmRequest> for SubjektivBehaviorRefreshHook {
|
||||
async fn call(&self, context: &PreRequestContext) -> Result<HookPreRequestAction, HookError> {
|
||||
let refresh = self
|
||||
.source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.map_err(|error| HookError::new(HookErrorCategory::Dependency, error))?;
|
||||
if let Some(refresh) = refresh {
|
||||
let system_items = context.system_items().ok_or_else(|| {
|
||||
HookError::new(
|
||||
HookErrorCategory::Dependency,
|
||||
"Subject behavior refresh requires durable session append authority",
|
||||
)
|
||||
})?;
|
||||
system_items.append_subject_behavior_refresh(refresh.body, refresh.revision);
|
||||
}
|
||||
Ok(HookPreRequestAction::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
struct SubjektivResidentRestoreRefreshHook {
|
||||
source: Arc<dyn SystemPromptContributionSource>,
|
||||
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||
@@ -945,12 +1115,13 @@ pub(crate) fn is_builtin_subjektiv_consolidation_profile(
|
||||
|
||||
pub(crate) fn ordinary_subjektiv_features_enabled(manifest: &manifest::WorkerManifest) -> bool {
|
||||
manifest.feature.subjektiv.execution_enabled()
|
||||
&& !is_builtin_subjektiv_consolidation_profile(manifest)
|
||||
&& !manifest.feature.subjektiv.profile.consolidation_tools
|
||||
}
|
||||
|
||||
pub(crate) fn ordinary_subjektiv_resident_summary_source(
|
||||
manifest: &manifest::WorkerManifest,
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||
) -> std::io::Result<Option<Arc<dyn SystemPromptContributionSource>>> {
|
||||
if !ordinary_subjektiv_features_enabled(manifest) {
|
||||
return Ok(None);
|
||||
@@ -988,6 +1159,8 @@ pub(crate) fn ordinary_subjektiv_resident_summary_source(
|
||||
}
|
||||
Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource {
|
||||
client,
|
||||
prompts,
|
||||
behavior_state: Mutex::new(SubjectBehaviorRefreshState::default()),
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -1002,7 +1175,7 @@ impl SubjektivConsolidationFeatureInstallPlan {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
prompts: Arc<crate::prompt::catalog::PromptCatalog>,
|
||||
) -> std::io::Result<Option<Self>> {
|
||||
if !is_builtin_subjektiv_consolidation_profile(manifest) {
|
||||
if !manifest.feature.subjektiv.profile.consolidation_tools {
|
||||
return Ok(None);
|
||||
}
|
||||
let config = &manifest.feature.subjektiv;
|
||||
@@ -1120,6 +1293,10 @@ mod tests {
|
||||
use agen::tool::ToolDefinition;
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
|
||||
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap()))
|
||||
}
|
||||
|
||||
fn test_client() -> Arc<dyn WorkspaceClient> {
|
||||
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
|
||||
"workspace",
|
||||
@@ -1132,6 +1309,7 @@ mod tests {
|
||||
availability: memory::backend::MemoryResidentSummaryAvailability,
|
||||
content: Option<String>,
|
||||
scope_allowed: bool,
|
||||
behavior: Mutex<(u64, String)>,
|
||||
paths: Mutex<Vec<String>>,
|
||||
timeouts: Mutex<Vec<Duration>>,
|
||||
}
|
||||
@@ -1145,6 +1323,7 @@ mod tests {
|
||||
availability,
|
||||
content: content.map(str::to_string),
|
||||
scope_allowed: true,
|
||||
behavior: Mutex::new((3, "Be deliberate.".to_string())),
|
||||
paths: Mutex::new(Vec::new()),
|
||||
timeouts: Mutex::new(Vec::new()),
|
||||
}
|
||||
@@ -1155,6 +1334,7 @@ mod tests {
|
||||
availability: memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||
content: Some("must not be injected".into()),
|
||||
scope_allowed: false,
|
||||
behavior: Mutex::new((3, "must not be injected".to_string())),
|
||||
paths: Mutex::new(Vec::new()),
|
||||
timeouts: Mutex::new(Vec::new()),
|
||||
}
|
||||
@@ -1184,7 +1364,7 @@ mod tests {
|
||||
serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap();
|
||||
assert!(matches!(
|
||||
request.operation,
|
||||
server_api::SubjektivMemoryBackendOperation::ResidentSummary(_)
|
||||
server_api::SubjektivMemoryBackendOperation::ResidentContext(_)
|
||||
));
|
||||
if !self.scope_allowed {
|
||||
return Ok(WorkspaceResponse {
|
||||
@@ -1192,14 +1372,19 @@ mod tests {
|
||||
body: "subject scope unavailable".into(),
|
||||
});
|
||||
}
|
||||
let (behavior_revision, behavior_md) = self.behavior.lock().unwrap().clone();
|
||||
Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::to_string(
|
||||
&server_api::SubjektivMemoryBackendResponse::ResidentSummary(
|
||||
memory::backend::MemoryResidentSummaryOutput {
|
||||
&server_api::SubjektivMemoryBackendResponse::ResidentContext(
|
||||
server_api::SubjektivResidentContextOutput {
|
||||
behavior_md,
|
||||
behavior_revision,
|
||||
memory_surface: memory::backend::MemoryResidentSummaryOutput {
|
||||
availability: self.availability,
|
||||
content: self.content.clone(),
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
.unwrap(),
|
||||
@@ -1511,13 +1696,19 @@ permission = "write"
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||
Some("current subject surface"),
|
||||
));
|
||||
let ready = ordinary_subjektiv_resident_summary_source(&manifest, ready_client.clone())
|
||||
let ready = ordinary_subjektiv_resident_summary_source(
|
||||
&manifest,
|
||||
ready_client.clone(),
|
||||
test_prompts(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ready.load().await,
|
||||
SystemPromptContribution::Ready("current subject surface".into())
|
||||
);
|
||||
let loaded = ready.load().await;
|
||||
let SystemPromptContribution::Ready(loaded) = loaded else {
|
||||
panic!("subject resident context must be present");
|
||||
};
|
||||
assert!(loaded.contains("Be deliberate."));
|
||||
assert!(loaded.contains("current subject surface"));
|
||||
assert_eq!(
|
||||
ready_client.paths.lock().unwrap().clone(),
|
||||
vec!["/api/w/workspace/subjektiv/memory".to_string()]
|
||||
@@ -1531,13 +1722,18 @@ permission = "write"
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ready,
|
||||
None,
|
||||
));
|
||||
let ready_empty = ordinary_subjektiv_resident_summary_source(&manifest, ready_empty_client)
|
||||
let ready_empty = ordinary_subjektiv_resident_summary_source(
|
||||
&manifest,
|
||||
ready_empty_client,
|
||||
test_prompts(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ready_empty.load().await,
|
||||
SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.into())
|
||||
);
|
||||
let SystemPromptContribution::Ready(ready_empty) = ready_empty.load().await else {
|
||||
panic!("empty surface must retain Subject behavior");
|
||||
};
|
||||
assert!(ready_empty.contains("Be deliberate."));
|
||||
assert!(ready_empty.contains("intentionally empty"));
|
||||
|
||||
for availability in [
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
|
||||
@@ -1550,22 +1746,107 @@ permission = "write"
|
||||
availability,
|
||||
Some("must not be injected"),
|
||||
)),
|
||||
test_prompts(),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(source.load().await, SystemPromptContribution::Unavailable);
|
||||
let SystemPromptContribution::Ready(loaded) = source.load().await else {
|
||||
panic!("Subject behavior must survive unavailable surfaces");
|
||||
};
|
||||
assert!(loaded.contains("Be deliberate."));
|
||||
assert!(!loaded.contains("must not be injected"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subject_resident_source_emits_only_changed_behavior_revisions() {
|
||||
let manifest = subject_manifest("workspace");
|
||||
let client = Arc::new(SubjectResidentClient::new(
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
|
||||
None,
|
||||
));
|
||||
let source =
|
||||
ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let initial = source.load().await;
|
||||
assert!(matches!(initial, SystemPromptContribution::Ready(_)));
|
||||
assert!(
|
||||
source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
*client.behavior.lock().unwrap() = (4, "Ask when uncertain.".to_string());
|
||||
let refresh = source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("changed behavior must produce one refresh");
|
||||
assert_eq!(refresh.revision, 4);
|
||||
assert!(refresh.body.contains("Ask when uncertain."));
|
||||
source.confirm_resident_context_revision(refresh.revision);
|
||||
assert!(
|
||||
source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
*client.behavior.lock().unwrap() = (5, String::new());
|
||||
let cleared = source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("cleared behavior must produce one refresh");
|
||||
assert_eq!(cleared.revision, 5);
|
||||
assert!(cleared.body.contains("No user-managed behavior is set"));
|
||||
source.confirm_resident_context_revision(cleared.revision);
|
||||
assert!(
|
||||
source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
source.invalidate_resident_context_representation();
|
||||
let replay_after_rewrite = source
|
||||
.load_changed_resident_context()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("history rewrite must require a durable current representation");
|
||||
assert_eq!(replay_after_rewrite.revision, 5);
|
||||
assert!(
|
||||
replay_after_rewrite
|
||||
.body
|
||||
.contains("No user-managed behavior is set")
|
||||
);
|
||||
// Initial load and every live update/clear/replay probe retain the
|
||||
// target-side bounded resident request contract.
|
||||
assert_eq!(
|
||||
client.timeouts.lock().unwrap().as_slice(),
|
||||
&[RESIDENT_SUMMARY_REQUEST_TIMEOUT; 7]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subject_resident_source_fails_closed_without_trusted_subject_scope() {
|
||||
let manifest = subject_manifest("workspace");
|
||||
let client = Arc::new(SubjectResidentClient::denied());
|
||||
let source = ordinary_subjektiv_resident_summary_source(&manifest, client.clone())
|
||||
let source =
|
||||
ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(source.load().await, SystemPromptContribution::Unavailable);
|
||||
let SystemPromptContribution::Ready(failure) = source.load().await else {
|
||||
panic!("fetch failure must be explicit in resident context");
|
||||
};
|
||||
assert!(failure.contains("could not fetch"));
|
||||
assert_eq!(
|
||||
client.paths.lock().unwrap().clone(),
|
||||
vec!["/api/w/workspace/subjektiv/memory".to_string()]
|
||||
@@ -1593,7 +1874,7 @@ permission = "write"
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
ordinary_subjektiv_resident_summary_source(&disabled, test_client())
|
||||
ordinary_subjektiv_resident_summary_source(&disabled, test_client(), test_prompts())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
@@ -1605,20 +1886,28 @@ permission = "write"
|
||||
Some("policy-only surface must not load"),
|
||||
));
|
||||
assert!(
|
||||
ordinary_subjektiv_resident_summary_source(&policy_only, unattached_client.clone())
|
||||
ordinary_subjektiv_resident_summary_source(
|
||||
&policy_only,
|
||||
unattached_client.clone(),
|
||||
test_prompts()
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(unattached_client.paths.lock().unwrap().is_empty());
|
||||
|
||||
let foreign = subject_manifest("other-workspace");
|
||||
assert!(ordinary_subjektiv_resident_summary_source(&foreign, test_client()).is_err());
|
||||
assert!(
|
||||
ordinary_subjektiv_resident_summary_source(&foreign, test_client(), test_prompts())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_restore_refresh_is_suppressed_for_internal_hosts() {
|
||||
let manifest = subject_manifest("workspace");
|
||||
let source = ordinary_subjektiv_resident_summary_source(&manifest, test_client())
|
||||
let source =
|
||||
ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let prompts = Arc::new(ArcSwap::from(
|
||||
@@ -1634,7 +1923,8 @@ permission = "write"
|
||||
#[test]
|
||||
fn subject_restore_refresh_feature_declares_only_restore_lifecycle() {
|
||||
let manifest = subject_manifest("workspace");
|
||||
let source = ordinary_subjektiv_resident_summary_source(&manifest, test_client())
|
||||
let source =
|
||||
ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let feature = SubjektivResidentRestoreRefreshFeature::new(
|
||||
@@ -1647,8 +1937,13 @@ permission = "write"
|
||||
let descriptor = feature.descriptor();
|
||||
|
||||
assert!(descriptor.tools.is_empty());
|
||||
assert_eq!(descriptor.hooks.len(), 1);
|
||||
assert_eq!(descriptor.hooks.len(), 3);
|
||||
assert_eq!(descriptor.hooks[0].point, FeatureHookPoint::WorkerRestored);
|
||||
assert_eq!(descriptor.hooks[1].point, FeatureHookPoint::PreLlmRequest);
|
||||
assert_eq!(
|
||||
descriptor.hooks[2].point,
|
||||
FeatureHookPoint::BeforeSessionRewrite
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -104,6 +104,23 @@ impl SubjektivSurfaceLifecycleFeature {
|
||||
if !lifecycle_enabled || !dedicated {
|
||||
return Ok(None);
|
||||
}
|
||||
Self::for_job(
|
||||
workspace_client,
|
||||
manifest,
|
||||
client,
|
||||
prompts,
|
||||
workspace_context,
|
||||
)
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
pub(crate) fn for_job(
|
||||
workspace_client: Arc<dyn WorkspaceClient>,
|
||||
manifest: WorkerManifest,
|
||||
client: Box<dyn LlmClient>,
|
||||
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
) -> std::io::Result<Self> {
|
||||
manifest
|
||||
.feature
|
||||
.subjektiv
|
||||
@@ -121,7 +138,7 @@ impl SubjektivSurfaceLifecycleFeature {
|
||||
"subjektiv surface generation requires Backend Workspace API authority",
|
||||
));
|
||||
}
|
||||
Ok(Some(Self {
|
||||
Ok(Self {
|
||||
task: SubjektivSurfaceLifecycleTask {
|
||||
workspace_client,
|
||||
manifest,
|
||||
@@ -129,7 +146,18 @@ impl SubjektivSurfaceLifecycleFeature {
|
||||
prompts,
|
||||
workspace_context,
|
||||
},
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
/// The Job tool awaits this clean-context lifecycle before submitting its result.
|
||||
pub(crate) async fn complete_for_job(
|
||||
&self,
|
||||
cancellation: BackgroundTaskCancellation,
|
||||
reusable_surface: Option<&serde_json::Value>,
|
||||
) -> Result<serde_json::Value, HookError> {
|
||||
self.task
|
||||
.generate(None, cancellation, reusable_surface)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,80 +210,125 @@ impl FeatureBackgroundTask for SubjektivSurfaceLifecycleTask {
|
||||
context: BackgroundTaskContext,
|
||||
cancellation: BackgroundTaskCancellation,
|
||||
) -> Result<(), HookError> {
|
||||
self.generate(Some(&context), cancellation, None)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
impl SubjektivSurfaceLifecycleTask {
|
||||
async fn generate(
|
||||
&self,
|
||||
context: Option<&BackgroundTaskContext>,
|
||||
cancellation: BackgroundTaskCancellation,
|
||||
reusable_surface: Option<&serde_json::Value>,
|
||||
) -> Result<serde_json::Value, HookError> {
|
||||
for attempt in 0..MAX_GENERATION_ATTEMPTS {
|
||||
if let Some(context) = context {
|
||||
context.generation_fence.ensure_current()?;
|
||||
}
|
||||
if cancellation.is_cancelled() {
|
||||
return Ok(());
|
||||
return Err(HookError::new(
|
||||
HookErrorCategory::Cancelled,
|
||||
"surface generation cancelled",
|
||||
));
|
||||
}
|
||||
let generation = self
|
||||
.workspace_client
|
||||
.prepare_subjektiv_memory_surface()
|
||||
.await
|
||||
.map_err(surface_hook_error)?;
|
||||
if context.is_none() {
|
||||
if let Some(snapshot_id) = &generation.current_snapshot_id {
|
||||
return Ok(serde_json::json!({
|
||||
"availability": "ready", "generation_id": generation.generation_id,
|
||||
"store_revision": generation.store_revision, "snapshot_id": snapshot_id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
// Preparation is Backend-fenced to the live attempt and immutable batch.
|
||||
// Only a ready snapshot is immutable within its store revision. Never
|
||||
// reuse a failed marker, which a later generation can replace.
|
||||
if let Some(surface) = reusable_surface.filter(|surface| {
|
||||
surface["availability"] == "ready"
|
||||
&& surface["store_revision"].as_u64() == Some(generation.store_revision)
|
||||
}) {
|
||||
return Ok(surface.clone());
|
||||
}
|
||||
if generation.materials.is_empty() && generation.active_memory_count > 0 {
|
||||
self.record_failure(&generation.generation_id, "input_budget_exhausted")
|
||||
return self
|
||||
.record_failure(&generation, "input_budget_exhausted", context.is_none())
|
||||
.await;
|
||||
tracing::warn!(
|
||||
active_memory_count = generation.active_memory_count,
|
||||
"subject Memory surface materials could not fit the bounded input"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let points = if generation.materials.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
match self.edit_surface(&generation, cancellation.clone()).await {
|
||||
Ok(points) => points,
|
||||
Err(failure) => {
|
||||
self.record_failure(&generation.generation_id, failure.reason_code)
|
||||
.await;
|
||||
match tokio::time::timeout(
|
||||
TASK_TIMEOUT,
|
||||
self.edit_surface(&generation, cancellation.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(points)) => points,
|
||||
Ok(Err(failure)) => {
|
||||
if cancellation.is_cancelled() {
|
||||
return Err(HookError::new(
|
||||
HookErrorCategory::Cancelled,
|
||||
"surface generation cancelled",
|
||||
));
|
||||
}
|
||||
tracing::warn!(error = %failure.error, reason_code = failure.reason_code, "subject Memory surface editor failed");
|
||||
return Ok(());
|
||||
return self
|
||||
.record_failure(&generation, failure.reason_code, context.is_none())
|
||||
.await;
|
||||
}
|
||||
Err(_) => {
|
||||
return self
|
||||
.record_failure(&generation, "editor_timeout", context.is_none())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(context) = context {
|
||||
context.generation_fence.ensure_current()?;
|
||||
if cancellation.is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
let publish = self
|
||||
if cancellation.is_cancelled() {
|
||||
return Err(HookError::new(
|
||||
HookErrorCategory::Cancelled,
|
||||
"surface generation cancelled",
|
||||
));
|
||||
}
|
||||
match self
|
||||
.workspace_client
|
||||
.publish_subjektiv_memory_surface(server_api::SubjektivSurfacePublishRequest {
|
||||
generation_id: generation.generation_id.clone(),
|
||||
points,
|
||||
})
|
||||
.await;
|
||||
match publish {
|
||||
.await
|
||||
{
|
||||
Ok(output) => {
|
||||
tracing::debug!(
|
||||
snapshot_id = output.snapshot_id,
|
||||
store_revision = output.built_from_store_revision,
|
||||
empty = output.empty,
|
||||
"published subject Memory surface"
|
||||
);
|
||||
return Ok(());
|
||||
return Ok(serde_json::json!({
|
||||
"availability": "ready", "generation_id": generation.generation_id,
|
||||
"store_revision": output.built_from_store_revision, "snapshot_id": output.snapshot_id,
|
||||
}));
|
||||
}
|
||||
Err(WorkspaceMemoryBackendError::Http { status, .. })
|
||||
if status == reqwest::StatusCode::CONFLICT
|
||||
&& attempt + 1 < MAX_GENERATION_ATTEMPTS =>
|
||||
{
|
||||
// Confirmed Memory moved while editing. Discard the stale
|
||||
// output and rebuild once from a fresh bounded generation.
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
self.record_failure(&generation.generation_id, "publish_failed")
|
||||
.await;
|
||||
tracing::warn!(%error, "subject Memory surface publication failed");
|
||||
return Ok(());
|
||||
return self
|
||||
.record_failure(&generation, "publish_failed", context.is_none())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
unreachable!("bounded generation loop always returns on its last attempt")
|
||||
}
|
||||
|
||||
impl SubjektivSurfaceLifecycleTask {
|
||||
async fn edit_surface(
|
||||
&self,
|
||||
generation: &server_api::SubjektivSurfacePrepareResponse,
|
||||
@@ -344,17 +417,39 @@ impl SubjektivSurfaceLifecycleTask {
|
||||
.map_err(SurfaceEditorFailure::editor)
|
||||
}
|
||||
|
||||
async fn record_failure(&self, generation_id: &str, reason_code: &str) {
|
||||
if let Err(error) = self
|
||||
async fn record_failure(
|
||||
&self,
|
||||
generation: &server_api::SubjektivSurfacePrepareResponse,
|
||||
reason_code: &str,
|
||||
job_confirmation_required: bool,
|
||||
) -> Result<serde_json::Value, HookError> {
|
||||
let failure = self
|
||||
.workspace_client
|
||||
.fail_subjektiv_memory_surface(server_api::SubjektivSurfaceFailureRequest {
|
||||
generation_id: generation_id.to_string(),
|
||||
generation_id: generation.generation_id.clone(),
|
||||
reason_code: reason_code.to_string(),
|
||||
})
|
||||
.await
|
||||
.map_err(surface_hook_error)?;
|
||||
// Success must attest the actual persisted marker, not merely receipt of
|
||||
// a failure notification. A protected ready surface or moved revision
|
||||
// must yield a Backend rejection/non-failed status, never a failed claim.
|
||||
let confirmed_status = if job_confirmation_required {
|
||||
"failed_confirmed"
|
||||
} else {
|
||||
"failed"
|
||||
};
|
||||
if failure.status != confirmed_status || failure.store_revision != generation.store_revision
|
||||
{
|
||||
tracing::debug!(%error, "could not record subject Memory surface failure");
|
||||
return Err(HookError::new(
|
||||
HookErrorCategory::Internal,
|
||||
"Backend did not confirm a failed surface for this generation's revision; no Job result was submitted",
|
||||
));
|
||||
}
|
||||
Ok(serde_json::json!({
|
||||
"availability": "failed", "generation_id": generation.generation_id,
|
||||
"store_revision": failure.store_revision, "reason_code": reason_code,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,6 +632,7 @@ mod tests {
|
||||
) -> server_api::SubjektivSurfacePrepareResponse {
|
||||
server_api::SubjektivSurfacePrepareResponse {
|
||||
generation_id: "generation-1".into(),
|
||||
current_snapshot_id: None,
|
||||
store_revision: 1,
|
||||
active_memory_count: materials.len(),
|
||||
materials,
|
||||
@@ -561,6 +657,13 @@ mod tests {
|
||||
prepare_calls: AtomicUsize,
|
||||
publish_calls: AtomicUsize,
|
||||
failure_reasons: Mutex<Vec<String>>,
|
||||
job_results: Mutex<Vec<serde_json::Value>>,
|
||||
result_responses:
|
||||
Mutex<std::collections::VecDeque<Result<WorkspaceResponse, WorkspaceClientError>>>,
|
||||
current_revision: AtomicUsize,
|
||||
failure_status: Mutex<String>,
|
||||
failure_revision: AtomicUsize,
|
||||
ready_snapshot: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
impl SurfaceWorkspaceClient {
|
||||
@@ -571,6 +674,12 @@ mod tests {
|
||||
prepare_calls: AtomicUsize::new(0),
|
||||
publish_calls: AtomicUsize::new(0),
|
||||
failure_reasons: Mutex::new(Vec::new()),
|
||||
job_results: Mutex::new(Vec::new()),
|
||||
result_responses: Mutex::new(Default::default()),
|
||||
current_revision: AtomicUsize::new(0),
|
||||
failure_status: Mutex::new("failed".into()),
|
||||
failure_revision: AtomicUsize::new(0),
|
||||
ready_snapshot: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,6 +690,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn revision(&self, fallback: usize) -> u64 {
|
||||
let current = self.current_revision.load(Ordering::SeqCst);
|
||||
if current == 0 {
|
||||
fallback as u64
|
||||
} else {
|
||||
current as u64
|
||||
}
|
||||
}
|
||||
|
||||
fn response(value: server_api::SubjektivMemoryBackendResponse) -> WorkspaceResponse {
|
||||
WorkspaceResponse {
|
||||
status: 200,
|
||||
@@ -606,6 +724,28 @@ mod tests {
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
if request.path.ends_with("/workers/self/backend-job-result") {
|
||||
assert!(
|
||||
self.publish_calls.load(Ordering::SeqCst) > 0
|
||||
|| !self.failure_reasons.lock().unwrap().is_empty(),
|
||||
"result must follow publication or recorded failure"
|
||||
);
|
||||
self.job_results
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(serde_json::from_str(request.body.as_deref().unwrap()).unwrap());
|
||||
return self
|
||||
.result_responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| {
|
||||
Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: "{\"replayed\":false}".into(),
|
||||
})
|
||||
});
|
||||
}
|
||||
let request: server_api::SubjektivMemoryBackendRequest =
|
||||
serde_json::from_str(request.body.as_deref().unwrap_or_default())
|
||||
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
|
||||
@@ -615,7 +755,8 @@ mod tests {
|
||||
let mut prepared =
|
||||
generation(self.input_token_budget, vec![material("memory-1", 32)]);
|
||||
prepared.generation_id = format!("generation-{call}");
|
||||
prepared.store_revision = call as u64;
|
||||
prepared.store_revision = self.revision(call);
|
||||
prepared.current_snapshot_id = self.ready_snapshot.lock().unwrap().clone();
|
||||
Ok(Self::response(
|
||||
server_api::SubjektivMemoryBackendResponse::SurfacePrepared(prepared),
|
||||
))
|
||||
@@ -633,7 +774,7 @@ mod tests {
|
||||
server_api::SubjektivMemoryBackendResponse::SurfacePublished(
|
||||
server_api::SubjektivSurfacePublishResponse {
|
||||
snapshot_id: format!("surface-{call}"),
|
||||
built_from_store_revision: call as u64,
|
||||
built_from_store_revision: self.revision(call),
|
||||
empty: false,
|
||||
},
|
||||
),
|
||||
@@ -644,8 +785,13 @@ mod tests {
|
||||
Ok(Self::response(
|
||||
server_api::SubjektivMemoryBackendResponse::SurfaceFailed(
|
||||
server_api::SubjektivSurfaceFailureResponse {
|
||||
store_revision: self.prepare_calls.load(Ordering::SeqCst) as u64,
|
||||
status: "failed".into(),
|
||||
store_revision: if self.failure_revision.load(Ordering::SeqCst) > 0
|
||||
{
|
||||
self.failure_revision.load(Ordering::SeqCst) as u64
|
||||
} else {
|
||||
self.revision(self.prepare_calls.load(Ordering::SeqCst))
|
||||
},
|
||||
status: self.failure_status.lock().unwrap().clone(),
|
||||
},
|
||||
),
|
||||
))
|
||||
@@ -974,4 +1120,340 @@ permission = "write"
|
||||
let error = bounded_editor_input("system policy", &generation).unwrap_err();
|
||||
assert!(error.to_string().contains("input budget"));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn job_result_waits_for_surface_and_retries_the_exact_outcome() {
|
||||
for failed in [false, true] {
|
||||
let workspace = Arc::new(if failed {
|
||||
SurfaceWorkspaceClient::with_input_token_budget(1)
|
||||
} else {
|
||||
SurfaceWorkspaceClient::new(0)
|
||||
});
|
||||
*workspace.failure_status.lock().unwrap() = "failed_confirmed".into();
|
||||
let lifecycle = SubjektivSurfaceLifecycleFeature {
|
||||
task: test_task(workspace.clone(), Arc::new(AtomicUsize::new(0))),
|
||||
};
|
||||
let mut pending = Vec::new();
|
||||
let mut hooks = crate::hook::HookRegistryBuilder::default();
|
||||
FeatureRegistryBuilder::new()
|
||||
.with_module(
|
||||
crate::feature::builtin::backend_job_result::BackendJobResultFeature::new(
|
||||
workspace.clone(),
|
||||
)
|
||||
.with_surface(lifecycle),
|
||||
)
|
||||
.install_into_pending(&mut pending, &mut hooks);
|
||||
let (_, tool) = pending.remove(0)();
|
||||
let input = r#"{"result":{"subject_id":"subject-1","candidate_ids":["candidate-1"]}}"#;
|
||||
for _ in 0..2 {
|
||||
tool.execute(input, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let results = workspace.job_results.lock().unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
assert_eq!(results[0], results[1]);
|
||||
assert_eq!(
|
||||
results[0]["result"]["surface"]["availability"],
|
||||
if failed { "failed" } else { "ready" }
|
||||
);
|
||||
assert_eq!(
|
||||
results[0]["result"]["surface"]["generation_id"],
|
||||
"generation-1"
|
||||
);
|
||||
assert_eq!(results[0]["result"]["surface"]["store_revision"], 1);
|
||||
assert_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 1);
|
||||
assert!(
|
||||
tool.execute(
|
||||
r#"{"result":{"subject_id":"subject-1","candidate_ids":[]}}"#,
|
||||
agen::tool::ToolExecutionContext::direct()
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn job_uses_backend_confirmed_current_surface_without_regeneration() {
|
||||
let workspace = Arc::new(SurfaceWorkspaceClient::new(0));
|
||||
*workspace.ready_snapshot.lock().unwrap() = Some("already-current".into());
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let task = test_task(workspace.clone(), calls.clone());
|
||||
let outcome = task
|
||||
.generate(None, BackgroundTaskCancellation::default(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome["availability"], "ready");
|
||||
assert_eq!(outcome["generation_id"], "generation-1");
|
||||
assert_eq!(outcome["snapshot_id"], "already-current");
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(workspace.publish_calls.load(Ordering::SeqCst), 0);
|
||||
assert!(workspace.failure_reasons.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn job_result_rejects_model_surface_claims_before_generation() {
|
||||
let workspace = Arc::new(SurfaceWorkspaceClient::new(0));
|
||||
let lifecycle = SubjektivSurfaceLifecycleFeature {
|
||||
task: test_task(workspace.clone(), Arc::new(AtomicUsize::new(0))),
|
||||
};
|
||||
let mut pending = Vec::new();
|
||||
let mut hooks = crate::hook::HookRegistryBuilder::default();
|
||||
FeatureRegistryBuilder::new()
|
||||
.with_module(
|
||||
crate::feature::builtin::backend_job_result::BackendJobResultFeature::new(
|
||||
workspace.clone(),
|
||||
)
|
||||
.with_surface(lifecycle),
|
||||
)
|
||||
.install_into_pending(&mut pending, &mut hooks);
|
||||
let (_, tool) = pending.remove(0)();
|
||||
assert!(tool.execute(r#"{"result":{"subject_id":"subject-1","candidate_ids":[],"surface":{"availability":"ready"}}}"#, agen::tool::ToolExecutionContext::direct()).await.is_err());
|
||||
assert_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 0);
|
||||
assert!(workspace.job_results.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PendingSurfaceEditorClient(Arc<tokio::sync::Notify>);
|
||||
#[async_trait]
|
||||
impl LlmClient for PendingSurfaceEditorClient {
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
async fn stream(
|
||||
&self,
|
||||
_request: Request,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
|
||||
{
|
||||
self.0.notify_one();
|
||||
Ok(Box::pin(futures::stream::pending()))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelling_job_surface_waits_for_editor_and_never_submits_result() {
|
||||
let workspace = Arc::new(SurfaceWorkspaceClient::new(0));
|
||||
let entered = Arc::new(tokio::sync::Notify::new());
|
||||
let mut task = test_task(workspace.clone(), Arc::new(AtomicUsize::new(0)));
|
||||
task.client = Box::new(PendingSurfaceEditorClient(entered.clone()));
|
||||
let mut pending = Vec::new();
|
||||
let mut hooks = crate::hook::HookRegistryBuilder::default();
|
||||
FeatureRegistryBuilder::new()
|
||||
.with_module(
|
||||
crate::feature::builtin::backend_job_result::BackendJobResultFeature::new(
|
||||
workspace.clone(),
|
||||
)
|
||||
.with_surface(SubjektivSurfaceLifecycleFeature { task }),
|
||||
)
|
||||
.install_into_pending(&mut pending, &mut hooks);
|
||||
let (_, tool) = pending.remove(0)();
|
||||
let running_tool = tool.clone();
|
||||
let context = agen::tool::ToolExecutionContext::new("result-call", "batch", 0);
|
||||
let running_context = context.clone();
|
||||
let running = tokio::spawn(async move {
|
||||
running_tool
|
||||
.execute(
|
||||
r#"{"result":{"subject_id":"subject-1","candidate_ids":[]}}"#,
|
||||
running_context,
|
||||
)
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(5), entered.notified())
|
||||
.await
|
||||
.unwrap();
|
||||
tool.cancel_execution(&context).await.unwrap();
|
||||
let outcome = tokio::time::timeout(Duration::from_secs(5), running)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(matches!(outcome, Err(agen::tool::ToolError::Cancelled(_))));
|
||||
assert_eq!(workspace.publish_calls.load(Ordering::SeqCst), 0);
|
||||
assert!(workspace.job_results.lock().unwrap().is_empty());
|
||||
assert!(workspace.failure_reasons.lock().unwrap().is_empty());
|
||||
}
|
||||
fn job_result_tool(workspace: Arc<SurfaceWorkspaceClient>) -> Arc<dyn agen::tool::Tool> {
|
||||
*workspace.failure_status.lock().unwrap() = "failed_confirmed".into();
|
||||
let mut pending = Vec::new();
|
||||
let mut hooks = crate::hook::HookRegistryBuilder::default();
|
||||
FeatureRegistryBuilder::new()
|
||||
.with_module(
|
||||
crate::feature::builtin::backend_job_result::BackendJobResultFeature::new(
|
||||
workspace.clone(),
|
||||
)
|
||||
.with_surface(SubjektivSurfaceLifecycleFeature {
|
||||
task: test_task(workspace, Arc::new(AtomicUsize::new(0))),
|
||||
}),
|
||||
)
|
||||
.install_into_pending(&mut pending, &mut hooks);
|
||||
pending.remove(0)().1
|
||||
}
|
||||
|
||||
const CORRECT_RESULT: &str =
|
||||
r#"{"result":{"subject_id":"subject-1","candidate_ids":["candidate-1"]}}"#;
|
||||
const WRONG_RESULT: &str = r#"{"result":{"subject_id":"wrong-subject","candidate_ids":[]}}"#;
|
||||
|
||||
fn rejection(status: u16) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
Ok(WorkspaceResponse {
|
||||
status,
|
||||
body: "explicit rejection before acceptance".into(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn job_result_definitive_rejection_allows_correction_and_reuses_current_ready_surface() {
|
||||
for status in [400, 403, 422] {
|
||||
let workspace = Arc::new(SurfaceWorkspaceClient::new(0));
|
||||
workspace.current_revision.store(1, Ordering::SeqCst);
|
||||
workspace
|
||||
.result_responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back(rejection(status));
|
||||
let tool = job_result_tool(workspace.clone());
|
||||
assert!(
|
||||
tool.execute(WRONG_RESULT, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
let results = workspace.job_results.lock().unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
assert_ne!(
|
||||
results[0]["result"]["subject_id"],
|
||||
results[1]["result"]["subject_id"]
|
||||
);
|
||||
assert_eq!(
|
||||
results[0]["result"]["surface"],
|
||||
results[1]["result"]["surface"]
|
||||
);
|
||||
assert_eq!(
|
||||
workspace.prepare_calls.load(Ordering::SeqCst),
|
||||
2,
|
||||
"reuse must verify current revision"
|
||||
);
|
||||
assert_eq!(
|
||||
workspace.publish_calls.load(Ordering::SeqCst),
|
||||
1,
|
||||
"correction must not duplicate publication"
|
||||
);
|
||||
drop(results);
|
||||
assert!(
|
||||
tool.execute(WRONG_RESULT, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err(),
|
||||
"accepted results stay sealed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn job_result_rejection_rebuilds_conflicting_or_stale_surfaces() {
|
||||
for status in [400, 409] {
|
||||
let workspace = Arc::new(SurfaceWorkspaceClient::new(0));
|
||||
workspace.current_revision.store(1, Ordering::SeqCst);
|
||||
workspace
|
||||
.result_responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back(rejection(status));
|
||||
let tool = job_result_tool(workspace.clone());
|
||||
assert!(
|
||||
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
workspace.current_revision.store(2, Ordering::SeqCst);
|
||||
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
let results = workspace.job_results.lock().unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
assert_eq!(results[0]["result"]["surface"]["store_revision"], 1);
|
||||
assert_eq!(results[1]["result"]["surface"]["store_revision"], 2);
|
||||
assert_ne!(
|
||||
results[0]["result"]["surface"]["generation_id"],
|
||||
results[1]["result"]["surface"]["generation_id"]
|
||||
);
|
||||
assert_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(workspace.publish_calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn job_result_ambiguous_acceptance_never_reopens_even_after_later_rejection() {
|
||||
for first in [
|
||||
Err(WorkspaceClientError::Request(
|
||||
"accepted but response lost".into(),
|
||||
)),
|
||||
rejection(500),
|
||||
] {
|
||||
let workspace = Arc::new(SurfaceWorkspaceClient::new(0));
|
||||
workspace
|
||||
.result_responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.extend([first, rejection(403)]);
|
||||
let tool = job_result_tool(workspace.clone());
|
||||
assert!(
|
||||
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
tool.execute(WRONG_RESULT, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
tool.execute(WRONG_RESULT, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
let results = workspace.job_results.lock().unwrap();
|
||||
assert_eq!(results.len(), 3);
|
||||
assert_eq!(results[0], results[1]);
|
||||
assert_eq!(results[1], results[2]);
|
||||
assert_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(workspace.publish_calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn job_result_never_submits_unconfirmed_failure_or_wrong_revision() {
|
||||
for (status, revision) in [
|
||||
("failed", 0),
|
||||
("ready", 0),
|
||||
("stale", 0),
|
||||
("failed_confirmed", 2),
|
||||
] {
|
||||
let workspace = Arc::new(SurfaceWorkspaceClient::with_input_token_budget(1));
|
||||
workspace.current_revision.store(1, Ordering::SeqCst);
|
||||
let tool = job_result_tool(workspace.clone());
|
||||
*workspace.failure_status.lock().unwrap() = status.into();
|
||||
workspace.failure_revision.store(revision, Ordering::SeqCst);
|
||||
assert!(
|
||||
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(workspace.job_results.lock().unwrap().is_empty());
|
||||
// Nothing was dispatched/sealed: a real confirmed outcome remains retryable.
|
||||
*workspace.failure_status.lock().unwrap() = "failed_confirmed".into();
|
||||
workspace.failure_revision.store(0, Ordering::SeqCst);
|
||||
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(workspace.job_results.lock().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ use crate::feature::{
|
||||
};
|
||||
use crate::permission::permission_action_for;
|
||||
use crate::wip::{
|
||||
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipFeatureRoute,
|
||||
WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, WipOperationOutput,
|
||||
WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
|
||||
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipMountError,
|
||||
WipMountRegistry, WipNamespaceRoute, WipOperationError, WipOperationHandler,
|
||||
WipOperationOutput, WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
|
||||
};
|
||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
||||
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||
@@ -529,7 +529,7 @@ fn native_merge_request_tools(
|
||||
let (meta, tool) = definition(client.clone(), kind)();
|
||||
jsonschema::validator_for(&meta.input_schema).map_err(|error| {
|
||||
WipMountError::InvalidProjection {
|
||||
route: "/features/merge-request/merge-requests".into(),
|
||||
route: "/merge-requests".into(),
|
||||
message: format!("Merge Request tool schema cannot be retained: {error}"),
|
||||
}
|
||||
})?;
|
||||
@@ -562,9 +562,9 @@ pub fn mount_workspace_http_merge_request_wip(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
config: MergeRequestFeatureConfig,
|
||||
permissions: Option<ToolPermissionConfig>,
|
||||
feature_route: &WipFeatureRoute,
|
||||
namespace_route: &WipNamespaceRoute,
|
||||
) -> Result<(), WipMountError> {
|
||||
let collection_route = feature_route.child("merge-requests")?;
|
||||
let collection_route = namespace_route.root().to_string();
|
||||
let tools = native_merge_request_tools(client, config)?;
|
||||
let claimed_tools = tools
|
||||
.iter()
|
||||
@@ -1049,7 +1049,7 @@ fn merge_request_operation_declaration(
|
||||
.get("properties")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| WipMountError::InvalidProjection {
|
||||
route: "/features/merge-request/merge-requests".into(),
|
||||
route: "/merge-requests".into(),
|
||||
message: format!(
|
||||
"Merge Request tool `{}` schema has no properties",
|
||||
tool.name
|
||||
@@ -1474,7 +1474,9 @@ mod tests {
|
||||
#[test]
|
||||
fn native_mount_uses_host_route_and_accepts_only_one_canonical_item_segment() {
|
||||
let mut registry = WipMountRegistry::new();
|
||||
let feature_route = registry.allocate_feature_route("merge-request").unwrap();
|
||||
let namespace_route = registry
|
||||
.allocate_namespace("merge-request", "merge-requests")
|
||||
.unwrap();
|
||||
mount_workspace_http_merge_request_wip(
|
||||
&mut registry,
|
||||
Arc::new(TestWorkspaceHttpClient::new("workspace", "http://unused")),
|
||||
@@ -1484,17 +1486,14 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
&feature_route,
|
||||
&namespace_route,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
registry.routes().collect::<Vec<_>>(),
|
||||
["/features/merge-request/merge-requests"]
|
||||
);
|
||||
assert_eq!(registry.routes().collect::<Vec<_>>(), ["/merge-requests"]);
|
||||
let resolver = MergeRequestItemResolver {
|
||||
tools: HashMap::new(),
|
||||
permissions: None,
|
||||
collection_route: "/features/merge-request/merge-requests".into(),
|
||||
collection_route: "/merge-requests".into(),
|
||||
revisions: Arc::new(Mutex::new(MergeRequestRevisionState::default())),
|
||||
};
|
||||
assert!(
|
||||
@@ -1645,7 +1644,7 @@ mod tests {
|
||||
let resolver = MergeRequestItemResolver {
|
||||
tools: HashMap::new(),
|
||||
permissions: None,
|
||||
collection_route: "/features/merge-request/merge-requests".into(),
|
||||
collection_route: "/merge-requests".into(),
|
||||
revisions: Arc::clone(&revisions),
|
||||
};
|
||||
let first = resolver.resolve("MR-1").unwrap().object.validator;
|
||||
|
||||
@@ -21,9 +21,9 @@ use wip_protocol::{
|
||||
|
||||
use crate::permission::permission_action_for;
|
||||
use crate::wip::{
|
||||
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipFeatureRoute,
|
||||
WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, WipOperationOutput,
|
||||
WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
|
||||
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipMountError,
|
||||
WipMountRegistry, WipNamespaceRoute, WipOperationError, WipOperationHandler,
|
||||
WipOperationOutput, WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
|
||||
};
|
||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
||||
|
||||
@@ -551,9 +551,9 @@ pub fn mount_workspace_http_objective_wip(
|
||||
registry: &mut WipMountRegistry,
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
permissions: Option<ToolPermissionConfig>,
|
||||
feature_route: &WipFeatureRoute,
|
||||
namespace_route: &WipNamespaceRoute,
|
||||
) -> Result<(), WipMountError> {
|
||||
let collection_route = feature_route.child("objectives")?;
|
||||
let collection_route = namespace_route.root().to_string();
|
||||
let backend = WorkspaceHttpObjectiveBackend::new(client);
|
||||
let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default()));
|
||||
let collection_descriptor = objective_collection_descriptor();
|
||||
@@ -1775,7 +1775,9 @@ mod tests {
|
||||
}));
|
||||
|
||||
let mut registry = WipMountRegistry::new();
|
||||
let feature_route = registry.allocate_feature_route("objective").unwrap();
|
||||
let namespace_route = registry
|
||||
.allocate_namespace("objective", "objectives")
|
||||
.unwrap();
|
||||
mount_workspace_http_objective_wip(
|
||||
&mut registry,
|
||||
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
|
||||
@@ -1783,18 +1785,16 @@ mod tests {
|
||||
"http://backend",
|
||||
)),
|
||||
None,
|
||||
&feature_route,
|
||||
&namespace_route,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
registry.routes().collect::<Vec<_>>(),
|
||||
["/features/objective/objectives"]
|
||||
assert_eq!(registry.routes().collect::<Vec<_>>(), ["/objectives"]);
|
||||
assert_eq!(namespace_route.root(), "/objectives");
|
||||
assert!(
|
||||
registry
|
||||
.allocate_namespace("objective", "../tickets")
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
feature_route.child("objectives").unwrap(),
|
||||
"/features/objective/objectives"
|
||||
);
|
||||
assert!(feature_route.child("../tickets").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1805,7 +1805,7 @@ mod tests {
|
||||
crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"),
|
||||
)),
|
||||
permissions: None,
|
||||
collection_route: "/features/objective/objectives".into(),
|
||||
collection_route: "/objectives".into(),
|
||||
revisions: Arc::clone(&revisions),
|
||||
};
|
||||
let initial = resolver.resolve("O-3").unwrap();
|
||||
@@ -1971,7 +1971,7 @@ mod tests {
|
||||
let collection = ObjectiveCollectionWipHandler {
|
||||
backend,
|
||||
permissions: None,
|
||||
collection_route: "/features/objective/objectives".into(),
|
||||
collection_route: "/objectives".into(),
|
||||
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
|
||||
};
|
||||
let result = collection
|
||||
@@ -2013,7 +2013,7 @@ mod tests {
|
||||
let collection = ObjectiveCollectionWipHandler {
|
||||
backend: backend.clone(),
|
||||
permissions: None,
|
||||
collection_route: "/features/objective/objectives".into(),
|
||||
collection_route: "/objectives".into(),
|
||||
revisions: Arc::clone(&revisions),
|
||||
};
|
||||
let context = || WipCallContext {
|
||||
@@ -2170,7 +2170,7 @@ mod tests {
|
||||
crate::worker::TestWorkspaceHttpClient::new("workspace", base_url),
|
||||
)),
|
||||
permissions: None,
|
||||
collection_route: "/features/objective/objectives".into(),
|
||||
collection_route: "/objectives".into(),
|
||||
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
|
||||
};
|
||||
let output = match handler
|
||||
@@ -2191,10 +2191,7 @@ mod tests {
|
||||
let output = crate::wip::wip_to_json(&output.value).unwrap();
|
||||
assert_eq!(output["next_cursor"], "next-page");
|
||||
assert_eq!(output["has_more"], true);
|
||||
assert_eq!(
|
||||
output["objectives"][0]["path"],
|
||||
"/features/objective/objectives/O-3"
|
||||
);
|
||||
assert_eq!(output["objectives"][0]["path"], "/objectives/O-3");
|
||||
assert_eq!(output["objectives"][0]["linked_tickets"][0], "T-7");
|
||||
assert!(!output.to_string().contains("00001OBJECTIVE"));
|
||||
assert!(!output.to_string().contains("00001TICKET"));
|
||||
@@ -2243,7 +2240,7 @@ mod tests {
|
||||
let resolver = ObjectiveItemResolver {
|
||||
backend: backend.clone(),
|
||||
permissions: None,
|
||||
collection_route: "/features/objective/objectives".into(),
|
||||
collection_route: "/objectives".into(),
|
||||
revisions: Arc::clone(&revisions),
|
||||
};
|
||||
let before = resolver.resolve("O-3").unwrap().object.validator;
|
||||
|
||||
@@ -34,9 +34,9 @@ use crate::feature::{
|
||||
};
|
||||
use crate::permission::permission_action_for;
|
||||
use crate::wip::{
|
||||
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipFeatureRoute,
|
||||
WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, WipOperationOutput,
|
||||
WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
|
||||
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipMountError,
|
||||
WipMountRegistry, WipNamespaceRoute, WipOperationError, WipOperationHandler,
|
||||
WipOperationOutput, WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
|
||||
};
|
||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
||||
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||
@@ -1377,7 +1377,7 @@ fn native_ticket_tools(
|
||||
let (meta, tool) = definition();
|
||||
let projection = native_ticket_operation(&meta.name).ok_or_else(|| {
|
||||
WipMountError::InvalidProjection {
|
||||
route: "/features/ticket/tickets".into(),
|
||||
route: "/tickets".into(),
|
||||
message: format!(
|
||||
"enabled Ticket tool `{}` has no native projection",
|
||||
meta.name
|
||||
@@ -1386,7 +1386,7 @@ fn native_ticket_tools(
|
||||
})?;
|
||||
jsonschema::validator_for(&meta.input_schema).map_err(|error| {
|
||||
WipMountError::InvalidProjection {
|
||||
route: "/features/ticket/tickets".into(),
|
||||
route: "/tickets".into(),
|
||||
message: format!("Ticket tool schema cannot be retained: {error}"),
|
||||
}
|
||||
})?;
|
||||
@@ -1418,9 +1418,9 @@ pub fn mount_workspace_http_ticket_wip(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
access: TicketFeatureAccess,
|
||||
permissions: Option<ToolPermissionConfig>,
|
||||
feature_route: &WipFeatureRoute,
|
||||
namespace_route: &WipNamespaceRoute,
|
||||
) -> Result<(), WipMountError> {
|
||||
let collection_route = feature_route.child("tickets")?;
|
||||
let collection_route = namespace_route.root().to_string();
|
||||
let tools = native_ticket_tools(client, access)?;
|
||||
let claimed_tools = tools
|
||||
.iter()
|
||||
@@ -1920,7 +1920,7 @@ fn ticket_operation_declaration(
|
||||
.get("properties")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| WipMountError::InvalidProjection {
|
||||
route: "/features/ticket/tickets".into(),
|
||||
route: "/tickets".into(),
|
||||
message: format!("Ticket tool `{}` schema has no properties", tool.name),
|
||||
})?;
|
||||
let required = tool
|
||||
@@ -2103,7 +2103,7 @@ mod tests {
|
||||
#[test]
|
||||
fn native_ticket_mount_uses_host_route_and_rejects_non_ticket_children() {
|
||||
let mut registry = WipMountRegistry::new();
|
||||
let feature_route = registry.allocate_feature_route("ticket").unwrap();
|
||||
let namespace_route = registry.allocate_namespace("ticket", "tickets").unwrap();
|
||||
mount_workspace_http_ticket_wip(
|
||||
&mut registry,
|
||||
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
|
||||
@@ -2112,20 +2112,21 @@ mod tests {
|
||||
)),
|
||||
TicketFeatureAccess::review(),
|
||||
None,
|
||||
&feature_route,
|
||||
&namespace_route,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
registry.routes().collect::<Vec<_>>(),
|
||||
["/features/ticket/tickets"]
|
||||
assert_eq!(registry.routes().collect::<Vec<_>>(), ["/tickets"]);
|
||||
assert!(
|
||||
registry
|
||||
.allocate_namespace("ticket", "../objectives")
|
||||
.is_err()
|
||||
);
|
||||
assert!(feature_route.child("../objectives").is_err());
|
||||
|
||||
let revisions = Arc::new(Mutex::new(TicketRevisionState::default()));
|
||||
let resolver = TicketItemResolver {
|
||||
tools: HashMap::new(),
|
||||
permissions: None,
|
||||
collection_route: "/features/ticket/tickets".into(),
|
||||
collection_route: "/tickets".into(),
|
||||
revisions,
|
||||
};
|
||||
assert!(resolver.resolve("T-42").is_some());
|
||||
@@ -2189,7 +2190,7 @@ mod tests {
|
||||
let resolver = TicketItemResolver {
|
||||
tools: HashMap::new(),
|
||||
permissions: None,
|
||||
collection_route: "/features/ticket/tickets".into(),
|
||||
collection_route: "/tickets".into(),
|
||||
revisions: Arc::clone(&revisions),
|
||||
};
|
||||
let unobserved_internal = resolver.resolve("00001TICKET").unwrap().object.validator;
|
||||
@@ -2243,7 +2244,7 @@ mod tests {
|
||||
let resolver = TicketItemResolver {
|
||||
tools: HashMap::new(),
|
||||
permissions: None,
|
||||
collection_route: "/features/ticket/tickets".into(),
|
||||
collection_route: "/tickets".into(),
|
||||
revisions: Arc::clone(&revisions),
|
||||
};
|
||||
let target_v1 = resolver.resolve("T-43").unwrap().object.validator;
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
//! WIP-only, grant-bound Workspace configuration Feature. No local config store
|
||||
//! or OS Workdir/session is created by this logical attachment.
|
||||
|
||||
pub mod backend;
|
||||
mod content;
|
||||
pub mod wip;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use protocol::{
|
||||
FeatureInvocation, FeatureInvocationDescriptor, FeatureInvocationIdentity,
|
||||
FeatureInvocationResult, FeatureInvocationStatus, FeatureInvocationSyntax,
|
||||
InvocationArgumentDescriptor, InvocationArgumentType, InvocationCompletion, InvocationValue,
|
||||
};
|
||||
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureInvocationContext,
|
||||
FeatureInvocationHandler, FeatureInvocationHandlerError, FeatureModule,
|
||||
};
|
||||
use crate::worker::WorkspaceClient;
|
||||
|
||||
pub const FEATURE_ID: &str = "workspace-config";
|
||||
pub const INVOCATION_ID: &str = "builtin:workspace-config/attach";
|
||||
pub const ATTACHMENT_ALIAS: &str = "workspace-config";
|
||||
pub const CONTENT_ROOT: &str = "/workspace-config";
|
||||
const DEADLINE: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Requested restriction, not an authorization grant. `None` selects the
|
||||
/// effective Backend grant. Explicit read_write must never silently downgrade.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ConfigAccess {
|
||||
ReadOnly,
|
||||
ReadWrite,
|
||||
}
|
||||
|
||||
impl ConfigAccess {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::ReadOnly => "read_only",
|
||||
Self::ReadWrite => "read_write",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn requested_access(value: Option<&str>) -> Result<Option<ConfigAccess>, ConfigAttachError> {
|
||||
match value {
|
||||
None | Some("effective") => Ok(None),
|
||||
Some("read_only") => Ok(Some(ConfigAccess::ReadOnly)),
|
||||
Some("read_write") => Ok(Some(ConfigAccess::ReadWrite)),
|
||||
_ => Err(ConfigAttachError::InvalidRequest),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapter result from the *existing Backend attachment ledger*. No Worker-local
|
||||
/// attachment registry or authorization state is created from this result.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ConfigAttachment {
|
||||
pub connection_id: String,
|
||||
pub access: ConfigAccess,
|
||||
pub already_attached: bool,
|
||||
}
|
||||
|
||||
/// Safe diagnostics only. Raw Backend/transport errors may contain host paths or
|
||||
/// secrets and must be classified by the adapter, never copied into these results.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ConfigAttachError {
|
||||
InvalidRequest,
|
||||
Denied,
|
||||
Unavailable,
|
||||
OutcomeUnknown,
|
||||
}
|
||||
|
||||
impl ConfigAttachError {
|
||||
fn message(self) -> &'static str {
|
||||
match self {
|
||||
Self::InvalidRequest => "Invalid Workspace config attachment request",
|
||||
Self::Denied => "Workspace config access denied; explicit access is not downgraded",
|
||||
Self::Unavailable => "Workspace config attachment unavailable before effects",
|
||||
Self::OutcomeUnknown => {
|
||||
"Workspace config attachment outcome unknown; inspect current attachments before retrying"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn invocation_error(self) -> FeatureInvocationHandlerError {
|
||||
if self == Self::OutcomeUnknown {
|
||||
FeatureInvocationHandlerError::outcome_unknown(self.message())
|
||||
} else {
|
||||
FeatureInvocationHandlerError::failed(self.message())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Narrow application adapter seam, not a config compatibility Tool/API.
|
||||
///
|
||||
/// The production implementation captures an identity-bound WorkspaceClient and
|
||||
/// uses the shared typed Backend attach operation. Both validation (including
|
||||
/// T696 replay/resume) and attach must consult current authority. Attach rechecks
|
||||
/// authorization atomically with ledger mutation, enforces explicit access and
|
||||
/// alias identity. The execution key identifies the host receipt, not a grant;
|
||||
/// Backend alias attach is idempotent under its session lock. Never blind retry.
|
||||
#[async_trait]
|
||||
pub trait WorkspaceConfigAttachmentBackend: Send + Sync {
|
||||
fn is_available(&self) -> bool;
|
||||
fn validate_access(&self, access: Option<ConfigAccess>) -> Result<(), ConfigAttachError>;
|
||||
async fn attach(
|
||||
&self,
|
||||
idempotency_key: &str,
|
||||
access: Option<ConfigAccess>,
|
||||
) -> Result<ConfigAttachment, ConfigAttachError>;
|
||||
}
|
||||
|
||||
/// Shared slash/self-attach handler. Possession of Workdir tools is not a grant.
|
||||
#[derive(Clone)]
|
||||
pub struct WorkspaceConfigFeature {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
backend: Arc<dyn WorkspaceConfigAttachmentBackend>,
|
||||
content_backend: Option<Arc<backend::WorkspaceConfigBackend>>,
|
||||
wip_mode: bool,
|
||||
permits: Arc<tokio::sync::Semaphore>,
|
||||
}
|
||||
|
||||
impl WorkspaceConfigFeature {
|
||||
/// Host supplies the actual mode; a user invocation cannot enable WIP or
|
||||
/// change the Workspace/security context captured by the adapter.
|
||||
pub fn new(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
backend: Arc<dyn WorkspaceConfigAttachmentBackend>,
|
||||
wip_mode: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
client,
|
||||
backend,
|
||||
content_backend: None,
|
||||
wip_mode,
|
||||
permits: Arc::new(tokio::sync::Semaphore::new(16)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Controller registration gate: a flag cannot create Backend authority or
|
||||
/// turn a Tools/standalone Worker into a Workspace WIP Worker.
|
||||
pub fn configured(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
enabled: bool,
|
||||
wip_mode: bool,
|
||||
) -> Option<Self> {
|
||||
(enabled
|
||||
&& wip_mode
|
||||
&& client.is_available()
|
||||
&& client
|
||||
.workspace_id()
|
||||
.is_some_and(|id| !id.is_empty() && !id.chars().any(char::is_control)))
|
||||
.then(|| Self::for_workspace(client, true))
|
||||
}
|
||||
|
||||
/// Production adapter captures the injected identity-bound Workspace client.
|
||||
pub fn for_workspace(client: Arc<dyn WorkspaceClient>, wip_mode: bool) -> Self {
|
||||
let backend = Arc::new(backend::WorkspaceConfigBackend::new(client.clone()));
|
||||
let mut feature = Self::new(client, backend.clone(), wip_mode);
|
||||
feature.content_backend = Some(backend);
|
||||
feature
|
||||
}
|
||||
|
||||
pub fn is_available(&self) -> bool {
|
||||
self.wip_mode
|
||||
&& self.client.is_available()
|
||||
&& self.client.workspace_id().is_some_and(|id| !id.is_empty())
|
||||
&& self.backend.is_available()
|
||||
}
|
||||
|
||||
fn validate_access(&self, access: Option<ConfigAccess>) -> Result<(), ConfigAttachError> {
|
||||
if !self.is_available() {
|
||||
return Err(ConfigAttachError::Denied);
|
||||
}
|
||||
self.backend.validate_access(access)
|
||||
}
|
||||
|
||||
/// The only attach execution path used by T696 and native WIP. No router
|
||||
/// session, command capability, or attachment lifetime is synthesized here.
|
||||
pub async fn attach(
|
||||
&self,
|
||||
idempotency_key: &str,
|
||||
access: Option<ConfigAccess>,
|
||||
) -> Result<ConfigAttachment, ConfigAttachError> {
|
||||
if idempotency_key.is_empty() || idempotency_key.len() > 256 {
|
||||
return Err(ConfigAttachError::InvalidRequest);
|
||||
}
|
||||
self.validate_access(access)?;
|
||||
let _permit = tokio::time::timeout(DEADLINE, self.permits.acquire())
|
||||
.await
|
||||
.map_err(|_| ConfigAttachError::Unavailable)?
|
||||
.map_err(|_| ConfigAttachError::Unavailable)?;
|
||||
// Authority can change while queued. Backend still rechecks on execution.
|
||||
self.validate_access(access)?;
|
||||
let result = tokio::time::timeout(DEADLINE, self.backend.attach(idempotency_key, access))
|
||||
.await
|
||||
.map_err(|_| ConfigAttachError::OutcomeUnknown)??;
|
||||
// The adapter must map only a safe lifetime identity from a validated
|
||||
// Backend result. Invalid completion metadata cannot imply no effects.
|
||||
if result.connection_id.is_empty()
|
||||
|| result.connection_id.len() > 256
|
||||
|| !result
|
||||
.connection_id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'))
|
||||
|| access.is_some_and(|requested| requested != result.access)
|
||||
{
|
||||
return Err(ConfigAttachError::OutcomeUnknown);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn workspace_config_invocation_descriptor() -> FeatureInvocationDescriptor {
|
||||
FeatureInvocationDescriptor {
|
||||
identity: FeatureInvocationIdentity(INVOCATION_ID.into()),
|
||||
name: FEATURE_ID.into(),
|
||||
aliases: Vec::new(),
|
||||
display_name: "Workspace config".into(),
|
||||
description: "Select this Workspace's logical config attachment before the request reaches the assistant. Backend grants determine access; no host filesystem or command access is added.".into(),
|
||||
syntax: FeatureInvocationSyntax::Parenthesized,
|
||||
arguments: vec![InvocationArgumentDescriptor {
|
||||
name: "access".into(),
|
||||
position: Some(0),
|
||||
required: false,
|
||||
value_type: InvocationArgumentType::Enum {
|
||||
values: vec!["effective".into(), "read_only".into(), "read_write".into()],
|
||||
},
|
||||
completion: InvocationCompletion::None,
|
||||
description: Some("Default: effective Backend grant. read_only restricts the connection; explicit read_write fails if unavailable.".into()),
|
||||
}],
|
||||
client_adapter: None,
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureModule for WorkspaceConfigFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
let descriptor = FeatureDescriptor::builtin(FEATURE_ID, "Workspace config")
|
||||
.with_description("WIP-only logical Workspace config attachment preparation");
|
||||
if self.wip_mode && self.client.workspace_id().is_some_and(|id| !id.is_empty()) {
|
||||
descriptor.with_chat_invocation(workspace_config_invocation_descriptor())
|
||||
} else {
|
||||
descriptor
|
||||
}
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
if self.wip_mode && self.client.workspace_id().is_some_and(|id| !id.is_empty()) {
|
||||
context
|
||||
.chat_invocations()
|
||||
.register(workspace_config_invocation_descriptor(), self.clone())?;
|
||||
}
|
||||
// No tools are declared/installed, including in non-WIP mode.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn invocation_access(
|
||||
invocation: &FeatureInvocation,
|
||||
) -> Result<Option<ConfigAccess>, ConfigAttachError> {
|
||||
if invocation.identity.0 != INVOCATION_ID
|
||||
|| invocation.name != FEATURE_ID
|
||||
|| invocation.arguments.len() > 1
|
||||
{
|
||||
return Err(ConfigAttachError::InvalidRequest);
|
||||
}
|
||||
match invocation.arguments.first() {
|
||||
None => requested_access(None),
|
||||
Some(argument) if argument.name == "access" => match &argument.value {
|
||||
InvocationValue::String(value) => requested_access(Some(value)),
|
||||
_ => Err(ConfigAttachError::InvalidRequest),
|
||||
},
|
||||
_ => Err(ConfigAttachError::InvalidRequest),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FeatureInvocationHandler for WorkspaceConfigFeature {
|
||||
fn is_available(&self) -> bool {
|
||||
WorkspaceConfigFeature::is_available(self)
|
||||
}
|
||||
|
||||
fn validate(
|
||||
&self,
|
||||
invocation: &FeatureInvocation,
|
||||
) -> Result<(), FeatureInvocationHandlerError> {
|
||||
let access = invocation_access(invocation).map_err(ConfigAttachError::invocation_error)?;
|
||||
self.validate_access(access)
|
||||
.map_err(ConfigAttachError::invocation_error)
|
||||
}
|
||||
|
||||
async fn invoke(
|
||||
&self,
|
||||
context: FeatureInvocationContext,
|
||||
invocation: &FeatureInvocation,
|
||||
) -> Result<FeatureInvocationResult, FeatureInvocationHandlerError> {
|
||||
if context.invocation_id != invocation.invocation_id {
|
||||
return Err(ConfigAttachError::InvalidRequest.invocation_error());
|
||||
}
|
||||
let access = invocation_access(invocation).map_err(ConfigAttachError::invocation_error)?;
|
||||
let result = self
|
||||
.attach(&context.invocation_id, access)
|
||||
.await
|
||||
.map_err(ConfigAttachError::invocation_error)?;
|
||||
let action = if result.already_attached {
|
||||
"Already attached"
|
||||
} else {
|
||||
"Attached"
|
||||
};
|
||||
Ok(FeatureInvocationResult {
|
||||
invocation_id: context.invocation_id,
|
||||
identity: invocation.identity.clone(),
|
||||
status: FeatureInvocationStatus::Succeeded,
|
||||
message: format!(
|
||||
"{action}: {ATTACHMENT_ALIAS} (Workspace configuration; {}).",
|
||||
result.access.as_str()
|
||||
),
|
||||
context: Some(format!(
|
||||
"Workspace configuration is available as logical attachment `{ATTACHMENT_ALIAS}` at WIP `{CONTENT_ROOT}` with {} access. Discover structure through WIP; read and change bodies through published Operations. This attachment grants no OS paths or command execution. Backend validation, concurrency checks and activation rules apply; saving does not imply running Workers adopted new configuration.",
|
||||
result.access.as_str()
|
||||
)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "workspace_config_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod production_tests;
|
||||
@@ -0,0 +1,302 @@
|
||||
//! Identity-bound typed Workspace transport. Never retries a dispatched request.
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use server_api::{
|
||||
WorkspaceConfigAccess, WorkspaceConfigApiError, WorkspaceConfigAttachRequest,
|
||||
WorkspaceConfigAttachment, WorkspaceConfigFailureClassification,
|
||||
};
|
||||
use wip_protocol::{ProtocolError, ProtocolErrorCode};
|
||||
|
||||
use super::{
|
||||
ATTACHMENT_ALIAS, CONTENT_ROOT, ConfigAccess, ConfigAttachError, ConfigAttachment, DEADLINE,
|
||||
WorkspaceConfigAttachmentBackend,
|
||||
};
|
||||
use crate::wip::WipOperationError;
|
||||
use crate::worker::{
|
||||
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
|
||||
};
|
||||
|
||||
pub const MAX_TEXT_BYTES: usize = 256 * 1024;
|
||||
pub const MAX_PATH_BYTES: usize = 512;
|
||||
pub const MAX_NODES: usize = 256;
|
||||
pub const MAX_DEPTH: u32 = 8;
|
||||
const MAX_RESPONSE_BYTES: usize = 2 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WorkspaceConfigBackend {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
}
|
||||
|
||||
impl WorkspaceConfigBackend {
|
||||
pub fn new(client: Arc<dyn WorkspaceClient>) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
fn base(&self) -> Result<String, WipOperationError> {
|
||||
let workspace = self
|
||||
.client
|
||||
.workspace_id()
|
||||
.filter(|id| !id.is_empty())
|
||||
.ok_or_else(|| {
|
||||
failure(
|
||||
ProtocolErrorCode::PermissionDenied,
|
||||
"Workspace config authority unavailable",
|
||||
)
|
||||
})?;
|
||||
let encoded: String = workspace
|
||||
.bytes()
|
||||
.map(|byte| {
|
||||
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_') {
|
||||
(byte as char).to_string()
|
||||
} else {
|
||||
format!("%{byte:02X}")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(format!("/api/w/{encoded}/workers/self/workspace-config"))
|
||||
}
|
||||
|
||||
fn request<T: DeserializeOwned>(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
mutation: bool,
|
||||
) -> Result<T, WipOperationError> {
|
||||
let response = self
|
||||
.client
|
||||
.execute_with_timeout(request, DEADLINE)
|
||||
.map_err(|error| {
|
||||
if mutation && !matches!(error, WorkspaceClientError::InvalidPath(_)) {
|
||||
unknown()
|
||||
} else {
|
||||
failure(
|
||||
ProtocolErrorCode::Internal,
|
||||
"Workspace config transport unavailable",
|
||||
)
|
||||
}
|
||||
})?;
|
||||
if response.body.len() > MAX_RESPONSE_BYTES {
|
||||
return Err(if mutation {
|
||||
unknown()
|
||||
} else {
|
||||
failure(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"Workspace config response exceeds limit",
|
||||
)
|
||||
});
|
||||
}
|
||||
if !response.is_success() {
|
||||
if let Ok(error) = serde_json::from_str::<WorkspaceConfigApiError>(&response.body) {
|
||||
if error.classification == WorkspaceConfigFailureClassification::Unknown {
|
||||
return Err(unknown());
|
||||
}
|
||||
// Do not reflect arbitrary transport/provider text into model output.
|
||||
let code = match error.status {
|
||||
401 | 403 | 404 => ProtocolErrorCode::PermissionDenied,
|
||||
409 => ProtocolErrorCode::ValidatorMismatch,
|
||||
413 | 429 => ProtocolErrorCode::ResourceLimitExceeded,
|
||||
400 | 422 => ProtocolErrorCode::InvalidArguments,
|
||||
_ => ProtocolErrorCode::Internal,
|
||||
};
|
||||
return Err(failure(
|
||||
code,
|
||||
"Workspace config request rejected without commit",
|
||||
));
|
||||
}
|
||||
return Err(if mutation {
|
||||
unknown()
|
||||
} else {
|
||||
failure(
|
||||
if matches!(response.status, 401 | 403 | 404) {
|
||||
ProtocolErrorCode::PermissionDenied
|
||||
} else {
|
||||
ProtocolErrorCode::Internal
|
||||
},
|
||||
"Workspace config request denied or unavailable",
|
||||
)
|
||||
});
|
||||
}
|
||||
serde_json::from_str(&response.body).map_err(|_| {
|
||||
if mutation {
|
||||
unknown()
|
||||
} else {
|
||||
failure(
|
||||
ProtocolErrorCode::Internal,
|
||||
"Invalid Workspace config response",
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// GET checks the current grant even when the Worker has not attached yet.
|
||||
pub fn current(&self) -> Result<Option<WorkspaceConfigAttachment>, WipOperationError> {
|
||||
let result: Option<WorkspaceConfigAttachment> =
|
||||
self.request(WorkspaceRequest::get(self.base()?), false)?;
|
||||
if let Some(attachment) = &result {
|
||||
self.validate_attachment(attachment)?;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn validate_attachment(
|
||||
&self,
|
||||
attachment: &WorkspaceConfigAttachment,
|
||||
) -> Result<(), WipOperationError> {
|
||||
if Some(attachment.workspace_id.as_str()) != self.client.workspace_id()
|
||||
|| attachment.alias != ATTACHMENT_ALIAS
|
||||
|| attachment.content_path != CONTENT_ROOT
|
||||
|| attachment.connection_id.is_empty()
|
||||
|| attachment.connection_id.len() > 256
|
||||
|| !attachment
|
||||
.connection_id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'))
|
||||
{
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::PermissionDenied,
|
||||
"Invalid Workspace config attachment identity",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn post<T: DeserializeOwned + Send + 'static, R: Serialize>(
|
||||
&self,
|
||||
suffix: &str,
|
||||
request: &R,
|
||||
mutation: bool,
|
||||
) -> Result<T, WipOperationError> {
|
||||
let body = serde_json::to_string(request).map_err(|_| {
|
||||
failure(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"Invalid Workspace config request",
|
||||
)
|
||||
})?;
|
||||
if body.len() > MAX_RESPONSE_BYTES {
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"Workspace config request exceeds limit",
|
||||
));
|
||||
}
|
||||
let request = WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("{}{suffix}", self.base()?),
|
||||
body,
|
||||
);
|
||||
let this = self.clone();
|
||||
tokio::time::timeout(
|
||||
DEADLINE,
|
||||
tokio::task::spawn_blocking(move || this.request(request, mutation)),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
if mutation {
|
||||
unknown()
|
||||
} else {
|
||||
failure(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"Workspace config request deadline exceeded",
|
||||
)
|
||||
}
|
||||
})?
|
||||
.map_err(|_| {
|
||||
if mutation {
|
||||
unknown()
|
||||
} else {
|
||||
failure(
|
||||
ProtocolErrorCode::Internal,
|
||||
"Workspace config transport unavailable",
|
||||
)
|
||||
}
|
||||
})?
|
||||
}
|
||||
|
||||
pub async fn current_async(
|
||||
&self,
|
||||
) -> Result<Option<WorkspaceConfigAttachment>, WipOperationError> {
|
||||
let this = self.clone();
|
||||
tokio::time::timeout(
|
||||
DEADLINE,
|
||||
tokio::task::spawn_blocking(move || this.current()),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
failure(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"Workspace config request deadline exceeded",
|
||||
)
|
||||
})?
|
||||
.map_err(|_| {
|
||||
failure(
|
||||
ProtocolErrorCode::Internal,
|
||||
"Workspace config transport unavailable",
|
||||
)
|
||||
})?
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WorkspaceConfigAttachmentBackend for WorkspaceConfigBackend {
|
||||
fn is_available(&self) -> bool {
|
||||
self.client.is_available()
|
||||
}
|
||||
fn validate_access(&self, access: Option<ConfigAccess>) -> Result<(), ConfigAttachError> {
|
||||
let current = self.current().map_err(attach_error)?;
|
||||
if access == Some(ConfigAccess::ReadWrite)
|
||||
&& current.is_some_and(|a| a.access == WorkspaceConfigAccess::ReadOnly)
|
||||
{
|
||||
return Err(ConfigAttachError::Denied);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn attach(
|
||||
&self,
|
||||
_execution_key: &str,
|
||||
access: Option<ConfigAccess>,
|
||||
) -> Result<ConfigAttachment, ConfigAttachError> {
|
||||
// Alias attach is idempotent under the Backend session lock. The T696
|
||||
// fenced receipt owns replay; no new idempotency protocol or blind retry.
|
||||
let request = WorkspaceConfigAttachRequest {
|
||||
alias: Some(ATTACHMENT_ALIAS.into()),
|
||||
access: access.map(|a| match a {
|
||||
ConfigAccess::ReadOnly => WorkspaceConfigAccess::ReadOnly,
|
||||
ConfigAccess::ReadWrite => WorkspaceConfigAccess::ReadWrite,
|
||||
}),
|
||||
};
|
||||
let attachment: WorkspaceConfigAttachment =
|
||||
self.post("", &request, true).await.map_err(attach_error)?;
|
||||
self.validate_attachment(&attachment)
|
||||
.map_err(|_| ConfigAttachError::OutcomeUnknown)?;
|
||||
Ok(ConfigAttachment {
|
||||
connection_id: attachment.connection_id,
|
||||
access: match attachment.access {
|
||||
WorkspaceConfigAccess::ReadOnly => ConfigAccess::ReadOnly,
|
||||
WorkspaceConfigAccess::ReadWrite => ConfigAccess::ReadWrite,
|
||||
},
|
||||
already_attached: attachment.already_attached,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn attach_error(error: WipOperationError) -> ConfigAttachError {
|
||||
match error {
|
||||
WipOperationError::OutcomeUnknown(_) => ConfigAttachError::OutcomeUnknown,
|
||||
WipOperationError::Protocol(error) => match error.code {
|
||||
ProtocolErrorCode::PermissionDenied => ConfigAttachError::Denied,
|
||||
ProtocolErrorCode::InvalidArguments => ConfigAttachError::InvalidRequest,
|
||||
_ => ConfigAttachError::Unavailable,
|
||||
},
|
||||
_ => ConfigAttachError::Unavailable,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn failure(code: ProtocolErrorCode, message: &str) -> WipOperationError {
|
||||
WipOperationError::Protocol(ProtocolError {
|
||||
code,
|
||||
message: message.into(),
|
||||
})
|
||||
}
|
||||
pub(super) fn unknown() -> WipOperationError {
|
||||
WipOperationError::OutcomeUnknown("Workspace config outcome unknown; refresh before any subsequent operation; no automatic retry".into())
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user