From e9397fa0ddb296f65311aad79dcb5c7df6fcf1c5 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 11 Oct 2026 01:23:30 +0900 Subject: [PATCH] feat: add shared Workspace Ticket classification tags --- crates/client/src/workspace_product.rs | 181 ++++ crates/server-api/src/lib.rs | 203 +++++ .../src/repository_openapi_typescript.rs | 28 + crates/ticket/src/latest_schema.sql | 38 + crates/ticket/src/lib.rs | 72 +- crates/ticket/src/sqlite_schema.rs | 294 ++++++- crates/ticket/src/tags.rs | 323 +++++++ crates/ticket/src/tags_tests.rs | 801 ++++++++++++++++++ crates/ticket/src/tool.rs | 22 + .../feature/builtin/resource_projection.rs | 75 +- crates/worker/src/feature/builtin/ticket.rs | 179 +++- .../worker/src/feature/builtin/ticket/tags.rs | 284 +++++++ .../src/feature/builtin/ticket/tags/tests.rs | 526 ++++++++++++ crates/workspace-server/src/authority.rs | 34 + .../src/schema_equivalence_tests.rs | 3 + crates/workspace-server/src/server.rs | 110 ++- .../src/server_ticket_tags_tests.rs | 139 +++ crates/workspace-server/src/store.rs | 7 +- .../src/workspace_deletion.rs | 3 + docs/design/workspace-ticket-tags.md | 49 ++ docs/report/T-740-subworker-cwd-scope.md | 9 + docs/report/T-740-validation.md | 50 ++ openapi/server-api.json | 546 +++++++++++- .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 22 +- .../src/lib/generated/workdir-api.ts | 2 +- .../src/lib/generated/worker-launch-api.ts | 2 +- 29 files changed, 3959 insertions(+), 49 deletions(-) create mode 100644 crates/ticket/src/tags.rs create mode 100644 crates/ticket/src/tags_tests.rs create mode 100644 crates/worker/src/feature/builtin/ticket/tags.rs create mode 100644 crates/worker/src/feature/builtin/ticket/tags/tests.rs create mode 100644 crates/workspace-server/src/server_ticket_tags_tests.rs create mode 100644 docs/design/workspace-ticket-tags.md create mode 100644 docs/report/T-740-subworker-cwd-scope.md create mode 100644 docs/report/T-740-validation.md diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index 32411e020..0598e8d07 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -91,6 +91,56 @@ impl BackendWorkspaceProductClient { .map_err(|error| crate::backend_workspace::server_client_error(&self.api, error)) } + pub fn list_workspace_tags( + &self, + request: &ticket::TagListRequest, + ) -> Result, BackendWorkspaceClientError> { + let workspace_id = self.workspace_id.clone(); + let request = request.clone(); + self.generated(move |client| async move { + client + .workspace_ticket_tag_list( + workspace_id, + server_api::WorkspaceTicketTagListRequest(request), + ) + .await + .map(|response| response.0) + }) + } + + pub fn mutate_workspace_tag( + &self, + request: &ticket::WorkspaceTagMutation, + ) -> Result { + let workspace_id = self.workspace_id.clone(); + let request = request.clone(); + self.generated(move |client| async move { + client + .workspace_ticket_tag_mutate( + workspace_id, + server_api::WorkspaceTicketTagMutationRequest(request), + ) + .await + .map(|response| response.0) + }) + } + + pub fn set_ticket_tags( + &self, + id: &TicketIdOrSlug, + request: &ticket::TicketTagSetRequest, + ) -> Result, BackendWorkspaceClientError> { + let workspace_id = self.workspace_id.clone(); + let id = ticket_reference(id); + let request = request.clone(); + self.generated(move |client| async move { + client + .ticket_tags_set(workspace_id, id, server_api::TicketTagsSetRequest(request)) + .await + .map(|response| response.0) + }) + } + pub fn list_tickets( &self, query: &TicketListQuery, @@ -602,6 +652,31 @@ impl BackendWorkspaceProductClient { } impl TicketBackend for BackendWorkspaceProductClient { + fn list_workspace_tags( + &self, + request: ticket::TagListRequest, + ) -> ticket::Result> { + BackendWorkspaceProductClient::list_workspace_tags(self, &request) + .map_err(ticket_client_error) + } + + fn mutate_workspace_tag( + &self, + request: ticket::WorkspaceTagMutation, + ) -> ticket::Result { + BackendWorkspaceProductClient::mutate_workspace_tag(self, &request) + .map_err(ticket_client_error) + } + + fn set_ticket_tags( + &self, + id: TicketIdOrSlug, + request: ticket::TicketTagSetRequest, + ) -> ticket::Result> { + BackendWorkspaceProductClient::set_ticket_tags(self, &id, &request) + .map_err(ticket_client_error) + } + fn default_intake_ready_state_change_body(&self, from: &str) -> String { let workspace_id = self.workspace_id.clone(); let from = from.to_string(); @@ -996,6 +1071,112 @@ mod tests { (format!("http://{address}"), receiver, handle) } + #[test] + fn classification_client_uses_scoped_routes_and_preserves_cas_and_replay_input() { + let tag = serde_json::json!({"workspace_id":"workspace-a", "id":"tag-a", "name":"Ready", "last_operation_key":"create-1", "deleted":false}); + let single = tag.to_string(); + let list = serde_json::json!([tag]).to_string(); + let (base_url, requests, handle) = response_sequence_server(vec![ + ("200 OK", &list), + ("200 OK", &single), + ("200 OK", &list), + ]); + let client = BackendWorkspaceProductClient::new_with_access_token( + base_url, + "workspace-a", + "test-token", + ) + .unwrap(); + let listed = client + .list_workspace_tags(&ticket::TagListRequest { + after: Some("tag-0".into()), + limit: Some(7), + }) + .unwrap(); + assert_eq!(listed[0].id, "tag-a"); + let request = requests.recv().unwrap(); + assert!( + request.starts_with("GET /api/w/workspace-a/ticket-tags?"), + "{request}" + ); + assert!( + request.contains("after=tag-0") && request.contains("limit=7"), + "{request}" + ); + assert!( + request + .to_lowercase() + .contains("authorization: bearer test-token") + ); + let mutation = ticket::WorkspaceTagMutation { + operation_key: "rename-1".into(), + action: ticket::WorkspaceTagAction::Rename { + tag_id: "tag-a".into(), + expected_last_operation_key: "create-1".into(), + name: "New".into(), + }, + }; + TicketBackend::mutate_workspace_tag(&client, mutation.clone()).unwrap(); + let request = requests.recv().unwrap(); + assert!( + request.starts_with("POST /api/w/workspace-a/ticket-tags "), + "{request}" + ); + let body: serde_json::Value = + serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap(); + assert_eq!(body, serde_json::json!(mutation)); + let set = ticket::TicketTagSetRequest { + operation_key: "set-1".into(), + expected_tag_ids: vec!["tag-old".into()], + tag_ids: vec!["tag-a".into(), "tag-b".into()], + }; + let receipt = + TicketBackend::set_ticket_tags(&client, TicketIdOrSlug::from("T-740"), set.clone()) + .unwrap(); + assert_eq!(receipt[0].name, "Ready"); + let request = requests.recv().unwrap(); + assert!( + request.starts_with("PUT /api/w/workspace-a/tickets/T-740/tags "), + "{request}" + ); + let body: serde_json::Value = + serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap(); + assert_eq!(body, serde_json::json!(set)); + handle.join().unwrap(); + } + + #[test] + fn classification_client_returns_backend_denials_without_local_fallback() { + for status in ["403 Forbidden", "409 Conflict"] { + let (base_url, requests, handle) = one_response_server( + status, + r#"{"error":"ticket_conflict","message":"stale or unauthorized","diagnostics":[]}"#, + ); + let client = BackendWorkspaceProductClient::new_with_access_token( + base_url, + "workspace-a", + "token", + ) + .unwrap(); + let result = client.set_ticket_tags( + &TicketIdOrSlug::from("T-740"), + &ticket::TicketTagSetRequest { + operation_key: "retry-key".into(), + expected_tag_ids: vec![], + tag_ids: vec!["tag-a".into()], + }, + ); + assert!(result.is_err()); + assert!( + requests + .recv() + .unwrap() + .starts_with("PUT /api/w/workspace-a/tickets/T-740/tags ") + ); + handle.join().unwrap(); + } + } + #[test] fn legacy_state_adapter_uses_snapshot_content_without_overriding_stale_expected_state() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index d5758ad75..4c0635ea0 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -376,6 +376,11 @@ impl_openapi_schema!( TicketStateUpdateRequest, TicketSummarySearchQuery, TicketThreadEventRequest, + WorkspaceTicketTagListRequest, + WorkspaceTicketTagMutationRequest, + TicketTagsSetRequest, + WorkspaceTicketTagList, + WorkspaceTicketTag, RuntimeManagementApiError, RuntimeWorkersQuery, RuntimeWorkerSpawnRequest, @@ -2976,6 +2981,31 @@ pub trait ServerApi { #[body] request: CreateTicketRecordRequest, ) -> Result; + #[get("/api/w/{workspace_id}/ticket-tags", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn workspace_ticket_tag_list( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[query] request: WorkspaceTicketTagListRequest, + ) -> Result; + + #[post("/api/w/{workspace_id}/ticket-tags", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn workspace_ticket_tag_mutate( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: WorkspaceTicketTagMutationRequest, + ) -> Result; + + #[put("/api/w/{workspace_id}/tickets/{id}/tags", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_tags_set( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: TicketTagsSetRequest, + ) -> Result; + #[post("/api/w/{workspace_id}/tickets/query", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] async fn ticket_query( &self, @@ -7260,6 +7290,9 @@ pub struct InvalidProjectRecord { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct TicketListItemSummary { + #[serde(default)] + #[cfg_attr(feature = "typescript", ts(type = "Array"))] + pub tags: Vec, pub id: String, pub resource_key: String, pub title: String, @@ -7567,6 +7600,15 @@ pub struct TicketQueryRequest { pub review_status: Option, #[serde(default)] pub attention: Vec, + /// Match tickets containing every supplied tag ID (AND). + #[serde(default)] + pub tag_ids: Vec, + /// Match tickets containing at least one supplied tag ID (OR). + #[serde(default)] + pub tag_ids_any: Vec, + /// Exclude tickets containing any supplied tag ID (NOT). + #[serde(default)] + pub tag_ids_none: Vec, pub related_ticket_id: Option, pub relation_kind: Option, pub linked_objective_id: Option, @@ -7583,6 +7625,8 @@ pub struct TicketQueryRequest { #[serde(deny_unknown_fields)] pub struct TicketQueryItem { pub id: String, + #[cfg_attr(feature = "typescript", ts(type = "Array"))] + pub tags: Vec, pub resource_key: String, pub title: String, pub state: String, @@ -7638,6 +7682,8 @@ pub struct TicketShowRequest { #[serde(deny_unknown_fields)] pub struct TicketDetail { pub id: String, + #[cfg_attr(feature = "typescript", ts(type = "Array"))] + pub tags: Vec, pub resource_key: String, pub title: String, pub state: String, @@ -7703,6 +7749,14 @@ macro_rules! transparent_ticket_dto { } transparent_ticket_dto!(TicketRecord, ticket::Ticket); +transparent_ticket_dto!(WorkspaceTicketTagListRequest, ticket::TagListRequest); +transparent_ticket_dto!( + WorkspaceTicketTagMutationRequest, + ticket::WorkspaceTagMutation +); +transparent_ticket_dto!(TicketTagsSetRequest, ticket::TicketTagSetRequest); +transparent_ticket_dto!(WorkspaceTicketTagList, Vec); +transparent_ticket_dto!(WorkspaceTicketTag, ticket::WorkspaceTag); transparent_ticket_dto!(TicketRecordRef, ticket::TicketRef); transparent_ticket_dto!(TicketRecordSummaryList, Vec); transparent_ticket_dto!(TicketDependencyCheckResponse, ticket::TicketDependencyCheck); @@ -11121,6 +11175,155 @@ mod skill_typescript_tests { mod tests { use super::*; + #[test] + fn ticket_tag_query_filters_default_empty_and_roundtrip() { + let request: TicketQueryRequest = serde_json::from_value(serde_json::json!({})).unwrap(); + assert!(request.tag_ids.is_empty()); + assert!(request.tag_ids_any.is_empty()); + assert!(request.tag_ids_none.is_empty()); + let request: TicketQueryRequest = serde_json::from_value(serde_json::json!({ + "tag_ids": ["tag-a", "tag-b"], + "tag_ids_any": ["tag-c", "tag-d"], + "tag_ids_none": ["tag-e"] + })) + .unwrap(); + let value = serde_json::to_value(request).unwrap(); + assert_eq!(value["tag_ids"], serde_json::json!(["tag-a", "tag-b"])); + assert_eq!(value["tag_ids_any"], serde_json::json!(["tag-c", "tag-d"])); + assert_eq!(value["tag_ids_none"], serde_json::json!(["tag-e"])); + } + + #[test] + fn ticket_tag_mutations_require_operation_keys_and_observed_cas() { + for action in [ + serde_json::json!({"kind": "create", "name": "bug"}), + serde_json::json!({"kind": "rename", "tag_id": "tag-a", "expected_last_operation_key": "observed", "name": "defect"}), + serde_json::json!({"kind": "delete", "tag_id": "tag-a", "expected_last_operation_key": "observed"}), + ] { + let value = serde_json::json!({"operation_key": "new-operation", "action": action}); + let parsed: WorkspaceTicketTagMutationRequest = + serde_json::from_value(value.clone()).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), value); + let mut missing_operation = value.clone(); + missing_operation + .as_object_mut() + .unwrap() + .remove("operation_key"); + assert!( + serde_json::from_value::(missing_operation) + .is_err() + ); + if action["kind"] != "create" { + let mut missing_cas = value; + missing_cas["action"] + .as_object_mut() + .unwrap() + .remove("expected_last_operation_key"); + assert!( + serde_json::from_value::(missing_cas) + .is_err() + ); + } + } + let value = serde_json::json!({ + "operation_key": "replace-tags", + "expected_tag_ids": ["tag-a", "tag-b"], + "tag_ids": ["tag-b", "tag-c"] + }); + let parsed: TicketTagsSetRequest = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), value); + for key in ["operation_key", "expected_tag_ids", "tag_ids"] { + let mut missing = value.clone(); + missing.as_object_mut().unwrap().remove(key); + assert!( + serde_json::from_value::(missing).is_err(), + "{key}" + ); + } + } + + #[test] + fn ticket_tag_list_and_responses_are_transparent() { + let query = serde_json::json!({"after": "tag-a", "limit": 200}); + let parsed: WorkspaceTicketTagListRequest = serde_json::from_value(query.clone()).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), query); + let tag = serde_json::json!({ + "workspace_id": "workspace-a", "id": "tag-a", "name": "bug", + "last_operation_key": "create-tag", "deleted": false + }); + let parsed: WorkspaceTicketTag = serde_json::from_value(tag.clone()).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), tag); + let list = serde_json::json!([tag]); + let parsed: WorkspaceTicketTagList = serde_json::from_value(list.clone()).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), list); + for schema in [ + schemars::schema_for!(TicketDetail), + schemars::schema_for!(TicketQueryItem), + ] { + let schema = serde_json::to_value(schema).unwrap(); + assert_eq!(schema["properties"]["tags"]["type"], "array"); + assert_eq!( + schema["properties"]["tags"]["items"]["$ref"], + "#/$defs/WorkspaceTag" + ); + } + } + + #[test] + fn ticket_tag_operations_are_typed_workspace_routes() { + for (name, method, path) in [ + ( + "workspace_ticket_tag_list", + HttpMethod::Get, + "/api/w/{workspace_id}/ticket-tags", + ), + ( + "workspace_ticket_tag_mutate", + HttpMethod::Post, + "/api/w/{workspace_id}/ticket-tags", + ), + ( + "ticket_tags_set", + HttpMethod::Put, + "/api/w/{workspace_id}/tickets/{id}/tags", + ), + ] { + let operation = ServerApiMetadata::OPERATIONS + .iter() + .find(|operation| operation.operation_id == name) + .unwrap(); + assert_eq!(operation.method, method); + assert_eq!(operation.path, path); + } + let contract = canonical_openapi_document().unwrap().to_json().unwrap(); + let contract: serde_json::Value = serde_json::from_str(&contract).unwrap(); + for (path, method) in [ + ("/api/w/{workspace_id}/ticket-tags", "post"), + ("/api/w/{workspace_id}/tickets/{id}/tags", "put"), + ] { + let operation = &contract["paths"][path][method]; + assert!( + operation["responses"].get("409").is_some(), + "CAS conflict must be documented" + ); + assert!(operation["requestBody"]["required"].as_bool().unwrap()); + } + } + + #[cfg(feature = "typescript")] + #[test] + fn ticket_tag_typescript_references_are_registered() { + use ts_rs::TS; + let config = ts_rs::Config::default(); + assert!(TicketDetail::decl(&config).contains("tags: Array")); + assert!(TicketQueryItem::decl(&config).contains("tags: Array")); + let contract = canonical_openapi_document().unwrap().to_json().unwrap(); + let generated = + repository_openapi_typescript::generate_ticket_browser_typescript(&contract).unwrap(); + assert!(generated.contains("export type WorkspaceTag =")); + assert!(generated.contains("tags: Array")); + } + #[test] fn ticket_role_assignment_request_defaults_empty_bindings_and_requires_each_selected_connection() { diff --git a/crates/server-api/src/repository_openapi_typescript.rs b/crates/server-api/src/repository_openapi_typescript.rs index cd0dcf3cb..4103b51dd 100644 --- a/crates/server-api/src/repository_openapi_typescript.rs +++ b/crates/server-api/src/repository_openapi_typescript.rs @@ -423,6 +423,34 @@ fn validate_ticket_browser_operations( paths: &Map, ) -> Result, GenerationError> { let operations = [ + OperationContract { + path: "/api/w/{workspace_id}/ticket-tags", + method: "get", + operation_id: "workspace_ticket_tag_list", + request: None, + successes: &[("200", "Array_of_WorkspaceTag")], + }, + OperationContract { + path: "/api/w/{workspace_id}/ticket-tags", + method: "post", + operation_id: "workspace_ticket_tag_mutate", + request: Some("WorkspaceTagMutation"), + successes: &[("200", "WorkspaceTag")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/{id}/tags", + method: "put", + operation_id: "ticket_tags_set", + request: Some("TicketTagSetRequest"), + successes: &[("200", "Array_of_WorkspaceTag")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/query", + method: "post", + operation_id: "ticket_query", + request: Some("TicketQueryRequest"), + successes: &[("200", "TicketQueryResponse")], + }, OperationContract { path: "/api/w/{workspace_id}/tickets", method: "get", diff --git a/crates/ticket/src/latest_schema.sql b/crates/ticket/src/latest_schema.sql index a06926101..30820565a 100644 --- a/crates/ticket/src/latest_schema.sql +++ b/crates/ticket/src/latest_schema.sql @@ -131,3 +131,41 @@ CREATE INDEX typed_tickets_workspace_title ON typed_tickets(workspace_id, title COLLATE NOCASE, ticket_id); CREATE INDEX typed_tickets_workspace_updated ON typed_tickets(workspace_id, updated_at DESC, ticket_id); + +-- Workspace tag foundation (v8): shared by fresh schema and v7 upgrade. +CREATE TABLE workspace_tags ( + workspace_id TEXT NOT NULL, + tag_id TEXT NOT NULL, + name TEXT NOT NULL, + normalized_name TEXT NOT NULL, + last_operation_key TEXT NOT NULL, + deleted INTEGER NOT NULL CHECK (deleted IN (0, 1)), + PRIMARY KEY (workspace_id, tag_id) +); +CREATE UNIQUE INDEX workspace_tags_active_name + ON workspace_tags(workspace_id, normalized_name) WHERE deleted = 0; +CREATE TABLE typed_ticket_tags ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + tag_id TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, tag_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, tag_id) REFERENCES workspace_tags(workspace_id, tag_id) ON DELETE RESTRICT +); +CREATE INDEX typed_ticket_tags_workspace_tag + ON typed_ticket_tags(workspace_id, tag_id, ticket_id); +-- Immutable operation receipt and Workspace audit: exact input includes actor +-- attributes; before/result preserve stable identities and display-name snapshots. +-- No foreign keys to mutable rows: history survives deletion of current links. +CREATE TABLE workspace_tag_operations ( + workspace_id TEXT NOT NULL, + operation_key TEXT NOT NULL, + kind TEXT NOT NULL, + target_id TEXT NOT NULL, + input_json TEXT NOT NULL, + source_context_json TEXT NOT NULL, + before_json TEXT NOT NULL, + result_json TEXT NOT NULL, + at TEXT NOT NULL, + PRIMARY KEY (workspace_id, operation_key) +); diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index ee50132b9..a9d0fda02 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -22,9 +22,16 @@ use thiserror::Error; pub mod config; mod sqlite_schema; +mod tags; +pub use tags::{ + TagListRequest, TicketTagSetRequest, WorkspaceTag, WorkspaceTagAction, WorkspaceTagMutation, +}; pub mod tool; -pub use sqlite_schema::{migrate_sqlite_ticket_schema, verify_sqlite_ticket_schema}; +pub use sqlite_schema::{ + migrate_sqlite_ticket_schema, verify_pre_classification_ticket_schema, + verify_sqlite_ticket_schema, +}; const MAX_INTAKE_SUMMARY_BODY_BYTES: usize = 16 * 1024; const MAX_ORCHESTRATION_PLAN_TEXT_BYTES: usize = 16 * 1024; @@ -116,6 +123,7 @@ fn io_err(path: impl Into, source: io::Error) -> TicketError { fn read_ticket_summary_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { let workflow_state = row.get::<_, String>(7)?; Ok(TicketSummary { + tags: Vec::new(), id: row.get(0)?, resource_key: None, slug: row.get(1)?, @@ -360,6 +368,7 @@ pub enum TicketEventKind { Decision, ImplementationReport, StateChanged, + TagsChanged, IntakeSummary, StatusChanged, Close, @@ -375,6 +384,7 @@ impl TicketEventKind { Self::Decision => "decision", Self::ImplementationReport => "implementation_report", Self::StateChanged => "state_changed", + Self::TagsChanged => "tags_changed", Self::IntakeSummary => "intake_summary", Self::StatusChanged => "status_changed", Self::Close => "close", @@ -390,6 +400,7 @@ impl TicketEventKind { Self::Decision => "Decision".to_string(), Self::ImplementationReport => "Implementation report".to_string(), Self::StateChanged => "State changed".to_string(), + Self::TagsChanged => "Tags changed".to_string(), Self::IntakeSummary => "Intake summary".to_string(), Self::StatusChanged => "Status changed".to_string(), Self::Close => "Closed".to_string(), @@ -408,6 +419,7 @@ impl From<&str> for TicketEventKind { "implementation_report" => Self::ImplementationReport, "review" => Self::Comment, "state_changed" => Self::StateChanged, + "tags_changed" => Self::TagsChanged, "intake_summary" => Self::IntakeSummary, "status_changed" => Self::StatusChanged, "close" | "closed" => Self::Close, @@ -1635,6 +1647,8 @@ pub struct TicketMeta { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct TicketSummary { + #[serde(default)] + pub tags: Vec, pub id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub resource_key: Option, @@ -1718,6 +1732,8 @@ pub struct TicketArtifactRef { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct Ticket { + #[serde(default)] + pub tags: Vec, pub meta: TicketMeta, pub document: TicketDocument, pub events: Vec, @@ -1774,6 +1790,13 @@ impl TicketDoctorReport { } pub trait TicketBackend { + fn list_workspace_tags(&self, request: TagListRequest) -> Result>; + fn mutate_workspace_tag(&self, request: WorkspaceTagMutation) -> Result; + fn set_ticket_tags( + &self, + id: TicketIdOrSlug, + request: TicketTagSetRequest, + ) -> Result>; fn default_intake_ready_state_change_body(&self, from: &str) -> String; fn list(&self, filter: TicketListQuery) -> Result>; fn show(&self, id: TicketIdOrSlug) -> Result; @@ -1848,6 +1871,16 @@ pub trait TicketBackend { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "operation", rename_all = "snake_case")] pub enum TicketBackendOperation { + ListWorkspaceTags { + request: TagListRequest, + }, + MutateWorkspaceTag { + request: WorkspaceTagMutation, + }, + SetTicketTags { + id: TicketIdOrSlug, + request: TicketTagSetRequest, + }, DefaultIntakeReadyStateChangeBody { from: String, }, @@ -1938,6 +1971,8 @@ pub enum TicketBackendOperation { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "result", content = "value", rename_all = "snake_case")] pub enum TicketBackendOperationResult { + WorkspaceTags(Vec), + WorkspaceTag(WorkspaceTag), Text(String), Unit, Tickets(Vec), @@ -1961,6 +1996,15 @@ where B: TicketBackend + ?Sized, { Ok(match operation { + TicketBackendOperation::ListWorkspaceTags { request } => { + TicketBackendOperationResult::WorkspaceTags(backend.list_workspace_tags(request)?) + } + TicketBackendOperation::MutateWorkspaceTag { request } => { + TicketBackendOperationResult::WorkspaceTag(backend.mutate_workspace_tag(request)?) + } + TicketBackendOperation::SetTicketTags { id, request } => { + TicketBackendOperationResult::WorkspaceTags(backend.set_ticket_tags(id, request)?) + } TicketBackendOperation::DefaultIntakeReadyStateChangeBody { from } => { TicketBackendOperationResult::Text( backend.default_intake_ready_state_change_body(&from), @@ -2259,6 +2303,7 @@ impl SqliteTicketBackend { .map_err(sqlite_err)?; for summary in &mut summaries { summary.resource_key = Self::resource_key_for(conn, &self.workspace_id, &summary.id)?; + summary.tags = self.load_ticket_tags(conn, &summary.id)?; } let has_more = summaries.len() > query.limit; summaries.truncate(query.limit); @@ -2343,6 +2388,7 @@ impl SqliteTicketBackend { updated_at, ) = row.map_err(sqlite_err)?; summaries.push(TicketSummary { + tags: self.load_ticket_tags(conn, &id)?, resource_key: Self::resource_key_for(conn, &self.workspace_id, &id)?, id, slug, @@ -2929,6 +2975,7 @@ impl SqliteTicketBackend { let artifacts = self.load_artifacts(conn, ticket_id)?; let relations = self.relation_view_for_meta(conn, &meta)?; Ok(Ticket { + tags: self.load_ticket_tags(conn, ticket_id)?, meta, document: TicketDocument { body: MarkdownText::new(body), @@ -3098,7 +3145,9 @@ impl SqliteTicketBackend { } meta.labels = self.load_ordered_values(conn, "typed_ticket_labels", "label", &meta.id)?; - summaries.push(ticket_summary_from_meta(meta)); + let mut summary = ticket_summary_from_meta(meta); + summary.tags = self.load_ticket_tags(conn, &summary.id)?; + summaries.push(summary); } Ok(summaries) } @@ -3173,6 +3222,19 @@ fn finish_sqlite_transaction(conn: &Connection, result: Result) -> Result< } impl TicketBackend for SqliteTicketBackend { + fn list_workspace_tags(&self, request: TagListRequest) -> Result> { + self.list_tags(request) + } + fn mutate_workspace_tag(&self, request: WorkspaceTagMutation) -> Result { + self.mutate_tag(request) + } + fn set_ticket_tags( + &self, + id: TicketIdOrSlug, + request: TicketTagSetRequest, + ) -> Result> { + self.set_tags(id, request) + } fn default_intake_ready_state_change_body(&self, from: &str) -> String { if is_japanese_record_language(self.record_language()) { format!("Ticket planning が完了しました。state {from} -> ready。\n") @@ -3253,6 +3315,7 @@ impl TicketBackend for SqliteTicketBackend { .author .unwrap_or_else(|| "SqliteTicketBackend".to_string()); let ticket = Ticket { + tags: Vec::new(), meta, document: TicketDocument { body, @@ -3400,7 +3463,8 @@ impl TicketBackend for SqliteTicketBackend { let states = self.state_index(conn)?; let relations = self.all_relations(conn)?; let blockers = transitive_dependency_blockers(&ticket_id, &states, &relations)?; - let summary = ticket_summary_from_meta(ticket.meta); + let mut summary = ticket_summary_from_meta(ticket.meta); + summary.tags = ticket.tags; let mut projection = project_ticket_workspace_item(&summary, &blockers, None); projection.queue_guard = ticket_queue_guard(&summary, &blockers, None); let queue_tickets = vec![ticket_id]; @@ -3854,6 +3918,7 @@ impl TicketBackend for SqliteTicketBackend { fn ticket_summary_from_meta(meta: TicketMeta) -> TicketSummary { TicketSummary { + tags: Vec::new(), id: meta.id, resource_key: meta.resource_key, slug: meta.slug, @@ -4664,6 +4729,7 @@ mod tests { fn summary_with_state(state: TicketWorkflowState) -> TicketSummary { TicketSummary { + tags: Vec::new(), id: "000TEST".to_string(), resource_key: Some("T-1".to_string()), slug: "000TEST".to_string(), diff --git a/crates/ticket/src/sqlite_schema.rs b/crates/ticket/src/sqlite_schema.rs index 6b8f3c990..d7fe21617 100644 --- a/crates/ticket/src/sqlite_schema.rs +++ b/crates/ticket/src/sqlite_schema.rs @@ -7,8 +7,8 @@ use crate::{Result, TicketError, sqlite_err}; const MIGRATION_TABLE: &str = "ticket_schema_migrations"; const MAX_SCHEMA_DIAGNOSTICS: usize = 32; -const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 7; -const PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION: i64 = 6; +const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 8; +const PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION: i64 = 7; const BASELINE_MIGRATION_NAME: &str = "ticket schema baseline"; #[derive(Clone, Copy)] @@ -41,6 +41,9 @@ struct ExpectedForeignKey { } const OWNED_TABLES: &[&str] = &[ + "workspace_tags", + "typed_ticket_tags", + "workspace_tag_operations", "typed_tickets", "typed_ticket_targets", "typed_ticket_labels", @@ -96,6 +99,57 @@ const TICKET_COLUMNS: &[ExpectedColumn] = &[ column("resolution", "TEXT", false, 0), ]; +const TAG_COLUMNS: &[ExpectedColumn] = &[ + column("workspace_id", "TEXT", true, 1), + column("tag_id", "TEXT", true, 2), + column("name", "TEXT", true, 0), + column("normalized_name", "TEXT", true, 0), + column("last_operation_key", "TEXT", true, 0), + column("deleted", "INTEGER", true, 0), +]; +const TICKET_TAG_COLUMNS: &[ExpectedColumn] = &[ + column("workspace_id", "TEXT", true, 1), + column("ticket_id", "TEXT", true, 2), + column("tag_id", "TEXT", true, 3), +]; +const TAG_OPERATION_COLUMNS: &[ExpectedColumn] = &[ + column("workspace_id", "TEXT", true, 1), + column("operation_key", "TEXT", true, 2), + column("kind", "TEXT", true, 0), + column("target_id", "TEXT", true, 0), + column("input_json", "TEXT", true, 0), + column("source_context_json", "TEXT", true, 0), + column("before_json", "TEXT", true, 0), + column("result_json", "TEXT", true, 0), + column("at", "TEXT", true, 0), +]; +const TICKET_TAG_FOREIGN_KEYS: &[ExpectedForeignKey] = &[ + ExpectedForeignKey { + from: "workspace_id", + target_table: "typed_tickets", + to: "workspace_id", + on_delete: "CASCADE", + }, + ExpectedForeignKey { + from: "ticket_id", + target_table: "typed_tickets", + to: "ticket_id", + on_delete: "CASCADE", + }, + ExpectedForeignKey { + from: "workspace_id", + target_table: "workspace_tags", + to: "workspace_id", + on_delete: "RESTRICT", + }, + ExpectedForeignKey { + from: "tag_id", + target_table: "workspace_tags", + to: "tag_id", + on_delete: "RESTRICT", + }, +]; + const TARGET_COLUMNS: &[ExpectedColumn] = &[ column("workspace_id", "TEXT", true, 1), column("ticket_id", "TEXT", true, 2), @@ -288,12 +342,17 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> { PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION, BASELINE_MIGRATION_NAME.to_string(), )]) + || applied == BTreeMap::from([(6, BASELINE_MIGRATION_NAME.to_string())]) { - migrate_v6_to_v7(connection)?; + let previous = *applied.keys().next().expect("one migration"); + if previous == 6 { + migrate_v6_to_v7(connection)?; + } + migrate_v7_to_v8(connection)?; connection .execute( "DELETE FROM ticket_schema_migrations WHERE version = ?1", - params![PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION], + params![previous], ) .map_err(sqlite_err)?; connection @@ -325,6 +384,17 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> { /// Verifies the current Ticket-owned SQLite schema without executing DDL. pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { + verify_ticket_schema_shape(connection, false) +} + +/// Retained Workspace schema-66 migration produces the pre-classification v7 +/// Ticket shape. Validate that intermediate contract before the normal startup +/// path upgrades it; never require future tag tables inside that old transaction. +pub fn verify_pre_classification_ticket_schema(connection: &Connection) -> Result<()> { + verify_ticket_schema_shape(connection, true) +} + +fn verify_ticket_schema_shape(connection: &Connection, pre_classification: bool) -> Result<()> { let mut diagnostics = Vec::new(); collect_table_diagnostics( @@ -337,7 +407,18 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { match load_applied_migrations(connection) { Ok(applied) => { - if let Err(error) = validate_applied_migrations(&applied) { + let history = if pre_classification { + if applied == BTreeMap::from([(7, BASELINE_MIGRATION_NAME.to_owned())]) { + Ok(()) + } else { + Err(TicketError::Sqlite( + "pre-classification schema requires the retained v7 baseline".into(), + )) + } + } else { + validate_applied_migrations(&applied) + }; + if let Err(error) = history { push_diagnostic(&mut diagnostics, error.to_string()); } else if applied.len() != MIGRATIONS.len() { push_diagnostic( @@ -354,6 +435,17 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { } for (table, columns, foreign_keys) in [ + ("workspace_tags", TAG_COLUMNS, TICKET_FOREIGN_KEYS), + ( + "typed_ticket_tags", + TICKET_TAG_COLUMNS, + TICKET_TAG_FOREIGN_KEYS, + ), + ( + "workspace_tag_operations", + TAG_OPERATION_COLUMNS, + TICKET_FOREIGN_KEYS, + ), ("typed_tickets", TICKET_COLUMNS, TICKET_FOREIGN_KEYS), ("typed_ticket_targets", TARGET_COLUMNS, CHILD_FOREIGN_KEYS), ("typed_ticket_labels", LABEL_COLUMNS, CHILD_FOREIGN_KEYS), @@ -394,9 +486,47 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { CHILD_FOREIGN_KEYS, ), ] { + if pre_classification + && matches!( + table, + "workspace_tags" | "typed_ticket_tags" | "workspace_tag_operations" + ) + { + continue; + } collect_table_diagnostics(connection, table, columns, foreign_keys, &mut diagnostics); } + if !pre_classification { + collect_index_diagnostics( + connection, + "workspace_tags", + Some("workspace_tags_active_name"), + true, + &["workspace_id", "normalized_name"], + &mut diagnostics, + ); + collect_index_diagnostics( + connection, + "typed_ticket_tags", + Some("typed_ticket_tags_workspace_tag"), + false, + &["workspace_id", "tag_id", "ticket_id"], + &mut diagnostics, + ); + let tag_name_index_sql: Option = connection.query_row("SELECT sql FROM sqlite_master WHERE type='index' AND name='workspace_tags_active_name'", [], |row| row.get(0)).optional().map_err(sqlite_err)?; + if !tag_name_index_sql.is_some_and(|sql| { + sql.split_whitespace() + .collect::() + .to_lowercase() + .ends_with("wheredeleted=0") + }) { + push_diagnostic( + &mut diagnostics, + "workspace_tags_active_name must be partial WHERE deleted = 0".into(), + ); + } + } collect_index_diagnostics( connection, "typed_ticket_targets", @@ -449,6 +579,14 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { } for table in OWNED_TABLES { + if pre_classification + && matches!( + *table, + "workspace_tags" | "typed_ticket_tags" | "workspace_tag_operations" + ) + { + continue; + } collect_foreign_key_check_diagnostics(connection, table, &mut diagnostics); } @@ -474,6 +612,14 @@ fn create_latest_ticket_schema(connection: &Connection) -> Result<()> { .map_err(sqlite_err) } +fn migrate_v7_to_v8(connection: &Connection) -> Result<()> { + // Use the exact canonical DDL rather than duplicating migration definitions. + let (_, tag_schema) = include_str!("latest_schema.sql") + .split_once("-- Workspace tag foundation (v8): shared by fresh schema and v7 upgrade.") + .expect("canonical tag schema marker"); + connection.execute_batch(tag_schema).map_err(sqlite_err) +} + fn migrate_v6_to_v7(connection: &Connection) -> Result<()> { connection .execute_batch( @@ -1029,7 +1175,10 @@ mod tests { create_latest_ticket_schema(&connection).unwrap(); connection .execute_batch( - "DROP TABLE typed_ticket_targets; + "DROP TABLE typed_ticket_tags; + DROP TABLE workspace_tag_operations; + DROP TABLE workspace_tags; + DROP TABLE typed_ticket_targets; ALTER TABLE typed_tickets ADD COLUMN repository_id TEXT; ALTER TABLE typed_tickets ADD COLUMN ref_selector TEXT; CREATE TABLE ticket_schema_migrations ( @@ -1137,10 +1286,137 @@ mod tests { assert!(!migrated_attributes.contains_key("ref_selector")); assert_eq!( load_applied_migrations(&connection).unwrap(), - BTreeMap::from([(7, BASELINE_MIGRATION_NAME.to_owned())]) + BTreeMap::from([(8, BASELINE_MIGRATION_NAME.to_owned())]) ); } + #[test] + fn migrates_v7_to_identical_fresh_schema_without_touching_legacy_labels() { + let upgraded = Connection::open_in_memory().unwrap(); + let legacy_sql = include_str!("latest_schema.sql") + .split_once("-- Workspace tag foundation (v8): shared by fresh schema and v7 upgrade.") + .unwrap() + .0; + upgraded.execute_batch(legacy_sql).unwrap(); + upgraded.execute_batch( + "CREATE TABLE ticket_schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL); + INSERT INTO ticket_schema_migrations VALUES (7, 'ticket schema baseline', 'old'); + INSERT INTO typed_tickets (workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) + VALUES ('ws','ticket','ticket','Title','open','task','P2','body','ready',1); + INSERT INTO typed_ticket_labels VALUES ('ws','ticket',0,' Legacy Label ');" + ).unwrap(); + migrate_sqlite_ticket_schema(&upgraded).unwrap(); + verify_sqlite_ticket_schema(&upgraded).unwrap(); + let fresh = Connection::open_in_memory().unwrap(); + migrate_sqlite_ticket_schema(&fresh).unwrap(); + let schema = |conn: &Connection| { + let mut statement = conn.prepare("SELECT type,name,tbl_name,sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY type,name").unwrap(); + statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)? + .map(|sql| sql.split_whitespace().collect::()), + )) + }) + .unwrap() + .collect::, _>>() + .unwrap() + }; + assert_eq!(schema(&upgraded), schema(&fresh)); + let label: String = upgraded + .query_row("SELECT label FROM typed_ticket_labels", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(label, " Legacy Label "); + let tags: i64 = upgraded + .query_row("SELECT COUNT(*) FROM workspace_tags", [], |row| row.get(0)) + .unwrap(); + assert_eq!(tags, 0); // Legacy labels are never promoted to catalog tags. + migrate_sqlite_ticket_schema(&upgraded).unwrap(); + assert_eq!(schema(&upgraded), schema(&fresh)); + } + + #[test] + fn tag_schema_verification_detects_drift_without_repair() { + for (drift, expected) in [ + ("DROP TABLE typed_ticket_tags", "typed_ticket_tags"), + ( + "ALTER TABLE workspace_tags RENAME COLUMN last_operation_key TO wrong_observed_key", + "last_operation_key", + ), + ( + "ALTER TABLE workspace_tag_operations DROP COLUMN result_json", + "result_json", + ), + ( + "DROP INDEX typed_ticket_tags_workspace_tag", + "typed_ticket_tags_workspace_tag", + ), + ( + "DROP INDEX workspace_tags_active_name; CREATE UNIQUE INDEX workspace_tags_active_name ON workspace_tags(workspace_id, normalized_name)", + "partial WHERE deleted = 0", + ), + ( + "DROP TABLE typed_ticket_tags; CREATE TABLE typed_ticket_tags (workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,tag_id TEXT NOT NULL, PRIMARY KEY(workspace_id,ticket_id,tag_id)); CREATE INDEX typed_ticket_tags_workspace_tag ON typed_ticket_tags(workspace_id,tag_id,ticket_id)", + "missing foreign key", + ), + ] { + let connection = Connection::open_in_memory().unwrap(); + migrate_sqlite_ticket_schema(&connection).unwrap(); + connection.execute_batch(drift).unwrap(); + let error = verify_sqlite_ticket_schema(&connection).unwrap_err(); + assert!( + error.to_string().contains(expected), + "after {drift}: {error}" + ); + assert!(migrate_sqlite_ticket_schema(&connection).is_err()); + } + } + + #[test] + fn tag_schema_rejects_cross_workspace_links_and_preserves_tombstones() { + let connection = Connection::open_in_memory().unwrap(); + connection + .pragma_update(None, "foreign_keys", "ON") + .unwrap(); + migrate_sqlite_ticket_schema(&connection).unwrap(); + connection.execute_batch( + "INSERT INTO typed_tickets (workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) + VALUES ('one','ticket','ticket','Title','open','task','P2','body','ready',1); + INSERT INTO workspace_tags VALUES ('two','tag','Name','name','create',0);" + ).unwrap(); + assert!( + connection + .execute( + "INSERT INTO typed_ticket_tags VALUES ('one','ticket','tag')", + [] + ) + .is_err() + ); + connection + .execute( + "INSERT INTO workspace_tags VALUES ('one','tag','Name','name','create',0)", + [], + ) + .unwrap(); + connection + .execute( + "INSERT INTO typed_ticket_tags VALUES ('one','ticket','tag')", + [], + ) + .unwrap(); + assert!( + connection + .execute("DELETE FROM workspace_tags WHERE workspace_id='one'", []) + .is_err() + ); + verify_sqlite_ticket_schema(&connection).unwrap(); + } + #[test] fn rejects_unknown_future_migration_history_without_changing_schema() { let connection = Connection::open_in_memory().unwrap(); @@ -1155,7 +1431,7 @@ mod tests { let error = migrate_sqlite_ticket_schema(&connection).unwrap_err(); assert!(error.to_string().contains( - "migration history must contain only the canonical version 7 baseline marker" + "migration history must contain only the canonical version 8 baseline marker" )); assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2); } @@ -1177,7 +1453,7 @@ mod tests { let error = migrate_sqlite_ticket_schema(&connection).unwrap_err(); assert!(error.to_string().contains( - "migration history must contain only the canonical version 7 baseline marker" + "migration history must contain only the canonical version 8 baseline marker" )); assert!(!table_exists(&connection, "typed_tickets").unwrap()); } diff --git a/crates/ticket/src/tags.rs b/crates/ticket/src/tags.rs new file mode 100644 index 000000000..cd829693b --- /dev/null +++ b/crates/ticket/src/tags.rs @@ -0,0 +1,323 @@ +//! Workspace tag identity, display-name policy, and transactional SQLite authority. +//! +//! Display names use Rust `str::trim` (Unicode whitespace), then Unicode +//! `str::to_lowercase` for active-name uniqueness. No NFC/NFKC normalization or +//! full case folding is performed: canonically equivalent spellings can differ. +//! Any control character in the input is rejected, including trimmed edges. +//! The trimmed display name must be nonempty and at most 128 UTF-8 bytes. +//! IDs are immutable; deleted names may be recreated under a new ID. + +use super::*; + +#[cfg(test)] +#[path = "tags_tests.rs"] +mod tests; + +/// Workspace-scoped immutable identity with a mutable display name. +/// Names are Unicode-whitespace trimmed, limited to 128 UTF-8 bytes, and +/// deduplicated among active tags using Rust Unicode lowercase (not full case +/// folding or NFC/NFKC). Empty names and any input control characters are invalid. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct WorkspaceTag { + pub workspace_id: String, + pub id: String, + pub name: String, + pub last_operation_key: String, + pub deleted: bool, +} + +/// Active tags in ascending ID order, strictly after `after`; default/max 200. +/// Larger limits are clamped to 200; zero returns an empty page. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TagListRequest { + pub after: Option, + #[schemars(range(min = 0, max = 200))] + pub limit: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceTagMutation { + pub operation_key: String, + pub action: WorkspaceTagAction, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum WorkspaceTagAction { + Create { + name: String, + }, + Rename { + tag_id: String, + expected_last_operation_key: String, + name: String, + }, + Delete { + tag_id: String, + expected_last_operation_key: String, + }, +} + +/// Compare entire active ID set, then replace it atomically. IDs must be unique; +/// input order is immaterial to CAS, but remains significant for exact replay. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketTagSetRequest { + pub expected_tag_ids: Vec, + pub tag_ids: Vec, + pub operation_key: String, +} + +fn normalize_name(input: &str) -> Result<(String, String)> { + let name = input.trim(); + if name.is_empty() || name.len() > 128 || input.chars().any(char::is_control) { + return Err(TicketError::Conflict("tag name must be nonempty, contain no control characters, and be at most 128 UTF-8 bytes after trimming".into())); + } + Ok((name.to_owned(), name.to_lowercase())) +} + +fn sorted_ids(ids: &[String]) -> Result> { + if ids.len() > 100 || ids.iter().any(|id| id.len() > 128) { + return Err(TicketError::Conflict( + "tag sets require at most 100 IDs of at most 128 bytes".into(), + )); + } + let mut sorted = ids.to_vec(); + sorted.sort(); + if sorted.iter().any(|id| id.trim().is_empty()) + || sorted.windows(2).any(|pair| pair[0] == pair[1]) + { + return Err(TicketError::Conflict( + "tag IDs must be nonempty and unique".into(), + )); + } + Ok(sorted) +} + +fn read_tag(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(WorkspaceTag { + workspace_id: row.get(0)?, + id: row.get(1)?, + name: row.get(2)?, + last_operation_key: row.get(3)?, + deleted: row.get::<_, i64>(4)? != 0, + }) +} + +impl SqliteTicketBackend { + pub(crate) fn load_ticket_tags( + &self, + conn: &Connection, + ticket_id: &str, + ) -> Result> { + let mut statement = conn.prepare("SELECT tag.workspace_id,tag.tag_id,tag.name,tag.last_operation_key,tag.deleted FROM workspace_tags AS tag JOIN typed_ticket_tags AS link ON link.workspace_id=tag.workspace_id AND link.tag_id=tag.tag_id WHERE link.workspace_id=?1 AND link.ticket_id=?2 AND tag.deleted=0 ORDER BY tag.tag_id").map_err(sqlite_err)?; + statement + .query_map(params![self.workspace_id, ticket_id], read_tag) + .map_err(sqlite_err)? + .collect::, _>>() + .map_err(sqlite_err) + } + + fn load_tag(&self, conn: &Connection, id: &str) -> Result { + conn.query_row("SELECT workspace_id,tag_id,name,last_operation_key,deleted FROM workspace_tags WHERE workspace_id=?1 AND tag_id=?2", params![self.workspace_id,id], read_tag).optional().map_err(sqlite_err)?.ok_or_else(|| TicketError::NotFound(format!("workspace tag {id}"))) + } + + fn tag_input(&self, kind: &str, target: &str, request: &impl Serialize) -> Result { + // Bind authenticated identity, not its mutable assignment/role context: + // a receipt is still the same request after the Worker is reassigned. + // The complete original context is retained separately in the audit row. + let identity: BTreeMap<_, _> = self + .event_attributes + .iter() + .filter(|(key, _)| { + matches!( + key.as_str(), + "source_account_id" + | "source_user_id" + | "source_runtime_id" + | "source_worker_id" + ) + }) + .collect(); + serde_json::to_string(&(kind, target, request, identity)).map_err(sqlite_err) + } + + fn replay_tag_operation( + &self, + conn: &Connection, + key: &str, + input: &str, + ) -> Result> { + validate_required_event_value("operation_key", key)?; + let receipt: Option<(String,String)> = conn.query_row("SELECT input_json,result_json FROM workspace_tag_operations WHERE workspace_id=?1 AND operation_key=?2",params![self.workspace_id,key],|row| Ok((row.get(0)?,row.get(1)?))).optional().map_err(sqlite_err)?; + match receipt { + Some((original, result)) if original == input => { + Ok(Some(serde_json::from_str(&result).map_err(sqlite_err)?)) + } + Some(_) => Err(TicketError::OperationFingerprintMismatch { + operation_key: key.to_owned(), + }), + None => Ok(None), + } + } + + fn record_tag_operation( + &self, + conn: &Connection, + key: &str, + kind: &str, + target: &str, + input: &str, + snapshots: (&impl Serialize, &impl Serialize), + ) -> Result<()> { + conn.execute("INSERT INTO workspace_tag_operations (workspace_id,operation_key,kind,target_id,input_json,before_json,result_json,at,source_context_json) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)", params![self.workspace_id,key,kind,target,input,serde_json::to_string(snapshots.0).map_err(sqlite_err)?,serde_json::to_string(snapshots.1).map_err(sqlite_err)?,now_utc(),serde_json::to_string(&self.event_attributes).map_err(sqlite_err)?]).map_err(sqlite_err)?; + Ok(()) + } + + fn audit_ticket_tags( + &self, + conn: &Connection, + ticket_id: &str, + key: &str, + before: &[WorkspaceTag], + after: &[WorkspaceTag], + ) -> Result<()> { + self.insert_event( + conn, + ticket_id, + &TicketEvent { + kind: TicketEventKind::TagsChanged, + author: Some(default_author()), + at: Some(now_utc()), + status: None, + from: None, + to: None, + reason: None, + state_field: None, + heading: Some("Tags changed".into()), + body: MarkdownText::new("Ticket tags changed"), + references: Vec::new(), + attributes: BTreeMap::from([ + ("operation_key".into(), key.to_owned()), + ( + "tags_before".into(), + serde_json::to_string(before).map_err(sqlite_err)?, + ), + ( + "tags_after".into(), + serde_json::to_string(after).map_err(sqlite_err)?, + ), + ]), + }, + ) + } + + pub(crate) fn list_tags(&self, request: TagListRequest) -> Result> { + self.with_read(|conn| { + let mut statement = conn.prepare("SELECT workspace_id,tag_id,name,last_operation_key,deleted FROM workspace_tags WHERE workspace_id=?1 AND deleted=0 AND (?2 IS NULL OR tag_id>?2) ORDER BY tag_id LIMIT ?3").map_err(sqlite_err)?; + statement.query_map(params![self.workspace_id,request.after,request.limit.unwrap_or(200).min(200) as i64],read_tag).map_err(sqlite_err)?.collect::,_>>().map_err(sqlite_err) + }) + } + + pub(crate) fn mutate_tag(&self, request: WorkspaceTagMutation) -> Result { + self.with_write(|conn| { + let input = self.tag_input("mutate_workspace_tag", "", &request)?; + if let Some(result) = self.replay_tag_operation(conn,&request.operation_key,&input)? { return Ok(result); } + let (before, result, kind) = match &request.action { + WorkspaceTagAction::Create { name } => { + let (name, normalized) = normalize_name(name)?; + self.require_unique_tag_name(conn,&normalized,None)?; + let millis = unix_epoch_millis_now().map_err(|error| TicketError::Conflict(format!("failed to read tag ID timestamp: {error}")))?; + let id = allocate_record_id(millis, |candidate| { + conn.query_row("SELECT 1 FROM workspace_tags WHERE workspace_id=?1 AND tag_id=?2",params![self.workspace_id,candidate],|_| Ok(())).optional().map(|row| row.is_some()).unwrap_or(true) + }).map_err(|error| TicketError::Conflict(error.to_string()))?; + let tag = WorkspaceTag { workspace_id: self.workspace_id.clone(), id, name, last_operation_key: request.operation_key.clone(), deleted: false }; + conn.execute("INSERT INTO workspace_tags (workspace_id,tag_id,name,normalized_name,last_operation_key,deleted) VALUES (?1,?2,?3,?4,?5,0)",params![tag.workspace_id,tag.id,tag.name,normalized,tag.last_operation_key]).map_err(sqlite_err)?; + (None,tag,"create") + } + WorkspaceTagAction::Rename { tag_id,expected_last_operation_key,.. } | WorkspaceTagAction::Delete { tag_id,expected_last_operation_key } => { + let previous = self.load_tag(conn,tag_id)?; + if previous.deleted || &previous.last_operation_key != expected_last_operation_key { + return Err(TicketError::Conflict(format!("stale or deleted tag {tag_id}: expected operation {expected_last_operation_key:?}, actual {:?}",previous.last_operation_key))); + } + let mut tag = previous.clone(); + tag.last_operation_key = request.operation_key.clone(); + let kind = if let WorkspaceTagAction::Rename { name,.. } = &request.action { + let (name,normalized) = normalize_name(name)?; + self.require_unique_tag_name(conn,&normalized,Some(tag_id))?; + tag.name = name; + conn.execute("UPDATE workspace_tags SET name=?3,normalized_name=?4,last_operation_key=?5 WHERE workspace_id=?1 AND tag_id=?2",params![self.workspace_id,tag_id,tag.name,normalized,request.operation_key]).map_err(sqlite_err)?; + "rename" + } else { + // Capture active snapshots before tombstoning; links and audit are + // cleared/written under the same immediate lock as attachments. + let ticket_ids = { + let mut stmt = conn.prepare("SELECT ticket_id FROM typed_ticket_tags WHERE workspace_id=?1 AND tag_id=?2 ORDER BY ticket_id").map_err(sqlite_err)?; + stmt.query_map(params![self.workspace_id,tag_id],|row| row.get::<_,String>(0)).map_err(sqlite_err)?.collect::,_>>().map_err(sqlite_err)? + }; + for ticket_id in ticket_ids { + let before = self.load_ticket_tags(conn,&ticket_id)?; + let after: Vec<_> = before.iter().filter(|tag| &tag.id != tag_id).cloned().collect(); + conn.execute("DELETE FROM typed_ticket_tags WHERE workspace_id=?1 AND ticket_id=?2 AND tag_id=?3",params![self.workspace_id,ticket_id,tag_id]).map_err(sqlite_err)?; + self.audit_ticket_tags(conn,&ticket_id,&request.operation_key,&before,&after)?; + } + tag.deleted = true; + conn.execute("UPDATE workspace_tags SET deleted=1,last_operation_key=?3 WHERE workspace_id=?1 AND tag_id=?2",params![self.workspace_id,tag_id,request.operation_key]).map_err(sqlite_err)?; + "delete" + }; + (Some(previous),tag,kind) + } + }; + self.record_tag_operation(conn,&request.operation_key,kind,&result.id,&input,(&before,&result))?; + Ok(result) + }) + } + + fn require_unique_tag_name( + &self, + conn: &Connection, + normalized: &str, + except: Option<&str>, + ) -> Result<()> { + let duplicate = conn.query_row("SELECT 1 FROM workspace_tags WHERE workspace_id=?1 AND normalized_name=?2 AND deleted=0 AND (?3 IS NULL OR tag_id<>?3)",params![self.workspace_id,normalized,except],|_| Ok(())).optional().map_err(sqlite_err)?.is_some(); + if duplicate { + return Err(TicketError::Conflict( + "active workspace tag name already exists".into(), + )); + } + Ok(()) + } + + pub(crate) fn set_tags( + &self, + id: TicketIdOrSlug, + request: TicketTagSetRequest, + ) -> Result> { + self.with_write(|conn| { + // Preserve exact selector input, not just its resolved identity. + let input = self.tag_input("set_ticket_tags", "", &(&id,&request))?; + if let Some(result) = self.replay_tag_operation(conn,&request.operation_key,&input)? { return Ok(result); } + let ticket_id = self.resolve_ticket_id(conn,id)?; + self.require_active_source_assignment(conn,&ticket_id)?; + let before = self.load_ticket_tags(conn,&ticket_id)?; + let expected = sorted_ids(&request.expected_tag_ids)?; + let desired = sorted_ids(&request.tag_ids)?; + if before.iter().map(|tag| tag.id.clone()).collect::>() != expected { + return Err(TicketError::Conflict("stale Ticket tag set".into())); + } + let after = desired.iter().map(|id| { + let tag = self.load_tag(conn,id)?; + if tag.deleted { return Err(TicketError::Conflict(format!("workspace tag {id} is deleted"))); } + Ok(tag) + }).collect::>>()?; + conn.execute("DELETE FROM typed_ticket_tags WHERE workspace_id=?1 AND ticket_id=?2",params![self.workspace_id,ticket_id]).map_err(sqlite_err)?; + for tag in &after { + conn.execute("INSERT INTO typed_ticket_tags (workspace_id,ticket_id,tag_id) VALUES (?1,?2,?3)",params![self.workspace_id,ticket_id,tag.id]).map_err(sqlite_err)?; + } + if before != after { self.audit_ticket_tags(conn,&ticket_id,&request.operation_key,&before,&after)?; } + self.record_tag_operation(conn,&request.operation_key,"set_ticket_tags",&ticket_id,&input,(&before,&after))?; + Ok(after) + }) + } +} diff --git a/crates/ticket/src/tags_tests.rs b/crates/ticket/src/tags_tests.rs new file mode 100644 index 000000000..4387e4369 --- /dev/null +++ b/crates/ticket/src/tags_tests.rs @@ -0,0 +1,801 @@ +use super::*; +use tempfile::tempdir; + +fn create_tag(backend: &SqliteTicketBackend, key: &str, name: &str) -> WorkspaceTag { + backend + .mutate_workspace_tag(WorkspaceTagMutation { + operation_key: key.into(), + action: WorkspaceTagAction::Create { name: name.into() }, + }) + .unwrap() +} +fn rename_tag( + backend: &SqliteTicketBackend, + key: &str, + tag: &WorkspaceTag, + name: &str, +) -> WorkspaceTag { + backend + .mutate_workspace_tag(WorkspaceTagMutation { + operation_key: key.into(), + action: WorkspaceTagAction::Rename { + tag_id: tag.id.clone(), + expected_last_operation_key: tag.last_operation_key.clone(), + name: name.into(), + }, + }) + .unwrap() +} +fn delete_request(key: &str, tag: &WorkspaceTag) -> WorkspaceTagMutation { + WorkspaceTagMutation { + operation_key: key.into(), + action: WorkspaceTagAction::Delete { + tag_id: tag.id.clone(), + expected_last_operation_key: tag.last_operation_key.clone(), + }, + } +} +fn set_request( + key: &str, + expected: &[WorkspaceTag], + desired: &[WorkspaceTag], +) -> TicketTagSetRequest { + TicketTagSetRequest { + expected_tag_ids: expected.iter().map(|t| t.id.clone()).collect(), + tag_ids: desired.iter().map(|t| t.id.clone()).collect(), + operation_key: key.into(), + } +} +fn audit_count(backend: &SqliteTicketBackend) -> i64 { + backend + .open_connection() + .unwrap() + .query_row( + "SELECT COUNT(*) FROM workspace_tag_operations WHERE workspace_id=?1", + params![backend.workspace_id], + |row| row.get(0), + ) + .unwrap() +} + +#[test] +fn tag_names_have_documented_unicode_lowercase_not_casefold_policy() { + let dir = tempdir().unwrap(); + let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap(); + let tag = create_tag(&backend, "a", "\u{2003}Ä BC\u{2003}"); + assert_eq!(tag.name, "Ä BC"); + for name in ["ä bc", " Ä BC "] { + assert!( + backend + .mutate_workspace_tag(WorkspaceTagMutation { + operation_key: "duplicate".into(), + action: WorkspaceTagAction::Create { name: name.into() } + }) + .is_err() + ); + } + for name in [ + "", + " \u{2003}", + "bad\nname", + "\tname", + "a\0", + &"é".repeat(65), + ] { + assert!( + backend + .mutate_workspace_tag(WorkspaceTagMutation { + operation_key: "invalid".into(), + action: WorkspaceTagAction::Create { name: name.into() } + }) + .is_err() + ); + } + create_tag(&backend, "boundary", &"é".repeat(64)); + create_tag(&backend, "sharp", "ß"); + create_tag(&backend, "ss", "SS"); // Lowercase, not full case folding. + create_tag(&backend, "composed", "é"); + create_tag(&backend, "decomposed", "e\u{301}"); // No Unicode normalization. + assert_eq!(audit_count(&backend), 6); +} + +#[test] +fn tag_rename_delete_cas_and_name_conflicts_leave_no_audit_or_changes() { + let dir = tempdir().unwrap(); + let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap(); + let a = create_tag(&backend, "a", "A"); + let b = create_tag(&backend, "b", "B"); + let renamed = rename_tag(&backend, "rename", &a, "New"); + assert_eq!(a.id, renamed.id); + for action in [ + WorkspaceTagAction::Rename { + tag_id: a.id.clone(), + expected_last_operation_key: "a".into(), + name: "Stale".into(), + }, + WorkspaceTagAction::Delete { + tag_id: a.id.clone(), + expected_last_operation_key: "a".into(), + }, + WorkspaceTagAction::Rename { + tag_id: renamed.id.clone(), + expected_last_operation_key: "rename".into(), + name: " b ".into(), + }, + WorkspaceTagAction::Rename { + tag_id: renamed.id.clone(), + expected_last_operation_key: "rename".into(), + name: "\n".into(), + }, + ] { + assert!( + backend + .mutate_workspace_tag(WorkspaceTagMutation { + operation_key: "rejected".into(), + action + }) + .is_err() + ); + } + assert_eq!(audit_count(&backend), 3); + let active = backend + .list_workspace_tags(TagListRequest::default()) + .unwrap(); + assert!(active.contains(&renamed) && active.contains(&b)); +} + +#[test] +fn workspace_mutation_exact_replay_returns_stored_snapshot_before_cas() { + let dir = tempdir().unwrap(); + let db = dir.path().join("tags.db"); + let backend = SqliteTicketBackend::open(&db, "ws") + .unwrap() + .with_event_attributes(BTreeMap::from([( + "source_worker_id".into(), + "actor-a".into(), + )])); + let request = WorkspaceTagMutation { + operation_key: "create".into(), + action: WorkspaceTagAction::Create { name: " A ".into() }, + }; + let original = backend.mutate_workspace_tag(request.clone()).unwrap(); + let renamed = rename_tag(&backend, "rename", &original, "B"); + let deletion = delete_request("delete", &renamed); + let tombstone = backend.mutate_workspace_tag(deletion.clone()).unwrap(); + assert!(tombstone.deleted); + assert_eq!( + backend.mutate_workspace_tag(request.clone()).unwrap(), + original + ); + assert_eq!( + backend.mutate_workspace_tag(deletion.clone()).unwrap(), + tombstone + ); + let restarted = SqliteTicketBackend::open_verified(&db, "ws") + .unwrap() + .with_event_attributes(backend.event_attributes.clone()); + assert_eq!( + restarted.mutate_workspace_tag(request.clone()).unwrap(), + original + ); + let other_actor = restarted.with_event_attributes(BTreeMap::from([( + "source_worker_id".into(), + "actor-b".into(), + )])); + assert!(matches!( + other_actor.mutate_workspace_tag(request), + Err(TicketError::OperationFingerprintMismatch { .. }) + )); + assert!(matches!( + backend.mutate_workspace_tag(WorkspaceTagMutation { + operation_key: "create".into(), + action: WorkspaceTagAction::Create { name: "A".into() } + }), + Err(TicketError::OperationFingerprintMismatch { .. }) + )); + assert_eq!(audit_count(&backend), 3); + assert!( + backend + .list_workspace_tags(TagListRequest::default()) + .unwrap() + .is_empty() + ); +} + +#[test] +fn tag_deletion_detaches_every_ticket_keeps_audit_snapshots_and_recreate_new_id() { + let dir = tempdir().unwrap(); + let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap(); + let tag = create_tag(&backend, "create", "Old name"); + let mut ids = Vec::new(); + for i in 0..2 { + let id = backend + .create(NewTicket::new(format!("ticket {i}"))) + .unwrap() + .id; + backend + .set_ticket_tags( + id.clone().into(), + set_request(&format!("attach-{i}"), &[], std::slice::from_ref(&tag)), + ) + .unwrap(); + ids.push(id); + } + let renamed = rename_tag(&backend, "rename", &tag, "New name"); + backend + .mutate_workspace_tag(delete_request("delete", &renamed)) + .unwrap(); + let replacement = create_tag(&backend, "recreate", "new NAME"); + assert_ne!(replacement.id, tag.id); + for id in ids { + let ticket = backend.show(id.into()).unwrap(); + assert!(ticket.tags.is_empty()); + let events: Vec<_> = ticket + .events + .iter() + .filter(|e| e.kind == TicketEventKind::TagsChanged) + .collect(); + assert_eq!(events.len(), 2); + let first: Vec = + serde_json::from_str(&events[0].attributes["tags_after"]).unwrap(); + assert_eq!(first, vec![tag.clone()]); + let before_delete: Vec = + serde_json::from_str(&events[1].attributes["tags_before"]).unwrap(); + assert_eq!(before_delete, vec![renamed.clone()]); + assert_eq!(events[1].attributes["tags_after"], "[]"); + } + let conn = backend.open_connection().unwrap(); + let deleted: i64 = conn + .query_row( + "SELECT deleted FROM workspace_tags WHERE workspace_id='ws' AND tag_id=?1", + params![tag.id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(deleted, 1); + assert_eq!(audit_count(&backend), 6); +} + +#[test] +fn ticket_tag_set_compares_whole_set_validates_ids_and_replays_historical_result() { + let dir = tempdir().unwrap(); + let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap(); + let a = create_tag(&backend, "a", "A"); + let b = create_tag(&backend, "b", "B"); + let id = backend.create(NewTicket::new("ticket")).unwrap().id; + let request = set_request("set", &[], &[b.clone(), a.clone()]); + let stored = backend + .set_ticket_tags(id.clone().into(), request.clone()) + .unwrap(); + assert!(stored.windows(2).all(|pair| pair[0].id < pair[1].id)); + for bad in [ + set_request("bad", std::slice::from_ref(&a), &[]), + TicketTagSetRequest { + expected_tag_ids: stored.iter().map(|t| t.id.clone()).collect(), + tag_ids: vec![a.id.clone(), a.id.clone()], + operation_key: "bad".into(), + }, + TicketTagSetRequest { + expected_tag_ids: stored.iter().map(|t| t.id.clone()).collect(), + tag_ids: vec!["missing".into()], + operation_key: "bad".into(), + }, + ] { + assert!(backend.set_ticket_tags(id.clone().into(), bad).is_err()); + } + assert_eq!(backend.show(id.clone().into()).unwrap().tags, stored); + let renamed = rename_tag(&backend, "rename", &a, "Renamed"); + backend + .mutate_workspace_tag(delete_request("delete", &renamed)) + .unwrap(); + assert_eq!( + backend + .set_ticket_tags(id.clone().into(), request.clone()) + .unwrap(), + stored + ); + let mut changed = request.clone(); + changed.tag_ids.reverse(); + assert!(matches!( + backend.set_ticket_tags(id.clone().into(), changed), + Err(TicketError::OperationFingerprintMismatch { .. }) + )); + let current = backend.show(id.clone().into()).unwrap(); + assert_eq!(current.tags, vec![b]); + assert_eq!( + current + .events + .iter() + .filter(|e| e.kind == TicketEventKind::TagsChanged) + .count(), + 2 + ); + let other_actor = backend.clone().with_event_attributes(BTreeMap::from([( + "source_worker_id".into(), + "actor-b".into(), + )])); + assert!(matches!( + other_actor.set_ticket_tags(id.into(), request), + Err(TicketError::OperationFingerprintMismatch { .. }) + )); + assert_eq!(audit_count(&backend), 5); +} + +#[test] +fn tags_and_operation_keys_are_workspace_scoped() { + let dir = tempdir().unwrap(); + let db = dir.path().join("tags.db"); + let one = SqliteTicketBackend::open(&db, "one").unwrap(); + let two = SqliteTicketBackend::open(&db, "two").unwrap(); + let a = create_tag(&one, "same-key", "Same name"); + let b = create_tag(&two, "same-key", "Same name"); + let ticket = two.create(NewTicket::new("ticket")).unwrap().id; + assert!( + two.mutate_workspace_tag(delete_request("delete", &a)) + .is_err() + ); + assert!( + two.set_ticket_tags(ticket.clone().into(), set_request("attach", &[], &[a])) + .is_err() + ); + two.set_ticket_tags( + ticket.into(), + set_request("attach", &[], std::slice::from_ref(&b)), + ) + .unwrap(); + assert_eq!( + one.list_workspace_tags(TagListRequest::default()) + .unwrap() + .len(), + 1 + ); + assert_eq!( + two.list_workspace_tags(TagListRequest::default()).unwrap(), + vec![b] + ); + assert_eq!(audit_count(&one), 1); +} + +#[test] +fn tags_do_not_change_content_workflow_queue_targets_or_legacy_labels_in_any_state() { + let dir = tempdir().unwrap(); + let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap(); + let tag = create_tag(&backend, "tag", "Tag"); + for (i, state) in [ + TicketWorkflowState::Planning, + TicketWorkflowState::Ready, + TicketWorkflowState::Queued, + TicketWorkflowState::InProgress, + TicketWorkflowState::Done, + TicketWorkflowState::Closed, + ] + .into_iter() + .enumerate() + { + let mut new = NewTicket::new(format!("ticket {i}")); + new.workflow_state = Some(state); + new.labels = vec!["legacy label".into()]; + new.queued_by = Some("user".into()); + new.queued_at = Some("yesterday".into()); + let id = backend.create(new).unwrap().id; + let before = backend.show(id.clone().into()).unwrap(); + backend + .set_ticket_tags( + id.clone().into(), + set_request(&format!("set-{i}"), &[], std::slice::from_ref(&tag)), + ) + .unwrap(); + let after = backend.show(id.clone().into()).unwrap(); + assert_eq!(before.meta, after.meta); + assert_eq!(before.document, after.document); + assert_eq!(before.resolution, after.resolution); + assert_eq!( + ticket_content_digest(&before), + ticket_content_digest(&after) + ); + assert_eq!(after.tags, vec![tag.clone()]); + let summary = backend + .list(TicketListQuery::all()) + .unwrap() + .into_iter() + .find(|s| s.id == id) + .unwrap(); + assert_eq!(summary.tags, after.tags); + let projection = backend.list_workspace_projection(200).unwrap(); + assert_eq!( + projection + .items + .iter() + .find(|s| s.summary.id == id) + .unwrap() + .summary + .tags, + after.tags + ); + let page = backend + .list_workspace_projection_page(SqliteTicketListPageQuery { + limit: 200, + ..Default::default() + }) + .unwrap(); + assert_eq!( + page.items + .iter() + .find(|s| s.summary.id == id) + .unwrap() + .summary + .tags, + after.tags + ); + } +} + +#[test] +fn tag_list_has_bounded_id_keyset_order_and_excludes_deleted() { + let dir = tempdir().unwrap(); + let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap(); + // Fixture insertion avoids spending this query test on 205 mutation transactions. + backend + .with_write(|conn| { + for i in 0..205 { + conn.execute( + "INSERT INTO workspace_tags VALUES ('ws',?1,?1,?1,'fixture',0)", + params![format!("tag-{i:03}")], + ) + .map_err(sqlite_err)?; + } + Ok(()) + }) + .unwrap(); + let first = backend + .list_workspace_tags(TagListRequest { + after: None, + limit: Some(usize::MAX), + }) + .unwrap(); + assert_eq!(first.len(), 200); + assert_eq!(first[0].id, "tag-000"); + assert_eq!(first[199].id, "tag-199"); + assert_eq!( + backend + .list_workspace_tags(TagListRequest::default()) + .unwrap(), + first + ); + backend + .mutate_workspace_tag(delete_request("delete", &first[0])) + .unwrap(); + let tail = backend + .list_workspace_tags(TagListRequest { + after: Some("tag-199".into()), + limit: Some(200), + }) + .unwrap(); + assert_eq!(tail.len(), 5); + assert!( + backend + .list_workspace_tags(TagListRequest { + after: None, + limit: Some(0) + }) + .unwrap() + .is_empty() + ); + assert!( + backend + .list_workspace_tags(TagListRequest { + after: None, + limit: Some(1) + }) + .unwrap() + .iter() + .all(|t| t.id == "tag-001") + ); +} + +#[test] +fn failed_tag_mutation_hook_rolls_back_links_tombstone_audit_and_receipt() { + let dir = tempdir().unwrap(); + let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap(); + let tag = create_tag(&backend, "create", "Tag"); + let id = backend.create(NewTicket::new("ticket")).unwrap().id; + let failing = backend.clone().with_mutation_hook(Arc::new(|_, _| { + Err(TicketError::Conflict("test hook".into())) + })); + let attach = set_request("attach", &[], std::slice::from_ref(&tag)); + assert!( + failing + .set_ticket_tags(id.clone().into(), attach.clone()) + .is_err() + ); + assert!(backend.show(id.clone().into()).unwrap().tags.is_empty()); + assert_eq!(audit_count(&backend), 1); + backend.set_ticket_tags(id.clone().into(), attach).unwrap(); + let before = backend.show(id.clone().into()).unwrap(); + let deletion = delete_request("delete", &tag); + assert!(failing.mutate_workspace_tag(deletion.clone()).is_err()); + assert_eq!(backend.show(id.into()).unwrap(), before); + assert_eq!( + backend + .list_workspace_tags(TagListRequest::default()) + .unwrap(), + vec![tag] + ); + assert_eq!(audit_count(&backend), 2); + backend.mutate_workspace_tag(deletion).unwrap(); + assert_eq!(audit_count(&backend), 3); +} + +#[test] +fn concurrent_tag_replay_and_delete_attach_are_serialized() { + use std::sync::Barrier; + use std::thread; + let dir = tempdir().unwrap(); + let db = dir.path().join("tags.db"); + let backend = SqliteTicketBackend::open(&db, "ws").unwrap(); + let barrier = Arc::new(Barrier::new(3)); + let mut joins = Vec::new(); + for _ in 0..2 { + let backend = backend.clone(); + let barrier = barrier.clone(); + joins.push(thread::spawn(move || { + barrier.wait(); + create_tag(&backend, "create", "Tag") + })); + } + barrier.wait(); + let tag = joins.remove(0).join().unwrap(); + assert_eq!(joins.remove(0).join().unwrap(), tag); + assert_eq!(audit_count(&backend), 1); + let id = backend.create(NewTicket::new("ticket")).unwrap().id; + let barrier = Arc::new(Barrier::new(3)); + let attach = { + let backend = backend.clone(); + let barrier = barrier.clone(); + let tag = tag.clone(); + let id = id.clone(); + thread::spawn(move || { + barrier.wait(); + backend.set_ticket_tags(id.into(), set_request("attach", &[], &[tag])) + }) + }; + let delete = { + let backend = backend.clone(); + let barrier = barrier.clone(); + let tag = tag.clone(); + thread::spawn(move || { + barrier.wait(); + backend.mutate_workspace_tag(delete_request("delete", &tag)) + }) + }; + barrier.wait(); + let attached = attach.join().unwrap(); + assert!(delete.join().unwrap().unwrap().deleted); + assert!(backend.show(id.into()).unwrap().tags.is_empty()); + assert!( + backend + .list_workspace_tags(TagListRequest::default()) + .unwrap() + .is_empty() + ); + assert_eq!(audit_count(&backend), if attached.is_ok() { 3 } else { 2 }); + let links: i64 = backend + .open_connection() + .unwrap() + .query_row("SELECT COUNT(*) FROM typed_ticket_tags", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(links, 0); +} + +#[test] +fn ticket_tag_receipts_bind_selector_and_operation_kind_and_replay_before_assignment_fence() { + let dir = tempdir().unwrap(); + let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap(); + let tag = create_tag(&backend, "create", "Tag"); + let id = backend.create(NewTicket::new("ticket")).unwrap().id; + let request = set_request("attach", &[], std::slice::from_ref(&tag)); + let mut reused = request.clone(); + reused.operation_key = "create".into(); + assert!(matches!( + backend.set_ticket_tags(id.clone().into(), reused), + Err(TicketError::OperationFingerprintMismatch { .. }) + )); + let conn = backend.open_connection().unwrap(); + conn.execute_batch("CREATE TABLE ticket_active_worker_assignments (workspace_id TEXT,ticket_id TEXT,runtime_id TEXT,worker_id TEXT,assignment_id TEXT);").unwrap(); + conn.execute("INSERT INTO ticket_active_worker_assignments VALUES ('ws',?1,'runtime','worker','assignment')",params![id]).unwrap(); + let worker = backend.clone().with_event_attributes(BTreeMap::from([ + ("source_actor_role".into(), "ticket_worker".into()), + ("source_runtime_id".into(), "runtime".into()), + ("source_worker_id".into(), "worker".into()), + ("source_assignment_id".into(), "assignment".into()), + ])); + let stored = worker + .set_ticket_tags(TicketIdOrSlug::Id(id.clone()), request.clone()) + .unwrap(); + conn.execute("DELETE FROM ticket_active_worker_assignments", []) + .unwrap(); + assert_eq!( + worker + .set_ticket_tags(TicketIdOrSlug::Id(id.clone()), request.clone()) + .unwrap(), + stored + ); + assert!(matches!( + worker.set_ticket_tags(TicketIdOrSlug::Query(id.clone()), request), + Err(TicketError::OperationFingerprintMismatch { .. }) + )); + assert!( + worker + .set_ticket_tags( + id.clone().into(), + set_request("new-operation", &stored, &[]) + ) + .is_err() + ); + assert_eq!(backend.show(id.clone().into()).unwrap().tags, stored); + backend + .mutate_workspace_tag(delete_request("delete", &tag)) + .unwrap(); + assert!( + backend + .set_ticket_tags( + id.into(), + set_request("deleted-attach", &[], std::slice::from_ref(&tag)) + ) + .is_err() + ); + assert_eq!(audit_count(&backend), 3); +} + +#[test] +fn tag_domain_dispatch_and_wire_contracts_are_exact() { + let dir = tempdir().unwrap(); + let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap(); + let request = WorkspaceTagMutation { + operation_key: "create".into(), + action: WorkspaceTagAction::Create { + name: "Name".into(), + }, + }; + assert_eq!( + serde_json::to_value(&request).unwrap(), + serde_json::json!({"operation_key":"create","action":{"kind":"create","name":"Name"}}) + ); + let operation = TicketBackendOperation::MutateWorkspaceTag { request }; + let operation = serde_json::from_value(serde_json::to_value(operation).unwrap()).unwrap(); + let TicketBackendOperationResult::WorkspaceTag(tag) = + execute_ticket_backend_operation(&backend, operation).unwrap() + else { + panic!("tag result") + }; + let id = backend.create(NewTicket::new("ticket")).unwrap().id; + let result = execute_ticket_backend_operation( + &backend, + TicketBackendOperation::SetTicketTags { + id: id.clone().into(), + request: set_request("set", &[], std::slice::from_ref(&tag)), + }, + ) + .unwrap(); + assert!( + matches!(result,TicketBackendOperationResult::WorkspaceTags(tags) if tags==vec![tag.clone()]) + ); + let result = execute_ticket_backend_operation( + &backend, + TicketBackendOperation::ListWorkspaceTags { + request: TagListRequest::default(), + }, + ) + .unwrap(); + assert!(matches!(result,TicketBackendOperationResult::WorkspaceTags(tags) if tags==vec![tag])); + let ticket = backend.show(id.into()).unwrap(); + let mut wire = serde_json::to_value(&ticket).unwrap(); + wire.as_object_mut().unwrap().remove("tags"); + assert!( + serde_json::from_value::(wire) + .unwrap() + .tags + .is_empty() + ); + let summary = backend.list(TicketListQuery::all()).unwrap().remove(0); + let mut wire = serde_json::to_value(summary).unwrap(); + wire.as_object_mut().unwrap().remove("tags"); + assert!( + serde_json::from_value::(wire) + .unwrap() + .tags + .is_empty() + ); + let schema = schemars::schema_for!(WorkspaceTagAction); + assert!( + serde_json::to_string(&schema) + .unwrap() + .contains("expected_last_operation_key") + ); +} + +#[test] +fn unchanged_tag_set_records_receipt_without_claiming_a_membership_change() { + let dir = tempdir().unwrap(); + let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap(); + let id = backend.create(NewTicket::new("ticket")).unwrap().id; + let before = backend.show(id.clone().into()).unwrap(); + let request = set_request("noop", &[], &[]); + assert!( + backend + .set_ticket_tags(id.clone().into(), request.clone()) + .unwrap() + .is_empty() + ); + assert!( + backend + .set_ticket_tags(id.clone().into(), request) + .unwrap() + .is_empty() + ); + assert_eq!( + backend.show(id.clone().into()).unwrap().events, + before.events + ); + assert_eq!(audit_count(&backend), 1); + for request in [ + TicketTagSetRequest { + expected_tag_ids: vec!["x".into(); 101], + tag_ids: vec![], + operation_key: "large-observation".into(), + }, + TicketTagSetRequest { + expected_tag_ids: vec![], + tag_ids: (0..101).map(|i| format!("tag-{i}")).collect(), + operation_key: "large-desired".into(), + }, + TicketTagSetRequest { + expected_tag_ids: vec![], + tag_ids: vec!["x".repeat(129)], + operation_key: "large-id".into(), + }, + ] { + assert!(backend.set_ticket_tags(id.clone().into(), request).is_err()); + assert_eq!( + backend.show(id.clone().into()).unwrap().events, + before.events + ); + assert_eq!(audit_count(&backend), 1); + } +} + +#[test] +fn tag_receipt_preserves_original_context_but_reassignment_does_not_change_request_identity() { + let dir = tempdir().unwrap(); + let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap(); + let original_context = BTreeMap::from([ + ("source_runtime_id".into(), "runtime".into()), + ("source_worker_id".into(), "worker".into()), + ("source_actor_role".into(), "ticket_worker".into()), + ("source_assignment_id".into(), "assignment-a".into()), + ]); + let original = backend + .clone() + .with_event_attributes(original_context.clone()); + let request = WorkspaceTagMutation { + operation_key: "create".into(), + action: WorkspaceTagAction::Create { + name: "ready".into(), + }, + }; + let tag = original.mutate_workspace_tag(request.clone()).unwrap(); + let reassigned = backend.clone().with_event_attributes(BTreeMap::from([ + ("source_runtime_id".into(), "runtime".into()), + ("source_worker_id".into(), "worker".into()), + ("source_actor_role".into(), "contributor".into()), + ])); + assert_eq!(reassigned.mutate_workspace_tag(request).unwrap(), tag); + let stored: String = backend.with_read(|conn| conn.query_row("SELECT source_context_json FROM workspace_tag_operations WHERE workspace_id='ws' AND operation_key='create'", [], |row| row.get(0)).map_err(sqlite_err)).unwrap(); + assert_eq!( + serde_json::from_str::>(&stored).unwrap(), + original_context + ); + assert_eq!(audit_count(&backend), 1); +} diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index 7b5a2f310..431b0d376 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -249,6 +249,28 @@ impl From for TicketToolBackend { } impl TicketBackend for TicketToolBackend { + fn list_workspace_tags( + &self, + request: crate::TagListRequest, + ) -> TicketResult> { + self.backend.list_workspace_tags(request) + } + + fn mutate_workspace_tag( + &self, + request: crate::WorkspaceTagMutation, + ) -> TicketResult { + self.backend.mutate_workspace_tag(request) + } + + fn set_ticket_tags( + &self, + id: TicketIdOrSlug, + request: crate::TicketTagSetRequest, + ) -> TicketResult> { + self.backend.set_ticket_tags(id, request) + } + fn default_intake_ready_state_change_body(&self, from: &str) -> String { self.backend.default_intake_ready_state_change_body(from) } diff --git a/crates/worker/src/feature/builtin/resource_projection.rs b/crates/worker/src/feature/builtin/resource_projection.rs index e249066b4..65e731ec4 100644 --- a/crates/worker/src/feature/builtin/resource_projection.rs +++ b/crates/worker/src/feature/builtin/resource_projection.rs @@ -14,6 +14,7 @@ struct ModelTicketQueryItem { ticket: String, title: String, state: String, + tags: Vec, readiness: Option, priority: Option, created_at: Option, @@ -39,6 +40,7 @@ pub(super) struct ModelTicketDetail { title: String, body: String, state: String, + tags: Vec, readiness: Option, priority: Option, created_at: Option, @@ -94,8 +96,16 @@ struct ModelWorkerSummary { worker: String, } +#[derive(Debug, Serialize)] +struct ModelTicketTagChange { + before: Vec, + after: Vec, +} + #[derive(Debug, Serialize)] struct ModelTicketEvent { + #[serde(skip_serializing_if = "Option::is_none")] + tag_changes: Option, sequence: usize, kind: String, body: Option, @@ -236,6 +246,7 @@ fn project_ticket_query_item(value: &Value) -> Result Result Result { fn project_ticket_event(value: &Value) -> Result { 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, 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) -> Result, 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, 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() + ); + } } diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index 706580a51..4994bfea8 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -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, relation_kind: Option, + /// All stable tag IDs must be attached (AND); at most 100 unique IDs. + #[serde(default)] + #[schemars(length(max = 100))] + tag_ids: Vec, + /// At least one stable tag ID must be attached (OR), unless empty. + #[serde(default)] + #[schemars(length(max = 100))] + tag_ids_any: Vec, + /// None of these stable tag IDs may be attached (NOT). + #[serde(default)] + #[schemars(length(max = 100))] + tag_ids_none: Vec, /// Linked Objective reference. Prefer `O-*`; canonical internal ids remain accepted for compatibility. linked_objective_id: Option, updated_after: Option, @@ -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 { 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::( client, @@ -1332,6 +1393,35 @@ macro_rules! expect_ticket_result { } impl TicketBackend for WorkspaceHttpTicketBackend { + fn list_workspace_tags( + &self, + request: ticket::TagListRequest, + ) -> TicketResult> { + expect_ticket_result!( + self.invoke(TicketBackendOperation::ListWorkspaceTags { request }), + TicketBackendOperationResult::WorkspaceTags + ) + } + fn mutate_workspace_tag( + &self, + request: ticket::WorkspaceTagMutation, + ) -> TicketResult { + expect_ticket_result!( + self.invoke(TicketBackendOperation::MutateWorkspaceTag { request }), + TicketBackendOperationResult::WorkspaceTag + ) + } + fn set_ticket_tags( + &self, + id: TicketIdOrSlug, + request: ticket::TicketTagSetRequest, + ) -> TicketResult> { + 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 { 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::>(), - ["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")); diff --git a/crates/worker/src/feature/builtin/ticket/tags.rs b/crates/worker/src/feature/builtin/ticket/tags.rs new file mode 100644 index 000000000..46f33cfbb --- /dev/null +++ b/crates/worker/src/feature/builtin/ticket/tags.rs @@ -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, + /// Maximum results, 0..=200; defaults to 200. + #[schemars(range(max = 200))] + limit: Option, +} +#[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, + /// Desired entire ID set. Attach/remove multiple tags atomically; [] removes all. + #[schemars(length(max = 100))] + tag_ids: Vec, + 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(input: &str) -> Result { + serde_json::from_str(input).map_err(|error| ToolError::InvalidArgument(error.to_string())) +} + +#[derive(Clone)] +struct TagTool { + client: Arc, + kind: TagToolKind, +} + +#[async_trait] +impl Tool for TagTool { + async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result { + 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, 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, + ) + }) +} diff --git a/crates/worker/src/feature/builtin/ticket/tags/tests.rs b/crates/worker/src/feature/builtin/ticket/tags/tests.rs new file mode 100644 index 000000000..bb29d58c5 --- /dev/null +++ b/crates/worker/src/feature/builtin/ticket/tags/tests.rs @@ -0,0 +1,526 @@ +use super::*; +use crate::worker::{WorkspaceClientError, WorkspaceResponse}; + +#[derive(Debug)] +struct RecordingClient { + requests: Mutex>, + 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 { + self.requests.lock().unwrap().push(request); + Ok(WorkspaceResponse { + status: self.status, + body: self.body.clone(), + }) + } +} +fn recording(body: Value) -> Arc { + 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::>()}); + 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::>()), + ] { + 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::().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 = 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 = + 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" + ); +} diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index 8f8001af2..d8234a1ed 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -391,6 +391,21 @@ impl SqliteWorkspaceAuthority { .collect::>(); predicates.push(format!("t.workflow_state IN ({})", states.join(","))); } + for id in &query.tag_ids { + let id = bind(SqlValue::Text(id.clone())); + predicates.push(format!("EXISTS (SELECT 1 FROM typed_ticket_tags l JOIN workspace_tags tag ON tag.workspace_id=l.workspace_id AND tag.tag_id=l.tag_id WHERE l.workspace_id=t.workspace_id AND l.ticket_id=t.ticket_id AND l.tag_id={id} AND tag.deleted=0)")); + } + for (ids, negate) in [(&query.tag_ids_any, false), (&query.tag_ids_none, true)] { + if !ids.is_empty() { + let ids = ids + .iter() + .map(|id| bind(SqlValue::Text(id.clone()))) + .collect::>() + .join(","); + let not = if negate { "NOT " } else { "" }; + predicates.push(format!("{not}EXISTS (SELECT 1 FROM typed_ticket_tags l JOIN workspace_tags tag ON tag.workspace_id=l.workspace_id AND tag.tag_id=l.tag_id WHERE l.workspace_id=t.workspace_id AND l.ticket_id=t.ticket_id AND l.tag_id IN ({ids}) AND tag.deleted=0)")); + } + } if let Some(text) = query.query.as_deref().filter(|text| !text.is_empty()) { let pattern = bind(SqlValue::Text(format!("%{}%", text.to_lowercase()))); predicates.push(format!( @@ -926,6 +941,7 @@ impl SqliteWorkspaceAuthority { queued_at: ticket.meta.queued_at, targets, risk_flags: ticket.meta.risk_flags, + tags: ticket.tags, body, body_truncated, event_count: ticket.events.len(), @@ -982,6 +998,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority { state: item.summary.workflow_state.as_str().to_string(), priority: item.summary.priority, updated_at: item.summary.updated_at, + tags: item.summary.tags, queued_by: item.summary.queued_by, queued_at: item.summary.queued_at, workspace_action_priority: workspace_action_priority_name(projection.priority) @@ -1945,6 +1962,13 @@ fn ticket_evidence_summary( } fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> { + for ids in [&query.tag_ids, &query.tag_ids_any, &query.tag_ids_none] { + if ids.len() > 100 || ids.iter().any(|id| id.is_empty() || id.len() > 128) { + return Err(Error::InvalidInput( + "tag filters require at most 100 nonempty stable IDs of at most 128 bytes".into(), + )); + } + } for state in &query.states { if ticket::TicketWorkflowState::parse(state).is_none() { return Err(Error::InvalidRecordId(format!( @@ -2161,6 +2185,13 @@ fn ticket_matches_query( authoritative_events: &[TicketEvent], query: &TicketQueryRequest, ) -> bool { + let has_tag = |id: &String| detail.tags.iter().any(|tag| &tag.id == id); + if !query.tag_ids.iter().all(has_tag) + || (!query.tag_ids_any.is_empty() && !query.tag_ids_any.iter().any(has_tag)) + || query.tag_ids_none.iter().any(has_tag) + { + return false; + } if !query.states.is_empty() && !query.states.iter().any(|state| state == &summary.state) { return false; } @@ -2319,6 +2350,7 @@ fn ticket_query_item( title: summary.title, state: summary.state, readiness: detail.readiness.clone(), + tags: detail.tags.clone(), priority: summary.priority, created_at: detail.created_at.clone(), updated_at: summary.updated_at, @@ -2675,6 +2707,7 @@ fn ticket_summary_from_ticket(ticket: &ticket::Ticket) -> Result kind: ticket.meta.kind.clone(), priority: ticket.meta.priority.clone(), labels: ticket.meta.labels.clone(), + tags: ticket.tags.clone(), readiness: ticket.meta.readiness.clone(), workflow_state: ticket.meta.workflow_state, workflow_state_explicit: ticket.meta.workflow_state_explicit, @@ -2702,6 +2735,7 @@ fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> Result Rou .merge(server_api::server_api_axum::ticket_close_record( service.clone(), )) + .merge(server_api::server_api_axum::workspace_ticket_tag_list( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_ticket_tag_mutate( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_tags_set( + service.clone(), + )) .merge(server_api::server_api_axum::ticket_relation_view( service.clone(), )) @@ -10410,6 +10419,81 @@ impl server_api::ServerApi for ServerApiContractService { Ok(()) } + async fn workspace_ticket_tag_list( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::WorkspaceTicketTagListRequest, + ) -> std::result::Result + { + let result = execute_ticket_rest_operation_with_actor( + self.workspace_api()?, + &workspace_id, + contract_request_headers(&context)?, + context.actor.as_ref(), + TicketBackendOperation::ListWorkspaceTags { request: request.0 }, + ) + .await + .map_err(ApiError::into_repository_api_error)?; + let Json(value) = ticket_rest_result(result, |result| match result { + TicketBackendOperationResult::WorkspaceTags(value) => Some(value), + _ => None, + }) + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::WorkspaceTicketTagList(value)) + } + + async fn workspace_ticket_tag_mutate( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::WorkspaceTicketTagMutationRequest, + ) -> std::result::Result { + let result = execute_ticket_rest_operation_with_actor( + self.workspace_api()?, + &workspace_id, + contract_request_headers(&context)?, + context.actor.as_ref(), + TicketBackendOperation::MutateWorkspaceTag { request: request.0 }, + ) + .await + .map_err(ApiError::into_repository_api_error)?; + let Json(value) = ticket_rest_result(result, |result| match result { + TicketBackendOperationResult::WorkspaceTag(value) => Some(value), + _ => None, + }) + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::WorkspaceTicketTag(value)) + } + + async fn ticket_tags_set( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + id: String, + request: server_api::TicketTagsSetRequest, + ) -> std::result::Result + { + let result = execute_ticket_rest_operation_with_actor( + self.workspace_api()?, + &workspace_id, + contract_request_headers(&context)?, + context.actor.as_ref(), + TicketBackendOperation::SetTicketTags { + id: TicketIdOrSlug::Query(id), + request: request.0, + }, + ) + .await + .map_err(ApiError::into_repository_api_error)?; + let Json(value) = ticket_rest_result(result, |result| match result { + TicketBackendOperationResult::WorkspaceTags(value) => Some(value), + _ => None, + }) + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::WorkspaceTicketTagList(value)) + } + async fn ticket_relation_view( &self, context: server_api::ServerRequestContext, @@ -14606,6 +14690,16 @@ async fn execute_ticket_rest_operation( api: &WorkspaceApi, workspace_id: &str, headers: HeaderMap, + operation: TicketBackendOperation, +) -> ApiResult { + execute_ticket_rest_operation_with_actor(api, workspace_id, headers, None, operation).await +} + +async fn execute_ticket_rest_operation_with_actor( + api: &WorkspaceApi, + workspace_id: &str, + headers: HeaderMap, + actor: Option<&RequestActor>, mut operation: TicketBackendOperation, ) -> ApiResult { validate_workspace_scope(api, workspace_id)?; @@ -14654,6 +14748,12 @@ async fn execute_ticket_rest_operation( .map(|ticket| ticket.meta.workflow_state.as_str().to_string()) .unwrap_or_else(|| ticket_operation_initial_state(&operation)); let mut event_attributes = BTreeMap::new(); + if source.is_none() + && let Some(actor) = actor + { + event_attributes.insert("source_account_id".into(), actor.account_id.clone()); + event_attributes.insert("source_user_id".into(), actor.user_id.clone()); + } if matches!(operation, TicketBackendOperation::QueueReady { .. }) { let ticket = before.as_ref().ok_or_else(|| { Error::TicketAssignmentConflict( @@ -14784,7 +14884,8 @@ async fn execute_ticket_rest_operation( None, ), }; - if is_mutation { + // Classification has no routing or execution side effects. + if is_mutation && !matches!(operation_kind, "mutate_workspace_tag" | "set_ticket_tags") { if let TicketBackendOperationResult::QueueOutcome(outcome) = &result { for ticket_id in &outcome.queued_tickets { notify_ticket_recipients( @@ -16948,6 +17049,7 @@ fn ticket_mutation_target(operation: &TicketBackendOperation) -> Option<&TicketI | TicketBackendOperation::MarkReady { id, .. } | TicketBackendOperation::QueueReady { id, .. } | TicketBackendOperation::Close { id, .. } + | TicketBackendOperation::SetTicketTags { id, .. } | TicketBackendOperation::AddTicketRelation { id, .. } | TicketBackendOperation::RemoveTicketRelation { id, .. } | TicketBackendOperation::AddOrchestrationPlanRecord { id, .. } => Some(id), @@ -17015,6 +17117,8 @@ fn ticket_mutation_operation_kind(operation: &TicketBackendOperation) -> &'stati TicketBackendOperation::MarkReady { .. } => "mark_ready", TicketBackendOperation::QueueReady { .. } => "queue_ready", TicketBackendOperation::Close { .. } => "close", + TicketBackendOperation::MutateWorkspaceTag { .. } => "mutate_workspace_tag", + TicketBackendOperation::SetTicketTags { .. } => "set_ticket_tags", TicketBackendOperation::AddTicketRelation { .. } => "add_relation", TicketBackendOperation::RemoveTicketRelation { .. } => "remove_relation", TicketBackendOperation::AddOrchestrationPlanRecord { .. } => "add_plan_record", @@ -33006,6 +33110,10 @@ impl IntoResponse for ApiError { #[cfg(test)] mod tests { + mod ticket_tags_tests { + include!("server_ticket_tags_tests.rs"); + } + mod api_logging_tests; mod auth_logging_tests; mod drive_api_tests; diff --git a/crates/workspace-server/src/server_ticket_tags_tests.rs b/crates/workspace-server/src/server_ticket_tags_tests.rs new file mode 100644 index 000000000..7a9393cca --- /dev/null +++ b/crates/workspace-server/src/server_ticket_tags_tests.rs @@ -0,0 +1,139 @@ +use super::*; + +#[tokio::test] +async fn ticket_tags_round_trip_filter_and_preserve_execution_and_decision_projections() { + let dir = tempfile::tempdir().unwrap(); + let (api, execution) = test_api_with_recording_backend(dir.path()).await; + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend.create(ticket::NewTicket::new("Classification only")).unwrap(); + let other = backend.create(ticket::NewTicket::new("Other ticket")).unwrap(); + let app = build_inner_router(api.clone()); + let tags_path = format!("/api/w/{TEST_WORKSPACE_ID}/ticket-tags"); + let ticket_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}", reference.id); + let before = get_json(app.clone(), &ticket_path).await; + let ready = post_json(app.clone(), &tags_path, json!({ + "operation_key":"create-ready", "action":{"kind":"create", "name":" ready "} + })).await; + let bug = post_json(app.clone(), &tags_path, json!({ + "operation_key":"create-bug", "action":{"kind":"create", "name":"Bug"} + })).await; + let ready_id = ready["id"].as_str().unwrap(); + let bug_id = bug["id"].as_str().unwrap(); + let assignment = json!({"operation_key":"classify", "expected_tag_ids":[], "tag_ids":[ready_id,bug_id]}); + let attached = request_json(app.clone(), "PUT", &format!("{ticket_path}/tags"), Some(assignment.clone()), StatusCode::OK).await; + assert_eq!(attached.as_array().unwrap().len(), 2); + let replay = request_json(app.clone(), "PUT", &format!("{ticket_path}/tags"), Some(assignment), StatusCode::OK).await; + assert_eq!(attached, replay); + let after = get_json(app.clone(), &ticket_path).await; + for field in ["state", "resolution", "content_digest", "current_worker", "assignments", "action_eligibility", "targets", "evidence", "merge_requests", "queued_by", "queued_at"] { + assert_eq!(before[field], after[field], "classification changed {field}"); + } + assert_eq!(after["tags"].as_array().unwrap().len(), 2); + assert!(execution.protocol_methods().is_empty()); + let list = get_json(app.clone(), &format!("/api/w/{TEST_WORKSPACE_ID}/tickets")).await; + let item = list["items"].as_array().unwrap().iter().find(|item| item["id"] == reference.id).unwrap(); + assert_eq!(item["tags"].as_array().unwrap().len(), 2); + let query_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/query"); + for (filter, count) in [ + (json!({"tag_ids":[ready_id,bug_id]}), 1), + (json!({"tag_ids_any":[ready_id,"absent"]}), 1), + (json!({"tag_ids_none":[ready_id]}), 1), + (json!({"tag_ids":[ready_id,"absent"]}), 0), + ] { + let result = post_json(app.clone(), &query_path, filter.clone()).await; + assert_eq!(result["items"].as_array().unwrap().len(), count, "filter {filter}"); + if count == 1 && filter.get("tag_ids_none").is_none() { + assert_eq!(result["items"][0]["tags"].as_array().unwrap().len(), 2); + } else if filter.get("tag_ids_none").is_some() { + assert_eq!(result["items"][0]["id"], other.id); + } + } + let renamed = post_json(app.clone(), &tags_path, json!({ + "operation_key":"rename", "action":{"kind":"rename", "tag_id":bug_id, + "expected_last_operation_key":"create-bug", "name":"Defect"} + })).await; + assert_eq!(renamed["id"], bug["id"]); + let shown = get_json(app.clone(), &ticket_path).await; + assert!(shown["tags"].as_array().unwrap().iter().any(|tag| tag["name"] == "Defect")); + request_json(app.clone(), "POST", &tags_path, Some(json!({ + "operation_key":"stale-rename", "action":{"kind":"rename", "tag_id":bug_id, + "expected_last_operation_key":"create-bug", "name":"Wrong"} + })), StatusCode::CONFLICT).await; + post_json(app.clone(), &tags_path, json!({ + "operation_key":"delete", "action":{"kind":"delete", "tag_id":bug_id, + "expected_last_operation_key":"rename"} + })).await; + let deleted = get_json(app.clone(), &ticket_path).await; + assert_eq!(deleted["tags"].as_array().unwrap().len(), 1); + request_json(app.clone(), "PUT", &format!("{ticket_path}/tags"), Some(json!({ + "operation_key":"stale-set", "expected_tag_ids":[ready_id,bug_id], "tag_ids":[] + })), StatusCode::CONFLICT).await; + let untagged = request_json(app.clone(), "PUT", &format!("{ticket_path}/tags"), Some(json!({ + "operation_key":"detach", "expected_tag_ids":[ready_id], "tag_ids":[] + })), StatusCode::OK).await; + assert_eq!(untagged, json!([])); + let final_detail = get_json(app, &ticket_path).await; + assert_eq!(final_detail["state"], before["state"]); + assert_eq!(final_detail["content_digest"], before["content_digest"]); +} + +#[tokio::test] +async fn ticket_tag_routes_require_workspace_authority_and_reject_forged_worker_source() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let owner_token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); + let collaborator_token = seed_test_api_token(api.store.as_ref(), "tags-collaborator"); + let app = build_router(api.clone()); + let path = format!("/api/w/{TEST_WORKSPACE_ID}/ticket-tags"); + let input = json!({"operation_key":"authorized", "action":{"kind":"create", "name":"ready"}}); + request_json(app.clone(), "POST", &path, Some(input.clone()), StatusCode::UNAUTHORIZED).await; + + let tag = request_json_authenticated(app.clone(), "POST", &path, Some(input), &owner_token, StatusCode::OK).await; + assert_eq!(tag["workspace_id"], TEST_WORKSPACE_ID); + // Classification follows shared Ticket Workspace write authority, not the + // owner-only repository/secrets-management policy. A distinct authenticated + // actor may manage tags but cannot replay another actor's receipt. + request_json_authenticated(app.clone(), "POST", &path, Some(json!({"operation_key":"authorized", "action":{"kind":"create", "name":"ready"}})), &collaborator_token, StatusCode::CONFLICT).await; + request_json_authenticated(app.clone(), "POST", "/api/w/other-workspace/ticket-tags", Some(json!({"operation_key":"foreign", "action":{"kind":"create", "name":"foreign"}})), &collaborator_token, StatusCode::NOT_FOUND).await; + let response = app.oneshot(Request::builder().method("POST").uri(&path) + .header(axum::http::header::AUTHORIZATION, format!("Bearer {owner_token}")) + .header("x-yoi-runtime-id", "untrusted-runtime") + .header("x-yoi-worker-id", "untrusted-worker") + .header(CONTENT_TYPE,"application/json") + .body(Body::from(json!({"operation_key":"forged", "action":{"kind":"create", "name":"forged"}}).to_string())).unwrap()).await.unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(status, StatusCode::BAD_REQUEST, "{}", String::from_utf8_lossy(&body)); + let tags = browser_ticket_backend(&api).unwrap().list_workspace_tags(ticket::TagListRequest::default()).unwrap(); + assert_eq!(tags.len(), 1); +} + +#[tokio::test] +async fn tag_query_filters_precede_bounded_candidate_scan_and_bind_cursors() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let backend = browser_ticket_backend(&api).unwrap(); + for i in 0..5 { backend.create(ticket::NewTicket::new(format!("a unrelated {i}"))).unwrap(); } + let tag = backend.mutate_workspace_tag(ticket::WorkspaceTagMutation { + operation_key: "create".into(), action: ticket::WorkspaceTagAction::Create { name: "bug".into() }, + }).unwrap(); + for i in 0..3 { + let id = backend.create(ticket::NewTicket::new(format!("z tagged {i}"))).unwrap().id; + backend.set_ticket_tags(id.into(), ticket::TicketTagSetRequest { + expected_tag_ids: vec![], tag_ids: vec![tag.id.clone()], operation_key: format!("set-{i}"), + }).unwrap(); + } + let app = build_inner_router(api); + let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/query"); + let mut input = json!({"tag_ids":[tag.id], "sort":"title", "limit":1}); + for i in 0..3 { + let page = post_json(app.clone(), &path, input.clone()).await; + assert_eq!(page["items"].as_array().unwrap().len(),1); + assert_eq!(page["items"][0]["title"], format!("z tagged {i}")); + if i < 2 { + let cursor = page["page"]["next_cursor"].clone(); + request_json(app.clone(), "POST", &path, Some(json!({"tag_ids_any":[tag.id], "sort":"title", "limit":1, "cursor":cursor})), StatusCode::NOT_FOUND).await; + input["cursor"] = cursor; + } else { assert_eq!(page["page"]["has_more"], false); } + } +} diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 3db49cc5d..bffe6f466 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -13512,7 +13512,7 @@ fn apply_ticket_targets_and_workdir_capabilities_schema(conn: &Connection) -> Re TICKET_SCHEMA_BASELINE_NAME ], )?; - ticket::verify_sqlite_ticket_schema(db).map_err(|error| { + ticket::verify_pre_classification_ticket_schema(db).map_err(|error| { Error::Store(format!( "schema-66 Ticket schema verification failed: {error}" )) @@ -14896,6 +14896,9 @@ mod tests { } fn downgrade_schema_66_ticket_and_workdir_authority(conn: &Connection) -> Result<()> { + // A retained pre-target fixture must not carry today's classification + // tables while resetting the component migration marker to v6. + conn.execute_batch("DROP TABLE typed_ticket_tags; DROP TABLE workspace_tag_operations; DROP TABLE workspace_tags;")?; conn.execute_batch("DROP TABLE IF EXISTS worker_restore_intents;")?; conn.execute_batch( "DROP TABLE backend_job_deliveries; @@ -15935,7 +15938,7 @@ mod tests { migrate_ticket_targets_and_workdir_capabilities_v65_to_v66(&conn).unwrap(); assert_eq!(current_schema_version(&conn).unwrap(), 66); - ticket::verify_sqlite_ticket_schema(&conn).unwrap(); + ticket::verify_pre_classification_ticket_schema(&conn).unwrap(); assert!(!column_exists(&conn, "typed_tickets", "repository_id").unwrap()); assert!(!column_exists(&conn, "typed_tickets", "ref_selector").unwrap()); assert_eq!( diff --git a/crates/workspace-server/src/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index dfc1b70da..2e6837f62 100644 --- a/crates/workspace-server/src/workspace_deletion.rs +++ b/crates/workspace-server/src/workspace_deletion.rs @@ -62,6 +62,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ "typed_ticket_relations", "typed_ticket_risk_flags", "typed_ticket_targets", + "typed_ticket_tags", "typed_tickets", "workdir_create_credential_candidates", "workdir_create_credential_retentions", @@ -93,6 +94,8 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ "workspace_config_tree_history", "workspace_config_trees", "workspace_create_operations", + "workspace_tag_operations", + "workspace_tags", "workspace_memory_documents", "workspace_memory_settings", "workspace_resource_key_counters", diff --git a/docs/design/workspace-ticket-tags.md b/docs/design/workspace-ticket-tags.md new file mode 100644 index 000000000..ef8fd36ff --- /dev/null +++ b/docs/design/workspace-ticket-tags.md @@ -0,0 +1,49 @@ +# Workspace Ticket classification tags + +T-740 adds classification independently of Ticket state, close judgments, Orchestrator tracking, Worker lifecycle, repository grants, and MR evidence. T-739 owns case-state/close migration; T-743 owns the final Web management UI. + +## Identity and names + +`WorkspaceTag` contains `workspace_id`, immutable `id`, display `name`, `last_operation_key`, and `deleted`. Every operation resolves the ID within the route Workspace, never by display name or an unscoped numeric identity. A rename preserves links; a deleted tag is a tombstone, and recreating its name creates a new identity. + +Names are trimmed with Rust Unicode whitespace trimming, then compared by Unicode lowercase. Display casing is retained. No Unicode canonical/compatibility normalization or locale-specific case folding is implied: canonically equivalent but differently encoded strings can remain distinct. Empty names, control characters, and names longer than 128 UTF-8 bytes are rejected. Active normalized names are unique per Workspace; deleted names are available for reuse. `ready`, `closed`, and other apparent control words are ordinary classification names. + +Legacy `TicketMeta.labels` is not the classification authority and is not reinterpreted or removed by this change. + +## Mutation and observation + +- `GET /api/w/{workspace_id}/ticket-tags`: active tags ordered by stable ID; optional `after` ID and `limit` (bounded to 200). Continue with the last returned ID. A full page is not a promise of another page; continue until exhausted. +- `POST /api/w/{workspace_id}/ticket-tags`: `WorkspaceTagMutation` with `operation_key` and `action` (`kind: create | rename | delete`). Rename/delete require `tag_id` and the observed `expected_last_operation_key`; create/rename require `name`. +- `PUT /api/w/{workspace_id}/tickets/{id}/tags`: `TicketTagSetRequest` with `operation_key`, `expected_tag_ids`, and desired `tag_ids`. Add/remove multiple IDs by computing a desired set from the current detail/list/query projection. This explicit full-set comparison prevents silently overwriting another user's unrelated membership change. IDs are unique sets, not ordered lists for membership comparison; duplicate IDs are rejected. Exact replay retains the original array order as part of the original input. At most 100 active tags may be attached to one Ticket. + +The tag's observed operation key compares the last committed change of that exact identity, including rename-back, before rename/delete. Membership compares the actual current active ID set, not a generic update number, timestamp, body digest, or state. An intervening change which restores exactly the same set is intentionally equivalent: the requested operation depends only on present membership, not on unseen membership history. An observed set containing a subsequently deleted ID is stale and fails, rather than implicitly dropping references. + +SQLite immediate transactions serialize deletion and attachment. Delete atomically tombstones the tag and removes current membership; attachment rejects missing/deleted/foreign-Workspace identities. Neither outcome leaves an active dangling link. Foreign keys preserve Workspace/Ticket/tag identity integrity. + +Operation keys belong to the Workspace classification mutation namespace. Authenticated identity is compared separately from mutable assignment/role context: reassignment does not make an identical receipt into another request, while the original full context remains in the audit. Exact replay compares the operation input, target, and authenticated source identity and returns the original stored response before stale/live checks; it does not repeat audit/event writes. Reusing a key for different input or identity conflicts. A replay is a receipt of the original result, not a fresh observation (its response can contain a subsequently renamed/deleted tag); reread detail/list before editing. Retry an uncertain request with its original key and input. + +## Authorization and history + +Browser and Worker operations use the same Backend tag store and existing Ticket Workspace authorization. The current shared Ticket policy permits authenticated Server users and authorized Runtime-forwarded Workers; tag management does not introduce an owner-only repository/secrets policy or a new membership/grant system. Runtime-forwarded sources are verified using existing source-proof checks, not trusted from arbitrary headers. Authenticated source attribution is retained in audit records; caller-supplied names/roles cannot grant authority. No local filesystem fallback or separate Worker tag namespace is provided. + +Workspace mutations preserve an append-only audit with tag identity and display-name snapshots. Ticket membership changes preserve typed thread events with before/after identity/name snapshots. Deletion preserves tombstones and prior snapshots, so history still means the names and identities observed at the time, not today's display names. Historical events are not rewritten on rename or delete. An unchanged membership set records a receipt without claiming a `tags_changed` thread event. Explicit deletion of the entire Workspace purges its tag catalog, memberships, and receipts through the existing domain purge inventory. + +Classification does not edit the Ticket's editable item content digest, state, close reason, resolution, assignment, or grants; it does not enter routing notifications or start/stop/delegate Workers. Close reasons remain a separate judgment contract even if a UI presents both using similar visual chips. + +## Projections, filtering, and consumers + +Domain `Ticket`/`TicketSummary`, human list/detail/query projections, `ShowTicket`/`QueryTicket`, and typed client responses expose `tags: WorkspaceTag[]`. Deleted tags are absent from current projections but remain in receipts/audit/history. + +Ticket query/search takes stable IDs: + +- `tag_ids`: all IDs must be attached (AND). +- `tag_ids_any`: at least one ID must be attached (OR), unless empty. +- `tag_ids_none`: none may be attached (NOT). + +Nonempty groups combine with AND and with existing text/state/objective/relation/evidence filters. Unknown/deleted/foreign IDs never resolve to another tag: required IDs yield no matches, excluded IDs exclude nothing. Filters are bounded to 100 IDs per group. SQL filtering occurs before bounded candidate selection, then authoritative projections recheck membership. Pagination is not a database snapshot across requests; query-bound cursors cannot be reused with changed filters. + +Tools expose Workspace tag creation/list/rename/delete and Ticket tag-set editing. The WIP native operations use the same tool/client boundary and permissions. OpenAPI is generated from typed ServerApi/domain schemas; browser Ticket types are generated from the canonical OpenAPI document. T-743 should use these public operations and explicit stable IDs, not invent Web-only tag state or depend on tag names for execution policy. + +## Shared integration with T-739 + +Both branches touch Ticket operation enums, summary/detail construction, ServerApi query/detail contracts, tool projections, generated OpenAPI/TypeScript, and Ticket SQLite schema validation. Merge must compose migrations and current schema, not merely pick a higher schema version or overwrite one branch's baseline. Preserve tag tables, receipts, audit rows, tombstones and memberships through case-state migration. Re-run migration/equivalence tests and regenerate public contracts after composition. T-740 itself is buildable on the existing case-state model and does not import T-739's state/close migration. diff --git a/docs/report/T-740-subworker-cwd-scope.md b/docs/report/T-740-subworker-cwd-scope.md new file mode 100644 index 000000000..2fbe12744 --- /dev/null +++ b/docs/report/T-740-subworker-cwd-scope.md @@ -0,0 +1,9 @@ +# T-740: delegated cwd and attachment coordinates + +During T-740, two implementation children were spawned with a read rule for `.` and narrow write rules, plus `cwd: crates/ticket` or `cwd: crates/server-api`. Their committed sessions reported only the narrow crate attachment accessible; `docs/development/rust-testing-strategy.md` and, for the API child, the delegated client/Worker files were inaccessible. The API child correctly stopped rather than trying to bypass the denial. Its partial API changes were inspected and retained. + +The parent had already read the test-design policy and reviewed the delegated tests against it. Remaining client/Worker work was assigned to a new child with `cwd: .`; that child could read the policy and implement its delegated targets. Parent integration and tests verified the actual checkout, not only child summaries. + +This observation does not establish the provider's internal cause. The public `SubWorkerSpawn` description says cwd selects tool working directory, not Workdir/Profile/memory/Ticket roots or additional authority. Narrow cwd plus wider read/delegation rules should have predictable attachment coordinates, or the tool should report that only a restricted attachment can be provided before work starts. An early capability/attachment summary would avoid partial implementations and make mandatory policy-document access verifiable. + +No authority workaround, process stop of the running host, or change to the control-plane tooling is included in T-740. diff --git a/docs/report/T-740-validation.md b/docs/report/T-740-validation.md new file mode 100644 index 000000000..cefcddf3d --- /dev/null +++ b/docs/report/T-740-validation.md @@ -0,0 +1,50 @@ +# T-740 implementation and validation + +## Result + +Classification foundation is implemented independently of case-state migration and the final Web UI. Contract/design and T-739/T-743 integration notes: `docs/design/workspace-ticket-tags.md`. + +Changed boundaries: + +- `ticket`: stable Workspace tag identity, normalized display-name uniqueness, tag catalog mutation/list, transactional multi-membership replacement, observation comparisons, identity-bound exact receipts, historical snapshots, schema v8 migration/verification, and projections. +- `server-api` / `client`: typed scoped routes and client methods, list/detail/query tag fields, AND/OR/NOT ID filters, OpenAPI and browser type generation. +- `workspace-server`: generated route registration, authenticated source attribution, SQL filtering before bounded candidate scans, authoritative projection recheck, suppression of execution/routing notifications, retained pre-classification schema verification and fixture correction, Workspace deletion purge inventory. +- `worker`: normal classification tools, native collection/item WIP operations, query filters, current identities/observation keys, and historical before/after snapshots in model-visible tag-change events. + +## Executed checks + +Tests were designed against `docs/development/rust-testing-strategy.md`. Narrow development checks included: + +- `cargo test -p ticket --lib tags`: 14 tag authority tests passed, including name policy, duplicate/foreign/deleted references, stale observation, exact/incorrect replay, changed actor, assignment-context changes, multi-attach/remove, rollback, concurrent delete/attach and replay, no-op audit semantics, bounded lists/sets, and control-word tags across all existing states. +- `cargo test -p server-api --lib ticket_tag`: 4 passed; with `--features typescript`, 5 passed. +- `cargo test -p worker --lib feature::builtin::ticket::tags`: 7 passed, covering model-visible schemas, permission/authority errors, scoped HTTP operations and WIP binding. +- `cargo test -p worker --lib ticket_tag_history_projection`: passed. +- `cargo test -p yoi-workspace-server --lib ticket_tags_tests`: 3 passed, covering real router mutation/projection/filter, authentication/forged source/other Workspace rejection, actor-bound receipts, no state/content/assignment/grant/execution projection changes, and query pagination/filter-bound cursors. +- `cargo test -p yoi-workspace-server --lib fresh_and_upgraded_state_contract_schemas`: passed, including tag tables/constraints. +- `cargo test -p yoi-workspace-server --lib schema_v65_migrates`: passed after distinguishing retained intermediate schema from current classification schema. +- `cargo test -p yoi-workspace-server --lib explicit_purge_inventory`: passed. + +Full changed-crate command passed: + +```sh +cargo test -p ticket -p server-api -p client -p worker -p yoi-workspace-server --quiet +``` + +Unit suites at that point: client 57, server-api 92, ticket 120, worker 968, workspace-server 814 passed / 1 ignored. All included integration and doc-test targets also passed. Worker was subsequently rerun after adding the historical tag projection test (969 unit tests); its included integration/doc tests passed. + +The one ignored Server test is the existing repositoryless Drive real-Web-adapter roundtrip, unrelated to classification. It was not executed. + +- Root `cargo check`: passed, including normal TUI/Server compile closure. +- `cargo test -p server-api --features typescript`: 101 passed; doc tests passed. Existing ts-rs warnings about unsupported serde attributes remain. +- Canonical `export_openapi -- --check`: passed. +- `generate_repository_openapi_types`, `generate_runtime_api_types`, `generate_ticket_api_types`, `generate_workdir_api_types`, `generate_worker_launch_api_types`, `generate_companion_api_types` with `-- --check`: all passed. Other feature-gated type catalogs are protected by the full server-api TypeScript tests. +- `cargo fmt --all -- --check`, `git diff --check HEAD`: passed. +- `env -u LD_LIBRARY_PATH deno run --allow-read --allow-run=git tools/check-state-contract-names.ts`: passed, 0 unapproved / 0 stale exceptions; exception inventory unchanged. + +Initial development/full-suite failures concerned in-flight trait implementations, stale generated artifacts, status-code expectations, and a retained schema fixture/intermediate verifier. They were corrected and the commands above rerun; the final changed-crate run had no failures. + +## Not claimed / follow-up + +No final Web tag UI, full browser/TUI/runtime E2E, real-model judgment test, workspace-wide test run, Nix/Docker build, or live production-data migration was performed. T-739 branch integration is not tested here: schema composition and contract regeneration must preserve the catalog, memberships, tombstones, receipts and tag audit/thread events. This branch is independently buildable on the existing Ticket-state model. + +MR publication and trusted independent review are separate handoff evidence. This implementation does not merge the MR or complete/close T-740. diff --git a/openapi/server-api.json b/openapi/server-api.json index 53424c5a8..1f22b31a2 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -81,6 +81,12 @@ }, "type": "array" }, + "Array_of_WorkspaceTag": { + "items": { + "$ref": "#/components/schemas/WorkspaceTag" + }, + "type": "array" + }, "AttachmentUploadCancelResponse": { "additionalProperties": false, "properties": { @@ -10590,6 +10596,27 @@ ], "type": "string" }, + "TagListRequest": { + "description": "Active tags in ascending ID order, strictly after `after`; default/max 200.\nLarger limits are clamped to 200; zero returns an empty page.", + "properties": { + "after": { + "type": [ + "string", + "null" + ] + }, + "limit": { + "format": "uint", + "maximum": 200, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, "Ticket": { "properties": { "artifacts": { @@ -10622,6 +10649,13 @@ "type": "null" } ] + }, + "tags": { + "default": [], + "items": { + "$ref": "#/components/schemas/WorkspaceTag" + }, + "type": "array" } }, "required": [ @@ -11017,6 +11051,12 @@ "state": { "type": "string" }, + "tags": { + "items": { + "$ref": "#/components/schemas/WorkspaceTag" + }, + "type": "array" + }, "targets": { "items": { "$ref": "#/components/schemas/TicketTarget" @@ -11035,6 +11075,7 @@ }, "required": [ "id", + "tags", "resource_key", "title", "state", @@ -11471,6 +11512,7 @@ "decision", "implementation_report", "state_changed", + "tags_changed", "intake_summary", "status_changed", "close" @@ -11753,6 +11795,13 @@ "state": { "type": "string" }, + "tags": { + "default": [], + "items": { + "$ref": "#/components/schemas/WorkspaceTag" + }, + "type": "array" + }, "title": { "type": "string" }, @@ -12166,6 +12215,12 @@ "state": { "type": "string" }, + "tags": { + "items": { + "$ref": "#/components/schemas/WorkspaceTag" + }, + "type": "array" + }, "title": { "type": "string" }, @@ -12193,6 +12248,7 @@ }, "required": [ "id", + "tags", "resource_key", "title", "state", @@ -12293,6 +12349,30 @@ }, "type": "array" }, + "tag_ids": { + "default": [], + "description": "Match tickets containing every supplied tag ID (AND).", + "items": { + "type": "string" + }, + "type": "array" + }, + "tag_ids_any": { + "default": [], + "description": "Match tickets containing at least one supplied tag ID (OR).", + "items": { + "type": "string" + }, + "type": "array" + }, + "tag_ids_none": { + "default": [], + "description": "Exclude tickets containing any supplied tag ID (NOT).", + "items": { + "type": "string" + }, + "type": "array" + }, "updated_after": { "type": [ "string", @@ -12859,6 +12939,13 @@ "status": { "$ref": "#/components/schemas/ExtensibleTicketStatus" }, + "tags": { + "default": [], + "items": { + "$ref": "#/components/schemas/WorkspaceTag" + }, + "type": "array" + }, "title": { "type": "string" }, @@ -12909,6 +12996,33 @@ }, "type": "object" }, + "TicketTagSetRequest": { + "additionalProperties": false, + "description": "Compare entire active ID set, then replace it atomically. IDs must be unique;\ninput order is immaterial to CAS, but remains significant for exact replay.", + "properties": { + "expected_tag_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "operation_key": { + "type": "string" + }, + "tag_ids": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "expected_tag_ids", + "tag_ids", + "operation_key" + ], + "type": "object" + }, "TicketTarget": { "additionalProperties": false, "properties": { @@ -16042,6 +16156,117 @@ ], "type": "object" }, + "WorkspaceTag": { + "description": "Workspace-scoped immutable identity with a mutable display name.\nNames are Unicode-whitespace trimmed, limited to 128 UTF-8 bytes, and\ndeduplicated among active tags using Rust Unicode lowercase (not full case\nfolding or NFC/NFKC). Empty names and any input control characters are invalid.", + "properties": { + "deleted": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "last_operation_key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "id", + "name", + "last_operation_key", + "deleted" + ], + "type": "object" + }, + "WorkspaceTagAction": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "create", + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "kind", + "name" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "expected_last_operation_key": { + "type": "string" + }, + "kind": { + "const": "rename", + "type": "string" + }, + "name": { + "type": "string" + }, + "tag_id": { + "type": "string" + } + }, + "required": [ + "kind", + "tag_id", + "expected_last_operation_key", + "name" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "expected_last_operation_key": { + "type": "string" + }, + "kind": { + "const": "delete", + "type": "string" + }, + "tag_id": { + "type": "string" + } + }, + "required": [ + "kind", + "tag_id", + "expected_last_operation_key" + ], + "type": "object" + } + ] + }, + "WorkspaceTagMutation": { + "additionalProperties": false, + "properties": { + "action": { + "$ref": "#/components/schemas/WorkspaceTagAction" + }, + "operation_key": { + "type": "string" + } + }, + "required": [ + "operation_key", + "action" + ], + "type": "object" + }, "WorkspaceWorkerDiscoveryItem": { "additionalProperties": false, "description": "Bounded, model-safe projection used by privileged Workspace Worker discovery.\nRuntime placement appears only in the typed subject required by Worker\ncontrol operations; provider and launch internals are intentionally omitted.", @@ -16135,7 +16360,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3" + "x-yoi-source-digest": "sha256:d65d33e53c3a08f21be2c5d3de1870b4748fdba71e2b960ea773043235977066" }, "openapi": "3.1.0", "paths": { @@ -32713,6 +32938,208 @@ ] } }, + "/api/w/{workspace_id}/ticket-tags": { + "get": { + "operationId": "workspace_ticket_tag_list", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "request", + "required": true, + "schema": { + "$ref": "#/components/schemas/TagListRequest" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Array_of_WorkspaceTag" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "post": { + "operationId": "workspace_ticket_tag_mutate", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceTagMutation" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceTag" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/tickets": { "get": { "operationId": "ticket_list", @@ -36248,6 +36675,123 @@ ] } }, + "/api/w/{workspace_id}/tickets/{id}/tags": { + "put": { + "operationId": "ticket_tags_set", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketTagSetRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Array_of_WorkspaceTag" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/tickets/{id}/thread-events": { "post": { "operationId": "ticket_thread_event_add", diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index a03341919..5881cdd71 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3) +// Input contract: 0.1.0 (sha256:d65d33e53c3a08f21be2c5d3de1870b4748fdba71e2b960ea773043235977066) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 93145145c..c0abb9755 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3) +// Input contract: 0.1.0 (sha256:d65d33e53c3a08f21be2c5d3de1870b4748fdba71e2b960ea773043235977066) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 388f6867b..ba6b1cfb7 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3) +// Input contract: 0.1.0 (sha256:d65d33e53c3a08f21be2c5d3de1870b4748fdba71e2b960ea773043235977066) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 8818a7ad0..b44a3da32 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,10 +1,12 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3) +// Input contract: 0.1.0 (sha256:d65d33e53c3a08f21be2c5d3de1870b4748fdba71e2b960ea773043235977066) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check +export type Array_of_WorkspaceTag = Array; + export type BrowserAppendTicketEventRequest = { author?: string | null; body: string; role: BrowserTicketThreadRole; }; export type BrowserCloseTicketRequest = { expected_content_digest: string; expected_state: BrowserTicketWorkflowState; operation_key: string; resolution: string; }; @@ -91,7 +93,7 @@ export type TicketAssignmentRole = "orchestrator" | "worker" | "owner" | "contri export type TicketAssignmentSummary = { assignment_id: string; runtime_id: string; worker_id: string; worker_resource_key?: string | null; }; -export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array; assignment_diagnostics: Array; assignments: Array; body: string; body_truncated: boolean; content_digest: string; created_at?: string | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array; linked_objectives: Array; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array; state: string; targets: Array; title: string; updated_at?: string | null; }; +export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array; assignment_diagnostics: Array; assignments: Array; body: string; body_truncated: boolean; content_digest: string; created_at?: string | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array; linked_objectives: Array; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array; state: string; tags: Array; targets: Array; title: string; updated_at?: string | null; }; export type TicketDetailDerivedRelation = { at: string; author: string; forward_kind: string; inverse_kind: string; note?: string | null; source_resource_key?: string | null; source_ticket: string; }; @@ -111,7 +113,7 @@ export type TicketEvidenceSummary = { approved_current_subject: boolean; complet export type TicketIntakeSummary = { author?: string | null; body: MarkdownText; references: Array; }; -export type TicketListItemSummary = { id: string; priority: string; queued_at?: string | null; queued_by?: string | null; record_source: string; resource_key: string; state: string; title: string; updated_at?: string | null; workspace_action_priority: string; }; +export type TicketListItemSummary = { id: string; priority: string; queued_at?: string | null; queued_by?: string | null; record_source: string; resource_key: string; state: string; tags?: Array; title: string; updated_at?: string | null; workspace_action_priority: string; }; export type TicketListResponse = { invalid_records: Array; items: Array; limit: number & { readonly "__yoiIntegerRange": "0..1000"; }; page: QueryPage; record_authority: string; workspace_id: string; }; @@ -119,6 +121,12 @@ export type TicketMarkReadyRequest = { intake_summary?: TicketIntakeSummary | nu export type TicketMergeRequestSummary = { current_subject_ref?: string | null; integration_evidence_error?: string | null; merge_request_id: string; repository_key: string; review_excerpt?: string | null; review_requested_at?: string | null; review_status: string; review_subject_ref?: string | null; review_submitted_at?: string | null; selector_from?: string | null; selector_to: string; source_ref_observation: TicketSourceRefObservation; state: string; updated_at: string; }; +export type TicketQueryItem = { blocker_count: number; content_digest: string; created_at?: string | null; current_worker?: TicketAssignmentSummary | null; evidence: TicketEvidenceSummary; id: string; linked_objective_ids: Array; linked_objective_keys: Array; matched_fields: Array; matching_event?: TicketEvidenceEvent | null; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array; priority: string; readiness?: string | null; relation_count: number; resource_key: string; snippet?: string | null; state: string; tags: Array; title: string; unresolved_blocker_count: number; unresolved_review_count: number; updated_at?: string | null; workspace_action_priority: string; }; + +export type TicketQueryRequest = { attention?: Array; cursor?: string | null; event_kinds?: Array; evidence?: Array; limit?: number & { readonly "__yoiIntegerRange": "0..1000"; } | null; linked_objective_id?: string | null; query?: string | null; related_ticket_id?: string | null; relation_kind?: string | null; review_status?: string | null; sort?: string | null; states?: Array; tag_ids?: Array; tag_ids_any?: Array; tag_ids_none?: Array; updated_after?: string | null; updated_before?: string | null; }; + +export type TicketQueryResponse = { items: Array; page: QueryPage; record_authority: string; }; + export type TicketQueueOutcome = { queued_tickets: Array; requested_ticket: string; }; export type TicketRef = { id: string; resource_key?: string | null; slug: string; status: TicketStatus; }; @@ -135,6 +143,8 @@ export type TicketSourceRefObservation = { status: "observed"; } | { code: strin export type TicketStatus = "Open" | "Closed"; +export type TicketTagSetRequest = { expected_tag_ids: Array; operation_key: string; tag_ids: Array; }; + export type TicketTarget = { access: TicketTargetAccess; ref_selector?: string | null; repository_key: string; }; export type TicketTargetAccess = "read_only" | "read_write"; @@ -142,3 +152,9 @@ export type TicketTargetAccess = "read_only" | "read_write"; export type TicketWorkerAttachmentBinding = { alias: string; connection_id: string; working_directory_id: string; }; export type TicketWorkflowState = "planning" | "ready" | "queued" | "in_progress" | "done" | "closed"; + +export type WorkspaceTag = { deleted: boolean; id: string; last_operation_key: string; name: string; workspace_id: string; }; + +export type WorkspaceTagAction = { kind: "create"; name: string; } | { expected_last_operation_key: string; kind: "rename"; name: string; tag_id: string; } | { expected_last_operation_key: string; kind: "delete"; tag_id: string; }; + +export type WorkspaceTagMutation = { action: WorkspaceTagAction; operation_key: string; }; diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index b40098ed6..4ac81bd89 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-workdir-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3) +// Input contract: 0.1.0 (sha256:d65d33e53c3a08f21be2c5d3de1870b4748fdba71e2b960ea773043235977066) // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/workdir-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 8814b40e8..c2d57fe81 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3) +// Input contract: 0.1.0 (sha256:d65d33e53c3a08f21be2c5d3de1870b4748fdba71e2b960ea773043235977066) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check