fix(worker): preserve Objective WIP authority semantics
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
//! local `.yoi/objectives` paths, so model-visible Objective tools go through
|
//! local `.yoi/objectives` paths, so model-visible Objective tools go through
|
||||||
//! the scoped Workspace API.
|
//! the scoped Workspace API.
|
||||||
|
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||||
@@ -23,7 +23,7 @@ use crate::permission::permission_action_for;
|
|||||||
use crate::wip::{
|
use crate::wip::{
|
||||||
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipFeatureRoute,
|
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipFeatureRoute,
|
||||||
WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, WipOperationOutput,
|
WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, WipOperationOutput,
|
||||||
WipProjection, WipProjectionKind, json_to_wip,
|
WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
|
||||||
};
|
};
|
||||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
||||||
|
|
||||||
@@ -536,7 +536,13 @@ const OBJECTIVE_TOOL_NAMES: [&str; 7] = [
|
|||||||
"ObjectiveUnlinkTicket",
|
"ObjectiveUnlinkTicket",
|
||||||
];
|
];
|
||||||
|
|
||||||
type ObjectiveRevisions = Arc<Mutex<HashMap<String, String>>>;
|
#[derive(Default)]
|
||||||
|
struct ObjectiveRevisionState {
|
||||||
|
revisions: HashMap<String, String>,
|
||||||
|
aliases_by_canonical: HashMap<String, BTreeSet<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
type ObjectiveRevisions = Arc<Mutex<ObjectiveRevisionState>>;
|
||||||
|
|
||||||
/// Contribute the Objective Feature's native collection and item projections to
|
/// Contribute the Objective Feature's native collection and item projections to
|
||||||
/// the Host-owned WIP registry. Ordinary Objective tools remain registered for
|
/// the Host-owned WIP registry. Ordinary Objective tools remain registered for
|
||||||
@@ -549,7 +555,7 @@ pub fn mount_workspace_http_objective_wip(
|
|||||||
) -> Result<(), WipMountError> {
|
) -> Result<(), WipMountError> {
|
||||||
let collection_route = feature_route.child("objectives")?;
|
let collection_route = feature_route.child("objectives")?;
|
||||||
let backend = WorkspaceHttpObjectiveBackend::new(client);
|
let backend = WorkspaceHttpObjectiveBackend::new(client);
|
||||||
let revisions = Arc::new(Mutex::new(HashMap::new()));
|
let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default()));
|
||||||
let collection_descriptor = objective_collection_descriptor();
|
let collection_descriptor = objective_collection_descriptor();
|
||||||
let collection_validator = descriptor_validator(&collection_descriptor);
|
let collection_validator = descriptor_validator(&collection_descriptor);
|
||||||
let collection_handler = Arc::new(ObjectiveCollectionWipHandler {
|
let collection_handler = Arc::new(ObjectiveCollectionWipHandler {
|
||||||
@@ -642,8 +648,7 @@ impl WipOperationHandler for ObjectiveCollectionWipHandler {
|
|||||||
limit,
|
limit,
|
||||||
cursor: optional_string_argument(arguments, "cursor")?,
|
cursor: optional_string_argument(arguments, "cursor")?,
|
||||||
};
|
};
|
||||||
let permission_input = serde_json::to_value(&input)
|
let permission_input = native_permission_input(arguments, None)?;
|
||||||
.map_err(|error| WipOperationError::OutcomeUnknown(error.to_string()))?;
|
|
||||||
authorize_native(&self.permissions, "QueryObjective", &permission_input)?;
|
authorize_native(&self.permissions, "QueryObjective", &permission_input)?;
|
||||||
let mut response = self
|
let mut response = self
|
||||||
.backend
|
.backend
|
||||||
@@ -683,8 +688,7 @@ impl WipOperationHandler for ObjectiveCollectionWipHandler {
|
|||||||
linked_tickets: string_list_argument(arguments, "linked_tickets")?
|
linked_tickets: string_list_argument(arguments, "linked_tickets")?
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
};
|
};
|
||||||
let permission_input = serde_json::to_value(&input)
|
let permission_input = native_permission_input(arguments, None)?;
|
||||||
.map_err(|error| WipOperationError::OutcomeUnknown(error.to_string()))?;
|
|
||||||
authorize_native(&self.permissions, "ObjectiveCreate", &permission_input)?;
|
authorize_native(&self.permissions, "ObjectiveCreate", &permission_input)?;
|
||||||
let mut response = self
|
let mut response = self
|
||||||
.backend
|
.backend
|
||||||
@@ -731,6 +735,7 @@ impl WipDynamicItemResolver for ObjectiveItemResolver {
|
|||||||
.revisions
|
.revisions
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|error| error.into_inner())
|
.unwrap_or_else(|error| error.into_inner())
|
||||||
|
.revisions
|
||||||
.get(item_reference)
|
.get(item_reference)
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| "unobserved".into());
|
.unwrap_or_else(|| "unobserved".into());
|
||||||
@@ -768,23 +773,17 @@ impl WipOperationHandler for ObjectiveItemWipHandler {
|
|||||||
arguments: &BTreeMap<String, Value>,
|
arguments: &BTreeMap<String, Value>,
|
||||||
_context: WipCallContext,
|
_context: WipCallContext,
|
||||||
) -> Result<WipOperationOutput, WipOperationError> {
|
) -> Result<WipOperationOutput, WipOperationError> {
|
||||||
let (tool_name, permission_input, response) = match operation {
|
let permission_input = native_permission_input(arguments, Some(&self.objective_reference))?;
|
||||||
|
let response = match operation {
|
||||||
"read" => {
|
"read" => {
|
||||||
let event_limit = optional_usize_argument(arguments, "event_limit")?;
|
let event_limit = optional_usize_argument(arguments, "event_limit")?;
|
||||||
ensure_optional_range("event_limit", event_limit, 1, 50)?;
|
ensure_optional_range("event_limit", event_limit, 1, 50)?;
|
||||||
let event_cursor = optional_string_argument(arguments, "event_cursor")?;
|
let event_cursor = optional_string_argument(arguments, "event_cursor")?;
|
||||||
let permission_input = json!({
|
|
||||||
"id": self.objective_reference,
|
|
||||||
"event_limit": event_limit,
|
|
||||||
"event_cursor": event_cursor,
|
|
||||||
});
|
|
||||||
authorize_native(&self.permissions, "ShowObjective", &permission_input)?;
|
authorize_native(&self.permissions, "ShowObjective", &permission_input)?;
|
||||||
let response = self
|
self.backend
|
||||||
.backend
|
|
||||||
.read_value(&self.objective_reference, event_limit, event_cursor)
|
.read_value(&self.objective_reference, event_limit, event_cursor)
|
||||||
.await
|
.await
|
||||||
.map_err(map_backend_error)?;
|
.map_err(map_backend_error)?
|
||||||
("ShowObjective", permission_input, response)
|
|
||||||
}
|
}
|
||||||
"edit" => {
|
"edit" => {
|
||||||
let input = ObjectiveEditRequest {
|
let input = ObjectiveEditRequest {
|
||||||
@@ -793,64 +792,46 @@ impl WipOperationHandler for ObjectiveItemWipHandler {
|
|||||||
new_string: optional_string_argument(arguments, "new_string")?,
|
new_string: optional_string_argument(arguments, "new_string")?,
|
||||||
replace_all: optional_bool_argument(arguments, "replace_all")?.unwrap_or(false),
|
replace_all: optional_bool_argument(arguments, "replace_all")?.unwrap_or(false),
|
||||||
};
|
};
|
||||||
let permission_input = json!({
|
|
||||||
"id": self.objective_reference,
|
|
||||||
"title": input.title,
|
|
||||||
"old_string": input.old_string,
|
|
||||||
"new_string": input.new_string,
|
|
||||||
"replace_all": input.replace_all,
|
|
||||||
});
|
|
||||||
authorize_native(&self.permissions, "ObjectiveEdit", &permission_input)?;
|
authorize_native(&self.permissions, "ObjectiveEdit", &permission_input)?;
|
||||||
let response = self
|
self.backend
|
||||||
.backend
|
|
||||||
.edit_value(&self.objective_reference, &input)
|
.edit_value(&self.objective_reference, &input)
|
||||||
.await
|
.await
|
||||||
.map_err(map_backend_error)?;
|
.map_err(map_backend_error)?
|
||||||
("ObjectiveEdit", permission_input, response)
|
|
||||||
}
|
}
|
||||||
"set_state" => {
|
"set_state" => {
|
||||||
let state = required_string_argument(arguments, "state")?;
|
let state = required_string_argument(arguments, "state")?;
|
||||||
let permission_input = json!({"id": self.objective_reference, "state": state});
|
|
||||||
authorize_native(&self.permissions, "ObjectiveSetState", &permission_input)?;
|
authorize_native(&self.permissions, "ObjectiveSetState", &permission_input)?;
|
||||||
let response = self
|
self.backend
|
||||||
.backend
|
|
||||||
.set_state_value(&self.objective_reference, &state)
|
.set_state_value(&self.objective_reference, &state)
|
||||||
.await
|
.await
|
||||||
.map_err(map_backend_error)?;
|
.map_err(map_backend_error)?
|
||||||
("ObjectiveSetState", permission_input, response)
|
|
||||||
}
|
}
|
||||||
"link_ticket" | "unlink_ticket" => {
|
"link_ticket" => {
|
||||||
let ticket_id = required_string_argument(arguments, "ticket_id")?;
|
let ticket_id = required_string_argument(arguments, "ticket_id")?;
|
||||||
let permission_input = json!({
|
authorize_native(&self.permissions, "ObjectiveLinkTicket", &permission_input)?;
|
||||||
"id": self.objective_reference,
|
let (_, response) = self
|
||||||
"ticket_id": ticket_id,
|
.backend
|
||||||
});
|
.link_ticket_value(&self.objective_reference, &ticket_id)
|
||||||
let (tool_name, response) = if operation == "link_ticket" {
|
.await
|
||||||
authorize_native(&self.permissions, "ObjectiveLinkTicket", &permission_input)?;
|
.map_err(map_backend_error)?;
|
||||||
let (_, response) = self
|
response
|
||||||
.backend
|
}
|
||||||
.link_ticket_value(&self.objective_reference, &ticket_id)
|
"unlink_ticket" => {
|
||||||
.await
|
let ticket_id = required_string_argument(arguments, "ticket_id")?;
|
||||||
.map_err(map_backend_error)?;
|
authorize_native(
|
||||||
("ObjectiveLinkTicket", response)
|
&self.permissions,
|
||||||
} else {
|
"ObjectiveUnlinkTicket",
|
||||||
authorize_native(
|
&permission_input,
|
||||||
&self.permissions,
|
)?;
|
||||||
"ObjectiveUnlinkTicket",
|
let (_, response) = self
|
||||||
&permission_input,
|
.backend
|
||||||
)?;
|
.unlink_ticket_value(&self.objective_reference, &ticket_id)
|
||||||
let (_, response) = self
|
.await
|
||||||
.backend
|
.map_err(map_backend_error)?;
|
||||||
.unlink_ticket_value(&self.objective_reference, &ticket_id)
|
response
|
||||||
.await
|
|
||||||
.map_err(map_backend_error)?;
|
|
||||||
("ObjectiveUnlinkTicket", response)
|
|
||||||
};
|
|
||||||
(tool_name, permission_input, response)
|
|
||||||
}
|
}
|
||||||
_ => return Err(operation_not_found()),
|
_ => return Err(operation_not_found()),
|
||||||
};
|
};
|
||||||
let _ = (tool_name, permission_input);
|
|
||||||
record_revision(&self.revisions, Some(&self.objective_reference), &response);
|
record_revision(&self.revisions, Some(&self.objective_reference), &response);
|
||||||
Ok(WipOperationOutput::native(
|
Ok(WipOperationOutput::native(
|
||||||
json_to_wip(&response).map_err(WipOperationError::OutcomeUnknown)?,
|
json_to_wip(&response).map_err(WipOperationError::OutcomeUnknown)?,
|
||||||
@@ -858,6 +839,28 @@ impl WipOperationHandler for ObjectiveItemWipHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn native_permission_input(
|
||||||
|
arguments: &BTreeMap<String, Value>,
|
||||||
|
bound_objective: Option<&str>,
|
||||||
|
) -> Result<Json, WipOperationError> {
|
||||||
|
let mut projected = serde_json::Map::new();
|
||||||
|
if let Some(objective) = bound_objective {
|
||||||
|
projected.insert("id".into(), Json::String(objective.to_string()));
|
||||||
|
}
|
||||||
|
for (name, value) in arguments {
|
||||||
|
if matches!(value, Value::Unit) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
projected.insert(
|
||||||
|
name.clone(),
|
||||||
|
wip_to_json(value).map_err(|message| {
|
||||||
|
protocol_failure(ProtocolErrorCode::InvalidArguments, message)
|
||||||
|
})?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(Json::Object(projected))
|
||||||
|
}
|
||||||
|
|
||||||
fn authorize_native(
|
fn authorize_native(
|
||||||
permissions: &Option<ToolPermissionConfig>,
|
permissions: &Option<ToolPermissionConfig>,
|
||||||
tool_name: &str,
|
tool_name: &str,
|
||||||
@@ -1020,15 +1023,32 @@ fn string_list_argument(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn record_revision(revisions: &ObjectiveRevisions, bound_reference: Option<&str>, response: &Json) {
|
fn record_revision(revisions: &ObjectiveRevisions, bound_reference: Option<&str>, response: &Json) {
|
||||||
let Some(revision) = response.get("revision").and_then(Json::as_str) else {
|
let (Some(revision), Some(canonical)) = (
|
||||||
|
response.get("revision").and_then(Json::as_str),
|
||||||
|
response.get("objective").and_then(Json::as_str),
|
||||||
|
) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let mut revisions = revisions.lock().unwrap_or_else(|error| error.into_inner());
|
let mut state = revisions.lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
state
|
||||||
|
.aliases_by_canonical
|
||||||
|
.entry(canonical.to_string())
|
||||||
|
.or_default()
|
||||||
|
.insert(canonical.to_string());
|
||||||
if let Some(reference) = bound_reference {
|
if let Some(reference) = bound_reference {
|
||||||
revisions.insert(reference.to_string(), revision.to_string());
|
state
|
||||||
|
.aliases_by_canonical
|
||||||
|
.entry(canonical.to_string())
|
||||||
|
.or_default()
|
||||||
|
.insert(reference.to_string());
|
||||||
}
|
}
|
||||||
if let Some(reference) = response.get("objective").and_then(Json::as_str) {
|
let aliases = state
|
||||||
revisions.insert(reference.to_string(), revision.to_string());
|
.aliases_by_canonical
|
||||||
|
.get(canonical)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
for alias in aliases {
|
||||||
|
state.revisions.insert(alias, revision.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1468,11 +1488,88 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use agen::tool::ToolDefinition;
|
use agen::tool::ToolDefinition;
|
||||||
use std::{
|
use std::{
|
||||||
|
collections::VecDeque,
|
||||||
io::{Read, Write},
|
io::{Read, Write},
|
||||||
net::TcpListener,
|
net::TcpListener,
|
||||||
thread,
|
thread,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct MockWorkspaceClient {
|
||||||
|
responses: Mutex<VecDeque<crate::worker::WorkspaceResponse>>,
|
||||||
|
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockWorkspaceClient {
|
||||||
|
fn new(responses: impl IntoIterator<Item = crate::worker::WorkspaceResponse>) -> Self {
|
||||||
|
Self {
|
||||||
|
responses: Mutex::new(responses.into_iter().collect()),
|
||||||
|
requests: Mutex::new(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requests(&self) -> Vec<WorkspaceRequest> {
|
||||||
|
self.requests
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner())
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkspaceClient for MockWorkspaceClient {
|
||||||
|
fn workspace_id(&self) -> Option<&str> {
|
||||||
|
Some("workspace")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind(&self) -> &str {
|
||||||
|
"objective-test"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_available(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute(
|
||||||
|
&self,
|
||||||
|
request: WorkspaceRequest,
|
||||||
|
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError> {
|
||||||
|
self.requests
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner())
|
||||||
|
.push(request);
|
||||||
|
self.responses
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner())
|
||||||
|
.pop_front()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
crate::worker::WorkspaceClientError::Unavailable(
|
||||||
|
"test response queue is empty".into(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn objective_response(revision: &str) -> crate::worker::WorkspaceResponse {
|
||||||
|
crate::worker::WorkspaceResponse {
|
||||||
|
status: 200,
|
||||||
|
body: json!({
|
||||||
|
"id": "00001OBJECTIVE",
|
||||||
|
"resource_key": "O-3",
|
||||||
|
"title": "Objective",
|
||||||
|
"body": "Body",
|
||||||
|
"body_truncated": false,
|
||||||
|
"state": "active",
|
||||||
|
"revision": revision,
|
||||||
|
"created_at": null,
|
||||||
|
"updated_at": null,
|
||||||
|
"linked_ticket_summaries": [],
|
||||||
|
"events": [],
|
||||||
|
"event_page": {"next_cursor": null, "has_more": false}
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn tool_names(definitions: Vec<ToolDefinition>) -> Vec<String> {
|
fn tool_names(definitions: Vec<ToolDefinition>) -> Vec<String> {
|
||||||
let mut names = definitions
|
let mut names = definitions
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -1702,7 +1799,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dynamic_objective_routes_accept_only_objective_references_and_refresh_revision() {
|
fn dynamic_objective_routes_accept_only_objective_references_and_refresh_revision() {
|
||||||
let revisions = Arc::new(Mutex::new(HashMap::new()));
|
let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default()));
|
||||||
let resolver = ObjectiveItemResolver {
|
let resolver = ObjectiveItemResolver {
|
||||||
backend: WorkspaceHttpObjectiveBackend::new(Arc::new(
|
backend: WorkspaceHttpObjectiveBackend::new(Arc::new(
|
||||||
crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"),
|
crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"),
|
||||||
@@ -1717,13 +1814,126 @@ mod tests {
|
|||||||
assert!(resolver.resolve("T-3").is_none());
|
assert!(resolver.resolve("T-3").is_none());
|
||||||
assert!(resolver.resolve("O-3/other").is_none());
|
assert!(resolver.resolve("O-3/other").is_none());
|
||||||
|
|
||||||
|
record_revision(
|
||||||
|
&revisions,
|
||||||
|
Some("00001OBJECTIVE"),
|
||||||
|
&json!({"objective": "O-3", "revision": "rev-1"}),
|
||||||
|
);
|
||||||
|
let canonical_rev_1 = resolver.resolve("O-3").unwrap().object.validator;
|
||||||
|
let internal_rev_1 = resolver.resolve("00001OBJECTIVE").unwrap().object.validator;
|
||||||
record_revision(
|
record_revision(
|
||||||
&revisions,
|
&revisions,
|
||||||
Some("O-3"),
|
Some("O-3"),
|
||||||
&json!({"objective": "O-3", "revision": "rev-2"}),
|
&json!({"objective": "O-3", "revision": "rev-2"}),
|
||||||
);
|
);
|
||||||
let refreshed = resolver.resolve("O-3").unwrap();
|
let canonical_rev_2 = resolver.resolve("O-3").unwrap().object.validator;
|
||||||
assert_ne!(initial.object.validator, refreshed.object.validator);
|
let internal_rev_2 = resolver.resolve("00001OBJECTIVE").unwrap().object.validator;
|
||||||
|
assert_ne!(canonical_rev_1, canonical_rev_2);
|
||||||
|
assert_ne!(internal_rev_1, internal_rev_2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_backend_errors_preserve_deterministic_protocol_outcomes() {
|
||||||
|
for status in [
|
||||||
|
reqwest::StatusCode::UNAUTHORIZED,
|
||||||
|
reqwest::StatusCode::FORBIDDEN,
|
||||||
|
] {
|
||||||
|
assert!(matches!(
|
||||||
|
map_backend_error(WorkspaceObjectiveBackendError::Http {
|
||||||
|
status,
|
||||||
|
body: "denied".into(),
|
||||||
|
}),
|
||||||
|
WipOperationError::Protocol(ProtocolError {
|
||||||
|
code: ProtocolErrorCode::PermissionDenied,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
map_backend_error(WorkspaceObjectiveBackendError::Http {
|
||||||
|
status: reqwest::StatusCode::NOT_FOUND,
|
||||||
|
body: "missing".into(),
|
||||||
|
}),
|
||||||
|
WipOperationError::Protocol(ProtocolError {
|
||||||
|
code: ProtocolErrorCode::NotFound,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
for status in [
|
||||||
|
reqwest::StatusCode::BAD_REQUEST,
|
||||||
|
reqwest::StatusCode::CONFLICT,
|
||||||
|
] {
|
||||||
|
assert!(matches!(
|
||||||
|
map_backend_error(WorkspaceObjectiveBackendError::Http {
|
||||||
|
status,
|
||||||
|
body: "invalid".into(),
|
||||||
|
}),
|
||||||
|
WipOperationError::Protocol(ProtocolError {
|
||||||
|
code: ProtocolErrorCode::InvalidArguments,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
map_backend_error(WorkspaceObjectiveBackendError::Http {
|
||||||
|
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
body: "unknown".into(),
|
||||||
|
}),
|
||||||
|
WipOperationError::OutcomeUnknown(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_permission_inputs_preserve_presence_and_route_bound_identity() {
|
||||||
|
let create_arguments =
|
||||||
|
BTreeMap::from([("title".into(), Value::String("Objective".into()))]);
|
||||||
|
let create = native_permission_input(&create_arguments, None)
|
||||||
|
.unwrap_or_else(|_| panic!("create permission input"));
|
||||||
|
assert_eq!(create, json!({"title": "Objective"}));
|
||||||
|
let read = native_permission_input(&BTreeMap::new(), Some("O-3"))
|
||||||
|
.unwrap_or_else(|_| panic!("read permission input"));
|
||||||
|
assert_eq!(read, json!({"id": "O-3"}));
|
||||||
|
|
||||||
|
let permissions = Some(ToolPermissionConfig {
|
||||||
|
default_action: ToolPermissionAction::Allow,
|
||||||
|
rules: vec![
|
||||||
|
manifest::ToolPermissionRule {
|
||||||
|
tool: "ObjectiveCreate".into(),
|
||||||
|
pattern: serde_json::to_string(&create).unwrap(),
|
||||||
|
action: ToolPermissionAction::Deny,
|
||||||
|
},
|
||||||
|
manifest::ToolPermissionRule {
|
||||||
|
tool: "ShowObjective".into(),
|
||||||
|
pattern: serde_json::to_string(&read).unwrap(),
|
||||||
|
action: ToolPermissionAction::Deny,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
authorize_native(&permissions, "ObjectiveCreate", &create),
|
||||||
|
Err(WipOperationError::Protocol(ProtocolError {
|
||||||
|
code: ProtocolErrorCode::PermissionDenied,
|
||||||
|
..
|
||||||
|
}))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
authorize_native(&permissions, "ShowObjective", &read),
|
||||||
|
Err(WipOperationError::Protocol(ProtocolError {
|
||||||
|
code: ProtocolErrorCode::PermissionDenied,
|
||||||
|
..
|
||||||
|
}))
|
||||||
|
));
|
||||||
|
let ask = Some(ToolPermissionConfig {
|
||||||
|
default_action: ToolPermissionAction::Ask,
|
||||||
|
rules: Vec::new(),
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
authorize_native(&ask, "ShowObjective", &read),
|
||||||
|
Err(WipOperationError::Protocol(ProtocolError {
|
||||||
|
code: ProtocolErrorCode::PermissionDenied,
|
||||||
|
..
|
||||||
|
}))
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1738,7 +1948,7 @@ mod tests {
|
|||||||
rules: Vec::new(),
|
rules: Vec::new(),
|
||||||
}),
|
}),
|
||||||
objective_reference: "O-3".into(),
|
objective_reference: "O-3".into(),
|
||||||
revisions: Arc::new(Mutex::new(HashMap::new())),
|
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
|
||||||
};
|
};
|
||||||
let result = denied
|
let result = denied
|
||||||
.call(
|
.call(
|
||||||
@@ -1762,7 +1972,7 @@ mod tests {
|
|||||||
backend,
|
backend,
|
||||||
permissions: None,
|
permissions: None,
|
||||||
collection_route: "/features/objective/objectives".into(),
|
collection_route: "/features/objective/objectives".into(),
|
||||||
revisions: Arc::new(Mutex::new(HashMap::new())),
|
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
|
||||||
};
|
};
|
||||||
let result = collection
|
let result = collection
|
||||||
.call(
|
.call(
|
||||||
@@ -1783,6 +1993,142 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn native_create_read_state_and_ticket_links_use_existing_backend_routes() {
|
||||||
|
let ticket_response = || crate::worker::WorkspaceResponse {
|
||||||
|
status: 200,
|
||||||
|
body: json!({"resource_key": "T-7"}).to_string(),
|
||||||
|
};
|
||||||
|
let client = Arc::new(MockWorkspaceClient::new([
|
||||||
|
objective_response("rev-1"),
|
||||||
|
objective_response("rev-1"),
|
||||||
|
objective_response("rev-2"),
|
||||||
|
ticket_response(),
|
||||||
|
objective_response("rev-3"),
|
||||||
|
ticket_response(),
|
||||||
|
objective_response("rev-4"),
|
||||||
|
]));
|
||||||
|
let backend = WorkspaceHttpObjectiveBackend::new(client.clone());
|
||||||
|
let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default()));
|
||||||
|
let collection = ObjectiveCollectionWipHandler {
|
||||||
|
backend: backend.clone(),
|
||||||
|
permissions: None,
|
||||||
|
collection_route: "/features/objective/objectives".into(),
|
||||||
|
revisions: Arc::clone(&revisions),
|
||||||
|
};
|
||||||
|
let context = || WipCallContext {
|
||||||
|
execution: ToolExecutionContext::direct(),
|
||||||
|
security_context: "test".into(),
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
collection
|
||||||
|
.call(
|
||||||
|
"create",
|
||||||
|
&BTreeMap::from([("title".into(), Value::String("Objective".into()),)]),
|
||||||
|
context(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
|
||||||
|
let item = ObjectiveItemWipHandler {
|
||||||
|
backend,
|
||||||
|
permissions: None,
|
||||||
|
objective_reference: "O-3".into(),
|
||||||
|
revisions,
|
||||||
|
};
|
||||||
|
assert!(item.call("read", &BTreeMap::new(), context()).await.is_ok());
|
||||||
|
assert!(
|
||||||
|
item.call(
|
||||||
|
"set_state",
|
||||||
|
&BTreeMap::from([("state".into(), Value::String("paused".into()))]),
|
||||||
|
context(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
for operation in ["link_ticket", "unlink_ticket"] {
|
||||||
|
assert!(
|
||||||
|
item.call(
|
||||||
|
operation,
|
||||||
|
&BTreeMap::from([("ticket_id".into(), Value::String("T-7".into()))]),
|
||||||
|
context(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let requests = client.requests();
|
||||||
|
assert_eq!(
|
||||||
|
requests
|
||||||
|
.iter()
|
||||||
|
.map(|request| (request.method, request.path.as_str()))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[
|
||||||
|
(WorkspaceRequestMethod::Post, "/api/w/workspace/objectives"),
|
||||||
|
(
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
"/api/w/workspace/objectives/O-3/show"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
"/api/w/workspace/objectives/O-3/state"
|
||||||
|
),
|
||||||
|
(WorkspaceRequestMethod::Get, "/api/w/workspace/tickets/T-7"),
|
||||||
|
(
|
||||||
|
WorkspaceRequestMethod::Post,
|
||||||
|
"/api/w/workspace/objectives/O-3/ticket-links"
|
||||||
|
),
|
||||||
|
(WorkspaceRequestMethod::Get, "/api/w/workspace/tickets/T-7"),
|
||||||
|
(
|
||||||
|
WorkspaceRequestMethod::Delete,
|
||||||
|
"/api/w/workspace/objectives/O-3/ticket-links/T-7"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn native_missing_objective_and_ticket_refs_are_deterministic_not_found() {
|
||||||
|
let missing = || crate::worker::WorkspaceResponse {
|
||||||
|
status: 404,
|
||||||
|
body: "missing".into(),
|
||||||
|
};
|
||||||
|
let client = Arc::new(MockWorkspaceClient::new([missing(), missing()]));
|
||||||
|
let item = ObjectiveItemWipHandler {
|
||||||
|
backend: WorkspaceHttpObjectiveBackend::new(client),
|
||||||
|
permissions: None,
|
||||||
|
objective_reference: "O-404".into(),
|
||||||
|
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
|
||||||
|
};
|
||||||
|
for (operation, arguments) in [
|
||||||
|
("read", BTreeMap::new()),
|
||||||
|
(
|
||||||
|
"link_ticket",
|
||||||
|
BTreeMap::from([("ticket_id".into(), Value::String("T-404".into()))]),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let result = item
|
||||||
|
.call(
|
||||||
|
operation,
|
||||||
|
&arguments,
|
||||||
|
WipCallContext {
|
||||||
|
execution: ToolExecutionContext::direct(),
|
||||||
|
security_context: "test".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(matches!(
|
||||||
|
result,
|
||||||
|
Err(WipOperationError::Protocol(ProtocolError {
|
||||||
|
code: ProtocolErrorCode::NotFound,
|
||||||
|
..
|
||||||
|
}))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn native_query_preserves_cursor_and_returns_canonical_item_paths() {
|
async fn native_query_preserves_cursor_and_returns_canonical_item_paths() {
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
@@ -1825,7 +2171,7 @@ mod tests {
|
|||||||
)),
|
)),
|
||||||
permissions: None,
|
permissions: None,
|
||||||
collection_route: "/features/objective/objectives".into(),
|
collection_route: "/features/objective/objectives".into(),
|
||||||
revisions: Arc::new(Mutex::new(HashMap::new())),
|
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
|
||||||
};
|
};
|
||||||
let output = match handler
|
let output = match handler
|
||||||
.call(
|
.call(
|
||||||
@@ -1890,7 +2236,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
});
|
});
|
||||||
let revisions = Arc::new(Mutex::new(HashMap::new()));
|
let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default()));
|
||||||
let backend = WorkspaceHttpObjectiveBackend::new(Arc::new(
|
let backend = WorkspaceHttpObjectiveBackend::new(Arc::new(
|
||||||
crate::worker::TestWorkspaceHttpClient::new("workspace", base_url),
|
crate::worker::TestWorkspaceHttpClient::new("workspace", base_url),
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -1955,6 +1955,7 @@ mod tests {
|
|||||||
use agen::llm_client::client::LlmClient;
|
use agen::llm_client::client::LlmClient;
|
||||||
use agen::llm_client::{ClientError, Request as LlmRequest, ResponseStream};
|
use agen::llm_client::{ClientError, Request as LlmRequest, ResponseStream};
|
||||||
use futures::stream;
|
use futures::stream;
|
||||||
|
use std::collections::VecDeque;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use wip_protocol::{EnumCase, FieldDeclaration, TypeDeclaration, UnionCase};
|
use wip_protocol::{EnumCase, FieldDeclaration, TypeDeclaration, UnionCase};
|
||||||
|
|
||||||
@@ -1974,6 +1975,82 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ScriptedWorkspaceClient {
|
||||||
|
responses: Mutex<VecDeque<crate::worker::WorkspaceResponse>>,
|
||||||
|
requests: Mutex<Vec<crate::worker::WorkspaceRequest>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScriptedWorkspaceClient {
|
||||||
|
fn new(responses: impl IntoIterator<Item = crate::worker::WorkspaceResponse>) -> Self {
|
||||||
|
Self {
|
||||||
|
responses: Mutex::new(responses.into_iter().collect()),
|
||||||
|
requests: Mutex::new(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requests(&self) -> Vec<crate::worker::WorkspaceRequest> {
|
||||||
|
self.requests
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner())
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::worker::WorkspaceClient for ScriptedWorkspaceClient {
|
||||||
|
fn workspace_id(&self) -> Option<&str> {
|
||||||
|
Some("workspace")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind(&self) -> &str {
|
||||||
|
"scripted-test"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_available(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute(
|
||||||
|
&self,
|
||||||
|
request: crate::worker::WorkspaceRequest,
|
||||||
|
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError> {
|
||||||
|
self.requests
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner())
|
||||||
|
.push(request);
|
||||||
|
self.responses
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner())
|
||||||
|
.pop_front()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
crate::worker::WorkspaceClientError::Unavailable(
|
||||||
|
"scripted response queue is empty".into(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn objective_detail_response(revision: &str) -> crate::worker::WorkspaceResponse {
|
||||||
|
crate::worker::WorkspaceResponse {
|
||||||
|
status: 200,
|
||||||
|
body: json!({
|
||||||
|
"id": "00001OBJECTIVE",
|
||||||
|
"resource_key": "O-3",
|
||||||
|
"title": "Objective",
|
||||||
|
"body": "Body",
|
||||||
|
"body_truncated": false,
|
||||||
|
"state": "active",
|
||||||
|
"revision": revision,
|
||||||
|
"created_at": null,
|
||||||
|
"updated_at": null,
|
||||||
|
"linked_ticket_summaries": [],
|
||||||
|
"events": [],
|
||||||
|
"event_page": {"next_cursor": null, "has_more": false}
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn meta(name: &str) -> ToolMeta {
|
fn meta(name: &str) -> ToolMeta {
|
||||||
ToolMeta::new(name)
|
ToolMeta::new(name)
|
||||||
.description("test tool")
|
.description("test tool")
|
||||||
@@ -2636,6 +2713,156 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn objective_native_runtime_covers_discovery_and_all_backend_operations() {
|
||||||
|
let query_response = crate::worker::WorkspaceResponse {
|
||||||
|
status: 200,
|
||||||
|
body: json!({
|
||||||
|
"items": [{
|
||||||
|
"id": "00001OBJECTIVE",
|
||||||
|
"resource_key": "O-3",
|
||||||
|
"title": "Objective",
|
||||||
|
"state": "active",
|
||||||
|
"created_at": null,
|
||||||
|
"updated_at": null,
|
||||||
|
"matched_fields": [],
|
||||||
|
"snippet": null,
|
||||||
|
"linked_ticket_count": 0,
|
||||||
|
"linked_tickets": [],
|
||||||
|
"linked_ticket_keys": []
|
||||||
|
}],
|
||||||
|
"page": {"next_cursor": null, "has_more": false},
|
||||||
|
"record_authority": "workspace_sqlite"
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
};
|
||||||
|
let ticket_response = || crate::worker::WorkspaceResponse {
|
||||||
|
status: 200,
|
||||||
|
body: json!({"resource_key": "T-7"}).to_string(),
|
||||||
|
};
|
||||||
|
let client = Arc::new(ScriptedWorkspaceClient::new([
|
||||||
|
query_response,
|
||||||
|
objective_detail_response("rev-1"),
|
||||||
|
objective_detail_response("rev-1"),
|
||||||
|
objective_detail_response("rev-2"),
|
||||||
|
objective_detail_response("rev-3"),
|
||||||
|
ticket_response(),
|
||||||
|
objective_detail_response("rev-4"),
|
||||||
|
ticket_response(),
|
||||||
|
objective_detail_response("rev-5"),
|
||||||
|
]));
|
||||||
|
let mut engine = Engine::<_, Mutable, ()>::new_annotated(DummyClient);
|
||||||
|
for definition in
|
||||||
|
crate::feature::builtin::objective::workspace_http_objective_tools(client.clone())
|
||||||
|
{
|
||||||
|
engine.register_tool(definition);
|
||||||
|
}
|
||||||
|
let permissions = Some(ToolPermissionConfig {
|
||||||
|
default_action: ToolPermissionAction::Allow,
|
||||||
|
rules: vec![manifest::ToolPermissionRule {
|
||||||
|
tool: "ObjectiveCreate".into(),
|
||||||
|
pattern: serde_json::to_string(&json!({"title": "Denied"})).unwrap(),
|
||||||
|
action: ToolPermissionAction::Deny,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
let mut registry = WipMountRegistry::new();
|
||||||
|
let feature_route = registry.allocate_feature_route("objective").unwrap();
|
||||||
|
crate::feature::builtin::objective::mount_workspace_http_objective_wip(
|
||||||
|
&mut registry,
|
||||||
|
client.clone(),
|
||||||
|
permissions.clone(),
|
||||||
|
&feature_route,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let runtime =
|
||||||
|
install_wip_mode_with_mounts(&mut engine, permissions, "worker-a".into(), registry)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let discovered = runtime.discover("/".into(), 3, false).await.unwrap();
|
||||||
|
let discovered = discovered.content.unwrap();
|
||||||
|
assert!(discovered.contains("/features/objective/objectives"));
|
||||||
|
assert!(!discovered.contains("/tools/QueryObjective"));
|
||||||
|
runtime
|
||||||
|
.inspect("yoi.objective/collection/v1".into(), false)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let collection_path = "/features/objective/objectives";
|
||||||
|
runtime
|
||||||
|
.call(
|
||||||
|
collection_path.into(),
|
||||||
|
"yoi.objective/collection/v1".into(),
|
||||||
|
"query".into(),
|
||||||
|
json!({}),
|
||||||
|
ToolExecutionContext::direct(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let denied = runtime
|
||||||
|
.call(
|
||||||
|
collection_path.into(),
|
||||||
|
"yoi.objective/collection/v1".into(),
|
||||||
|
"create".into(),
|
||||||
|
json!({"title": "Denied"}),
|
||||||
|
ToolExecutionContext::direct(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(denied.to_string().contains("PermissionDenied"));
|
||||||
|
assert_eq!(client.requests().len(), 1);
|
||||||
|
let created = runtime
|
||||||
|
.call(
|
||||||
|
collection_path.into(),
|
||||||
|
"yoi.objective/collection/v1".into(),
|
||||||
|
"create".into(),
|
||||||
|
json!({"title": "Objective"}),
|
||||||
|
ToolExecutionContext::direct(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
created
|
||||||
|
.content
|
||||||
|
.unwrap()
|
||||||
|
.contains("/features/objective/objectives/O-3")
|
||||||
|
);
|
||||||
|
|
||||||
|
let item_path = "/features/objective/objectives/O-3";
|
||||||
|
runtime.discover(item_path.into(), 0, false).await.unwrap();
|
||||||
|
runtime
|
||||||
|
.inspect("yoi.objective/item/v1".into(), false)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
for (operation, arguments) in [
|
||||||
|
("read", json!({})),
|
||||||
|
("edit", json!({"title": "Changed"})),
|
||||||
|
("set_state", json!({"state": "paused"})),
|
||||||
|
("link_ticket", json!({"ticket_id": "T-7"})),
|
||||||
|
("unlink_ticket", json!({"ticket_id": "T-7"})),
|
||||||
|
] {
|
||||||
|
runtime
|
||||||
|
.call(
|
||||||
|
item_path.into(),
|
||||||
|
"yoi.objective/item/v1".into(),
|
||||||
|
operation.into(),
|
||||||
|
arguments,
|
||||||
|
ToolExecutionContext::direct(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let requests = client.requests();
|
||||||
|
assert_eq!(requests.len(), 9);
|
||||||
|
assert_eq!(
|
||||||
|
requests
|
||||||
|
.iter()
|
||||||
|
.filter(|request| request.path.contains("ticket-links"))
|
||||||
|
.count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert!(requests.iter().all(|request| !request.path.contains("//")));
|
||||||
|
}
|
||||||
|
|
||||||
struct FailingTool {
|
struct FailingTool {
|
||||||
cancelled: bool,
|
cancelled: bool,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user