feat: add shared Workspace Ticket classification tags
This commit is contained in:
@@ -14,6 +14,7 @@ struct ModelTicketQueryItem {
|
||||
ticket: String,
|
||||
title: String,
|
||||
state: String,
|
||||
tags: Vec<ticket::WorkspaceTag>,
|
||||
readiness: Option<String>,
|
||||
priority: Option<String>,
|
||||
created_at: Option<String>,
|
||||
@@ -39,6 +40,7 @@ pub(super) struct ModelTicketDetail {
|
||||
title: String,
|
||||
body: String,
|
||||
state: String,
|
||||
tags: Vec<ticket::WorkspaceTag>,
|
||||
readiness: Option<String>,
|
||||
priority: Option<String>,
|
||||
created_at: Option<String>,
|
||||
@@ -94,8 +96,16 @@ struct ModelWorkerSummary {
|
||||
worker: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelTicketTagChange {
|
||||
before: Vec<ticket::WorkspaceTag>,
|
||||
after: Vec<ticket::WorkspaceTag>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelTicketEvent {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tag_changes: Option<ModelTicketTagChange>,
|
||||
sequence: usize,
|
||||
kind: String,
|
||||
body: Option<String>,
|
||||
@@ -236,6 +246,7 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
|
||||
ticket: resource_ref(item, "resource_key", "T-")?,
|
||||
title: string_field(item, "title")?,
|
||||
state: string_field(item, "state")?,
|
||||
tags: project_tags(item)?,
|
||||
readiness: optional_string(item, "readiness")?,
|
||||
priority: optional_string(item, "priority")?,
|
||||
created_at: optional_string(item, "created_at")?,
|
||||
@@ -287,6 +298,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
|
||||
title: string_field(root, "title")?,
|
||||
body: string_field(root, "body")?,
|
||||
state: string_field(root, "state")?,
|
||||
tags: project_tags(root)?,
|
||||
readiness: optional_string(root, "readiness")?,
|
||||
priority: optional_string(root, "priority")?,
|
||||
created_at: optional_string(root, "created_at")?,
|
||||
@@ -394,7 +406,27 @@ fn project_worker(value: &Value) -> Result<ModelWorkerSummary, String> {
|
||||
|
||||
fn project_ticket_event(value: &Value) -> Result<ModelTicketEvent, String> {
|
||||
let event = object(value, "Ticket event")?;
|
||||
let tag_changes = if event.get("kind").and_then(Value::as_str) == Some("tags_changed") {
|
||||
let attributes = event
|
||||
.get("attributes")
|
||||
.ok_or("missing tag audit attributes")?;
|
||||
let snapshot = |field| -> Result<Vec<ticket::WorkspaceTag>, String> {
|
||||
let encoded = attributes
|
||||
.get(field)
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("missing tag audit snapshot")?;
|
||||
serde_json::from_str(encoded)
|
||||
.map_err(|error| format!("invalid tag audit snapshot: {error}"))
|
||||
};
|
||||
Some(ModelTicketTagChange {
|
||||
before: snapshot("tags_before")?,
|
||||
after: snapshot("tags_after")?,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(ModelTicketEvent {
|
||||
tag_changes,
|
||||
sequence: usize_field(event, "sequence")?,
|
||||
kind: string_field(event, "kind")?,
|
||||
body: match event.get("body") {
|
||||
@@ -616,6 +648,24 @@ fn object_field<'a>(
|
||||
.ok_or_else(|| format!("missing or invalid {key}"))
|
||||
}
|
||||
|
||||
fn project_tags(object: &Map<String, Value>) -> Result<Vec<ticket::WorkspaceTag>, String> {
|
||||
let values = array_field(object, "tags")?;
|
||||
if values.len() > 100 {
|
||||
return Err("Ticket projection exceeds 100 tags".into());
|
||||
}
|
||||
values
|
||||
.iter()
|
||||
.map(|value| {
|
||||
let tag: ticket::WorkspaceTag = serde_json::from_value(value.clone())
|
||||
.map_err(|error| format!("invalid Workspace tag: {error}"))?;
|
||||
if tag.deleted {
|
||||
return Err("current Ticket projection contains a deleted tag".into());
|
||||
}
|
||||
Ok(tag)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn array_field<'a>(object: &'a Map<String, Value>, key: &str) -> Result<&'a [Value], String> {
|
||||
object
|
||||
.get(key)
|
||||
@@ -703,7 +753,7 @@ mod tests {
|
||||
"closed",
|
||||
] {
|
||||
let projected = project_ticket_detail(json!({
|
||||
"resource_key": "T-718", "content_digest": "rev-1", "title": "No Git required", "body": "Decision",
|
||||
"resource_key": "T-718", "tags": [], "content_digest": "rev-1", "title": "No Git required", "body": "Decision",
|
||||
"state": state, "events": [{"sequence": 1, "kind": "completed", "body": "No Git needed"}],
|
||||
"linked_objectives": [], "assignments": [], "implementation_reports": [],
|
||||
"merge_requests": [], "merge_request": null,
|
||||
@@ -738,7 +788,7 @@ mod tests {
|
||||
"linked_tickets": ["00001M0E82D1V"],
|
||||
"linked_ticket_summaries": [{
|
||||
"id": "00001M0E82D1V",
|
||||
"resource_key": "T-496",
|
||||
"resource_key": "T-496", "tags": [],
|
||||
"title": "Ticket",
|
||||
"state": "done",
|
||||
"updated_at": "2026-01-02T00:00:00Z"
|
||||
@@ -769,7 +819,7 @@ mod tests {
|
||||
"record_authority": "workspace_sqlite",
|
||||
"items": [{
|
||||
"id": "00001TICKETINTERNAL",
|
||||
"resource_key": "T-543",
|
||||
"resource_key": "T-543", "tags": [],
|
||||
"title": "Ticket",
|
||||
"state": "inprogress",
|
||||
"readiness": null,
|
||||
@@ -840,7 +890,7 @@ mod tests {
|
||||
fn ticket_detail_projection_accepts_current_blocker_shape() {
|
||||
let projected = project_ticket_detail(json!({
|
||||
"id": "internal-ticket",
|
||||
"resource_key": "T-588",
|
||||
"resource_key": "T-588", "tags": [],
|
||||
"content_digest": "ticket-content-digest",
|
||||
"title": "Queued Submit",
|
||||
"body": "Body",
|
||||
@@ -943,7 +993,7 @@ mod tests {
|
||||
"review_excerpt": "Reviewed source"
|
||||
});
|
||||
let projected = project_ticket_detail(json!({
|
||||
"resource_key": "T-716",
|
||||
"resource_key": "T-716", "tags": [],
|
||||
"content_digest": "current-ticket-content-digest",
|
||||
"title": "Source evidence",
|
||||
"body": "Body",
|
||||
@@ -1123,4 +1173,19 @@ mod tests {
|
||||
.expect_err("missing W-key must fail");
|
||||
assert!(error.contains("W-"));
|
||||
}
|
||||
#[test]
|
||||
fn ticket_tag_history_projection_preserves_observed_names_and_identities() {
|
||||
let tag = serde_json::json!({"workspace_id":"ws","id":"tag-1","name":"Old name","last_operation_key":"create","deleted":false});
|
||||
let event = serde_json::json!({"sequence":3,"kind":"tags_changed","body":"Ticket tags changed","at":"then","attributes":{"tags_before":serde_json::json!([tag.clone()]).to_string(),"tags_after":"[]"}});
|
||||
let projected = serde_json::to_value(project_ticket_event(&event).unwrap()).unwrap();
|
||||
assert_eq!(projected["tag_changes"]["before"], serde_json::json!([tag]));
|
||||
assert_eq!(projected["tag_changes"]["after"], serde_json::json!([]));
|
||||
let ordinary = serde_json::json!({"sequence":0,"kind":"comment","body":"hello","at":null});
|
||||
assert!(
|
||||
serde_json::to_value(project_ticket_event(&ordinary).unwrap())
|
||||
.unwrap()
|
||||
.get("tag_changes")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! module binds an authority-scoped Workspace client, declares the built-in
|
||||
//! feature, and contributes those tools through the normal registry path.
|
||||
|
||||
mod tags;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -167,6 +169,18 @@ struct WorkspaceQueryTicketInput {
|
||||
/// Related Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
related_ticket_id: Option<String>,
|
||||
relation_kind: Option<WorkspaceTicketRelationFilter>,
|
||||
/// All stable tag IDs must be attached (AND); at most 100 unique IDs.
|
||||
#[serde(default)]
|
||||
#[schemars(length(max = 100))]
|
||||
tag_ids: Vec<String>,
|
||||
/// At least one stable tag ID must be attached (OR), unless empty.
|
||||
#[serde(default)]
|
||||
#[schemars(length(max = 100))]
|
||||
tag_ids_any: Vec<String>,
|
||||
/// None of these stable tag IDs may be attached (NOT).
|
||||
#[serde(default)]
|
||||
#[schemars(length(max = 100))]
|
||||
tag_ids_none: Vec<String>,
|
||||
/// Linked Objective reference. Prefer `O-*`; canonical internal ids remain accepted for compatibility.
|
||||
linked_objective_id: Option<String>,
|
||||
updated_after: Option<String>,
|
||||
@@ -210,6 +224,13 @@ impl Tool for WorkspaceTicketReadTool {
|
||||
WorkspaceTicketReadKind::Query => {
|
||||
let input: WorkspaceQueryTicketInput = serde_json::from_str(&input)
|
||||
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
||||
for (name, ids) in [
|
||||
("tag_ids", &input.tag_ids),
|
||||
("tag_ids_any", &input.tag_ids_any),
|
||||
("tag_ids_none", &input.tag_ids_none),
|
||||
] {
|
||||
tags::validate_ids(name, ids)?;
|
||||
}
|
||||
(
|
||||
format!("/api/w/{workspace_id}/tickets/query"),
|
||||
serde_json::to_value(input)
|
||||
@@ -502,6 +523,7 @@ fn workspace_ticket_tool_description(name: &str) -> String {
|
||||
"CompleteTicket" => COMPLETE_TICKET_DESCRIPTION.into(),
|
||||
"TicketWorkflowState" => TICKET_STATE_UPDATE_DESCRIPTION.into(),
|
||||
"TicketClose" => TICKET_CLOSE_DESCRIPTION.into(),
|
||||
name if tags::TAG_TOOL_NAMES.contains(&name) => tags::description(name).into(),
|
||||
_ => ticket_tool_description(name, None),
|
||||
}
|
||||
}
|
||||
@@ -632,6 +654,9 @@ impl TicketFeatureAccess {
|
||||
.copied()
|
||||
.filter(|name| self.allows_tool(name))
|
||||
.chain((self.authoring || self.workflow || self.thread).then_some("CompleteTicket"))
|
||||
.chain(tags::TAG_TOOL_NAMES.iter().copied().filter(|name| {
|
||||
*name == "WorkspaceTagList" || self.authoring || self.workflow || self.thread
|
||||
}))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -648,7 +673,7 @@ impl TicketFeatureAccess {
|
||||
}
|
||||
}
|
||||
|
||||
const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket"];
|
||||
const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket", "WorkspaceTagList"];
|
||||
|
||||
const AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||
"TicketCreate",
|
||||
@@ -678,6 +703,11 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||
"TicketRelationRecord",
|
||||
"TicketRelationRemove",
|
||||
"CompleteTicket",
|
||||
"WorkspaceTagList",
|
||||
"WorkspaceTagCreate",
|
||||
"WorkspaceTagRename",
|
||||
"WorkspaceTagDelete",
|
||||
"TicketTagsSet",
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -693,6 +723,11 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[
|
||||
"TicketOrchestrationPlanRecord",
|
||||
"TicketOrchestrationPlanQuery",
|
||||
"CompleteTicket",
|
||||
"WorkspaceTagList",
|
||||
"WorkspaceTagCreate",
|
||||
"WorkspaceTagRename",
|
||||
"WorkspaceTagDelete",
|
||||
"TicketTagsSet",
|
||||
];
|
||||
|
||||
const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[
|
||||
@@ -1022,6 +1057,32 @@ impl WorkspaceHttpTicketBackend {
|
||||
) -> TicketResult<TicketBackendOperationResult> {
|
||||
let base = format!("/api/w/{workspace_id}/tickets");
|
||||
match operation {
|
||||
TicketBackendOperation::ListWorkspaceTags { request } => {
|
||||
let mut endpoint = format!(
|
||||
"/api/w/{workspace_id}/ticket-tags?limit={}",
|
||||
request.limit.unwrap_or(200).min(200)
|
||||
);
|
||||
if let Some(after) = request.after {
|
||||
endpoint.push_str("&after=");
|
||||
endpoint.push_str(&Self::ticket_path(&TicketIdOrSlug::Id(after)));
|
||||
}
|
||||
Self::request(client, WorkspaceRequestMethod::Get, endpoint, None)
|
||||
.map(TicketBackendOperationResult::WorkspaceTags)
|
||||
}
|
||||
TicketBackendOperation::MutateWorkspaceTag { request } => Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("/api/w/{workspace_id}/ticket-tags"),
|
||||
Some(json!(request)),
|
||||
)
|
||||
.map(TicketBackendOperationResult::WorkspaceTag),
|
||||
TicketBackendOperation::SetTicketTags { id, request } => Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Put,
|
||||
format!("{base}/{}/tags", Self::ticket_path(&id)),
|
||||
Some(json!(request)),
|
||||
)
|
||||
.map(TicketBackendOperationResult::WorkspaceTags),
|
||||
TicketBackendOperation::DefaultIntakeReadyStateChangeBody { from } => {
|
||||
let value = Self::request::<String>(
|
||||
client,
|
||||
@@ -1332,6 +1393,35 @@ macro_rules! expect_ticket_result {
|
||||
}
|
||||
|
||||
impl TicketBackend for WorkspaceHttpTicketBackend {
|
||||
fn list_workspace_tags(
|
||||
&self,
|
||||
request: ticket::TagListRequest,
|
||||
) -> TicketResult<Vec<ticket::WorkspaceTag>> {
|
||||
expect_ticket_result!(
|
||||
self.invoke(TicketBackendOperation::ListWorkspaceTags { request }),
|
||||
TicketBackendOperationResult::WorkspaceTags
|
||||
)
|
||||
}
|
||||
fn mutate_workspace_tag(
|
||||
&self,
|
||||
request: ticket::WorkspaceTagMutation,
|
||||
) -> TicketResult<ticket::WorkspaceTag> {
|
||||
expect_ticket_result!(
|
||||
self.invoke(TicketBackendOperation::MutateWorkspaceTag { request }),
|
||||
TicketBackendOperationResult::WorkspaceTag
|
||||
)
|
||||
}
|
||||
fn set_ticket_tags(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
request: ticket::TicketTagSetRequest,
|
||||
) -> TicketResult<Vec<ticket::WorkspaceTag>> {
|
||||
expect_ticket_result!(
|
||||
self.invoke(TicketBackendOperation::SetTicketTags { id, request }),
|
||||
TicketBackendOperationResult::WorkspaceTags
|
||||
)
|
||||
}
|
||||
|
||||
fn default_intake_ready_state_change_body(&self, from: &str) -> String {
|
||||
match self.invoke(TicketBackendOperation::DefaultIntakeReadyStateChangeBody {
|
||||
from: from.to_string(),
|
||||
@@ -1577,6 +1667,36 @@ struct NativeTicketOperation {
|
||||
|
||||
fn native_ticket_operation(tool_name: &str) -> Option<NativeTicketOperation> {
|
||||
let operation = match tool_name {
|
||||
"WorkspaceTagList" => NativeTicketOperation {
|
||||
operation: "tag_list",
|
||||
surface: NativeTicketSurface::Collection,
|
||||
identity_field: None,
|
||||
mutating: false,
|
||||
},
|
||||
"WorkspaceTagCreate" => NativeTicketOperation {
|
||||
operation: "tag_create",
|
||||
surface: NativeTicketSurface::Collection,
|
||||
identity_field: None,
|
||||
mutating: true,
|
||||
},
|
||||
"WorkspaceTagRename" => NativeTicketOperation {
|
||||
operation: "tag_rename",
|
||||
surface: NativeTicketSurface::Collection,
|
||||
identity_field: None,
|
||||
mutating: true,
|
||||
},
|
||||
"WorkspaceTagDelete" => NativeTicketOperation {
|
||||
operation: "tag_delete",
|
||||
surface: NativeTicketSurface::Collection,
|
||||
identity_field: None,
|
||||
mutating: true,
|
||||
},
|
||||
"TicketTagsSet" => NativeTicketOperation {
|
||||
operation: "tags_set",
|
||||
surface: NativeTicketSurface::Item,
|
||||
identity_field: Some("ticket"),
|
||||
mutating: true,
|
||||
},
|
||||
"QueryTicket" => NativeTicketOperation {
|
||||
operation: "query",
|
||||
surface: NativeTicketSurface::Collection,
|
||||
@@ -1721,10 +1841,21 @@ pub(crate) fn enabled_ticket_definitions(
|
||||
.collect();
|
||||
if allowed.contains(&"CompleteTicket") {
|
||||
definitions.push(workspace_ticket_decision_definition(
|
||||
client,
|
||||
client.clone(),
|
||||
WorkspaceTicketDecisionKind::Complete,
|
||||
));
|
||||
}
|
||||
for kind in [
|
||||
tags::TagToolKind::List,
|
||||
tags::TagToolKind::Create,
|
||||
tags::TagToolKind::Rename,
|
||||
tags::TagToolKind::Delete,
|
||||
tags::TagToolKind::Set,
|
||||
] {
|
||||
if allowed.contains(&kind.name()) {
|
||||
definitions.push(tags::definition(client.clone(), kind));
|
||||
}
|
||||
}
|
||||
definitions
|
||||
}
|
||||
|
||||
@@ -1959,13 +2090,16 @@ impl WipOperationHandler for TicketItemWipHandler {
|
||||
)?;
|
||||
let output = execute_native_ticket_tool(tool, &self.permissions, input, context).await?;
|
||||
let response = ticket_tool_output_json(output)?;
|
||||
record_ticket_observation(
|
||||
&self.observations,
|
||||
Some(&self.ticket_reference),
|
||||
&response,
|
||||
tool.projection.mutating,
|
||||
);
|
||||
if tool.projection.mutating {
|
||||
// Classification has its own membership CAS and never changes item content.
|
||||
if operation != "tags_set" {
|
||||
record_ticket_observation(
|
||||
&self.observations,
|
||||
Some(&self.ticket_reference),
|
||||
&response,
|
||||
tool.projection.mutating,
|
||||
);
|
||||
}
|
||||
if tool.projection.mutating && operation != "tags_set" {
|
||||
record_affected_ticket_mutations(&self.observations, &self.ticket_reference, &response);
|
||||
}
|
||||
Ok(WipOperationOutput::native(
|
||||
@@ -3053,6 +3187,11 @@ mod tests {
|
||||
"record_relation",
|
||||
"remove_relation",
|
||||
"complete",
|
||||
"tag_list",
|
||||
"tag_create",
|
||||
"tag_rename",
|
||||
"tag_delete",
|
||||
"tags_set",
|
||||
]
|
||||
);
|
||||
let item = ticket_descriptor(
|
||||
@@ -3102,6 +3241,11 @@ mod tests {
|
||||
"record_orchestration_plan",
|
||||
"query_orchestration_plans",
|
||||
"complete",
|
||||
"tag_list",
|
||||
"tag_create",
|
||||
"tag_rename",
|
||||
"tag_delete",
|
||||
"tags_set",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -3110,7 +3254,7 @@ mod tests {
|
||||
.iter()
|
||||
.map(|tool| tool.projection.operation)
|
||||
.collect::<Vec<_>>(),
|
||||
["query", "read"]
|
||||
["query", "read", "tag_list"]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3404,18 +3548,13 @@ mod tests {
|
||||
assert_eq!(show.name, "ShowTicket");
|
||||
assert!(show.input_schema["properties"]["event_limit"].is_object());
|
||||
let tool_names = TicketFeatureAccess::workspace_authoring().tool_names();
|
||||
assert_eq!(tool_names.len(), 12);
|
||||
assert!(
|
||||
tool_names.len() < 13,
|
||||
"authoring catalog must stay below the prior broad catalog"
|
||||
);
|
||||
assert_eq!(tool_names, WORKSPACE_AUTHORING_TOOL_NAMES);
|
||||
let workflow_names = TicketFeatureAccess::workflow().tool_names();
|
||||
assert_eq!(workflow_names.len(), 11);
|
||||
assert!(
|
||||
workflow_names.len() < 12,
|
||||
"workflow catalog must stay below the prior broad catalog"
|
||||
assert_eq!(workflow_names, WORKFLOW_TOOL_NAMES);
|
||||
assert_eq!(
|
||||
TicketFeatureAccess::review().tool_names(),
|
||||
READ_ONLY_TOOL_NAMES
|
||||
);
|
||||
assert_eq!(TicketFeatureAccess::review().tool_names().len(), 2);
|
||||
assert!(tool_names.contains(&"QueryTicket"));
|
||||
assert!(tool_names.contains(&"ShowTicket"));
|
||||
assert!(!tool_names.contains(&"TicketList"));
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Classification tools share the Ticket feature's scoped Backend transport.
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
use super::*;
|
||||
|
||||
pub(super) const TAG_TOOL_NAMES: &[&str] = &[
|
||||
"WorkspaceTagList",
|
||||
"WorkspaceTagCreate",
|
||||
"WorkspaceTagRename",
|
||||
"WorkspaceTagDelete",
|
||||
"TicketTagsSet",
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum TagToolKind {
|
||||
List,
|
||||
Create,
|
||||
Rename,
|
||||
Delete,
|
||||
Set,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ListInput {
|
||||
/// Continue strictly after this stable tag ID. Continue until an empty page.
|
||||
after: Option<String>,
|
||||
/// Maximum results, 0..=200; defaults to 200.
|
||||
#[schemars(range(max = 200))]
|
||||
limit: Option<usize>,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CreateInput {
|
||||
/// Display name: nonempty after Unicode trimming, no controls, at most 128 UTF-8 bytes.
|
||||
name: String,
|
||||
/// Retry an uncertain request with this same key and exact input.
|
||||
operation_key: String,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RenameInput {
|
||||
tag_id: String,
|
||||
/// Last operation key observed on this exact identity; rename-back is still a change.
|
||||
expected_last_operation_key: String,
|
||||
name: String,
|
||||
operation_key: String,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct DeleteInput {
|
||||
tag_id: String,
|
||||
expected_last_operation_key: String,
|
||||
operation_key: String,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SetInput {
|
||||
/// Ticket reference, preferably T-*; WIP binds it from the item route.
|
||||
ticket: String,
|
||||
/// Entire active ID set observed in ShowTicket/QueryTicket; never silently refreshed.
|
||||
#[schemars(length(max = 100))]
|
||||
expected_tag_ids: Vec<String>,
|
||||
/// Desired entire ID set. Attach/remove multiple tags atomically; [] removes all.
|
||||
#[schemars(length(max = 100))]
|
||||
tag_ids: Vec<String>,
|
||||
operation_key: String,
|
||||
}
|
||||
|
||||
impl TagToolKind {
|
||||
pub(super) fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::List => "WorkspaceTagList",
|
||||
Self::Create => "WorkspaceTagCreate",
|
||||
Self::Rename => "WorkspaceTagRename",
|
||||
Self::Delete => "WorkspaceTagDelete",
|
||||
Self::Set => "TicketTagsSet",
|
||||
}
|
||||
}
|
||||
fn schema(self) -> Value {
|
||||
match self {
|
||||
Self::List => json!(schemars::schema_for!(ListInput)),
|
||||
Self::Create => json!(schemars::schema_for!(CreateInput)),
|
||||
Self::Rename => json!(schemars::schema_for!(RenameInput)),
|
||||
Self::Delete => json!(schemars::schema_for!(DeleteInput)),
|
||||
Self::Set => json!(schemars::schema_for!(SetInput)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn description(name: &str) -> &'static str {
|
||||
match name {
|
||||
"WorkspaceTagList" => {
|
||||
"List active Workspace classification tags in stable ID order, bounded to 200. Continue after the last ID until exhausted; a full page does not promise another page."
|
||||
}
|
||||
"WorkspaceTagCreate" => {
|
||||
"Create a Workspace classification tag with a stable identity and replay key. Names are ordinary classification, never Ticket states, routing, or authority grants."
|
||||
}
|
||||
"WorkspaceTagRename" => {
|
||||
"Rename a Workspace classification tag by stable ID and observed last_operation_key. Links retain identity; history is not rewritten. Retry uncertain requests with the same key/input, then reread before further edits."
|
||||
}
|
||||
"WorkspaceTagDelete" => {
|
||||
"Tombstone a Workspace classification tag using its observed last_operation_key and atomically remove current Ticket membership. Prior history remains; recreating the name creates a new identity."
|
||||
}
|
||||
"TicketTagsSet" => {
|
||||
"Atomically compare and replace a Ticket's entire active classification tag ID set. Compute desired tag_ids from observed expected_tag_ids to attach/remove multiple tags without overwriting unrelated changes. At most 100 unique IDs. Classification does not edit state, content digest, assignment, or grants. A replay is the original receipt, not a fresh observation; reread before further edits."
|
||||
}
|
||||
_ => unreachable!("unknown classification tool"),
|
||||
}
|
||||
}
|
||||
|
||||
fn required(name: &str, value: &str) -> Result<(), ToolError> {
|
||||
if value.trim().is_empty() {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"{name} must not be empty"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn validate_name(name: &str) -> Result<(), ToolError> {
|
||||
if name.trim().is_empty() || name.trim().len() > 128 || name.chars().any(char::is_control) {
|
||||
return Err(ToolError::InvalidArgument("name must be nonempty, contain no control characters, and be at most 128 UTF-8 bytes after trimming".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn validate_ids(name: &str, ids: &[String]) -> Result<(), ToolError> {
|
||||
if ids.len() > 100 {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"{name} must contain at most 100 IDs"
|
||||
)));
|
||||
}
|
||||
let mut unique = BTreeSet::new();
|
||||
for id in ids {
|
||||
required(name, id)?;
|
||||
if !unique.insert(id) {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"{name} must contain unique IDs"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse<T: serde::de::DeserializeOwned>(input: &str) -> Result<T, ToolError> {
|
||||
serde_json::from_str(input).map_err(|error| ToolError::InvalidArgument(error.to_string()))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TagTool {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
kind: TagToolKind,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TagTool {
|
||||
async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
|
||||
self.client
|
||||
.workspace_id()
|
||||
.filter(|id| !id.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidArgument(
|
||||
"Classification tools require Backend Workspace identity; no local fallback"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
let backend = WorkspaceHttpTicketBackend::new(self.client.clone());
|
||||
let content = match self.kind {
|
||||
TagToolKind::List => {
|
||||
let input: ListInput = parse(input)?;
|
||||
if input.limit.is_some_and(|limit| limit > 200) {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"limit must be at most 200".into(),
|
||||
));
|
||||
}
|
||||
if let Some(after) = &input.after {
|
||||
required("after", after)?;
|
||||
}
|
||||
let limit = input.limit.unwrap_or(200);
|
||||
let mut tags = backend
|
||||
.list_workspace_tags(ticket::TagListRequest {
|
||||
after: input.after,
|
||||
limit: Some(limit),
|
||||
})
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
tags.truncate(limit);
|
||||
json!(tags)
|
||||
}
|
||||
TagToolKind::Set => {
|
||||
let input: SetInput = parse(input)?;
|
||||
required("ticket", &input.ticket)?;
|
||||
required("operation_key", &input.operation_key)?;
|
||||
validate_ids("expected_tag_ids", &input.expected_tag_ids)?;
|
||||
validate_ids("tag_ids", &input.tag_ids)?;
|
||||
let tags = backend
|
||||
.set_ticket_tags(
|
||||
TicketIdOrSlug::from(input.ticket.as_str()),
|
||||
ticket::TicketTagSetRequest {
|
||||
expected_tag_ids: input.expected_tag_ids,
|
||||
tag_ids: input.tag_ids,
|
||||
operation_key: input.operation_key,
|
||||
},
|
||||
)
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
if tags.len() > 100 {
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
"Backend returned more than 100 Ticket tags".into(),
|
||||
));
|
||||
}
|
||||
json!(tags)
|
||||
}
|
||||
kind => {
|
||||
let request = match kind {
|
||||
TagToolKind::Create => {
|
||||
let input: CreateInput = parse(input)?;
|
||||
validate_name(&input.name)?;
|
||||
ticket::WorkspaceTagMutation {
|
||||
operation_key: input.operation_key,
|
||||
action: ticket::WorkspaceTagAction::Create { name: input.name },
|
||||
}
|
||||
}
|
||||
TagToolKind::Rename => {
|
||||
let input: RenameInput = parse(input)?;
|
||||
required("tag_id", &input.tag_id)?;
|
||||
required(
|
||||
"expected_last_operation_key",
|
||||
&input.expected_last_operation_key,
|
||||
)?;
|
||||
validate_name(&input.name)?;
|
||||
ticket::WorkspaceTagMutation {
|
||||
operation_key: input.operation_key,
|
||||
action: ticket::WorkspaceTagAction::Rename {
|
||||
tag_id: input.tag_id,
|
||||
expected_last_operation_key: input.expected_last_operation_key,
|
||||
name: input.name,
|
||||
},
|
||||
}
|
||||
}
|
||||
TagToolKind::Delete => {
|
||||
let input: DeleteInput = parse(input)?;
|
||||
required("tag_id", &input.tag_id)?;
|
||||
required(
|
||||
"expected_last_operation_key",
|
||||
&input.expected_last_operation_key,
|
||||
)?;
|
||||
ticket::WorkspaceTagMutation {
|
||||
operation_key: input.operation_key,
|
||||
action: ticket::WorkspaceTagAction::Delete {
|
||||
tag_id: input.tag_id,
|
||||
expected_last_operation_key: input.expected_last_operation_key,
|
||||
},
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
required("operation_key", &request.operation_key)?;
|
||||
json!(
|
||||
backend
|
||||
.mutate_workspace_tag(request)
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?
|
||||
)
|
||||
}
|
||||
};
|
||||
Ok(ToolOutput {
|
||||
summary: self.kind.name().into(),
|
||||
content: Some(content.to_string()),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn definition(client: Arc<dyn WorkspaceClient>, kind: TagToolKind) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new(kind.name())
|
||||
.description(description(kind.name()))
|
||||
.input_schema(kind.schema()),
|
||||
Arc::new(TagTool {
|
||||
client: client.clone(),
|
||||
kind,
|
||||
}) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
use super::*;
|
||||
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RecordingClient {
|
||||
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||
body: String,
|
||||
identity: bool,
|
||||
status: u16,
|
||||
}
|
||||
impl WorkspaceClient for RecordingClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
self.identity.then_some("workspace")
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"classification-test"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
Ok(WorkspaceResponse {
|
||||
status: self.status,
|
||||
body: self.body.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
fn recording(body: Value) -> Arc<RecordingClient> {
|
||||
Arc::new(RecordingClient {
|
||||
requests: Mutex::new(Vec::new()),
|
||||
body: body.to_string(),
|
||||
identity: true,
|
||||
status: 200,
|
||||
})
|
||||
}
|
||||
fn tag() -> Value {
|
||||
json!({"workspace_id":"workspace", "id":"tag-1", "name":"Ready", "last_operation_key":"create-1", "deleted":false})
|
||||
}
|
||||
fn cases() -> Vec<(TagToolKind, Value, &'static str)> {
|
||||
vec![
|
||||
(TagToolKind::List, json!({"limit":5}), "WorkspaceTagList"),
|
||||
(
|
||||
TagToolKind::Create,
|
||||
json!({"name":"Ready", "operation_key":"create-1"}),
|
||||
"WorkspaceTagCreate",
|
||||
),
|
||||
(
|
||||
TagToolKind::Rename,
|
||||
json!({"tag_id":"tag-1", "expected_last_operation_key":"create-1", "name":"New", "operation_key":"rename-1"}),
|
||||
"WorkspaceTagRename",
|
||||
),
|
||||
(
|
||||
TagToolKind::Delete,
|
||||
json!({"tag_id":"tag-1", "expected_last_operation_key":"create-1", "operation_key":"delete-1"}),
|
||||
"WorkspaceTagDelete",
|
||||
),
|
||||
(
|
||||
TagToolKind::Set,
|
||||
json!({"ticket":"T-740", "expected_tag_ids":[], "tag_ids":["tag-1", "tag-2"], "operation_key":"set-1"}),
|
||||
"TicketTagsSet",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classification_tool_schemas_require_observations_and_reject_actor_claims() {
|
||||
for (kind, input, name) in cases() {
|
||||
let client = recording(json!([]));
|
||||
let (meta, tool) = definition(client.clone(), kind)();
|
||||
assert_eq!(meta.name, name);
|
||||
let validator = jsonschema::validator_for(&meta.input_schema).unwrap();
|
||||
assert!(validator.is_valid(&input), "{name}: {}", meta.input_schema);
|
||||
for field in meta.input_schema["required"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let field = field.as_str().unwrap();
|
||||
let mut invalid = input.clone();
|
||||
invalid.as_object_mut().unwrap().remove(field);
|
||||
assert!(!validator.is_valid(&invalid), "{name}.{field}");
|
||||
assert!(matches!(
|
||||
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
}
|
||||
let mut invalid = input.clone();
|
||||
invalid["author"] = json!("orchestrator");
|
||||
assert!(!validator.is_valid(&invalid));
|
||||
assert!(matches!(
|
||||
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
let schema = TagToolKind::Set.schema();
|
||||
assert_eq!(schema["properties"]["tag_ids"]["maxItems"], 100);
|
||||
assert_eq!(schema["properties"]["expected_tag_ids"]["maxItems"], 100);
|
||||
assert_eq!(
|
||||
TagToolKind::List.schema()["properties"]["limit"]["maximum"],
|
||||
200
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classification_reads_forward_id_filters_and_preserve_tag_observations() {
|
||||
let item = json!({"resource_key":"T-740", "title":"Classify", "state":"planning", "tags":[tag()], "matched_fields":[], "linked_objective_keys":[], "relation_count":0, "blocker_count":0, "unresolved_blocker_count":0, "unresolved_review_count":0, "merge_requests":[]});
|
||||
let client = recording(json!({"items":[item], "page":{"has_more":false, "next_cursor":null}}));
|
||||
let (meta, tool) =
|
||||
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)();
|
||||
for field in ["tag_ids", "tag_ids_any", "tag_ids_none"] {
|
||||
assert_eq!(meta.input_schema["properties"][field]["maxItems"], 100);
|
||||
}
|
||||
let input = json!({"tag_ids":["tag-1"], "tag_ids_any":["tag-2", "tag-3"], "tag_ids_none":["tag-4"], "limit":7});
|
||||
let output = tool
|
||||
.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
let value: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(value["tickets"][0]["tags"], json!([tag()]));
|
||||
{
|
||||
let requests = client.requests.lock().unwrap();
|
||||
let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
|
||||
for field in ["tag_ids", "tag_ids_any", "tag_ids_none"] {
|
||||
assert_eq!(body[field], input[field]);
|
||||
}
|
||||
}
|
||||
let detail = json!({"resource_key":"T-740", "content_digest":"content-1", "title":"Classify", "body":"Body", "state":"planning", "tags":[tag()], "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[]});
|
||||
let client = recording(detail);
|
||||
let (_, tool) =
|
||||
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)();
|
||||
let output = tool
|
||||
.execute(r#"{"id":"T-740"}"#, ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
let value: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(value["tags"], json!([tag()]));
|
||||
assert_eq!(value["content_digest"], "content-1");
|
||||
let client = recording(json!({}));
|
||||
let (_, tool) =
|
||||
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)();
|
||||
for field in ["tag_ids", "tag_ids_any", "tag_ids_none"] {
|
||||
let input = json!({field: (0..101).map(|i| format!("tag-{i}")).collect::<Vec<_>>()});
|
||||
assert!(matches!(
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
}
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classification_tools_reject_invalid_names_ids_and_keys_before_dispatch() {
|
||||
let client = recording(json!([]));
|
||||
for name in [" ".to_string(), "x\n".to_string(), "é".repeat(65)] {
|
||||
let (_, tool) = definition(client.clone(), TagToolKind::Create)();
|
||||
assert!(matches!(
|
||||
tool.execute(
|
||||
&json!({"name":name, "operation_key":"create"}).to_string(),
|
||||
ToolExecutionContext::direct()
|
||||
)
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
}
|
||||
for ids in [
|
||||
json!(["tag-1", "tag-1"]),
|
||||
json!([""]),
|
||||
json!((0..101).map(|i| format!("tag-{i}")).collect::<Vec<_>>()),
|
||||
] {
|
||||
let (_, tool) = definition(client.clone(), TagToolKind::Set)();
|
||||
for field in ["tag_ids", "expected_tag_ids"] {
|
||||
let mut input = json!({"ticket":"T-740", "tag_ids":[], "expected_tag_ids":[], "operation_key":"set"});
|
||||
input[field] = ids.clone();
|
||||
assert!(matches!(
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
for (kind, mut input, _) in cases()
|
||||
.into_iter()
|
||||
.filter(|(kind, _, _)| !matches!(kind, TagToolKind::List))
|
||||
{
|
||||
input["operation_key"] = json!(" ");
|
||||
let (_, tool) = definition(client.clone(), kind)();
|
||||
assert!(matches!(
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
}
|
||||
let (_, tool) = definition(client.clone(), TagToolKind::List)();
|
||||
assert!(
|
||||
tool.execute(r#"{"limit":201}"#, ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classification_tools_require_backend_workspace_and_bound_list_results() {
|
||||
let missing = Arc::new(RecordingClient {
|
||||
requests: Mutex::new(Vec::new()),
|
||||
body: "[]".into(),
|
||||
identity: false,
|
||||
status: 200,
|
||||
});
|
||||
for (kind, input, _) in cases() {
|
||||
let (_, tool) = definition(missing.clone(), kind)();
|
||||
assert!(matches!(
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
}
|
||||
assert!(missing.requests.lock().unwrap().is_empty());
|
||||
let client = recording(json!(vec![tag(); 201]));
|
||||
let (_, tool) = definition(client.clone(), TagToolKind::List)();
|
||||
for (input, expected) in [
|
||||
(json!({}), 200),
|
||||
(json!({"limit":2, "after":"a&b"}), 2),
|
||||
(json!({"limit":0}), 0),
|
||||
] {
|
||||
let output = tool
|
||||
.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
let value: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(value.as_array().unwrap().len(), expected);
|
||||
}
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(
|
||||
requests[1].path,
|
||||
"/api/w/workspace/ticket-tags?limit=2&after=a%26b"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classification_wip_uses_normal_permissions_and_route_bound_ticket_identity() {
|
||||
let client = recording(json!([tag()]));
|
||||
let tools =
|
||||
native_ticket_tools(client.clone(), TicketFeatureAccess::workspace_authoring()).unwrap();
|
||||
let set = tools
|
||||
.iter()
|
||||
.find(|tool| tool.name == "TicketTagsSet")
|
||||
.unwrap();
|
||||
let (_, input, _) = cases().pop().unwrap();
|
||||
let permissions = Some(ToolPermissionConfig {
|
||||
default_action: ToolPermissionAction::Allow,
|
||||
rules: vec![manifest::ToolPermissionRule {
|
||||
tool: "TicketTagsSet".into(),
|
||||
action: ToolPermissionAction::Deny,
|
||||
pattern: input.to_string(),
|
||||
}],
|
||||
});
|
||||
let error = execute_native_ticket_tool(
|
||||
set,
|
||||
&permissions,
|
||||
input,
|
||||
WipCallContext {
|
||||
execution: ToolExecutionContext::direct(),
|
||||
security_context: "worker".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
WipOperationError::Protocol(ProtocolError {
|
||||
code: ProtocolErrorCode::PermissionDenied,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
let handler = TicketItemWipHandler {
|
||||
tools: operation_map(
|
||||
tools
|
||||
.into_iter()
|
||||
.filter(|tool| tool.projection.surface == NativeTicketSurface::Item)
|
||||
.collect(),
|
||||
),
|
||||
permissions: None,
|
||||
ticket_reference: "T-740".into(),
|
||||
observations: Arc::new(Mutex::new(TicketObservationState::default())),
|
||||
};
|
||||
handler
|
||||
.observations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.content_digests
|
||||
.insert("T-740".into(), "content-1".into());
|
||||
let mut args = BTreeMap::from([
|
||||
("operation_key".into(), WipValue::String("set-1".into())),
|
||||
("tag_ids".into(), json_to_wip(&json!(["tag-1"])).unwrap()),
|
||||
("expected_tag_ids".into(), json_to_wip(&json!([])).unwrap()),
|
||||
]);
|
||||
assert!(
|
||||
handler
|
||||
.call(
|
||||
"tags_set",
|
||||
&args,
|
||||
WipCallContext {
|
||||
execution: ToolExecutionContext::direct(),
|
||||
security_context: "worker".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
assert_eq!(
|
||||
client.requests.lock().unwrap()[0].path,
|
||||
"/api/w/workspace/tickets/T-740/tags"
|
||||
);
|
||||
assert_eq!(
|
||||
handler
|
||||
.observations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.content_digests
|
||||
.get("T-740")
|
||||
.map(String::as_str),
|
||||
Some("content-1")
|
||||
);
|
||||
args.insert("ticket".into(), WipValue::String("T-foreign".into()));
|
||||
assert!(
|
||||
handler
|
||||
.call(
|
||||
"tags_set",
|
||||
&args,
|
||||
WipCallContext {
|
||||
execution: ToolExecutionContext::direct(),
|
||||
security_context: "worker".into()
|
||||
}
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
let read_only = TicketFeatureAccess::read_only().tool_names();
|
||||
assert!(read_only.contains(&"WorkspaceTagList"));
|
||||
assert!(!read_only.contains(&"TicketTagsSet"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classification_tools_preserve_backend_permission_and_stale_set_failures() {
|
||||
for status in [403, 409] {
|
||||
let client = Arc::new(RecordingClient {
|
||||
requests: Mutex::new(Vec::new()),
|
||||
body: "{}".into(),
|
||||
identity: true,
|
||||
status,
|
||||
});
|
||||
let (_, tool) = definition(client.clone(), TagToolKind::Set)();
|
||||
let (_, input, _) = cases().pop().unwrap();
|
||||
let error = tool
|
||||
.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains(&status.to_string()));
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classification_tools_http_mutations_survive_backend_reopen_and_stale_compare() {
|
||||
use std::io::{Read, Write};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join("tags.db");
|
||||
let backend = ticket::SqliteTicketBackend::open(&db, "workspace").unwrap();
|
||||
let reference = backend.create(NewTicket::new("Classify")).unwrap();
|
||||
let initial_content_digest =
|
||||
ticket::ticket_content_digest(&backend.show(reference.id.clone().into()).unwrap());
|
||||
let ticket_id = reference.id.clone();
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server_db = db.clone();
|
||||
let server = std::thread::spawn(move || {
|
||||
for _ in 0..10 {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(10)))
|
||||
.unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
let (headers, body) = loop {
|
||||
let mut chunk = [0u8; 8192];
|
||||
let count = stream.read(&mut chunk).unwrap();
|
||||
assert!(count > 0);
|
||||
bytes.extend_from_slice(&chunk[..count]);
|
||||
if let Some(end) = bytes.windows(4).position(|w| w == b"\r\n\r\n") {
|
||||
let headers = String::from_utf8(bytes[..end].to_vec()).unwrap();
|
||||
let length = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
line.to_lowercase()
|
||||
.strip_prefix("content-length:")
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if bytes.len() >= end + 4 + length {
|
||||
break (headers, bytes[end + 4..end + 4 + length].to_vec());
|
||||
}
|
||||
}
|
||||
};
|
||||
let backend =
|
||||
ticket::SqliteTicketBackend::open_verified(&server_db, "workspace").unwrap();
|
||||
let line = headers.lines().next().unwrap();
|
||||
let result = if line.starts_with("POST /api/w/workspace/ticket-tags ") {
|
||||
backend
|
||||
.mutate_workspace_tag(serde_json::from_slice(&body).unwrap())
|
||||
.map(|tag| json!(tag))
|
||||
} else if line.starts_with(&format!("PUT /api/w/workspace/tickets/{ticket_id}/tags ")) {
|
||||
backend
|
||||
.set_ticket_tags(
|
||||
ticket_id.clone().into(),
|
||||
serde_json::from_slice(&body).unwrap(),
|
||||
)
|
||||
.map(|tags| json!(tags))
|
||||
} else {
|
||||
assert!(
|
||||
line.starts_with("GET /api/w/workspace/ticket-tags?"),
|
||||
"{line}"
|
||||
);
|
||||
backend
|
||||
.list_workspace_tags(ticket::TagListRequest::default())
|
||||
.map(|tags| json!(tags))
|
||||
};
|
||||
let (status, response) = match result {
|
||||
Ok(value) => ("200 OK", value.to_string()),
|
||||
Err(error) => (
|
||||
"409 Conflict",
|
||||
json!({"message":error.to_string()}).to_string(),
|
||||
),
|
||||
};
|
||||
write!(stream, "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}", response.len()).unwrap();
|
||||
}
|
||||
});
|
||||
let client: Arc<dyn WorkspaceClient> = Arc::new(crate::worker::TestWorkspaceHttpClient::new(
|
||||
"workspace",
|
||||
base,
|
||||
));
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let call = |kind, input: Value| {
|
||||
runtime.block_on(async {
|
||||
let (_, tool) = definition(client.clone(), kind)();
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
})
|
||||
};
|
||||
let first: ticket::WorkspaceTag = serde_json::from_str(
|
||||
call(
|
||||
TagToolKind::Create,
|
||||
json!({"name":"ready", "operation_key":"create-a"}),
|
||||
)
|
||||
.unwrap()
|
||||
.content
|
||||
.as_deref()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let second: ticket::WorkspaceTag = serde_json::from_str(
|
||||
call(
|
||||
TagToolKind::Create,
|
||||
json!({"name":"closed", "operation_key":"create-b"}),
|
||||
)
|
||||
.unwrap()
|
||||
.content
|
||||
.as_deref()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let set = json!({"ticket":reference.id, "expected_tag_ids":[], "tag_ids":[first.id, second.id], "operation_key":"set-both"});
|
||||
let receipt = call(TagToolKind::Set, set.clone()).unwrap();
|
||||
assert_eq!(
|
||||
call(TagToolKind::Set, set).unwrap().content,
|
||||
receipt.content
|
||||
);
|
||||
assert!(call(TagToolKind::Set, json!({"ticket":reference.id, "expected_tag_ids":[], "tag_ids":[], "operation_key":"stale"})).is_err());
|
||||
let removed = call(TagToolKind::Set, json!({"ticket":reference.id, "expected_tag_ids":[second.id, first.id], "tag_ids":[], "operation_key":"remove-both"})).unwrap();
|
||||
assert_eq!(removed.content.as_deref(), Some("[]"));
|
||||
assert!(
|
||||
backend
|
||||
.show(reference.id.clone().into())
|
||||
.unwrap()
|
||||
.tags
|
||||
.is_empty()
|
||||
);
|
||||
call(TagToolKind::Set, json!({"ticket":reference.id, "expected_tag_ids":[], "tag_ids":[first.id, second.id], "operation_key":"reattach-both"})).unwrap();
|
||||
call(TagToolKind::Rename, json!({"tag_id":first.id, "expected_last_operation_key":first.last_operation_key, "name":"renamed", "operation_key":"rename-a"})).unwrap();
|
||||
call(TagToolKind::Delete, json!({"tag_id":second.id, "expected_last_operation_key":second.last_operation_key, "operation_key":"delete-b"})).unwrap();
|
||||
let listed = call(TagToolKind::List, json!({})).unwrap();
|
||||
let tags: Vec<ticket::WorkspaceTag> =
|
||||
serde_json::from_str(listed.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(tags.len(), 1);
|
||||
assert_eq!(tags[0].name, "renamed");
|
||||
server.join().unwrap();
|
||||
let reopened = ticket::SqliteTicketBackend::open_verified(&db, "workspace").unwrap();
|
||||
let ticket = reopened.show(reference.id.into()).unwrap();
|
||||
assert_eq!(ticket.tags, tags);
|
||||
assert_eq!(
|
||||
ticket::ticket_content_digest(&ticket),
|
||||
initial_content_digest
|
||||
);
|
||||
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Planning);
|
||||
assert_eq!(
|
||||
ticket
|
||||
.events
|
||||
.iter()
|
||||
.filter(|event| event.kind == ticket::TicketEventKind::TagsChanged)
|
||||
.count(),
|
||||
4,
|
||||
"replay and stale input must not append membership events"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user