diff --git a/crates/worker/src/feature/builtin/objective.rs b/crates/worker/src/feature/builtin/objective.rs index d15a36fe..a6fc79ea 100644 --- a/crates/worker/src/feature/builtin/objective.rs +++ b/crates/worker/src/feature/builtin/objective.rs @@ -146,6 +146,7 @@ impl WorkspaceHttpObjectiveBackend { async fn link_ticket(&self, input: ObjectiveLinkTicketInput) -> Result { let id = validate_id(&input.id, "ObjectiveLinkTicket")?; let ticket_id = validate_id(&input.ticket_id, "ObjectiveLinkTicket")?; + let ticket_resource_key = self.ticket_resource_key(ticket_id).await?; let url = format!("{}/ticket-links", self.objective_url(id)); let response = send_json::( self.client.as_ref(), @@ -159,7 +160,7 @@ impl WorkspaceHttpObjectiveBackend { .map_err(backend_error)?; Ok(objective_output( format!( - "Linked ticket {ticket_id} to objective {}", + "Linked ticket {ticket_resource_key} to objective {}", &response.resource_key ), response, @@ -172,19 +173,46 @@ impl WorkspaceHttpObjectiveBackend { ) -> Result { let id = validate_id(&input.id, "ObjectiveUnlinkTicket")?; let ticket_id = validate_id(&input.ticket_id, "ObjectiveUnlinkTicket")?; + let ticket_resource_key = self.ticket_resource_key(ticket_id).await?; let url = format!("{}/ticket-links/{}", self.objective_url(id), ticket_id); let response = delete_json::(self.client.as_ref(), &url) .await .map_err(backend_error)?; Ok(objective_output( format!( - "Unlinked ticket {ticket_id} from objective {}", + "Unlinked ticket {ticket_resource_key} from objective {}", &response.resource_key ), response, )?) } + async fn ticket_resource_key(&self, ticket_reference: &str) -> Result { + let workspace_id = self.client.workspace_id().unwrap_or_default(); + let response: serde_json::Value = decode_response( + self.client + .execute(WorkspaceRequest::get(format!( + "/api/w/{workspace_id}/tickets/{ticket_reference}" + ))) + .map_err(WorkspaceObjectiveBackendError::from) + .map_err(backend_error)?, + ) + .map_err(backend_error)?; + response + .get("resource_key") + .or_else(|| { + response + .get("meta") + .and_then(|meta| meta.get("resource_key")) + }) + .and_then(serde_json::Value::as_str) + .filter(|key| is_canonical_resource_key(key, "T-")) + .map(ToOwned::to_owned) + .ok_or_else(|| { + ToolError::ExecutionFailed("required T- human key is unavailable".to_string()) + }) + } + fn objective_url(&self, id: &str) -> String { let workspace_id = self.client.workspace_id().unwrap_or_default(); format!("/api/w/{workspace_id}/objectives/{id}") @@ -257,8 +285,14 @@ fn decode_response Deserialize<'de>>( serde_json::from_str(&response.body).map_err(Into::into) } +fn is_canonical_resource_key(resource_key: &str, prefix: &str) -> bool { + resource_key.strip_prefix(prefix).is_some_and(|sequence| { + !sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit()) + }) +} + fn objective_output(summary: String, response: ObjectiveDetail) -> Result { - if !response.resource_key.starts_with("O-") { + if !is_canonical_resource_key(&response.resource_key, "O-") { return Err(ToolError::ExecutionFailed( "required O- human key is unavailable".to_string(), )); @@ -624,6 +658,11 @@ struct ObjectiveDetail { mod tests { use super::*; use agen::tool::ToolDefinition; + use std::{ + io::{Read, Write}, + net::TcpListener, + thread, + }; fn tool_names(definitions: Vec) -> Vec { let mut names = definitions @@ -670,4 +709,83 @@ mod tests { let link = link_ticket_schema(); assert_eq!(link["required"], json!(["id", "ticket_id"])); } + + #[tokio::test(flavor = "multi_thread")] + async fn objective_link_summaries_resolve_internal_ticket_ids_to_human_keys() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let server = thread::spawn(move || { + for mutation in ["POST", "DELETE"] { + let (mut stream, _) = listener.accept().unwrap(); + let mut buffer = [0_u8; 8192]; + let len = stream.read(&mut buffer).unwrap(); + let request = String::from_utf8_lossy(&buffer[..len]); + assert!(request.starts_with("GET /api/w/workspace/tickets/00001INTERNAL HTTP/1.1")); + let response_body = serde_json::json!({"resource_key": "T-7"}).to_string(); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + response_body.len(), + response_body + ) + .unwrap(); + + let (mut stream, _) = listener.accept().unwrap(); + let mut buffer = [0_u8; 8192]; + let len = stream.read(&mut buffer).unwrap(); + let request = String::from_utf8_lossy(&buffer[..len]); + assert!(request.starts_with(&format!( + "{mutation} /api/w/workspace/objectives/O-3/ticket-links" + ))); + let response_body = serde_json::json!({ + "resource_key": "O-3", + "title": "Objective", + "state": "active" + }) + .to_string(); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + response_body.len(), + response_body + ) + .unwrap(); + } + }); + let backend = WorkspaceHttpObjectiveBackend::new(Arc::new( + crate::worker::TestWorkspaceHttpClient::new("workspace", base_url), + )); + + let linked = backend + .link_ticket(ObjectiveLinkTicketInput { + id: "O-3".to_string(), + ticket_id: "00001INTERNAL".to_string(), + }) + .await + .unwrap(); + let unlinked = backend + .unlink_ticket(ObjectiveUnlinkTicketInput { + id: "O-3".to_string(), + ticket_id: "00001INTERNAL".to_string(), + }) + .await + .unwrap(); + + server.join().unwrap(); + for output in [linked, unlinked] { + assert!(output.summary.contains("T-7")); + assert!(!output.summary.contains("00001INTERNAL")); + assert!(!output.content.unwrap().contains("00001INTERNAL")); + } + } + + #[test] + fn objective_output_rejects_noncanonical_human_keys() { + let response = ObjectiveDetail { + resource_key: "O-internal".to_string(), + title: "Objective".to_string(), + state: "active".to_string(), + }; + assert!(objective_output("created".to_string(), response).is_err()); + } } diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index 61864167..feba75c9 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -789,6 +789,32 @@ impl WorkspaceHttpTicketBackend { } } + fn resolve_ticket_resource_key( + client: Arc, + base: &str, + reference: &TicketIdOrSlug, + ) -> TicketResult { + let response: Value = Self::request( + client, + WorkspaceRequestMethod::Get, + format!("{base}/{}", Self::ticket_path(reference)), + None, + )?; + response + .get("resource_key") + .or_else(|| { + response + .get("meta") + .and_then(|meta| meta.get("resource_key")) + }) + .and_then(Value::as_str) + .filter(|key| is_canonical_ticket_resource_key(key)) + .map(ToOwned::to_owned) + .ok_or_else(|| { + TicketError::Conflict("required Ticket human key is unavailable".to_string()) + }) + } + fn request_unit( client: Arc, method: WorkspaceRequestMethod, @@ -958,12 +984,13 @@ impl WorkspaceHttpTicketBackend { })?), ), TicketBackendOperation::AddTicketRelation { id, relation } => { - let source_reference = match &id { - TicketIdOrSlug::Id(value) - | TicketIdOrSlug::Slug(value) - | TicketIdOrSlug::Query(value) => value.clone(), - }; - let target_reference = relation.target.clone(); + let source_resource_key = + Self::resolve_ticket_resource_key(client.clone(), &base, &id)?; + let target_resource_key = Self::resolve_ticket_resource_key( + client.clone(), + &base, + &TicketIdOrSlug::Id(relation.target.clone()), + )?; let mut relation: TicketRelation = Self::request( client, WorkspaceRequestMethod::Post, @@ -972,31 +999,29 @@ impl WorkspaceHttpTicketBackend { TicketError::Conflict(format!("serialize Ticket relation: {error}")) })?), )?; - relation.ticket_id = source_reference; - relation.target = target_reference; + relation.ticket_id = source_resource_key; + relation.target = target_resource_key; relation.author = "workspace".to_string(); Ok(TicketBackendOperationResult::Relation(relation)) } TicketBackendOperation::RemoveTicketRelation { id, kind, target } => { - let source_reference = match &id { - TicketIdOrSlug::Id(value) - | TicketIdOrSlug::Slug(value) - | TicketIdOrSlug::Query(value) => value.clone(), - }; + let source_resource_key = + Self::resolve_ticket_resource_key(client.clone(), &base, &id)?; + let target_resource_key = + Self::resolve_ticket_resource_key(client.clone(), &base, &target)?; let target = match target { TicketIdOrSlug::Id(value) | TicketIdOrSlug::Slug(value) | TicketIdOrSlug::Query(value) => value, }; - let target_reference = target.clone(); let mut relation: TicketRelation = Self::request( client, WorkspaceRequestMethod::Delete, format!("{base}/{}/relations", Self::ticket_path(&id)), Some(serde_json::json!({ "kind": kind, "target": target })), )?; - relation.ticket_id = source_reference; - relation.target = target_reference; + relation.ticket_id = source_resource_key; + relation.target = target_resource_key; relation.author = "workspace".to_string(); Ok(TicketBackendOperationResult::Relation(relation)) } @@ -1825,11 +1850,102 @@ provider = "github" server.join().unwrap(); } + #[test] + fn workspace_http_backend_records_relation_with_authoritative_human_keys() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + for (expected_path, resource_key) in [ + ("GET /api/w/workspace-a/tickets/01SOURCE HTTP/1.1", "T-1"), + ("GET /api/w/workspace-a/tickets/01TARGET HTTP/1.1", "T-2"), + ] { + let (mut stream, _) = listener.accept().unwrap(); + let mut buffer = [0_u8; 8192]; + let len = stream.read(&mut buffer).unwrap(); + let request = String::from_utf8_lossy(&buffer[..len]); + assert!(request.starts_with(expected_path)); + let body = serde_json::json!({"meta": {"resource_key": resource_key}}).to_string(); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), body + ) + .unwrap(); + } + + let (mut stream, _) = listener.accept().unwrap(); + let mut buffer = [0_u8; 8192]; + let len = stream.read(&mut buffer).unwrap(); + let request = String::from_utf8_lossy(&buffer[..len]); + assert!( + request.starts_with("POST /api/w/workspace-a/tickets/01SOURCE/relations HTTP/1.1") + ); + let body = serde_json::to_string(&TicketRelation { + ticket_id: "01SOURCE".to_string(), + kind: TicketRelationKind::DependsOn, + target: "01TARGET".to_string(), + note: None, + author: "worker-internal".to_string(), + at: "2026-08-06T00:00:00Z".to_string(), + }) + .unwrap(); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ) + .unwrap(); + }); + let backend = WorkspaceHttpTicketBackend::new(Arc::new( + crate::worker::TestWorkspaceHttpClient::new("workspace-a", format!("http://{addr}")), + )); + + let relation = backend + .add_ticket_relation( + TicketIdOrSlug::Id("01SOURCE".to_string()), + NewTicketRelation { + kind: TicketRelationKind::DependsOn, + target: "01TARGET".to_string(), + note: None, + author: None, + }, + ) + .unwrap(); + + server.join().unwrap(); + assert_eq!(relation.ticket_id, "T-1"); + assert_eq!(relation.target, "T-2"); + assert_eq!(relation.author, "workspace"); + } + #[test] fn workspace_http_backend_deletes_exact_ticket_relation() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); let server = thread::spawn(move || { + for (expected_path, resource_key) in [ + ("GET /api/w/workspace-a/tickets/01SOURCE HTTP/1.1", "T-1"), + ("GET /api/w/workspace-a/tickets/01TARGET HTTP/1.1", "T-2"), + ] { + let (mut stream, _) = listener.accept().unwrap(); + let mut buffer = [0_u8; 8192]; + let len = stream.read(&mut buffer).unwrap(); + let request = String::from_utf8_lossy(&buffer[..len]); + assert!(request.starts_with(expected_path)); + let response_body = serde_json::json!({ + "meta": {"resource_key": resource_key} + }) + .to_string(); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + response_body.len(), + response_body + ) + .unwrap(); + } + let (mut stream, _) = listener.accept().unwrap(); let mut buffer = [0_u8; 8192]; let len = stream.read(&mut buffer).unwrap(); @@ -1870,8 +1986,8 @@ provider = "github" .unwrap(); server.join().unwrap(); - assert_eq!(removed.ticket_id, "01SOURCE"); - assert_eq!(removed.target, "01TARGET"); + assert_eq!(removed.ticket_id, "T-1"); + assert_eq!(removed.target, "T-2"); } #[test]