refactor(worker): share file contracts across Tools and WIP providers
Flatten shared typed Tool arguments and derive WIP parameters and checkout allowlists from core schemas. Workspace settings uses shared read/write/edit processing within its captured-read and canonical tree-CAS commit path. Identical settings edits now reject before read or commit; provider response contracts and unknown-outcome handling stay intact. T-707 validation: root cargo check; fs-operation (58); tools (74 lib, 39 integration, 1 doctest); worker (802 lib, 109 integration); workdir --features http-client (125); yoi-workspace-server --lib workspace_config (19); cargo fmt --all -- --check; git diff --check HEAD.
This commit is contained in:
@@ -241,28 +241,21 @@ impl Provider {
|
||||
ops.push(operation(
|
||||
"read",
|
||||
"Read text with line offsets and numbered output",
|
||||
vec![
|
||||
parameter("offset", false, TypeExpr::Integer),
|
||||
parameter("limit", false, TypeExpr::Integer),
|
||||
],
|
||||
crate::file_operation::parameters("read", true).unwrap(),
|
||||
));
|
||||
}
|
||||
if has(Cap::Edit, "Edit") {
|
||||
ops.push(operation(
|
||||
"edit",
|
||||
"Replace a unique string (requires prior Read)",
|
||||
vec![
|
||||
parameter("old_string", true, TypeExpr::String),
|
||||
parameter("new_string", true, TypeExpr::String),
|
||||
parameter("replace_all", false, TypeExpr::Boolean),
|
||||
],
|
||||
crate::file_operation::parameters("edit", true).unwrap(),
|
||||
));
|
||||
}
|
||||
if has(Cap::Write, "Write") {
|
||||
ops.push(operation(
|
||||
"write",
|
||||
"Overwrite this existing file (requires prior Read)",
|
||||
vec![parameter("content", true, TypeExpr::String)],
|
||||
crate::file_operation::parameters("write", true).unwrap(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -193,6 +193,75 @@ fn provider(router: Arc<WorkdirSessionRouter>) -> Arc<Provider> {
|
||||
})
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
@@ -332,9 +332,8 @@ fn descriptor(names: &[String]) -> InterfaceDescriptor {
|
||||
name: name.clone(), documentation: None,
|
||||
parameters: match name.as_str() {
|
||||
"attach" => vec![parameter("access", false, TypeExpr::String)],
|
||||
"read" | "delete" => vec![],
|
||||
"write" => vec![parameter("content", true, TypeExpr::String)],
|
||||
"edit" => vec![parameter("old_string", true, TypeExpr::String), parameter("new_string", true, TypeExpr::String), parameter("replace_all", false, TypeExpr::Boolean)],
|
||||
"read" | "write" | "edit" => crate::file_operation::parameters(name, false).unwrap(),
|
||||
"delete" => vec![],
|
||||
"create" => vec![parameter("path", false, TypeExpr::String), parameter("content", true, TypeExpr::String), parameter("content_type", false, TypeExpr::String)],
|
||||
"apply_changes" => vec![parameter("changes", true, TypeExpr::Json)],
|
||||
_ => vec![],
|
||||
@@ -388,6 +387,40 @@ fn text(arguments: &BTreeMap<String, Value>, key: &str) -> Result<String, WipOpe
|
||||
_ => Err(invalid()),
|
||||
}
|
||||
}
|
||||
fn text_limits() -> fs_operation::text::TextLimits {
|
||||
fs_operation::text::TextLimits {
|
||||
max_input_bytes: Some(MAX_TEXT_BYTES),
|
||||
max_output_bytes: Some(MAX_TEXT_BYTES),
|
||||
max_replacements: None,
|
||||
}
|
||||
}
|
||||
fn decode_text<T: serde::de::DeserializeOwned>(
|
||||
arguments: &BTreeMap<String, Value>,
|
||||
) -> Result<T, WipOperationError> {
|
||||
let value = Value::Record(arguments.clone());
|
||||
fs_operation::text::decode(crate::wip::wip_to_json(&value).map_err(|_| invalid())?)
|
||||
.map_err(text_error)
|
||||
}
|
||||
fn text_error(error: fs_operation::text::TextError) -> WipOperationError {
|
||||
use fs_operation::text::TextError;
|
||||
match error {
|
||||
TextError::OutputTooLarge { .. }
|
||||
| TextError::Overflow
|
||||
| TextError::ReplacementLimitExceeded { .. } => failure(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"Workspace config edit exceeds text limit",
|
||||
),
|
||||
TextError::IdenticalStrings => failure(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"old_string and new_string are identical",
|
||||
),
|
||||
TextError::Decode(_)
|
||||
| TextError::EmptyOldString
|
||||
| TextError::NotFound
|
||||
| TextError::MultipleMatches { .. }
|
||||
| TextError::InputTooLarge { .. } => invalid(),
|
||||
}
|
||||
}
|
||||
fn content_type(value: Option<&Value>) -> Result<ConfigContentType, WipOperationError> {
|
||||
match value {
|
||||
None => Ok(ConfigContentType::Decodal),
|
||||
@@ -422,6 +455,10 @@ impl ConfigHandler {
|
||||
"Invalid Workspace config read response",
|
||||
));
|
||||
}
|
||||
let mut response = response;
|
||||
response.content = fs_operation::text::read(response.content, text_limits())
|
||||
.map_err(text_error)?
|
||||
.content;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@@ -582,6 +619,7 @@ impl ConfigHandler {
|
||||
.await;
|
||||
}
|
||||
if operation == "read" {
|
||||
let _: fs_operation::text::ReadArgs = decode_text(arguments)?;
|
||||
let result = self.read().await?;
|
||||
// Validator goes to Client state, not the model operation result.
|
||||
let value = json_to_wip(&json!({
|
||||
@@ -601,46 +639,26 @@ impl ConfigHandler {
|
||||
));
|
||||
}
|
||||
let changes = match operation {
|
||||
"write" => vec![Change::Update {
|
||||
path: self.node.path.clone(),
|
||||
content: text(arguments, "content")?,
|
||||
}],
|
||||
"edit" => {
|
||||
let original = self.read().await?.content;
|
||||
let old = text(arguments, "old_string")?;
|
||||
let new = text(arguments, "new_string")?;
|
||||
let all = match arguments.get("replace_all") {
|
||||
None => false,
|
||||
Some(Value::Boolean(all)) => *all,
|
||||
_ => return Err(invalid()),
|
||||
};
|
||||
if old.is_empty() {
|
||||
return Err(invalid());
|
||||
}
|
||||
let count = original.matches(&old).count();
|
||||
if count == 0 || (!all && count != 1) {
|
||||
return Err(invalid());
|
||||
}
|
||||
let count = if all { count } else { 1 };
|
||||
let size = original
|
||||
.len()
|
||||
.checked_sub(count.saturating_mul(old.len()))
|
||||
.and_then(|n| n.checked_add(count.checked_mul(new.len())?))
|
||||
.filter(|n| *n <= MAX_TEXT_BYTES);
|
||||
if size.is_none() {
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"Workspace config edit exceeds text limit",
|
||||
));
|
||||
}
|
||||
let content = if all {
|
||||
original.replace(&old, &new)
|
||||
} else {
|
||||
original.replacen(&old, &new, 1)
|
||||
};
|
||||
"write" => {
|
||||
let args: fs_operation::text::WriteArgs = decode_text(arguments)?;
|
||||
let result =
|
||||
fs_operation::text::write(args.content, text_limits()).map_err(text_error)?;
|
||||
vec![Change::Update {
|
||||
path: self.node.path.clone(),
|
||||
content,
|
||||
content: result.content,
|
||||
}]
|
||||
}
|
||||
"edit" => {
|
||||
let args: fs_operation::text::EditArgs = decode_text(arguments)?;
|
||||
// Reject basic invalid input before reading or entering the commit
|
||||
// path. The captured read and whole-tree CAS remain bound here.
|
||||
args.validate(text_limits()).map_err(text_error)?;
|
||||
let original = self.read().await?.content;
|
||||
let result = fs_operation::text::edit(&original, &args, text_limits())
|
||||
.map_err(text_error)?;
|
||||
vec![Change::Update {
|
||||
path: self.node.path.clone(),
|
||||
content: result.content,
|
||||
}]
|
||||
}
|
||||
"delete" => vec![Change::Delete {
|
||||
|
||||
@@ -857,6 +857,51 @@ async fn workspace_config_production_backend_failure_is_not_hidden_as_unauthoriz
|
||||
assert!(!message.contains("/private/host/config"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_config_edit_shared_argument_rejection_never_commits() {
|
||||
let client = Router::new();
|
||||
let (feature, runtime) = runtime(client.clone());
|
||||
feature.attach("initial", None).await.unwrap();
|
||||
let path = "/workspace-config/main.dcdl";
|
||||
observe_interface(&runtime, path, true).await;
|
||||
for (args, expected) in [
|
||||
(
|
||||
json!({"old_string":"old","new_string":"old"}),
|
||||
"old_string and new_string are identical",
|
||||
),
|
||||
(
|
||||
json!({"old_string":"old","new_string":"new","replace_all":"true"}),
|
||||
"expected boolean",
|
||||
),
|
||||
] {
|
||||
let error = call(&runtime, path, "edit", args).await.unwrap_err();
|
||||
assert!(error.to_string().contains(expected), "{error:?}");
|
||||
let state = client.state.lock().unwrap();
|
||||
assert!(state.commits.is_empty());
|
||||
assert_eq!(state.revision, 1);
|
||||
assert_eq!(state.entries["main.dcdl"].1, "model = old");
|
||||
// Argument-only rejection must not even fetch a preimage.
|
||||
assert!(
|
||||
state
|
||||
.requests
|
||||
.iter()
|
||||
.all(|request| !request.path.ends_with("/read"))
|
||||
);
|
||||
}
|
||||
// Rejection has not made the observation stale or poisoned the commit path.
|
||||
call(
|
||||
&runtime,
|
||||
path,
|
||||
"edit",
|
||||
json!({"old_string":"old","new_string":"新"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let state = client.state.lock().unwrap();
|
||||
assert_eq!(state.commits.len(), 1);
|
||||
assert_eq!(state.entries["main.dcdl"].1, "model = 新");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_config_production_ungranted_root_does_not_poison_worldspace_discovery() {
|
||||
let client = Router::new();
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! WIP presentation of the provider-neutral file argument contracts. Providers
|
||||
//! choose which read surface to publish; routing and preconditions are not args.
|
||||
use fs_operation::text::TextOperation;
|
||||
use wip_protocol::{ParameterDeclaration, TypeExpr};
|
||||
|
||||
pub(crate) fn parameters(name: &str, line_read: bool) -> Option<Vec<ParameterDeclaration>> {
|
||||
let schema = TextOperation::from_name(name, line_read)?.argument_schema();
|
||||
Some(schema_parameters(&schema))
|
||||
}
|
||||
|
||||
fn schema_parameters(schema: &schemars::Schema) -> Vec<ParameterDeclaration> {
|
||||
let schema = schema.as_value();
|
||||
let required = schema["required"].as_array();
|
||||
schema["properties"]
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flat_map(|properties| properties.iter())
|
||||
.map(|(name, property)| {
|
||||
// These core arguments are primitives. Optional Tool read fields
|
||||
// accept null; WIP keeps its existing optional integer declaration.
|
||||
let kind = property["type"].as_str().or_else(|| {
|
||||
property["type"]
|
||||
.as_array()?
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.find(|t| *t != "null")
|
||||
});
|
||||
let r#type = match kind {
|
||||
Some("string") => TypeExpr::String,
|
||||
Some("boolean") => TypeExpr::Boolean,
|
||||
Some("integer") => TypeExpr::Integer,
|
||||
_ => panic!("file operation arguments must be primitive: {name}"),
|
||||
};
|
||||
ParameterDeclaration {
|
||||
name: name.clone(),
|
||||
required: required
|
||||
.is_some_and(|items| items.iter().any(|item| item.as_str() == Some(name))),
|
||||
documentation: None,
|
||||
r#type,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod controller;
|
||||
pub mod discovery;
|
||||
pub mod entrypoint;
|
||||
pub mod feature;
|
||||
mod file_operation;
|
||||
pub mod fs_view;
|
||||
pub mod hook;
|
||||
pub(crate) mod in_flight;
|
||||
|
||||
Reference in New Issue
Block a user