fix: preserve native plan filters and disclose input coordinates
This commit is contained in:
@@ -130,9 +130,25 @@ async fn mr_only_controller_reserves_ticket_coordinates_without_publishing_ticke
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(definition.content.unwrap().contains("ticket: entry"));
|
||||
let definition: serde_json::Value = serde_json::from_str(&definition.content.unwrap()).unwrap();
|
||||
let signature = definition["interface_signature"].as_str().unwrap();
|
||||
assert!(signature.contains("ticket: entry"));
|
||||
let doc = signature
|
||||
.lines()
|
||||
.filter_map(|line| line.trim().strip_prefix("/// "))
|
||||
.map(|text| serde_json::from_str::<String>(text).unwrap())
|
||||
.find(|text| text.starts_with("Ticket Entry: collection root "))
|
||||
.unwrap();
|
||||
let root: String = serde_json::from_str(
|
||||
doc.strip_prefix("Ticket Entry: collection root ")
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let (_, invoke) = tools.get_tool("Invoke").unwrap();
|
||||
let input = serde_json::json!({"path":"/merge-requests","interface":object["interfaces"][0]["reference"],"operation":"open","arguments":{"ticket":"/tickets/T-42","repository":{"key":"main","source":"work/T-42","target":"develop"}}});
|
||||
let input = serde_json::json!({"path":"/merge-requests","interface":object["interfaces"][0]["reference"],"operation":"open","arguments":{"ticket":format!("{root}/T-42"),"repository":{"key":"main","source":"work/T-42","target":"develop"}}});
|
||||
invoke
|
||||
.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
|
||||
@@ -869,7 +869,7 @@ fn merge_request_descriptor(
|
||||
fn merge_request_operation_declaration(
|
||||
tool: &NativeMergeRequestTool,
|
||||
) -> Result<OperationDeclaration, WipMountError> {
|
||||
Ok(native::declaration(&tool.contract))
|
||||
Ok(native::declaration(&tool.contract, &tool.roots))
|
||||
}
|
||||
|
||||
fn merge_request_protocol_failure(
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
//! consumers must Inspect the current Object and then its Interface again, use Ticket Entries,
|
||||
//! group repository/page/integration records, and invoke `readiness`. Tool mode is unchanged.
|
||||
//!
|
||||
//! Examples below show Invoke's JSON binding, not Tool JSON. Substitute the actual allocated
|
||||
//! Ticket collection path discovered from the current registry (it need not be `/tickets`).
|
||||
//! Examples below show Invoke's JSON binding, not Tool JSON. Read the allocated Ticket collection
|
||||
//! root from the Entry parameter's Inspect documentation (it need not be `/tickets`). This input
|
||||
//! coordinate remains available when Ticket Objects are unpublished; it is not an observation or grant.
|
||||
//!
|
||||
//! ```text
|
||||
//! collection.open({ticket: "/tickets/T-797",
|
||||
@@ -186,8 +187,18 @@ pub(super) fn contract(kind: Kind) -> Contract {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn declaration(contract: &Contract) -> OperationDeclaration {
|
||||
contract.declaration()
|
||||
impl ReferenceRoots {
|
||||
fn contract_roots(&self) -> crate::feature::builtin::native_contract::ReferenceRoots<'_> {
|
||||
crate::feature::builtin::native_contract::ReferenceRoots {
|
||||
ticket: &self.ticket,
|
||||
merge_request: &self.merge_request,
|
||||
objective: &self.objective,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn declaration(contract: &Contract, roots: &ReferenceRoots) -> OperationDeclaration {
|
||||
contract.declaration(&roots.contract_roots())
|
||||
}
|
||||
|
||||
pub(super) fn convert(
|
||||
@@ -195,14 +206,7 @@ pub(super) fn convert(
|
||||
arguments: &BTreeMap<String, WipValue>,
|
||||
roots: &ReferenceRoots,
|
||||
) -> Result<Value, WipOperationError> {
|
||||
contract.convert(
|
||||
arguments,
|
||||
&crate::feature::builtin::native_contract::ReferenceRoots {
|
||||
ticket: &roots.ticket,
|
||||
merge_request: &roots.merge_request,
|
||||
objective: &roots.objective,
|
||||
},
|
||||
)
|
||||
contract.convert(arguments, &roots.contract_roots())
|
||||
}
|
||||
|
||||
pub(super) fn command(kind: Kind, mut input: Value) -> Result<command::Command, WipOperationError> {
|
||||
|
||||
@@ -59,7 +59,7 @@ async fn inspect_interface(runtime: &WipRuntime, path: &str) -> String {
|
||||
|
||||
#[test]
|
||||
fn native_contract_exposes_records_enums_nullable_and_entry_without_tool_schema() {
|
||||
let open = contract(Kind::Open).declaration();
|
||||
let open = contract(Kind::Open).declaration(&roots().contract_roots());
|
||||
assert!(matches!(open.parameters[0].r#type, TypeExpr::Entry));
|
||||
let TypeExpr::Record { ref fields } = open.parameters[1].r#type else {
|
||||
panic!("repository record")
|
||||
@@ -71,7 +71,7 @@ fn native_contract_exposes_records_enums_nullable_and_entry_without_tool_schema(
|
||||
.collect::<Vec<_>>(),
|
||||
[("key", true), ("source", true), ("target", true)]
|
||||
);
|
||||
let review = contract(Kind::Review).declaration();
|
||||
let review = contract(Kind::Review).declaration(&roots().contract_roots());
|
||||
assert!(review.parameters[0].required);
|
||||
assert!(matches!(review.parameters[0].r#type, TypeExpr::Enum { .. }));
|
||||
let TypeExpr::List { items } = &review.parameters[2].r#type else {
|
||||
@@ -405,3 +405,88 @@ async fn native_review_typed_findings_reaches_only_injected_route_and_never_retr
|
||||
);
|
||||
assert_eq!(body["capability_token"], "secret-capability");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mr_only_inspect_supplies_reserved_ticket_coordinates_without_observing_ticket() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(
|
||||
json!({"merge_request_id":"MR-1"}),
|
||||
)]));
|
||||
let mut registry = WipMountRegistry::new();
|
||||
registry
|
||||
.allocate_namespace("ticket", "hidden-cases")
|
||||
.unwrap();
|
||||
let route = registry
|
||||
.allocate_namespace("merge-request", "changes")
|
||||
.unwrap();
|
||||
mount_workspace_http_merge_request_wip(
|
||||
&mut registry,
|
||||
client.clone(),
|
||||
MergeRequestFeatureConfig {
|
||||
open: true,
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
&route,
|
||||
)
|
||||
.unwrap();
|
||||
let runtime = WipRuntime::from_mounts(registry, "mr-only-input-discovery".into()).unwrap();
|
||||
let tree = runtime.tree("/".into(), 1, false).await.unwrap();
|
||||
assert!(!tree.content.unwrap().contains("hidden-cases"));
|
||||
let object = runtime.inspect("/changes".into(), false).await.unwrap();
|
||||
let object: Value = serde_json::from_str(&object.content.unwrap()).unwrap();
|
||||
let shown = &object["interfaces"][0];
|
||||
let definition = runtime
|
||||
.inspect(shown["path"].as_str().unwrap().into(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
let definition: Value = serde_json::from_str(&definition.content.unwrap()).unwrap();
|
||||
let signature = definition["interface_signature"].as_str().unwrap();
|
||||
// The model has a known Ticket ID, but obtains the required input coordinates solely
|
||||
// from the native Inspect contract; it never observes or invokes the reserved domain.
|
||||
let doc = signature
|
||||
.lines()
|
||||
.filter_map(|line| line.trim().strip_prefix("/// "))
|
||||
.map(|text| serde_json::from_str::<String>(text).unwrap())
|
||||
.find(|text| text.starts_with("Ticket Entry: collection root "))
|
||||
.unwrap();
|
||||
let encoded = doc
|
||||
.strip_prefix("Ticket Entry: collection root ")
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap();
|
||||
let root: String = serde_json::from_str(encoded).unwrap();
|
||||
assert_eq!(root, "/hidden-cases");
|
||||
assert!(signature.contains("Coordinates do not imply Object publication"));
|
||||
assert!(runtime.inspect(root.clone(), false).await.is_err());
|
||||
let input = json!({"ticket":format!("{root}/T-797"),"repository":{"key":"main","source":"work","target":"develop"}});
|
||||
runtime
|
||||
.invoke(
|
||||
"/changes".into(),
|
||||
wip_protocol::InterfaceReference {
|
||||
scope: shown["reference"]["scope"].as_str().unwrap().into(),
|
||||
name: shown["reference"]["name"].as_str().unwrap().into(),
|
||||
},
|
||||
"open".into(),
|
||||
input,
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0].path, "/api/w/ws/tickets/T-797/merge-request");
|
||||
assert_eq!(
|
||||
client.authorizations.lock().unwrap()[0].resource,
|
||||
server_api::authorization::PermissionResource::Ticket {
|
||||
workspace_id: "ws".into(),
|
||||
ticket_id: "T-797".into()
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
runtime
|
||||
.inspect(format!("{root}/T-797"), false)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
//! These shapes are authored for native operations, not derived from Tool schemas. WIP validates
|
||||
//! the generated declaration before destination mapping; authorization and business constraints
|
||||
//! remain with the domain handler. Conversion neither resolves objects nor grants access.
|
||||
//! Declaration and conversion receive the same allocated reference roots. Input Entry documentation
|
||||
//! exposes these coordinates even for unpublished domains, without claiming an Object observation;
|
||||
//! the Interface validator changes with the coordinates and Backend authority is checked at execution.
|
||||
//!
|
||||
//! WIP 0.2 represents unions as records with `$case` and an optional `value`, and has no nullable
|
||||
//! type. Nullable inputs therefore expose an explicit `null | value(T)` union rather than an
|
||||
@@ -88,7 +91,7 @@ pub struct ReferenceRoots<'a> {
|
||||
}
|
||||
|
||||
impl Contract {
|
||||
pub fn declaration(&self) -> OperationDeclaration {
|
||||
pub fn declaration(&self, roots: &ReferenceRoots<'_>) -> OperationDeclaration {
|
||||
OperationDeclaration {
|
||||
name: self.name.into(),
|
||||
documentation: Some(Documentation {
|
||||
@@ -101,8 +104,8 @@ impl Contract {
|
||||
.map(|input| ParameterDeclaration {
|
||||
name: input.name.into(),
|
||||
required: input.required,
|
||||
documentation: input.ty.documentation(),
|
||||
r#type: input.ty.declaration(),
|
||||
documentation: input.ty.documentation(roots),
|
||||
r#type: input.ty.declaration(roots),
|
||||
})
|
||||
.collect(),
|
||||
// Typed result Entries are owned separately from this input contract.
|
||||
@@ -122,7 +125,7 @@ impl Contract {
|
||||
format: INTERFACE_FORMAT_V1.into(),
|
||||
documentation: None,
|
||||
types: Vec::new(),
|
||||
operations: vec![self.declaration()],
|
||||
operations: vec![self.declaration(roots)],
|
||||
};
|
||||
descriptor.validate().map_err(invalid)?;
|
||||
// validate_call is the upstream type validator. These inert targets exist only to
|
||||
@@ -150,29 +153,49 @@ impl Contract {
|
||||
}
|
||||
|
||||
impl Shape {
|
||||
fn documentation(&self) -> Option<Documentation> {
|
||||
fn documentation(&self, roots: &ReferenceRoots<'_>) -> Option<Documentation> {
|
||||
let summary = match self {
|
||||
Self::Reference(ReferenceKind::Ticket) => {
|
||||
"Ticket Entry: an exact item path under the current Ticket collection"
|
||||
}
|
||||
Self::Reference(ReferenceKind::MergeRequest) => {
|
||||
"Merge Request Entry: an exact item path under the current Merge Request collection"
|
||||
}
|
||||
Self::Reference(ReferenceKind::Objective) => {
|
||||
"Objective Entry: an exact item path under the current Objective collection; leaf O- followed by decimal digits, or an 8–128 character ASCII alphanumeric canonical ID"
|
||||
Self::Reference(kind) => {
|
||||
let name = match kind {
|
||||
ReferenceKind::Ticket => "Ticket",
|
||||
ReferenceKind::MergeRequest => "Merge Request",
|
||||
ReferenceKind::Objective => "Objective",
|
||||
};
|
||||
let root = kind.root(roots);
|
||||
if root.is_empty() {
|
||||
format!(
|
||||
"{name} Entry: no unique input collection is available; no reference is accepted"
|
||||
)
|
||||
} else {
|
||||
let leaf = match kind {
|
||||
ReferenceKind::Ticket => {
|
||||
"T- followed by decimal digits, or an 8–128 character ASCII alphanumeric canonical ID"
|
||||
}
|
||||
ReferenceKind::Objective => {
|
||||
"O- followed by decimal digits, or an 8–128 character ASCII alphanumeric canonical ID"
|
||||
}
|
||||
ReferenceKind::MergeRequest => {
|
||||
"1–128 ASCII alphanumeric or -_.~ characters, excluding . and .."
|
||||
}
|
||||
};
|
||||
format!(
|
||||
"{name} Entry: collection root {root:?}; append /<id> for an exact item path; id is {leaf}. Coordinates do not imply Object publication, existence, observation or execution authority; Backend authorization is checked at call time"
|
||||
)
|
||||
}
|
||||
}
|
||||
Self::Nullable(_) => {
|
||||
"Explicit null uses {$case: null}; a present value uses {$case: value, value: T}"
|
||||
.into()
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(Documentation {
|
||||
summary: summary.into(),
|
||||
summary,
|
||||
details: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn declaration(&self) -> TypeExpr {
|
||||
fn declaration(&self, roots: &ReferenceRoots<'_>) -> TypeExpr {
|
||||
match self {
|
||||
Self::String => TypeExpr::String,
|
||||
Self::Boolean => TypeExpr::Boolean,
|
||||
@@ -195,13 +218,13 @@ impl Shape {
|
||||
},
|
||||
UnionCase {
|
||||
name: "value".into(),
|
||||
documentation: inner.documentation(),
|
||||
payload: Some(inner.declaration()),
|
||||
documentation: inner.documentation(roots),
|
||||
payload: Some(inner.declaration(roots)),
|
||||
},
|
||||
],
|
||||
},
|
||||
Self::List(items) => TypeExpr::List {
|
||||
items: Box::new(items.declaration()),
|
||||
items: Box::new(items.declaration(roots)),
|
||||
},
|
||||
Self::Record(fields) => TypeExpr::Record {
|
||||
fields: fields
|
||||
@@ -209,8 +232,8 @@ impl Shape {
|
||||
.map(|input| FieldDeclaration {
|
||||
name: input.name.into(),
|
||||
required: input.required,
|
||||
documentation: input.ty.documentation(),
|
||||
r#type: input.ty.declaration(),
|
||||
documentation: input.ty.documentation(roots),
|
||||
r#type: input.ty.declaration(roots),
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
@@ -220,7 +243,10 @@ impl Shape {
|
||||
.map(|case| UnionCase {
|
||||
name: case.name.into(),
|
||||
documentation: None,
|
||||
payload: case.payload.as_ref().map(Self::declaration),
|
||||
payload: case
|
||||
.payload
|
||||
.as_ref()
|
||||
.map(|payload| payload.declaration(roots)),
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
@@ -350,16 +376,20 @@ fn insert(
|
||||
}
|
||||
|
||||
impl ReferenceKind {
|
||||
fn root<'a>(self, roots: &ReferenceRoots<'a>) -> &'a str {
|
||||
match self {
|
||||
Self::Ticket => roots.ticket,
|
||||
Self::MergeRequest => roots.merge_request,
|
||||
Self::Objective => roots.objective,
|
||||
}
|
||||
}
|
||||
|
||||
fn identity<'a>(
|
||||
self,
|
||||
path: &'a str,
|
||||
roots: &ReferenceRoots<'_>,
|
||||
) -> Result<&'a str, WipOperationError> {
|
||||
let root = match self {
|
||||
Self::Ticket => roots.ticket,
|
||||
Self::MergeRequest => roots.merge_request,
|
||||
Self::Objective => roots.objective,
|
||||
};
|
||||
let root = self.root(roots);
|
||||
wip_protocol::validate_path(root).map_err(invalid)?;
|
||||
wip_protocol::validate_path(path).map_err(invalid)?;
|
||||
let leaf = path
|
||||
@@ -499,7 +529,7 @@ mod tests {
|
||||
destination: Destination::Flatten,
|
||||
}]);
|
||||
assert!(matches!(
|
||||
contract.declaration().parameters[0].r#type,
|
||||
contract.declaration(&ROOTS).parameters[0].r#type,
|
||||
TypeExpr::Union { .. }
|
||||
));
|
||||
let arguments = fields([(
|
||||
@@ -578,7 +608,7 @@ mod tests {
|
||||
false,
|
||||
Shape::Nullable(Box::new(Shape::String)),
|
||||
)]);
|
||||
let declaration = contract.declaration();
|
||||
let declaration = contract.declaration(&ROOTS);
|
||||
assert!(!declaration.parameters[0].required);
|
||||
assert!(matches!(
|
||||
&declaration.parameters[0].r#type,
|
||||
@@ -684,7 +714,7 @@ mod tests {
|
||||
},
|
||||
)]);
|
||||
assert!(matches!(
|
||||
contract.declaration().parameters[0].r#type,
|
||||
contract.declaration(&ROOTS).parameters[0].r#type,
|
||||
TypeExpr::Union { .. }
|
||||
));
|
||||
for (value, expected_json, expected_reason) in [
|
||||
@@ -743,7 +773,7 @@ mod tests {
|
||||
true,
|
||||
Shape::Reference(ReferenceKind::Objective),
|
||||
)]);
|
||||
let parameter = &contract.declaration().parameters[0];
|
||||
let parameter = &contract.declaration(&ROOTS).parameters[0];
|
||||
assert_eq!(parameter.r#type, TypeExpr::Entry);
|
||||
assert!(
|
||||
parameter
|
||||
@@ -814,7 +844,7 @@ mod tests {
|
||||
("change", text("/workspace/changes/MR-9")),
|
||||
]);
|
||||
assert!(matches!(
|
||||
contract.declaration().parameters[0].r#type,
|
||||
contract.declaration(&ROOTS).parameters[0].r#type,
|
||||
TypeExpr::Entry
|
||||
));
|
||||
assert_eq!(
|
||||
|
||||
@@ -2040,8 +2040,14 @@ pub fn mount_workspace_http_ticket_wip(
|
||||
.collect::<Vec<_>>();
|
||||
let observations = Arc::new(Mutex::new(TicketObservationState::default()));
|
||||
|
||||
let roots = crate::feature::builtin::native_contract::ReferenceRoots {
|
||||
ticket: &collection_route,
|
||||
merge_request: &merge_request_route,
|
||||
objective: &objective_route,
|
||||
};
|
||||
let collection_descriptor = ticket_descriptor(
|
||||
&collection_tools,
|
||||
&roots,
|
||||
"Search and create authoritative Workspace Tickets",
|
||||
"Search results remain bounded and carry canonical item paths. Operations are exactly the Ticket tools enabled for this Worker.",
|
||||
)?;
|
||||
@@ -2077,6 +2083,7 @@ pub fn mount_workspace_http_ticket_wip(
|
||||
|
||||
let item_descriptor = ticket_descriptor(
|
||||
&item_tools,
|
||||
&roots,
|
||||
"Read and operate on the Ticket bound to this object route",
|
||||
"The subject Ticket identity comes exclusively from the target route. Separate relation and dependency targets remain explicit operation inputs.",
|
||||
)?;
|
||||
@@ -2593,12 +2600,13 @@ fn descriptor_validator(descriptor: &InterfaceDescriptor) -> Vec<u8> {
|
||||
|
||||
fn ticket_descriptor(
|
||||
tools: &[NativeTicketTool],
|
||||
roots: &crate::feature::builtin::native_contract::ReferenceRoots<'_>,
|
||||
summary: &str,
|
||||
details: &str,
|
||||
) -> Result<InterfaceDescriptor, WipMountError> {
|
||||
let operations = tools
|
||||
.iter()
|
||||
.map(ticket_operation_declaration)
|
||||
.map(|tool| ticket_operation_declaration(tool, roots))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(InterfaceDescriptor {
|
||||
format: INTERFACE_FORMAT_V1.into(),
|
||||
@@ -2613,8 +2621,9 @@ fn ticket_descriptor(
|
||||
|
||||
fn ticket_operation_declaration(
|
||||
tool: &NativeTicketTool,
|
||||
roots: &crate::feature::builtin::native_contract::ReferenceRoots<'_>,
|
||||
) -> Result<OperationDeclaration, WipMountError> {
|
||||
Ok(native::contract(tool.projection.operation).declaration())
|
||||
Ok(native::contract(tool.projection.operation).declaration(roots))
|
||||
}
|
||||
|
||||
fn protocol_failure(code: ProtocolErrorCode, message: impl Into<String>) -> WipOperationError {
|
||||
@@ -3287,7 +3296,17 @@ mod tests {
|
||||
let client = decision_client(200, TicketState::Closed);
|
||||
let tools =
|
||||
native_ticket_tools(client.clone(), TicketFeatureAccess::work_report()).unwrap();
|
||||
let descriptor = ticket_descriptor(&tools, "Ticket", "bound").unwrap();
|
||||
let descriptor = ticket_descriptor(
|
||||
&tools,
|
||||
&crate::feature::builtin::native_contract::ReferenceRoots {
|
||||
ticket: "/tickets",
|
||||
merge_request: "/merge-requests",
|
||||
objective: "/objectives",
|
||||
},
|
||||
"Ticket",
|
||||
"bound",
|
||||
)
|
||||
.unwrap();
|
||||
let case_update = descriptor
|
||||
.operations
|
||||
.iter()
|
||||
@@ -3405,6 +3424,11 @@ mod tests {
|
||||
.filter(|tool| tool.projection.surface == NativeTicketSurface::Item)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>(),
|
||||
&crate::feature::builtin::native_contract::ReferenceRoots {
|
||||
ticket: "/tickets",
|
||||
merge_request: "/merge-requests",
|
||||
objective: "/objectives",
|
||||
},
|
||||
"items",
|
||||
"route bound",
|
||||
)
|
||||
|
||||
@@ -606,7 +606,7 @@ pub(super) fn contract(op: &str) -> Contract {
|
||||
"query_orchestration_plans",
|
||||
"Read bounded project-level plan records; planning history is not execution authority.",
|
||||
vec![
|
||||
p("kind", false, plan_kind()),
|
||||
rename("kind", "relation_kind", false, plan_kind()),
|
||||
p("limit", false, Shape::Integer),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -234,7 +234,11 @@ fn assignment_principal_and_cas_are_declared_compounds_not_opaque_json() {
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let declaration = contract("orchestrator_assign").declaration();
|
||||
let declaration = contract("orchestrator_assign").declaration(&ReferenceRoots {
|
||||
ticket: "/cases",
|
||||
merge_request: "/changes",
|
||||
objective: "/goals",
|
||||
});
|
||||
assert!(
|
||||
declaration
|
||||
.parameters
|
||||
|
||||
@@ -255,3 +255,308 @@ async fn large_ticket_interface_is_explicitly_bounded_and_selected_operation_rem
|
||||
assert_eq!(client.requests().len(), 1);
|
||||
client.assert_authorizations_consumed();
|
||||
}
|
||||
|
||||
// The transport adapter applies the real SQLite query to an isolated multi-kind fixture.
|
||||
// Authorization is scripted; the native conversion, shared command, HTTP request and persisted
|
||||
// filter semantics are production paths. This does not claim Server authentication coverage.
|
||||
#[tokio::test]
|
||||
async fn native_plan_kind_reaches_persisted_query_through_inspect_and_dispatch() {
|
||||
use crate::worker::{
|
||||
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceResponse,
|
||||
};
|
||||
use ticket::{
|
||||
AcceptedOrchestrationPlan, NewOrchestrationPlanRecord, NewTicket, OrchestrationPlanKind,
|
||||
SqliteTicketBackend, TicketBackend as _, TicketIdOrSlug,
|
||||
};
|
||||
#[derive(Debug)]
|
||||
struct PersistedPlans {
|
||||
backend: SqliteTicketBackend,
|
||||
ticket: String,
|
||||
requests: Mutex<Vec<Json>>,
|
||||
}
|
||||
impl WorkspaceClient for PersistedPlans {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("plan-fixture")
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"persisted-plan-filter"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
if request.path.ends_with("/authorization/check") {
|
||||
let check: server_api::authorization::OperationAuthorizationRequest =
|
||||
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(check.operation, "ticket.orchestration_plan.query");
|
||||
assert_eq!(
|
||||
check.resource,
|
||||
server_api::authorization::PermissionResource::Ticket {
|
||||
workspace_id: "plan-fixture".into(),
|
||||
ticket_id: self.ticket.clone()
|
||||
}
|
||||
);
|
||||
return Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: "{}".into(),
|
||||
});
|
||||
}
|
||||
assert_eq!(
|
||||
request.path,
|
||||
"/api/w/plan-fixture/tickets/orchestration-plans/search"
|
||||
);
|
||||
let body: Json = serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
|
||||
let records = self
|
||||
.backend
|
||||
.query_orchestration_plan_records(
|
||||
serde_json::from_value(body["ticket"].clone()).unwrap(),
|
||||
serde_json::from_value(body["kind"].clone()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
self.requests.lock().unwrap().push(body);
|
||||
Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::to_string(&records).unwrap(),
|
||||
})
|
||||
}
|
||||
}
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let backend =
|
||||
SqliteTicketBackend::open(temp.path().join("plans.sqlite3"), "plan-fixture").unwrap();
|
||||
let id = backend
|
||||
.create(NewTicket::new("Synthetic native plan fixture"))
|
||||
.unwrap()
|
||||
.id;
|
||||
for kind in [
|
||||
OrchestrationPlanKind::AcceptedPlan,
|
||||
OrchestrationPlanKind::WaitingCapacityNote,
|
||||
] {
|
||||
backend
|
||||
.add_orchestration_plan_record(
|
||||
TicketIdOrSlug::Id(id.clone()),
|
||||
NewOrchestrationPlanRecord {
|
||||
kind,
|
||||
related_ticket: None,
|
||||
note: Some("Synthetic plan context".into()),
|
||||
author: None,
|
||||
accepted_plan: (kind == OrchestrationPlanKind::AcceptedPlan).then(|| {
|
||||
AcceptedOrchestrationPlan {
|
||||
summary: "Synthetic accepted plan".into(),
|
||||
branch: None,
|
||||
worktree: None,
|
||||
role_plan: None,
|
||||
}
|
||||
}),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let client = Arc::new(PersistedPlans {
|
||||
backend,
|
||||
ticket: id.clone(),
|
||||
requests: Mutex::new(vec![]),
|
||||
});
|
||||
let mut registry = WipMountRegistry::new();
|
||||
let namespace = registry.allocate_namespace("ticket", "cases").unwrap();
|
||||
crate::feature::builtin::ticket::mount_workspace_http_ticket_wip(
|
||||
&mut registry,
|
||||
client.clone(),
|
||||
crate::feature::builtin::ticket::TicketFeatureAccess {
|
||||
workflow: true,
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
&namespace,
|
||||
)
|
||||
.unwrap();
|
||||
let runtime = WipRuntime::from_mounts(registry, "plans".into()).unwrap();
|
||||
let path = format!("/cases/{id}");
|
||||
let object: Json = serde_json::from_str(
|
||||
&runtime
|
||||
.inspect(path.clone(), false)
|
||||
.await
|
||||
.unwrap()
|
||||
.content
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let shown = &object["interfaces"][0];
|
||||
let definition: Json = serde_json::from_str(
|
||||
&runtime
|
||||
.inspect(shown["path"].as_str().unwrap().into(), false)
|
||||
.await
|
||||
.unwrap()
|
||||
.content
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let signature = definition["interface_signature"].as_str().unwrap();
|
||||
assert!(signature.contains("query_orchestration_plans("));
|
||||
assert!(signature.contains("kind?:"));
|
||||
assert!(signature.contains("accepted_plan"));
|
||||
let reference = InterfaceReference {
|
||||
scope: shown["reference"]["scope"].as_str().unwrap().into(),
|
||||
name: shown["reference"]["name"].as_str().unwrap().into(),
|
||||
};
|
||||
for (arguments, expected, kind) in [
|
||||
(json!({}), 2, None),
|
||||
(json!({"kind":"accepted_plan"}), 1, Some("accepted_plan")),
|
||||
(
|
||||
json!({"kind":"waiting_capacity_note","limit":1}),
|
||||
1,
|
||||
Some("waiting_capacity_note"),
|
||||
),
|
||||
] {
|
||||
runtime.inspect(path.clone(), true).await.unwrap();
|
||||
let result = runtime
|
||||
.invoke(
|
||||
path.clone(),
|
||||
reference.clone(),
|
||||
"query_orchestration_plans".into(),
|
||||
arguments,
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let result: Json = serde_json::from_str(&result.content.unwrap()).unwrap();
|
||||
assert_eq!(result["count"], expected);
|
||||
assert_eq!(
|
||||
result["records"].as_array().unwrap().len(),
|
||||
expected as usize
|
||||
);
|
||||
if let Some(kind) = kind {
|
||||
assert_eq!(result["records"][0]["kind"], kind);
|
||||
}
|
||||
assert_eq!(
|
||||
client.requests.lock().unwrap().last().unwrap()["kind"],
|
||||
kind.map(Json::from).unwrap_or(Json::Null)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_inspect_explains_unpublished_objective_and_mr_input_roots() {
|
||||
let client = Arc::new(
|
||||
ScriptedWorkspaceClient::new([
|
||||
crate::worker::WorkspaceResponse {
|
||||
status: 200,
|
||||
body: json!({"items":[],"page":{"next_cursor":null,"has_more":false}}).to_string(),
|
||||
},
|
||||
crate::worker::WorkspaceResponse {
|
||||
status: 200,
|
||||
body: json!({"items":[],"next_cursor":null,"source_checks":[]}).to_string(),
|
||||
},
|
||||
])
|
||||
.with_authorizations([
|
||||
expected_authorization("ticket.query", "workspace", None),
|
||||
expected_authorization(
|
||||
"notification.diagnostics.read",
|
||||
"merge_request",
|
||||
Some(("merge_request_id", "MR-42")),
|
||||
),
|
||||
]),
|
||||
);
|
||||
let mut registry = WipMountRegistry::new();
|
||||
registry
|
||||
.allocate_namespace("objective", "hidden-goals")
|
||||
.unwrap();
|
||||
registry
|
||||
.allocate_namespace("merge-request", "hidden-changes")
|
||||
.unwrap();
|
||||
let namespace = registry.allocate_namespace("ticket", "cases").unwrap();
|
||||
crate::feature::builtin::ticket::mount_workspace_http_ticket_wip(
|
||||
&mut registry,
|
||||
client.clone(),
|
||||
crate::feature::builtin::ticket::TicketFeatureAccess::read_only(),
|
||||
None,
|
||||
&namespace,
|
||||
)
|
||||
.unwrap();
|
||||
let runtime = WipRuntime::from_mounts(registry, "unpublished-inputs".into()).unwrap();
|
||||
let tree = runtime.tree("/".into(), 1, false).await.unwrap();
|
||||
assert!(!tree.content.unwrap().contains("hidden-"));
|
||||
let object: Json = serde_json::from_str(
|
||||
&runtime
|
||||
.inspect("/cases".into(), false)
|
||||
.await
|
||||
.unwrap()
|
||||
.content
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let shown = &object["interfaces"][0];
|
||||
let definition: Json = serde_json::from_str(
|
||||
&runtime
|
||||
.inspect(shown["path"].as_str().unwrap().into(), false)
|
||||
.await
|
||||
.unwrap()
|
||||
.content
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let signature = definition["interface_signature"].as_str().unwrap();
|
||||
let root = |domain| -> String {
|
||||
let prefix = format!("{domain} Entry: collection root ");
|
||||
let doc = signature
|
||||
.lines()
|
||||
.filter_map(|line| line.trim().strip_prefix("/// "))
|
||||
.map(|text| serde_json::from_str::<String>(text).unwrap())
|
||||
.find(|text| text.starts_with(&prefix))
|
||||
.unwrap();
|
||||
let encoded = doc
|
||||
.strip_prefix(&prefix)
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap();
|
||||
serde_json::from_str(encoded).unwrap()
|
||||
};
|
||||
let objective = root("Objective");
|
||||
let mr = root("Merge Request");
|
||||
assert_eq!(objective, "/hidden-goals");
|
||||
assert_eq!(mr, "/hidden-changes");
|
||||
let reference = InterfaceReference {
|
||||
scope: shown["reference"]["scope"].as_str().unwrap().into(),
|
||||
name: shown["reference"]["name"].as_str().unwrap().into(),
|
||||
};
|
||||
for (operation, arguments) in [
|
||||
(
|
||||
"query",
|
||||
json!({"filter":{"objective":format!("{objective}/O-42")}}),
|
||||
),
|
||||
(
|
||||
"notification_diagnostics",
|
||||
json!({"filter":{"merge_request":format!("{mr}/MR-42")}}),
|
||||
),
|
||||
] {
|
||||
runtime
|
||||
.invoke(
|
||||
"/cases".into(),
|
||||
reference.clone(),
|
||||
operation.into(),
|
||||
arguments,
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
client.assert_authorizations_consumed();
|
||||
let requests = client.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let query: Json = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(query["linked_objective_id"], "O-42");
|
||||
assert!(
|
||||
requests[1]
|
||||
.path
|
||||
.ends_with("notification-diagnostics?merge_request_id=MR-42")
|
||||
);
|
||||
for path in [objective, mr] {
|
||||
assert!(
|
||||
runtime.inspect(path, false).await.is_err(),
|
||||
"input metadata does not publish Objects"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user