feat: limit checkout indexing to entrances and expose bounded typed discovery
Content directories always have no indexable children; direct authorized paths remain resolvable. Add List using provider Read/enumeration authority and separate List permission, dirs-first lexical live continuation, bounded retention/path bytes, scoped result coordinates and External DTO validation. List/Glob/Grep return Descriptor-typed entries without eager acquisition. Bind content Interfaces coherently to the live entrance scope. Validation: root cargo check; FS 58 unit+6 pagination, Workdir 125+5 (default and http-client), Tools 84 unit+39 integration+1 doctest, Worker 901 unit+110 integration, Server 691 unit+11 CLI, Server API 83 tests; exact crates.io WIP 0.2.0 resolution; format/diff checks. Focused model/local/remote/provider/race/scope checks passed. Commands and semantics in docs/development/wip-integration-validation.md. Publication/review paused: continuation ShowTicket returned HTTP 401. No Backend bypass, source push, MR approval, target integration or live environment update is claimed.
This commit is contained in:
+238
-138
@@ -11,11 +11,12 @@ 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,
|
||||
Documentation, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object,
|
||||
OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode,
|
||||
ReturnDeclaration, TypeExpr, Value,
|
||||
};
|
||||
use workdir::{
|
||||
CheckoutObservation, EntryKind, ListRequest, WorkdirPath, WorkdirSessionCapability as Cap,
|
||||
CheckoutObservation, EntryKind, WorkdirPath, WorkdirSessionCapability as Cap,
|
||||
WorkdirSessionRouter,
|
||||
};
|
||||
|
||||
@@ -126,11 +127,15 @@ impl Provider {
|
||||
}
|
||||
self.project(
|
||||
ROOT,
|
||||
descriptor(vec![operation(
|
||||
"list",
|
||||
"List currently accessible checkouts",
|
||||
vec![],
|
||||
)]),
|
||||
descriptor(vec![{
|
||||
let mut op = operation(
|
||||
"list",
|
||||
"List accessible checkout entrances (not filesystem contents)",
|
||||
vec![],
|
||||
);
|
||||
op.returns.r#type = TypeExpr::Json;
|
||||
op
|
||||
}]),
|
||||
state.finalize().to_vec(),
|
||||
"collection",
|
||||
Arc::new(Collection {
|
||||
@@ -169,8 +174,14 @@ impl Provider {
|
||||
}
|
||||
}
|
||||
async fn node(self: &Arc<Self>, path: &str) -> Result<Option<WipProjection>, ProtocolError> {
|
||||
Ok(self.publication_inner(path).await?.map(|p| p.projection))
|
||||
}
|
||||
async fn publication_inner(
|
||||
self: &Arc<Self>,
|
||||
path: &str,
|
||||
) -> Result<Option<WipPublication>, ProtocolError> {
|
||||
if path == ROOT {
|
||||
return Ok(Some(self.collection()));
|
||||
return WipPublication::self_scoped(self.collection()).map(Some);
|
||||
}
|
||||
let Some((alias, target)) = self.parse(path) else {
|
||||
return Ok(None);
|
||||
@@ -202,39 +213,86 @@ impl Provider {
|
||||
if observation.kind == EntryKind::File && !self.allowed("Read", &permission_input) {
|
||||
return Ok(None);
|
||||
}
|
||||
let workdir = selected.session.workdir().id().as_str().to_owned();
|
||||
let root = checkout_root(&alias);
|
||||
// The router guard pins this connection across both observations. Root
|
||||
// lifetime is attachment lifetime, not directory-content validator state.
|
||||
let scope = if target.is_root() {
|
||||
None
|
||||
} else {
|
||||
let root_observation =
|
||||
match selected.session.checkout_observe(WorkdirPath::root()).await {
|
||||
Ok(o) => o,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
self.entry_projection(
|
||||
&root,
|
||||
&alias,
|
||||
selected.generation,
|
||||
&workdir,
|
||||
WorkdirPath::root(),
|
||||
root_observation,
|
||||
)
|
||||
};
|
||||
let Some(projection) = self.entry_projection(
|
||||
path,
|
||||
&alias,
|
||||
selected.generation,
|
||||
&workdir,
|
||||
target,
|
||||
observation,
|
||||
) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if path == root {
|
||||
return WipPublication::self_scoped(projection).map(Some);
|
||||
}
|
||||
let Some(scope) = scope else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(WipPublication {
|
||||
projection,
|
||||
scope: Some(scope.object),
|
||||
}))
|
||||
}
|
||||
fn entry_projection(
|
||||
self: &Arc<Self>,
|
||||
path: &str,
|
||||
alias: &str,
|
||||
generation: u64,
|
||||
workdir: &str,
|
||||
target: WorkdirPath,
|
||||
observation: CheckoutObservation,
|
||||
) -> Option<WipProjection> {
|
||||
let ops = self.operations(&observation);
|
||||
if ops.is_empty() {
|
||||
return Ok(None);
|
||||
return 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 validator = self.wrap_validator(alias, generation, workdir, &observation.validator);
|
||||
let handler = Arc::new(FileHandler {
|
||||
provider: self.clone(),
|
||||
alias,
|
||||
generation: selected.generation,
|
||||
workdir,
|
||||
alias: alias.into(),
|
||||
generation,
|
||||
workdir: workdir.into(),
|
||||
target,
|
||||
observation,
|
||||
});
|
||||
let family = format!("{kind}-g{}", selected.generation);
|
||||
Ok(Some(self.project(
|
||||
path,
|
||||
descriptor(ops),
|
||||
validator,
|
||||
&family,
|
||||
handler,
|
||||
)))
|
||||
let family = format!("{kind}-g{generation}");
|
||||
let mut projection = self.project(path, descriptor(ops), validator, &family, handler);
|
||||
// Keep path-qualified local names (permissions/capabilities may differ
|
||||
// per target) while binding every content Interface to its live entrance.
|
||||
projection.interface.scope = checkout_root(alias);
|
||||
projection.interface.name.push_str("#target/");
|
||||
projection.interface.name.push_str(&encode_identity(path));
|
||||
projection.object.interfaces = vec![projection.interface.clone()];
|
||||
Some(projection)
|
||||
}
|
||||
|
||||
fn operations(&self, o: &CheckoutObservation) -> Vec<OperationDeclaration> {
|
||||
let mut ops = Vec::new();
|
||||
let has = |cap, name| o.capabilities.supports(cap) && self.may_allow(name);
|
||||
@@ -261,6 +319,13 @@ impl Provider {
|
||||
));
|
||||
}
|
||||
} else {
|
||||
if has(Cap::Read, "List") {
|
||||
ops.push(operation(
|
||||
"list",
|
||||
"List direct contents, directories first then lexical path. Bounded live pages, not a snapshot; pass returned after to continue. Limit defaults to 100, maximum 1000. List permission and provider Read/enumeration authority apply. Includes hidden/ignored names per provider List policy, unlike Glob/Grep.",
|
||||
vec![parameter("limit", false, TypeExpr::Integer), parameter("after", false, cursor_type())],
|
||||
));
|
||||
}
|
||||
if has(Cap::Glob, "Glob") {
|
||||
ops.push(operation(
|
||||
"glob",
|
||||
@@ -288,6 +353,15 @@ impl Provider {
|
||||
|
||||
#[async_trait]
|
||||
impl WipSubtreeProvider for Arc<Provider> {
|
||||
fn interface_target(&self, reference: &wip_protocol::InterfaceReference) -> Option<String> {
|
||||
if reference.scope == ROOT {
|
||||
return Some(ROOT.into());
|
||||
}
|
||||
let (_, encoded) = reference.name.rsplit_once("#target/")?;
|
||||
let path = decode_identity(encoded)?;
|
||||
let (alias, _) = self.parse(&path)?;
|
||||
(reference.scope == checkout_root(&alias)).then_some(path)
|
||||
}
|
||||
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
|
||||
let _permit = tokio::time::timeout(self.deadline, self.permits.acquire())
|
||||
.await
|
||||
@@ -298,7 +372,7 @@ impl WipSubtreeProvider for Arc<Provider> {
|
||||
)
|
||||
})?
|
||||
.map_err(|_| error(ProtocolErrorCode::Internal, "checkout unavailable"))?;
|
||||
tokio::time::timeout(self.deadline, self.node(path))
|
||||
tokio::time::timeout(self.deadline, self.publication_inner(path))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
error(
|
||||
@@ -306,7 +380,6 @@ impl WipSubtreeProvider for Arc<Provider> {
|
||||
"checkout observation deadline",
|
||||
)
|
||||
})?
|
||||
.and_then(|projection| projection.map(WipPublication::self_scoped).transpose())
|
||||
}
|
||||
async fn children(&self, path: &str) -> Result<Vec<String>, ProtocolError> {
|
||||
let _permit = tokio::time::timeout(self.deadline, self.permits.acquire())
|
||||
@@ -354,70 +427,9 @@ impl Provider {
|
||||
}
|
||||
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)
|
||||
// Contents are addressable, but never indexable. Host observe already
|
||||
// resolves the requested Object; no provider content List is needed.
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,6 +513,7 @@ impl WipOperationHandler for FileHandler {
|
||||
context: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
let tool = match operation {
|
||||
"list" => "List",
|
||||
"read" => "Read",
|
||||
"edit" => "Edit",
|
||||
"write" => "Write",
|
||||
@@ -528,7 +541,34 @@ impl WipOperationHandler for FileHandler {
|
||||
}
|
||||
let mut permission_input = args.clone();
|
||||
permission_input.insert("target_workdir".into(), json!(self.alias));
|
||||
if tool == "Glob" || tool == "Grep" {
|
||||
if tool == "List" {
|
||||
permission_input.insert("path".into(), json!(self.target.as_str()));
|
||||
if let Some(after) = args.get_mut("after") {
|
||||
let entry = after.get("entry").and_then(Json::as_str).ok_or_else(|| {
|
||||
protocol(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"after requires an entry",
|
||||
)
|
||||
})?;
|
||||
let Some((alias, path)) = self.provider.parse(entry) else {
|
||||
return Err(protocol(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"invalid continuation entry",
|
||||
));
|
||||
};
|
||||
if alias != self.alias || object_path(&alias, &path) != entry {
|
||||
return Err(protocol(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"continuation must belong to this checkout",
|
||||
));
|
||||
}
|
||||
let fields = after.as_object_mut().ok_or_else(|| {
|
||||
protocol(ProtocolErrorCode::InvalidArguments, "invalid continuation")
|
||||
})?;
|
||||
fields.remove("entry");
|
||||
fields.insert("path".into(), json!(path.as_str()));
|
||||
}
|
||||
} else 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" {
|
||||
@@ -588,49 +628,49 @@ impl WipOperationHandler for FileHandler {
|
||||
}
|
||||
})?
|
||||
.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()?;
|
||||
// Coordinates are not observations. Never prefetch unselected results;
|
||||
// a vanished entry cannot fail a successful discovery Operation.
|
||||
self.check_connection()?;
|
||||
let mut fields = BTreeMap::from([("summary".into(), Value::String(result.output.summary))]);
|
||||
if let Some(content) = result.output.content {
|
||||
fields.insert("content".into(), Value::String(content));
|
||||
}
|
||||
let items = if let Some(listing) = result.listing {
|
||||
fields.insert("truncated".into(), Value::Boolean(listing.truncated));
|
||||
if let Some(after) = listing.next_after {
|
||||
fields.insert("after".into(), cursor_value(&self.alias, &after));
|
||||
}
|
||||
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())
|
||||
})?;
|
||||
listing
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
Value::Record(BTreeMap::from([
|
||||
(
|
||||
"entry".into(),
|
||||
Value::String(object_path(&self.alias, &item.path)),
|
||||
),
|
||||
("kind".into(), Value::String(kind_name(item.kind).into())),
|
||||
(
|
||||
"size".into(),
|
||||
Value::Integer(i64::try_from(item.size).unwrap_or(i64::MAX)),
|
||||
),
|
||||
]))
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
result
|
||||
.paths
|
||||
.iter()
|
||||
.map(|path| {
|
||||
Value::Record(BTreeMap::from([(
|
||||
"entry".into(),
|
||||
Value::String(object_path(&self.alias, path)),
|
||||
)]))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
fields.insert("items".into(), Value::List(items));
|
||||
let value = Value::Record(fields);
|
||||
if let Some(raw) = result.validator {
|
||||
Ok(WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
@@ -693,10 +733,70 @@ fn operation(
|
||||
parameters,
|
||||
returns: ReturnDeclaration {
|
||||
documentation: None,
|
||||
r#type: TypeExpr::Json,
|
||||
r#type: result_type(name == "list"),
|
||||
},
|
||||
}
|
||||
}
|
||||
fn field(name: &str, required: bool, r#type: TypeExpr) -> FieldDeclaration {
|
||||
FieldDeclaration {
|
||||
name: name.into(),
|
||||
required,
|
||||
documentation: None,
|
||||
r#type,
|
||||
}
|
||||
}
|
||||
fn cursor_type() -> TypeExpr {
|
||||
TypeExpr::Record {
|
||||
fields: vec![
|
||||
field("kind", true, TypeExpr::String),
|
||||
field("entry", true, TypeExpr::Entry),
|
||||
],
|
||||
}
|
||||
}
|
||||
fn result_type(list: bool) -> TypeExpr {
|
||||
let mut item = vec![field("entry", true, TypeExpr::Entry)];
|
||||
if list {
|
||||
item.extend([
|
||||
field("kind", true, TypeExpr::String),
|
||||
field("size", true, TypeExpr::Integer),
|
||||
]);
|
||||
}
|
||||
let mut fields = vec![
|
||||
field("summary", true, TypeExpr::String),
|
||||
field("content", false, TypeExpr::String),
|
||||
field(
|
||||
"items",
|
||||
true,
|
||||
TypeExpr::List {
|
||||
items: Box::new(TypeExpr::Record { fields: item }),
|
||||
},
|
||||
),
|
||||
];
|
||||
if list {
|
||||
fields.extend([
|
||||
field("truncated", true, TypeExpr::Boolean),
|
||||
field("after", false, cursor_type()),
|
||||
]);
|
||||
}
|
||||
TypeExpr::Record { fields }
|
||||
}
|
||||
fn kind_name(kind: EntryKind) -> &'static str {
|
||||
match kind {
|
||||
EntryKind::Directory => "directory",
|
||||
EntryKind::File => "file",
|
||||
EntryKind::Symlink => "symlink",
|
||||
EntryKind::Other => "other",
|
||||
}
|
||||
}
|
||||
fn cursor_value(alias: &str, cursor: &workdir::ListCursor) -> Value {
|
||||
Value::Record(BTreeMap::from([
|
||||
("kind".into(), Value::String(kind_name(cursor.kind).into())),
|
||||
(
|
||||
"entry".into(),
|
||||
Value::String(object_path(alias, &cursor.path)),
|
||||
),
|
||||
]))
|
||||
}
|
||||
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}
|
||||
}
|
||||
|
||||
@@ -379,7 +379,7 @@ fn first_link(value: &Value) -> &str {
|
||||
panic!("expected typed link List")
|
||||
};
|
||||
assert_eq!(items.len(), 1);
|
||||
let Value::String(path) = field(&items[0], "path") else {
|
||||
let Value::String(path) = field(&items[0], "entry") else {
|
||||
panic!("expected typed link path")
|
||||
};
|
||||
path
|
||||
@@ -669,3 +669,89 @@ async fn checkout_http_malformed_response_after_write_is_unknown_without_retry()
|
||||
async fn checkout_http_lost_response_after_write_is_unknown_without_retry() {
|
||||
watchdog(committed_write_unknown(WriteResponseFault::LostBody)).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_checkout_list_pages_and_direct_inspect_read_use_provider_without_tree_enumeration()
|
||||
{
|
||||
use super::checkout_wip_tests::{json_content, model_inspect, model_invoke, model_operation};
|
||||
let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_ONLY).await;
|
||||
std::fs::create_dir(fixture.dir.path().join("empty")).unwrap();
|
||||
let remote = fixture.open(TOKEN).await.unwrap();
|
||||
let (runtime, _) = native_runtime(Arc::new(remote));
|
||||
let runtime = Arc::new(runtime);
|
||||
let tree = json_content(
|
||||
model_operation(
|
||||
runtime.clone(),
|
||||
"Tree",
|
||||
json!({"path":"/checkouts", "depth":8}),
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(tree["tree"]["children"][0]["children"], json!([]));
|
||||
assert!(
|
||||
!fixture
|
||||
.state
|
||||
.operations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|op| matches!(
|
||||
op,
|
||||
WorkdirSessionOperation::CheckoutSearch(_) | WorkdirSessionOperation::List(_)
|
||||
))
|
||||
);
|
||||
let entrance = model_inspect(runtime.clone(), "/checkouts/main").await;
|
||||
let first = json_content(
|
||||
model_invoke(runtime.clone(), &entrance, "list", json!({"limit":1}))
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(first["items"][0]["entry"], "/checkouts/main/empty");
|
||||
assert_eq!(first["items"][0]["kind"], "directory");
|
||||
assert_eq!(first["truncated"], true);
|
||||
let next = json_content(
|
||||
model_invoke(
|
||||
runtime.clone(),
|
||||
&entrance,
|
||||
"list",
|
||||
json!({"limit":1,"after":first["after"]}),
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(next["items"][0]["entry"], "/checkouts/main/src");
|
||||
assert_eq!(next["truncated"], false);
|
||||
let deep = model_inspect(runtime.clone(), "/checkouts/main/src/deep").await;
|
||||
for (operation, args) in [
|
||||
("list", json!({})),
|
||||
("glob", json!({"pattern":"*.txt"})),
|
||||
(
|
||||
"grep",
|
||||
json!({"pattern":"needle","output_mode":"files_with_matches"}),
|
||||
),
|
||||
] {
|
||||
let found = json_content(
|
||||
model_invoke(runtime.clone(), &deep, operation, args)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let file = found["items"][0]["entry"].as_str().unwrap();
|
||||
assert_eq!(file, "/checkouts/main/src/deep/a.txt");
|
||||
let inspected = model_inspect(runtime.clone(), file).await;
|
||||
let read = json_content(
|
||||
model_invoke(runtime.clone(), &inspected, "read", json!({}))
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(read["content"].as_str().unwrap().contains("needle"));
|
||||
fixture.public_output(&found.to_string());
|
||||
}
|
||||
let operations = fixture.state.operations.lock().unwrap();
|
||||
assert!(operations.iter().any(|op| matches!(op, WorkdirSessionOperation::CheckoutSearch(r) if matches!(&r.operation, CheckoutSearchOperation::List(l) if l.after.is_some()))));
|
||||
assert!(
|
||||
!operations
|
||||
.iter()
|
||||
.any(|op| matches!(op, WorkdirSessionOperation::List(_)))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ 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,
|
||||
GrepResult, ListRequest, ListResult, LocalWorkdirSession, ReadBytesRequest, ReadBytesResult,
|
||||
ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirAttachmentAlias,
|
||||
WorkdirError, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
||||
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ struct HookedSession {
|
||||
observe_hook: Mutex<Option<ObserveHook>>,
|
||||
stall_path: Mutex<Option<WorkdirPath>>,
|
||||
stalled_observations: AtomicUsize,
|
||||
searches: AtomicUsize,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HookedSession {
|
||||
@@ -52,6 +53,7 @@ impl HookedSession {
|
||||
observe_hook: Mutex::new(None),
|
||||
stall_path: Mutex::new(None),
|
||||
stalled_observations: AtomicUsize::new(0),
|
||||
searches: AtomicUsize::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -123,6 +125,7 @@ impl WorkdirSession for HookedSession {
|
||||
&self,
|
||||
request: workdir::CheckoutSearchRequest,
|
||||
) -> Result<workdir::CheckoutSearchResult, WorkdirError> {
|
||||
self.searches.fetch_add(1, Ordering::SeqCst);
|
||||
self.local.checkout_search(request).await
|
||||
}
|
||||
|
||||
@@ -484,7 +487,7 @@ async fn collection_list_and_root_enumeration_bound_stalled_observation_and_rele
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_post_search_link_observation_is_deadline_bounded_and_releases_permit() {
|
||||
async fn grep_returns_typed_entry_without_observing_stalled_result() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("a.txt"), "needle\n").unwrap();
|
||||
let session = HookedSession::new(&dir);
|
||||
@@ -499,8 +502,8 @@ async fn grep_post_search_link_observation_is_deadline_bounded_and_releases_perm
|
||||
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.
|
||||
// Directory/search can complete. The result path must never be observed
|
||||
// during result publication, even when it would block indefinitely.
|
||||
session.stall_observation(Some(WorkdirPath::new("a.txt").unwrap()));
|
||||
let arguments = BTreeMap::from([
|
||||
("pattern".into(), Value::String("needle".into())),
|
||||
@@ -514,13 +517,17 @@ async fn grep_post_search_link_observation_is_deadline_bounded_and_releases_perm
|
||||
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);
|
||||
.expect("completed search must return without acquiring its entries");
|
||||
let output = result.unwrap_or_else(|_| panic!("successful search must not observe each entry"));
|
||||
assert_eq!(
|
||||
wip_to_json(&output.value).unwrap()["items"],
|
||||
json!([{"entry": format!("{root}/a.txt")}])
|
||||
);
|
||||
assert_eq!(session.stalled_observations.load(Ordering::SeqCst), 0);
|
||||
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.
|
||||
// A second explicit search produces the same typed coordinate, without
|
||||
// acquiring it even when it could resolve successfully.
|
||||
session.stall_observation(None);
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
@@ -533,6 +540,33 @@ async fn grep_post_search_link_observation_is_deadline_bounded_and_releases_perm
|
||||
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_eq!(value["items"], json!([{"entry": format!("{root}/a.txt")}]));
|
||||
assert_permit_released(&p);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_content_children_never_call_provider_list_even_for_deep_paths() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("nested/deep")).unwrap();
|
||||
for n in 0..1100 {
|
||||
std::fs::write(dir.path().join(format!("file-{n}")), "fixture").unwrap();
|
||||
}
|
||||
let session = HookedSession::new(&dir);
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||
session.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let subtree = Arc::new(provider(router));
|
||||
assert_eq!(
|
||||
subtree.children(ROOT).await.unwrap(),
|
||||
vec![checkout_root("main")]
|
||||
);
|
||||
for path in ["/checkouts/main", "/checkouts/main/nested/deep"] {
|
||||
assert!(subtree.publication(path).await.unwrap().is_some());
|
||||
assert!(subtree.children(path).await.unwrap().is_empty());
|
||||
}
|
||||
assert_eq!(session.searches.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ async fn checkout_wip_search_preserves_root_and_nested_ignore_rules_and_links()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|item| item["path"].as_str().unwrap().to_string())
|
||||
.map(|item| item["entry"].as_str().unwrap().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
// Grep retains traversal order; Glob sorts its result paths.
|
||||
paths.sort();
|
||||
@@ -159,8 +159,8 @@ async fn checkout_wip_native_roundtrip_search_read_edit_write_create() {
|
||||
.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!(grep_result["items"][0]["entry"], file);
|
||||
let followed = grep_result["items"][0]["entry"].as_str().unwrap();
|
||||
assert_eq!(followed, file);
|
||||
assert!(
|
||||
call(
|
||||
@@ -588,8 +588,9 @@ async fn checkout_wip_scoped_child_search_and_revoke_cannot_expose_siblings() {
|
||||
.observe_live("/checkouts/child", 2)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(observed.children.unwrap().len(), 1);
|
||||
assert!(observed.children.unwrap().is_empty());
|
||||
for (op, args) in [
|
||||
("list", json!({"limit":1})),
|
||||
("glob", json!({"pattern":"**/*"})),
|
||||
(
|
||||
"grep",
|
||||
@@ -654,3 +655,339 @@ async fn checkout_wip_scoped_child_search_and_revoke_cannot_expose_siblings() {
|
||||
"changed"
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn json_content(output: ToolOutput) -> Json {
|
||||
serde_json::from_str(output.content.as_deref().unwrap()).unwrap()
|
||||
}
|
||||
pub(super) async fn model_operation(
|
||||
r: Arc<WipRuntime>,
|
||||
name: &str,
|
||||
args: Json,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let (_, tool) = wip_tool_definitions(r)
|
||||
.into_iter()
|
||||
.map(|definition| definition())
|
||||
.find(|(meta, _)| meta.name == name)
|
||||
.unwrap();
|
||||
tool.execute(&args.to_string(), Default::default()).await
|
||||
}
|
||||
pub(super) async fn model_inspect(r: Arc<WipRuntime>, path: &str) -> Json {
|
||||
json_content(
|
||||
model_operation(r, "Inspect", json!({"path":path}))
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
pub(super) async fn model_invoke(
|
||||
r: Arc<WipRuntime>,
|
||||
inspected: &Json,
|
||||
operation: &str,
|
||||
arguments: Json,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
model_operation(r, "Invoke", json!({"path":inspected["path"], "interface":inspected["interfaces"][0]["reference"], "operation":operation, "arguments":arguments})).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_depth_boundaries_are_independent_of_empty_small_or_large_contents() {
|
||||
for count in [0, 2, 1100] {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("nested/deep")).unwrap();
|
||||
for n in 0..count {
|
||||
std::fs::write(dir.path().join(format!("file-{n}")), "fixture").unwrap();
|
||||
}
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||
session(&dir, true),
|
||||
)
|
||||
.unwrap();
|
||||
let r = runtime(router, None);
|
||||
let one = r.host.observe_live("/checkouts", 1).await.unwrap();
|
||||
assert!(one.children.unwrap()[0].children.is_none());
|
||||
for depth in [2, 32] {
|
||||
let observation = r.host.observe_live("/checkouts", depth).await.unwrap();
|
||||
let entrances = observation.children.unwrap();
|
||||
assert_eq!(entrances.len(), 1);
|
||||
assert_eq!(entrances[0].children, Some(vec![]));
|
||||
}
|
||||
let world = r.host.observe_live("/", 32).await.unwrap();
|
||||
assert_eq!(
|
||||
world.children.unwrap()[0].children.as_ref().unwrap()[0].children,
|
||||
Some(vec![])
|
||||
);
|
||||
for path in ["/checkouts/main", "/checkouts/main/nested/deep"] {
|
||||
assert!(
|
||||
r.host
|
||||
.observe_live(path, 0)
|
||||
.await
|
||||
.unwrap()
|
||||
.children
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
r.host.observe_live(path, 1).await.unwrap().children,
|
||||
Some(vec![])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_model_list_glob_grep_entries_are_lazy_and_never_become_tree_edges() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("empty")).unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("nested/deep")).unwrap();
|
||||
std::fs::write(dir.path().join("nested/deep/a.txt"), "needle\n").unwrap();
|
||||
std::fs::write(dir.path().join("z.txt"), "needle\n").unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||
session(&dir, true),
|
||||
)
|
||||
.unwrap();
|
||||
let r = Arc::new(runtime(router, None));
|
||||
let entrance = model_inspect(r.clone(), "/checkouts/main").await;
|
||||
assert_eq!(entrance["path"], "/checkouts/main");
|
||||
assert_eq!(
|
||||
entrance["interfaces"][0]["reference"]["scope"],
|
||||
"/checkouts/main"
|
||||
);
|
||||
let projection = r
|
||||
.host
|
||||
.projection_live("/checkouts/main")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
entrance["object_signature"],
|
||||
wip_text_view::render_object(&projection.object).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
entrance["interfaces"][0]["signature"],
|
||||
wip_text_view::render_interface(&wip_text_view::Interface {
|
||||
reference: &projection.interface,
|
||||
descriptor: &projection.descriptor
|
||||
})
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
entrance["interfaces"][0]["signature"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("entry")
|
||||
);
|
||||
let mut after = None;
|
||||
let mut items = vec![];
|
||||
loop {
|
||||
let mut args = json!({"limit":1});
|
||||
if let Some(cursor) = after {
|
||||
args["after"] = cursor;
|
||||
}
|
||||
let page = json_content(
|
||||
model_invoke(r.clone(), &entrance, "list", args)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(page["items"].as_array().unwrap().len(), 1);
|
||||
items.push(page["items"][0].clone());
|
||||
if page["truncated"] == false {
|
||||
assert!(page.get("after").is_none());
|
||||
break;
|
||||
}
|
||||
after = Some(page["after"].clone());
|
||||
}
|
||||
assert_eq!(
|
||||
items
|
||||
.iter()
|
||||
.map(|item| item["entry"].clone())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
json!("/checkouts/main/empty"),
|
||||
json!("/checkouts/main/nested"),
|
||||
json!("/checkouts/main/z.txt")
|
||||
]
|
||||
);
|
||||
assert_eq!(items[0]["kind"], "directory");
|
||||
assert_eq!(items[2]["kind"], "file");
|
||||
let empty = model_inspect(r.clone(), items[0]["entry"].as_str().unwrap()).await;
|
||||
assert_eq!(
|
||||
json_content(
|
||||
model_invoke(r.clone(), &empty, "list", json!({}))
|
||||
.await
|
||||
.unwrap()
|
||||
)["items"],
|
||||
json!([])
|
||||
);
|
||||
let deep = model_inspect(r.clone(), "/checkouts/main/nested/deep").await;
|
||||
assert_eq!(
|
||||
deep["interfaces"][0]["reference"]["scope"],
|
||||
"/checkouts/main"
|
||||
);
|
||||
for (operation, args) in [
|
||||
("list", json!({})),
|
||||
("glob", json!({"pattern":"*.txt"})),
|
||||
(
|
||||
"grep",
|
||||
json!({"pattern":"needle", "output_mode":"files_with_matches"}),
|
||||
),
|
||||
] {
|
||||
let found = json_content(
|
||||
model_invoke(r.clone(), &deep, operation, args)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let path = found["items"][0]["entry"].as_str().unwrap();
|
||||
assert_eq!(path, "/checkouts/main/nested/deep/a.txt");
|
||||
// No result-wide eager acquisition by Host or Client.
|
||||
if operation == "list" {
|
||||
let state = r.state.lock().unwrap();
|
||||
assert!(state.client.object(&state.session, path).is_none());
|
||||
}
|
||||
let inspected = model_inspect(r.clone(), path).await;
|
||||
let read = json_content(
|
||||
model_invoke(r.clone(), &inspected, "read", json!({}))
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(read["content"].as_str().unwrap().contains("needle"));
|
||||
}
|
||||
let tree = json_content(
|
||||
model_operation(r.clone(), "Tree", json!({"path":"/checkouts", "depth":8}))
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(tree["tree"]["children"][0]["children"], json!([]));
|
||||
std::fs::remove_file(dir.path().join("nested/deep/a.txt")).unwrap();
|
||||
assert!(
|
||||
model_operation(
|
||||
r,
|
||||
"Inspect",
|
||||
json!({"path":"/checkouts/main/nested/deep/a.txt", "refresh":true})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_list_permission_cannot_be_bypassed_with_glob_or_command_identity() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("a.txt"), "fixture").unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||
session(&dir, true),
|
||||
)
|
||||
.unwrap();
|
||||
let r = runtime(
|
||||
router,
|
||||
Some(ToolPermissionConfig {
|
||||
default_action: ToolPermissionAction::Allow,
|
||||
rules: vec![manifest::ToolPermissionRule {
|
||||
tool: "List".into(),
|
||||
pattern: "*".into(),
|
||||
action: ToolPermissionAction::Deny,
|
||||
}],
|
||||
}),
|
||||
);
|
||||
let p = r
|
||||
.host
|
||||
.projection_live("/checkouts/main")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(!p.descriptor.operations.iter().any(|op| op.name == "list"));
|
||||
assert!(p.descriptor.operations.iter().any(|op| op.name == "glob"));
|
||||
let denied = p
|
||||
.handler
|
||||
.call(
|
||||
"list",
|
||||
&BTreeMap::new(),
|
||||
WipCallContext {
|
||||
execution: Default::default(),
|
||||
security_context: "fixture".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(denied, Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::PermissionDenied)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connection_before_read() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("a.txt"), "same\n").unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
let main = WorkdirAttachmentAlias::new("main").unwrap();
|
||||
router.attach(main.clone(), session(&dir, true)).unwrap();
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("main-extra").unwrap(),
|
||||
session(&dir, true),
|
||||
)
|
||||
.unwrap();
|
||||
let r = runtime(router.clone(), None);
|
||||
let path = "/checkouts/main/a.txt";
|
||||
let p = r.host.projection_live(path).await.unwrap().unwrap();
|
||||
let fetched = r.host.fetch_interface_live(&p.interface).await.unwrap();
|
||||
assert!(
|
||||
fetched.scope_ref.is_none(),
|
||||
"ref-less publication is legitimate"
|
||||
);
|
||||
// Ordinary content changes do not terminate the entrance's Interface lifetime.
|
||||
std::fs::write(dir.path().join("new.txt"), "fixture").unwrap();
|
||||
assert_eq!(
|
||||
r.host
|
||||
.fetch_interface_live(&p.interface)
|
||||
.await
|
||||
.unwrap()
|
||||
.interface,
|
||||
p.interface
|
||||
);
|
||||
let request = |reference, scope_ref| CallOperationRequest {
|
||||
target: wip_protocol::Target {
|
||||
path: path.into(),
|
||||
validator: None,
|
||||
},
|
||||
interface: wip_protocol::InterfaceTarget {
|
||||
reference,
|
||||
scope_ref,
|
||||
validator: None,
|
||||
},
|
||||
operation: "read".into(),
|
||||
arguments: BTreeMap::new(),
|
||||
};
|
||||
for scope in ["/checkouts/main-extra", "/checkouts/main/a"] {
|
||||
let mut wrong = p.interface.clone();
|
||||
wrong.scope = scope.into();
|
||||
assert!(r.host.fetch_interface_live(&wrong).await.is_err());
|
||||
let forged = request(wrong, None);
|
||||
let result = r
|
||||
.host
|
||||
.call(
|
||||
forged,
|
||||
WipCallContext {
|
||||
execution: Default::default(),
|
||||
security_context: "fixture".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result,Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::InterfaceMismatch)
|
||||
);
|
||||
}
|
||||
let forged = request(p.interface.clone(), Some("old".into()));
|
||||
assert!(
|
||||
matches!(r.host.call(forged, WipCallContext { execution:Default::default(),security_context:"fixture".into() }).await, Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::InterfaceMismatch)
|
||||
);
|
||||
router.detach(&main).await.unwrap();
|
||||
router.attach(main, session(&dir, true)).unwrap();
|
||||
let forged = request(p.interface.clone(), None);
|
||||
assert!(
|
||||
matches!(r.host.call(forged, WipCallContext { execution:Default::default(),security_context:"fixture".into() }).await, Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::InterfaceMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,6 +143,7 @@ impl WorkerFsView {
|
||||
.list(ListRequest {
|
||||
path: logical.clone(),
|
||||
limit: DIR_FILE_REF_ENTRY_LIMIT,
|
||||
after: None,
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)
|
||||
@@ -222,6 +223,7 @@ impl WorkerFsView {
|
||||
.list(ListRequest {
|
||||
path: parent,
|
||||
limit: COMPLETION_LIMIT,
|
||||
after: None,
|
||||
})
|
||||
.await
|
||||
else {
|
||||
|
||||
Reference in New Issue
Block a user