diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index 33570f44a..25e8895ae 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -92,6 +92,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, @@ -551,6 +601,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(); @@ -975,6 +1050,24 @@ mod tests { let mut input = NewTicket::new("Full case record"); input.body = MarkdownText::new("Full body absent from the endpoint summary"); let reference = backend.create(input).unwrap(); + let tag = backend + .mutate_workspace_tag(ticket::WorkspaceTagMutation { + operation_key: "classify-create".into(), + action: ticket::WorkspaceTagAction::Create { + name: "closed".into(), + }, + }) + .unwrap(); + backend + .set_ticket_tags( + reference.id.clone().into(), + ticket::TicketTagSetRequest { + operation_key: "classify-set".into(), + expected_tag_ids: Vec::new(), + tag_ids: vec![tag.id.clone()], + }, + ) + .unwrap(); let mut snapshot = backend.show(reference.id.clone().into()).unwrap(); if action["action"] != "close" { snapshot = backend @@ -1033,6 +1126,9 @@ mod tests { panic!("case operation must return a full Ticket"); }; assert_eq!(actual, expected); + assert_eq!(actual.tags, vec![tag]); + assert!(!actual.case_history.is_empty()); + assert_eq!(actual.closure_operation_key, expected.closure_operation_key); let post = requests.recv().unwrap(); assert!( post.starts_with(&format!( @@ -1057,6 +1153,112 @@ mod tests { } } + #[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(); + } + } + fn case_request() -> ticket::TicketCaseUpdate { ticket::TicketCaseUpdate { operation_key: "caller-case-key".into(), diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index e0c22770b..b34b84458 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -372,6 +372,11 @@ impl_openapi_schema!( TicketShowRequest, TicketSummarySearchQuery, TicketThreadEventRequest, + WorkspaceTicketTagListRequest, + WorkspaceTicketTagMutationRequest, + TicketTagsSetRequest, + WorkspaceTicketTagList, + WorkspaceTicketTag, RuntimeManagementApiError, RuntimeWorkersQuery, RuntimeWorkerSpawnRequest, @@ -2972,6 +2977,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, @@ -7198,6 +7228,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, @@ -7536,7 +7569,8 @@ impl TicketAttention { pub struct TicketQueryRequest { pub query: Option, #[serde(default)] - pub states: Vec, + #[cfg_attr(feature = "typescript", ts(type = "Array"))] + pub states: Vec, #[serde(default)] pub event_kinds: Vec, #[serde(default)] @@ -7544,6 +7578,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, @@ -7560,6 +7603,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: BrowserTicketState, @@ -7625,6 +7670,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: BrowserTicketState, @@ -7730,6 +7777,14 @@ pub struct TicketCaseUpdateRequest { #[serde(default)] pub references: Vec, } +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); @@ -11074,6 +11129,210 @@ 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!({ + "states": ["open", "closed"], + "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"])); + assert_eq!(value["states"], serde_json::json!(["open", "closed"])); + } + + #[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), + schemars::schema_for!(TicketListItemSummary), + ] { + let schema = serde_json::to_value(schema).unwrap(); + assert_eq!(schema["properties"]["tags"]["type"], "array"); + assert_eq!( + schema["properties"]["tags"]["items"]["$ref"], + "#/$defs/WorkspaceTag" + ); + for field in ["closure_operation_key", "current_closure", "legacy_handoff"] { + assert!(schema["properties"].get(field).is_some(), "{field}"); + } + assert_eq!( + schema["$defs"]["BrowserTicketState"]["enum"], + serde_json::json!(["open", "closed"]) + ); + } + let detail = serde_json::to_value(schemars::schema_for!(TicketDetail)).unwrap(); + assert!(detail["properties"]["closure_history"].is_object()); + } + + #[test] + fn ticket_classification_requests_reject_case_judgments_and_actor_claims() { + let mutation = serde_json::json!({"operation_key":"classify", "action":{"kind":"create", "name":"closed"}}); + let set = serde_json::json!({"operation_key":"classify", "expected_tag_ids":[], "tag_ids":["tag-1"]}); + for (field, value) in [ + ("author", serde_json::json!("orchestrator")), + ("expected_state", serde_json::json!("closed")), + ("expected_content_digest", serde_json::json!("digest")), + ("reason", serde_json::json!("completed")), + ] { + let mut invalid_mutation = mutation.clone(); + invalid_mutation[field] = value.clone(); + assert!( + serde_json::from_value::(invalid_mutation) + .is_err(), + "mutation {field}" + ); + let mut invalid_set = set.clone(); + invalid_set[field] = value; + assert!( + serde_json::from_value::(invalid_set).is_err(), + "set {field}" + ); + } + let query = serde_json::json!({"states":["closed"], "tag_ids":["tag-1"], "tag_ids_any":["tag-2"], "tag_ids_none":["tag-3"]}); + assert!(serde_json::from_value::(query.clone()).is_ok()); + for state in [ + "planning", + "ready", + "queued", + "inprogress", + "done", + "invalid", + ] { + let mut invalid = query.clone(); + invalid["states"] = serde_json::json!([state]); + assert!( + serde_json::from_value::(invalid).is_err(), + "{state}" + ); + } + } + + #[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() { @@ -12769,7 +13028,7 @@ mod tests { "states": ["open", "closed"], "attention": ["blocked", "missing_evidence"] })) .unwrap(); - assert_eq!(parsed.states[0], BrowserTicketState::Open); + assert_eq!(parsed.states[0], ticket::TicketState::Open); for state in [ "planning", "ready", diff --git a/crates/server-api/src/repository_openapi_typescript.rs b/crates/server-api/src/repository_openapi_typescript.rs index 8bdc73a3c..1e96670c3 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 284272a44..d62200154 100644 --- a/crates/ticket/src/latest_schema.sql +++ b/crates/ticket/src/latest_schema.sql @@ -164,3 +164,40 @@ BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END; CREATE TRIGGER typed_ticket_case_state_update BEFORE UPDATE OF workflow_state ON typed_tickets WHEN NEW.workflow_state NOT IN ('open', 'closed') BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END; +-- 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 c85112eb1..3b8a29e4f 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -22,9 +22,17 @@ 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, migrate_sqlite_ticket_schema_in_transaction, + verify_legacy_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; @@ -133,6 +141,7 @@ fn parse_sql_case_state(raw: &str, index: usize) -> rusqlite::Result) -> 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)?, @@ -349,6 +358,7 @@ pub enum TicketEventKind { Decision, ImplementationReport, StateChanged, + TagsChanged, IntakeSummary, StatusChanged, Close, @@ -364,6 +374,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", @@ -379,6 +390,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(), @@ -397,6 +409,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, @@ -1584,6 +1597,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, @@ -1668,6 +1683,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, @@ -1729,6 +1746,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; @@ -1788,6 +1812,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, }, @@ -1863,6 +1897,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), @@ -1886,6 +1922,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), @@ -2165,6 +2210,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); @@ -2242,6 +2288,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, @@ -2730,6 +2777,7 @@ impl SqliteTicketBackend { current_closure, case_history, legacy_workflow_snapshot, + tags: self.load_ticket_tags(conn, ticket_id)?, meta, document: TicketDocument { body: MarkdownText::new(body), @@ -2899,7 +2947,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) } @@ -2971,6 +3021,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 のrouting readinessをreadyにしました。case stateは{from}のままです。\n") @@ -3053,6 +3116,7 @@ impl TicketBackend for SqliteTicketBackend { current_closure: None, case_history: Vec::new(), legacy_workflow_snapshot: None, + tags: Vec::new(), meta, document: TicketDocument { body, @@ -3219,7 +3283,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]; @@ -3668,6 +3733,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, @@ -4464,6 +4530,7 @@ mod tests { fn summary_with_state(state: TicketState) -> 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 40bd2905a..1a8c022cd 100644 --- a/crates/ticket/src/sqlite_schema.rs +++ b/crates/ticket/src/sqlite_schema.rs @@ -7,23 +7,9 @@ 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 = 9; -const PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION: i64 = 6; +const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 10; const BASELINE_MIGRATION_NAME: &str = "ticket schema baseline"; -#[derive(Clone, Copy)] -struct Migration { - version: i64, - name: &'static str, - apply: fn(&Connection) -> Result<()>, -} - -const MIGRATIONS: &[Migration] = &[Migration { - version: LATEST_SQLITE_TICKET_SCHEMA_VERSION, - name: BASELINE_MIGRATION_NAME, - apply: create_latest_ticket_schema, -}]; - #[derive(Clone, Copy)] struct ExpectedColumn { name: &'static str, @@ -41,6 +27,9 @@ struct ExpectedForeignKey { } const OWNED_TABLES: &[&str] = &[ + "workspace_tags", + "typed_ticket_tags", + "workspace_tag_operations", "typed_tickets", "typed_ticket_targets", "typed_ticket_labels", @@ -99,6 +88,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), @@ -257,71 +297,7 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> { .execute_batch("BEGIN IMMEDIATE") .map_err(sqlite_err)?; - let result = (|| { - connection - .execute_batch( - "CREATE TABLE IF NOT EXISTS ticket_schema_migrations ( - version INTEGER PRIMARY KEY, - name TEXT NOT NULL, - applied_at TEXT NOT NULL - );", - ) - .map_err(sqlite_err)?; - verify_table(connection, MIGRATION_TABLE, MIGRATION_COLUMNS, &[], false)?; - - let applied = load_applied_migrations(connection)?; - if applied.is_empty() { - let migration = MIGRATIONS - .first() - .ok_or_else(|| TicketError::Sqlite("Ticket migration catalog is empty".into()))?; - (migration.apply)(connection)?; - connection - .execute( - "INSERT INTO ticket_schema_migrations (version, name, applied_at) - VALUES (?1, ?2, ?3)", - params![ - migration.version, - migration.name, - chrono::Utc::now().to_rfc3339() - ], - ) - .map_err(sqlite_err)?; - } else if applied - == BTreeMap::from([( - PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION, - BASELINE_MIGRATION_NAME.to_string(), - )]) - { - migrate_v6_to_v7(connection)?; - migrate_v7_to_v9(connection)?; - connection - .execute( - "DELETE FROM ticket_schema_migrations WHERE version = ?1", - params![PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION], - ) - .map_err(sqlite_err)?; - connection - .execute( - "INSERT INTO ticket_schema_migrations (version, name, applied_at) - VALUES (?1, ?2, ?3)", - params![ - LATEST_SQLITE_TICKET_SCHEMA_VERSION, - BASELINE_MIGRATION_NAME, - chrono::Utc::now().to_rfc3339() - ], - ) - .map_err(sqlite_err)?; - } else if applied == BTreeMap::from([(7, BASELINE_MIGRATION_NAME.to_string())]) { - migrate_v7_to_v9(connection)?; - connection.execute("UPDATE ticket_schema_migrations SET version = 9, applied_at = ?1 WHERE version = 7", params![chrono::Utc::now().to_rfc3339()]).map_err(sqlite_err)?; - } else { - // Version 8 belongs to T-740; compose its tag migration explicitly at integration. - validate_applied_migrations(&applied)?; - } - - verify_sqlite_ticket_schema(connection) - })(); - + let result = migrate_sqlite_ticket_schema_in_transaction(connection); match result { Ok(()) => connection.execute_batch("COMMIT").map_err(sqlite_err), Err(error) => { @@ -331,8 +307,154 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> { } } +/// Composes retained component 6/7, tags-only 8, or cases-only 9 into component 10. +/// The host owns the surrounding transaction (for example Workspace 90→91). +/// A savepoint rolls back this component on failure, without committing host work. +/// Current component 10 is verified only; immutable judgments are never reapplied. +pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> Result<()> { + if connection.is_autocommit() { + return Err(TicketError::Sqlite( + "Ticket component migration requires a host transaction".into(), + )); + } + connection + .execute_batch("SAVEPOINT ticket_component_composition") + .map_err(sqlite_err)?; + let result = (|| { + connection + .execute_batch( + "CREATE TABLE IF NOT EXISTS ticket_schema_migrations ( + version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL + );", + ) + .map_err(sqlite_err)?; + verify_table(connection, MIGRATION_TABLE, MIGRATION_COLUMNS, &[], false)?; + let applied = load_applied_migrations(connection)?; + if applied.is_empty() { + create_latest_ticket_schema(connection)?; + connection + .execute( + "INSERT INTO ticket_schema_migrations VALUES (10, ?1, ?2)", + params![BASELINE_MIGRATION_NAME, chrono::Utc::now().to_rfc3339()], + ) + .map_err(sqlite_err)?; + } else { + let version = match applied.iter().next() { + Some((&version, name)) + if applied.len() == 1 + && name == BASELINE_MIGRATION_NAME + && matches!(version, 6..=10) => + { + version + } + _ => { + validate_applied_migrations(&applied)?; + unreachable!() + } + }; + if version == 6 { + migrate_v6_to_v7(connection)?; + connection + .execute( + "UPDATE ticket_schema_migrations SET version = 7 WHERE version = 6", + [], + ) + .map_err(sqlite_err)?; + } + let shape = TicketSchemaShape::from_version(if version == 6 { 7 } else { version }); + verify_ticket_schema_shape(connection, shape)?; + match shape { + TicketSchemaShape::Legacy7 => { + add_tag_schema(connection)?; + migrate_legacy_cases(connection)?; + } + TicketSchemaShape::Tags8 => migrate_legacy_cases(connection)?, + TicketSchemaShape::Cases9 => add_tag_schema(connection)?, + TicketSchemaShape::Composed10 => {} + } + if version != 10 { + connection + .execute( + "UPDATE ticket_schema_migrations SET version = 10, applied_at = ?1", + params![chrono::Utc::now().to_rfc3339()], + ) + .map_err(sqlite_err)?; + } + } + verify_sqlite_ticket_schema(connection) + })(); + match result { + Ok(()) => connection + .execute_batch("RELEASE ticket_component_composition") + .map_err(sqlite_err), + Err(error) => { + let _ = connection.execute_batch( + "ROLLBACK TO ticket_component_composition; RELEASE ticket_component_composition", + ); + Err(error) + } + } +} + +#[derive(Clone, Copy)] +enum TicketSchemaShape { + Legacy7, + Tags8, + Cases9, + Composed10, +} +impl TicketSchemaShape { + fn from_version(version: i64) -> Self { + match version { + 7 => Self::Legacy7, + 8 => Self::Tags8, + 9 => Self::Cases9, + 10 => Self::Composed10, + _ => unreachable!(), + } + } + fn version(self) -> i64 { + match self { + Self::Legacy7 => 7, + Self::Tags8 => 8, + Self::Cases9 => 9, + Self::Composed10 => 10, + } + } + fn tags(self) -> bool { + matches!(self, Self::Tags8 | Self::Composed10) + } + fn cases(self) -> bool { + matches!(self, Self::Cases9 | Self::Composed10) + } +} + /// Verifies the current Ticket-owned SQLite schema without executing DDL. pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { + verify_ticket_schema_shape(connection, TicketSchemaShape::Composed10) +} + +/// 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, TicketSchemaShape::Legacy7) +} + +/// Verifies retained six-state component 7 or tags-only component 8, without +/// treating historical workflow values as the current two-state case authority. +pub fn verify_legacy_sqlite_ticket_schema(connection: &Connection) -> Result<()> { + let applied = load_applied_migrations(connection)?; + let version = applied.keys().next().copied().unwrap_or_default(); + if !matches!(version, 7 | 8) { + return Err(TicketError::Sqlite( + "legacy Ticket schema requires component 7 or 8".into(), + )); + } + verify_ticket_schema_shape(connection, TicketSchemaShape::from_version(version)) +} + +fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape) -> Result<()> { let mut diagnostics = Vec::new(); collect_table_diagnostics( @@ -345,23 +467,35 @@ 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 applied + == BTreeMap::from([(shape.version(), BASELINE_MIGRATION_NAME.to_owned())]) + { + Ok(()) + } else { + Err(TicketError::Sqlite(format!( + "Ticket schema migration history must contain only the canonical version {} baseline marker", + shape.version() + ))) + }; + if let Err(error) = history { push_diagnostic(&mut diagnostics, error.to_string()); - } else if applied.len() != MIGRATIONS.len() { - push_diagnostic( - &mut diagnostics, - format!( - "Ticket schema is not current: found {} migration(s), expected {}", - applied.len(), - MIGRATIONS.len() - ), - ); } } Err(error) => push_diagnostic(&mut diagnostics, error.to_string()), } 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), @@ -402,9 +536,47 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { CHILD_FOREIGN_KEYS, ), ] { + if !shape.tags() + && matches!( + table, + "workspace_tags" | "typed_ticket_tags" | "workspace_tag_operations" + ) + { + continue; + } collect_table_diagnostics(connection, table, columns, foreign_keys, &mut diagnostics); } + if shape.tags() { + 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", @@ -456,64 +628,144 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { collect_absent_table_diagnostic(connection, legacy_table, &mut diagnostics); } - for (table, columns) in [ - ( + if shape.cases() { + for (table, columns) in [ + ( + "typed_ticket_case_metadata", + vec![ + column("workspace_id", "TEXT", true, 1), + column("ticket_id", "TEXT", true, 2), + column("closure_operation_key", "TEXT", false, 0), + ], + ), + ( + "typed_ticket_case_history", + vec![ + column("workspace_id", "TEXT", true, 1), + column("ticket_id", "TEXT", true, 2), + column("ordinal", "INTEGER", true, 0), + column("operation_key", "TEXT", true, 3), + column("request_fingerprint", "TEXT", true, 0), + column("judgment_json", "TEXT", true, 0), + ], + ), + ( + "typed_ticket_legacy_workflow_snapshot", + vec![ + column("workspace_id", "TEXT", true, 1), + column("ticket_id", "TEXT", true, 2), + column("snapshot_json", "TEXT", true, 0), + ], + ), + ] { + if let Err(error) = verify_table(connection, table, &columns, CHILD_FOREIGN_KEYS, false) + { + push_diagnostic(&mut diagnostics, error.to_string()); + } + } + collect_index_diagnostics( + connection, + "typed_ticket_case_history", + None, + true, + &["workspace_id", "ticket_id", "ordinal"], + &mut diagnostics, + ); + for name in [ + "typed_ticket_case_history_no_update", + "typed_ticket_case_history_no_delete", + "typed_ticket_legacy_workflow_no_update", + "typed_ticket_legacy_workflow_no_delete", + "typed_ticket_case_state_insert", + "typed_ticket_case_state_update", + ] { + let sql: Option = connection + .query_row( + "SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = ?1", + params![name], + |row| row.get(0), + ) + .optional() + .map_err(sqlite_err)?; + let canonical = include_str!("case_schema.sql") + .split("CREATE TRIGGER ") + .skip(1) + .find(|ddl| ddl.starts_with(name)) + .expect("canonical case trigger") + .split_once("END;") + .expect("trigger terminator") + .0; + let canonical = format!("CREATE TRIGGER {canonical}END"); + let normalize = |sql: &str| { + sql.trim_end_matches(';') + .split_whitespace() + .collect::() + .to_lowercase() + }; + match sql { + None => push_diagnostic( + &mut diagnostics, + format!("missing Ticket case trigger {name}"), + ), + Some(sql) if normalize(&sql) != normalize(&canonical) => push_diagnostic( + &mut diagnostics, + format!("invalid Ticket case trigger {name}"), + ), + Some(_) => {} + } + } + } else { + for table in [ "typed_ticket_case_metadata", - vec![ - column("workspace_id", "TEXT", true, 1), - column("ticket_id", "TEXT", true, 2), - column("closure_operation_key", "TEXT", false, 0), - ], - ), - ( "typed_ticket_case_history", - vec![ - column("workspace_id", "TEXT", true, 1), - column("ticket_id", "TEXT", true, 2), - column("ordinal", "INTEGER", true, 0), - column("operation_key", "TEXT", true, 3), - column("request_fingerprint", "TEXT", true, 0), - column("judgment_json", "TEXT", true, 0), - ], - ), - ( "typed_ticket_legacy_workflow_snapshot", - vec![ - column("workspace_id", "TEXT", true, 1), - column("ticket_id", "TEXT", true, 2), - column("snapshot_json", "TEXT", true, 0), - ], - ), - ] { - if let Err(error) = verify_table(connection, table, &columns, CHILD_FOREIGN_KEYS, false) { - push_diagnostic(&mut diagnostics, error.to_string()); + ] { + collect_absent_table_diagnostic(connection, table, &mut diagnostics); } } - for name in [ - "typed_ticket_case_history_no_update", - "typed_ticket_case_history_no_delete", - "typed_ticket_legacy_workflow_no_update", - "typed_ticket_legacy_workflow_no_delete", - "typed_ticket_case_state_insert", - "typed_ticket_case_state_update", - ] { - let exists = connection - .query_row( - "SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = ?1", - params![name], - |_| Ok(()), - ) - .optional() - .map_err(sqlite_err)? - .is_some(); - if !exists { - push_diagnostic( - &mut diagnostics, - format!("missing Ticket case trigger {name}"), - ); + if !shape.tags() { + for table in [ + "workspace_tags", + "typed_ticket_tags", + "workspace_tag_operations", + ] { + collect_absent_table_diagnostic(connection, table, &mut diagnostics); } } + let invalid_states: i64 = connection.query_row( + if shape.cases() { + "SELECT COUNT(*) FROM typed_tickets WHERE workflow_state NOT IN ('open','closed')" + } else { + "SELECT COUNT(*) FROM typed_tickets WHERE workflow_state NOT IN ('planning','ready','queued','inprogress','done','closed')" + }, [], |row| row.get(0)).map_err(sqlite_err)?; + if invalid_states != 0 { + push_diagnostic( + &mut diagnostics, + format!( + "component {} has {invalid_states} unsupported Ticket states", + shape.version() + ), + ); + } for table in OWNED_TABLES { + if !shape.tags() + && matches!( + *table, + "workspace_tags" | "typed_ticket_tags" | "workspace_tag_operations" + ) + { + continue; + } + if !shape.cases() + && matches!( + *table, + "typed_ticket_case_metadata" + | "typed_ticket_case_history" + | "typed_ticket_legacy_workflow_snapshot" + ) + { + continue; + } collect_foreign_key_check_diagnostics(connection, table, &mut diagnostics); } @@ -539,9 +791,8 @@ fn create_latest_ticket_schema(connection: &Connection) -> Result<()> { .map_err(sqlite_err) } -/// Independent 7→9 case migration. Does not rewrite historical events or -/// create/resume worker identities. Version 8 tag composition is deliberately separate. -fn migrate_v7_to_v9(connection: &Connection) -> Result<()> { +/// Retained 7/8 case migration: leave tags and historical receipts untouched. +fn migrate_legacy_cases(connection: &Connection) -> Result<()> { let snapshots = { let mut stmt = connection.prepare("SELECT workspace_id, ticket_id, workflow_state, workflow_state_explicit, status, queued_by, queued_at, resolution FROM typed_tickets").map_err(sqlite_err)?; stmt.query_map([], |row| { @@ -589,7 +840,7 @@ fn migrate_v7_to_v9(connection: &Connection) -> Result<()> { for (workspace_id, ticket_id, snapshot) in snapshots { connection.execute("INSERT INTO typed_ticket_legacy_workflow_snapshot (workspace_id, ticket_id, snapshot_json) VALUES (?1, ?2, ?3)", params![workspace_id, ticket_id, serde_json::to_string(&snapshot).map_err(sqlite_err)?]).map_err(sqlite_err)?; if matches!(snapshot.workflow_state.as_str(), "done" | "closed") { - let key = format!("migration-v9:{ticket_id}"); + let key = format!("migration-v10:{ticket_id}"); let judgment = crate::TicketCaseHistoryEntry { operation_key: key.clone(), previous_closure_operation_key: None, @@ -608,15 +859,22 @@ fn migrate_v7_to_v9(connection: &Connection) -> Result<()> { ) }), references: Vec::new(), - author: "ticket-schema-migration-v9".into(), + author: "ticket-schema-migration-v10".into(), at: chrono::Utc::now().to_rfc3339(), }; - connection.execute("INSERT INTO typed_ticket_case_history (workspace_id, ticket_id, ordinal, operation_key, request_fingerprint, judgment_json) VALUES (?1, ?2, 0, ?3, 'migration-v9', ?4)", params![workspace_id, ticket_id, key, serde_json::to_string(&judgment).map_err(sqlite_err)?]).map_err(sqlite_err)?; + connection.execute("INSERT INTO typed_ticket_case_history (workspace_id, ticket_id, ordinal, operation_key, request_fingerprint, judgment_json) VALUES (?1, ?2, 0, ?3, 'migration-v10', ?4)", params![workspace_id, ticket_id, key, serde_json::to_string(&judgment).map_err(sqlite_err)?]).map_err(sqlite_err)?; connection.execute("INSERT INTO typed_ticket_case_metadata (workspace_id, ticket_id, closure_operation_key) VALUES (?1, ?2, ?3)", params![workspace_id, ticket_id, key]).map_err(sqlite_err)?; } } Ok(()) } +fn add_tag_schema(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 @@ -780,7 +1038,7 @@ fn load_applied_migrations(connection: &Connection) -> Result) -> Result<()> { let expected = BTreeMap::from([( LATEST_SQLITE_TICKET_SCHEMA_VERSION, - MIGRATIONS[0].name.to_string(), + BASELINE_MIGRATION_NAME.to_string(), )]); if applied == &expected { Ok(()) @@ -1283,10 +1541,134 @@ mod tests { assert!(!migrated_attributes.contains_key("ref_selector")); assert_eq!( load_applied_migrations(&connection).unwrap(), - BTreeMap::from([(9, BASELINE_MIGRATION_NAME.to_owned())]) + BTreeMap::from([(10, 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!("../tests/fixtures/schema7.sql"); + 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','open',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(); @@ -1301,7 +1683,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 9 baseline marker" + "migration history must contain only the canonical version 10 baseline marker" )); assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2); } @@ -1323,7 +1705,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 9 baseline marker" + "migration history must contain only the canonical version 10 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..24c28e682 --- /dev/null +++ b/crates/ticket/src/tags_tests.rs @@ -0,0 +1,804 @@ +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", "open"); + let closed_tag = create_tag(&backend, "closed-tag", "closed"); + for (i, state) in [TicketState::Open, TicketState::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}"), &[], &[tag.clone(), closed_tag.clone()]), + ) + .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!(before.closure_operation_key, after.closure_operation_key); + assert_eq!(before.current_closure, after.current_closure); + assert_eq!(before.case_history, after.case_history); + assert_eq!( + before.legacy_workflow_snapshot, + after.legacy_workflow_snapshot + ); + let mut expected_tags = vec![tag.clone(), closed_tag.clone()]; + expected_tags.sort_by(|a, b| a.id.cmp(&b.id)); + assert_eq!(after.tags, expected_tags); + 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 b0b1fe8e3..435232ada 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -242,6 +242,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/ticket/tests/fixtures/schema8_tags.sql b/crates/ticket/tests/fixtures/schema8_tags.sql new file mode 100644 index 000000000..9c2796871 --- /dev/null +++ b/crates/ticket/tests/fixtures/schema8_tags.sql @@ -0,0 +1,38 @@ +-- Frozen standalone component8 tag extension; combine with schema7.sql. +-- 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/tests/fixtures/schema9_cases.sql b/crates/ticket/tests/fixtures/schema9_cases.sql new file mode 100644 index 000000000..a269bbfa8 --- /dev/null +++ b/crates/ticket/tests/fixtures/schema9_cases.sql @@ -0,0 +1,33 @@ +-- Frozen standalone component9 case extension; combine with schema7.sql. +-- Ticket case authority. Legacy workflow data is a handoff snapshot, never current authority. +CREATE TABLE typed_ticket_case_metadata ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, closure_operation_key TEXT, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_case_history ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, + operation_key TEXT NOT NULL, request_fingerprint TEXT NOT NULL, judgment_json TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, operation_key), + UNIQUE (workspace_id, ticket_id, ordinal), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_legacy_workflow_snapshot ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, snapshot_json TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TRIGGER typed_ticket_case_history_no_update BEFORE UPDATE ON typed_ticket_case_history +BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END; +CREATE TRIGGER typed_ticket_case_history_no_delete BEFORE DELETE ON typed_ticket_case_history +BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END; +CREATE TRIGGER typed_ticket_legacy_workflow_no_update BEFORE UPDATE ON typed_ticket_legacy_workflow_snapshot +BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END; +CREATE TRIGGER typed_ticket_legacy_workflow_no_delete BEFORE DELETE ON typed_ticket_legacy_workflow_snapshot +BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END; +CREATE TRIGGER typed_ticket_case_state_insert BEFORE INSERT ON typed_tickets +WHEN NEW.workflow_state NOT IN ('open', 'closed') +BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END; +CREATE TRIGGER typed_ticket_case_state_update BEFORE UPDATE OF workflow_state ON typed_tickets +WHEN NEW.workflow_state NOT IN ('open', 'closed') +BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END; diff --git a/crates/ticket/tests/migration.rs b/crates/ticket/tests/migration.rs index 7909a48ac..2d9c312c5 100644 --- a/crates/ticket/tests/migration.rs +++ b/crates/ticket/tests/migration.rs @@ -1,5 +1,5 @@ //! Adopted compatibility contract: canonical standalone schema7 six-state snapshots -//! migrate independently to schema9, preserving original evidence and routing handoff. +//! compose into schema10, preserving original evidence and routing handoff. use rusqlite::{Connection, params}; use tempfile::TempDir; use ticket::*; @@ -78,7 +78,7 @@ fn standalone_schema7_migration_preserves_six_state_snapshot_and_evidence_withou row.get(0) }) .unwrap(); - assert_eq!(marker, 9); + assert_eq!(marker, 10); let after_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::>().unwrap(); assert_eq!(after_events, historical_events); let store = SqliteTicketBackend::open_verified(&path, "workspace").unwrap(); @@ -234,7 +234,7 @@ fn unknown_legacy_state_rolls_back_the_independent_case_migration() { } #[test] -fn schema8_tag_composition_is_rejected_until_explicitly_integrated() { +fn falsely_numbered_schema8_without_tags_is_rejected_without_writes() { let connection = Connection::open_in_memory().unwrap(); schema7(&connection); connection @@ -250,3 +250,569 @@ fn schema8_tag_composition_is_rejected_until_explicitly_integrated() { 8 ); } + +// Read actual SQLite values, not merely counts: receipt JSON, original context, +// name snapshots and event/reference/attribute rows must survive byte-for-byte. +fn rows(connection: &Connection, table: &str) -> Vec> { + let mut stmt = connection + .prepare(&format!("SELECT * FROM {table} ORDER BY rowid")) + .unwrap(); + let count = stmt.column_count(); + stmt.query_map([], |row| (0..count).map(|i| row.get(i)).collect()) + .unwrap() + .collect::>() + .unwrap() +} + +fn copy_tables(connection: &Connection, source: &std::path::Path, tables: &[&str]) { + connection + .execute( + "ATTACH DATABASE ?1 AS fixture_source", + [source.to_str().unwrap()], + ) + .unwrap(); + for table in tables { + connection + .execute_batch(&format!( + "INSERT INTO {table} SELECT * FROM fixture_source.{table}" + )) + .unwrap(); + } + connection + .execute_batch("DETACH DATABASE fixture_source") + .unwrap(); +} + +fn mutate(backend: &SqliteTicketBackend, key: &str, action: WorkspaceTagAction) -> WorkspaceTag { + backend + .mutate_workspace_tag(WorkspaceTagMutation { + operation_key: key.into(), + action, + }) + .unwrap() +} + +fn case_request(ticket: &Ticket, key: &str, action: TicketCaseAction) -> TicketCaseUpdate { + TicketCaseUpdate { + expected_content_digest: ticket_content_digest(ticket), + expected_state: ticket.meta.workflow_state, + expected_closure_operation_key: ticket.closure_operation_key.clone(), + operation_key: key.into(), + action, + explanation: "Retain this judgment".into(), + references: vec![TicketReference { + kind: "result".into(), + target: "retained-result".into(), + }], + author: Some("authenticated-user".into()), + } +} + +#[test] +fn tagged_schema8_composes_cases_preserving_tombstones_membership_context_receipts_and_thread() { + let temp = TempDir::new().unwrap(); + let source_path = temp.path().join("tag-operations.db"); + let source = SqliteTicketBackend::open(&source_path, "workspace") + .unwrap() + .with_event_attributes(std::collections::BTreeMap::from([ + ("source_account_id".into(), "account".into()), + ("source_user_id".into(), "user".into()), + ("source_actor_role".into(), "human".into()), + ])); + let id = source + .create(NewTicket::new("historical tagged ticket")) + .unwrap() + .id; + // Generate real durable T-740 receipts/audit/event snapshots through its + // unchanged classification boundary, then seed only those rows into frozen8. + let create = WorkspaceTagMutation { + operation_key: "create".into(), + action: WorkspaceTagAction::Create { + name: "open".into(), + }, + }; + let original = source.mutate_workspace_tag(create.clone()).unwrap(); + let attach = TicketTagSetRequest { + operation_key: "attach".into(), + expected_tag_ids: vec![], + tag_ids: vec![original.id.clone()], + }; + let attach_result = source + .set_ticket_tags(id.clone().into(), attach.clone()) + .unwrap(); + let renamed = mutate( + &source, + "rename", + WorkspaceTagAction::Rename { + tag_id: original.id.clone(), + expected_last_operation_key: "create".into(), + name: "closed".into(), + }, + ); + let deleted = mutate( + &source, + "delete", + WorkspaceTagAction::Delete { + tag_id: renamed.id.clone(), + expected_last_operation_key: "rename".into(), + }, + ); + let recreated = mutate( + &source, + "recreate", + WorkspaceTagAction::Create { + name: "closed".into(), + }, + ); + assert_ne!(recreated.id, deleted.id); + source + .set_ticket_tags( + id.clone().into(), + TicketTagSetRequest { + operation_key: "attach-recreated".into(), + expected_tag_ids: vec![], + tag_ids: vec![recreated.id.clone()], + }, + ) + .unwrap(); + + let path = temp.path().join("retained8.db"); + let connection = Connection::open(&path).unwrap(); + connection + .pragma_update(None, "foreign_keys", "ON") + .unwrap(); + schema7(&connection); + connection + .execute_batch(include_str!("fixtures/schema8_tags.sql")) + .unwrap(); + connection + .execute("UPDATE ticket_schema_migrations SET version=8", []) + .unwrap(); + let preserved = [ + "workspace_tags", + "typed_ticket_tags", + "workspace_tag_operations", + "typed_ticket_events", + "typed_ticket_event_attributes", + "typed_ticket_event_references", + ]; + copy_tables(&connection, &source_path, &["typed_tickets"]); + copy_tables(&connection, &source_path, &preserved); + // Six-state retained data, not a current10 DB with its number changed. + connection.execute("UPDATE typed_tickets SET workflow_state='done', status='done', resolution='Historical explanation', queued_by='old-router',queued_at='old-time'", []).unwrap(); + connection.execute("INSERT INTO typed_ticket_events (workspace_id,ticket_id,event_index,kind,author,body) VALUES ('workspace',?1,99,'close','old-user','Original close')", [&id]).unwrap(); + connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace',?1,99,'operation_key','old-close-receipt')", [&id]).unwrap(); + connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace',?1,99,'request_fingerprint','old-close-fingerprint')", [&id]).unwrap(); + let before: Vec<_> = preserved.iter().map(|t| rows(&connection, t)).collect(); + verify_legacy_sqlite_ticket_schema(&connection).unwrap(); + assert!(verify_sqlite_ticket_schema(&connection).is_err()); + assert!(verify_pre_classification_ticket_schema(&connection).is_err()); + migrate_sqlite_ticket_schema(&connection).unwrap(); + verify_sqlite_ticket_schema(&connection).unwrap(); + assert_eq!( + rows(&connection, "ticket_schema_migrations")[0][0], + rusqlite::types::Value::Integer(10) + ); + for (table, expected) in preserved.iter().zip(&before) { + assert_eq!(&rows(&connection, table), expected, "changed {table}"); + } + let backend = SqliteTicketBackend::open_verified(&path, "workspace") + .unwrap() + .with_event_attributes(std::collections::BTreeMap::from([ + ("source_account_id".into(), "account".into()), + ("source_user_id".into(), "user".into()), + ("source_actor_role".into(), "human".into()), + ])); + let ticket = backend.show(id.clone().into()).unwrap(); + assert_eq!(ticket.tags, vec![recreated]); + assert_eq!( + ticket_content_digest(&ticket), + ticket_content_digest(&source.show(id.clone().into()).unwrap()) + ); + assert!(matches!( + backend.update_case( + &id, + case_request(&ticket, "old-close-receipt", TicketCaseAction::Reopen) + ), + Err(TicketError::OperationFingerprintMismatch { .. }) + )); + assert_eq!( + ticket.current_closure.as_ref().unwrap().reason, + TicketClosureReason::Unknown + ); + assert_eq!( + ticket.current_closure.as_ref().unwrap().explanation, + "Historical explanation" + ); + assert_eq!( + ticket + .legacy_workflow_snapshot + .as_ref() + .unwrap() + .workflow_state, + "done" + ); + assert_eq!( + backend.mutate_workspace_tag(create.clone()).unwrap(), + original + ); + assert_eq!( + backend + .set_ticket_tags(id.clone().into(), attach.clone()) + .unwrap(), + attach_result + ); + let other = backend + .clone() + .with_event_attributes(std::collections::BTreeMap::from([ + ("source_account_id".into(), "other-account".into()), + ("source_user_id".into(), "user".into()), + ])); + assert!(matches!( + other.mutate_workspace_tag(create), + Err(TicketError::OperationFingerprintMismatch { .. }) + )); + assert!(matches!( + other.set_ticket_tags(id.clone().into(), attach), + Err(TicketError::OperationFingerprintMismatch { .. }) + )); + assert!( + backend + .set_ticket_tags( + id.clone().into(), + TicketTagSetRequest { + operation_key: "attach-deleted".into(), + expected_tag_ids: ticket.tags.iter().map(|t| t.id.clone()).collect(), + tag_ids: vec![deleted.id.clone()], + } + ) + .is_err() + ); + assert!( + connection + .execute( + "INSERT INTO typed_ticket_tags VALUES ('foreign',?1,?2)", + params![id, deleted.id] + ) + .is_err() + ); + let judgments = rows(&connection, "typed_ticket_case_history"); + migrate_sqlite_ticket_schema(&connection).unwrap(); + assert_eq!(rows(&connection, "typed_ticket_case_history"), judgments); + for (table, expected) in preserved.iter().zip(&before) { + assert_eq!( + &rows(&connection, table), + expected, + "replay changed {table}" + ); + } + let reopened = backend + .update_case( + &id, + case_request(&ticket, "reopen", TicketCaseAction::Reopen), + ) + .unwrap(); + assert_eq!(reopened.tags, ticket.tags); + assert_eq!(reopened.case_history[0], ticket.case_history[0]); + assert_eq!( + ticket_content_digest(&reopened), + ticket_content_digest(&ticket) + ); + assert!(reopened.current_closure.is_none()); + let fk_failures: i64 = connection + .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(fk_failures, 0); +} + +#[test] +fn independent_case_schema9_adds_only_tags_without_reapplying_judgments_or_receipts() { + let temp = TempDir::new().unwrap(); + let source_path = temp.path().join("case-operations.db"); + let source = SqliteTicketBackend::open(&source_path, "workspace") + .unwrap() + .with_event_attributes(std::collections::BTreeMap::from([ + ("source_runtime_id".into(), "runtime".into()), + ("source_worker_id".into(), "worker".into()), + ])); + let id = source.create(NewTicket::new("existing case")).unwrap().id; + let before = source.show(id.clone().into()).unwrap(); + let close = case_request( + &before, + "close", + TicketCaseAction::Close { + reason: TicketClosureReason::Duplicate, + }, + ); + let closed = source.update_case(&id, close.clone()).unwrap(); + let corrected = source + .update_case( + &id, + case_request( + &closed, + "correct", + TicketCaseAction::CorrectClosure { + reason: TicketClosureReason::NotPlanned, + }, + ), + ) + .unwrap(); + let reopened = source + .update_case( + &id, + case_request(&corrected, "reopen", TicketCaseAction::Reopen), + ) + .unwrap(); + let path = temp.path().join("retained9.db"); + let connection = Connection::open(&path).unwrap(); + schema7(&connection); + connection + .execute_batch(include_str!("fixtures/schema9_cases.sql")) + .unwrap(); + connection + .execute("UPDATE ticket_schema_migrations SET version=9", []) + .unwrap(); + let tables = [ + "typed_tickets", + "typed_ticket_case_metadata", + "typed_ticket_case_history", + "typed_ticket_legacy_workflow_snapshot", + "typed_ticket_events", + "typed_ticket_event_attributes", + "typed_ticket_event_references", + "workspace_resource_keys", + "workspace_resource_key_counters", + ]; + copy_tables(&connection, &source_path, &tables); + // A migrated closed case already judged Unknown by standalone9 must remain + // exactly that judgment, with its original migration actor/key and handoff. + connection.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit,resolution) VALUES ('workspace','old9','old9','Old migrated case','closed','task','P2','criteria','closed',1,'Original legacy close')", []).unwrap(); + let snapshot = TicketLegacyWorkflowSnapshot { + workflow_state: "done".into(), + workflow_state_explicit: true, + status: "done".into(), + queued_by: Some("original-router".into()), + queued_at: Some("original-time".into()), + resolution: Some("Original legacy close".into()), + }; + let judgment = TicketCaseHistoryEntry { + operation_key: "migration-v9:old9".into(), + previous_closure_operation_key: None, + action: TicketCaseAction::Close { + reason: TicketClosureReason::Unknown, + }, + explanation: "Original legacy close".into(), + references: vec![], + author: "ticket-schema-migration-v9".into(), + at: "original-migration-time".into(), + }; + connection + .execute( + "INSERT INTO typed_ticket_legacy_workflow_snapshot VALUES ('workspace','old9',?1)", + [serde_json::to_string(&snapshot).unwrap()], + ) + .unwrap(); + connection.execute("INSERT INTO typed_ticket_case_history VALUES ('workspace','old9',0,'migration-v9:old9','migration-v9',?1)", [serde_json::to_string(&judgment).unwrap()]).unwrap(); + connection.execute("INSERT INTO typed_ticket_case_metadata VALUES ('workspace','old9','migration-v9:old9')", []).unwrap(); + let original: Vec<_> = tables.iter().map(|t| rows(&connection, t)).collect(); + migrate_sqlite_ticket_schema(&connection).unwrap(); + migrate_sqlite_ticket_schema(&connection).unwrap(); + for (table, expected) in tables.iter().zip(&original) { + assert_eq!( + &rows(&connection, table), + expected, + "changed independent9 {table}" + ); + } + assert!(rows(&connection, "workspace_tags").is_empty()); + let backend = SqliteTicketBackend::open_verified(&path, "workspace") + .unwrap() + .with_event_attributes(std::collections::BTreeMap::from([ + ("source_runtime_id".into(), "runtime".into()), + ("source_worker_id".into(), "worker".into()), + ])); + let old9 = backend.show("old9".into()).unwrap(); + assert_eq!(old9.case_history, vec![judgment]); + assert_eq!(old9.legacy_workflow_snapshot, Some(snapshot)); + assert_eq!( + old9.current_closure.unwrap().reason, + TicketClosureReason::Unknown + ); + assert_eq!(backend.show(id.clone().into()).unwrap(), reopened); + assert_eq!(backend.update_case(&id, close.clone()).unwrap(), reopened); + let other = backend + .clone() + .with_event_attributes(std::collections::BTreeMap::from([ + ("source_runtime_id".into(), "runtime".into()), + ("source_worker_id".into(), "other-worker".into()), + ])); + assert!(matches!( + other.update_case(&id, close), + Err(TicketError::OperationFingerprintMismatch { .. }) + )); + assert_eq!(rows(&connection, "typed_ticket_case_history"), original[2]); +} + +#[test] +fn migration_inside_host_transaction_is_resumable_and_never_commits_host_writes() { + let connection = Connection::open_in_memory().unwrap(); + schema7(&connection); + verify_pre_classification_ticket_schema(&connection).unwrap(); + assert!(migrate_sqlite_ticket_schema_in_transaction(&connection).is_err()); + connection.execute_batch("CREATE TABLE host_work (value TEXT); BEGIN IMMEDIATE; INSERT INTO host_work VALUES ('pending');").unwrap(); + migrate_sqlite_ticket_schema_in_transaction(&connection).unwrap(); + verify_sqlite_ticket_schema(&connection).unwrap(); + assert!(!connection.is_autocommit()); + connection.execute_batch("ROLLBACK").unwrap(); + verify_pre_classification_ticket_schema(&connection).unwrap(); + assert!(rows(&connection, "host_work").is_empty()); + connection.execute_batch("BEGIN IMMEDIATE").unwrap(); + migrate_sqlite_ticket_schema_in_transaction(&connection).unwrap(); + migrate_sqlite_ticket_schema_in_transaction(&connection).unwrap(); + connection.execute_batch("COMMIT").unwrap(); + verify_sqlite_ticket_schema(&connection).unwrap(); +} + +#[test] +fn falsely_numbered_schema9_and_weakened_immutable_triggers_are_rejected_without_migration() { + for drift in [ + "UPDATE ticket_schema_migrations SET version=9", + "UPDATE ticket_schema_migrations SET version=9; DROP TRIGGER typed_ticket_case_history_no_update; CREATE TRIGGER typed_ticket_case_history_no_update BEFORE UPDATE ON typed_ticket_case_history BEGIN SELECT 1; END;", + ] { + let connection = Connection::open_in_memory().unwrap(); + schema7(&connection); + if drift.contains("DROP TRIGGER") { + connection + .execute_batch(include_str!("fixtures/schema9_cases.sql")) + .unwrap(); + } + connection.execute_batch(drift).unwrap(); + let marker = rows(&connection, "ticket_schema_migrations"); + assert!(migrate_sqlite_ticket_schema(&connection).is_err()); + assert_eq!(rows(&connection, "ticket_schema_migrations"), marker); + let tags: i64 = connection + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE name='workspace_tags'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(tags, 0); + } +} + +#[test] +fn failed_component_conversion_rolls_back_its_ddl_and_keeps_host_transaction_retryable() { + let connection = Connection::open_in_memory().unwrap(); + schema7(&connection); + connection.execute_batch("CREATE TABLE host_work (value TEXT); + INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) + VALUES ('workspace','ticket','ticket','Case','open','task','P2','criteria','ready',1); + CREATE TRIGGER host_abort_case_update BEFORE UPDATE OF workflow_state ON typed_tickets + BEGIN SELECT RAISE(ABORT,'host failure'); END; + BEGIN IMMEDIATE; INSERT INTO host_work VALUES ('retained host work');").unwrap(); + assert!(migrate_sqlite_ticket_schema_in_transaction(&connection).is_err()); + assert!(!connection.is_autocommit()); + verify_pre_classification_ticket_schema(&connection).unwrap(); + assert_eq!(rows(&connection, "host_work").len(), 1); + let tags: i64 = connection + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE name='workspace_tags'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(tags, 0, "DDL before failed state conversion must roll back"); + connection + .execute_batch("DROP TRIGGER host_abort_case_update") + .unwrap(); + migrate_sqlite_ticket_schema_in_transaction(&connection).unwrap(); + connection.execute_batch("COMMIT").unwrap(); + verify_sqlite_ticket_schema(&connection).unwrap(); + assert_eq!(rows(&connection, "host_work").len(), 1); +} + +#[test] +fn retained_workspace_foreign_keys_survive_composition_without_rebuilding_ticket_or_tags() { + let connection = Connection::open_in_memory().unwrap(); + connection + .pragma_update(None, "foreign_keys", "ON") + .unwrap(); + connection.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY); INSERT INTO workspaces VALUES ('workspace');").unwrap(); + // Ticket accepts stricter host-owned Workspace references and must preserve + // them, not rebuild parents against its weaker standalone authority schema. + let schema = include_str!("fixtures/schema7.sql"); + let (before, ticket) = schema.split_once("CREATE TABLE typed_tickets (").unwrap(); + let (ticket, after) = ticket + .split_once("CREATE TABLE typed_ticket_targets (") + .unwrap(); + let ticket = ticket.replace("PRIMARY KEY (workspace_id, ticket_id)", + "PRIMARY KEY (workspace_id, ticket_id), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE"); + connection + .execute_batch(&format!( + "{before}CREATE TABLE typed_tickets ({ticket}CREATE TABLE typed_ticket_targets ({after}" + )) + .unwrap(); + let tags = include_str!("fixtures/schema8_tags.sql") + .replace("PRIMARY KEY (workspace_id, tag_id)", "PRIMARY KEY (workspace_id, tag_id), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE") + .replace("PRIMARY KEY (workspace_id, operation_key)", "PRIMARY KEY (workspace_id, operation_key), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE"); + connection.execute_batch(&tags).unwrap(); + connection.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(8,'ticket schema baseline','retained'); + INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) + VALUES('workspace','ticket','ticket','Case','done','task','P2','criteria','done',1); + INSERT INTO workspace_tags VALUES('workspace','tag','closed','closed','create',0); + INSERT INTO typed_ticket_tags VALUES('workspace','ticket','tag');").unwrap(); + let signature = |table| { + let mut stmt = connection + .prepare(&format!( + "SELECT * FROM pragma_foreign_key_list('{table}') ORDER BY id,seq" + )) + .unwrap(); + let count = stmt.column_count(); + stmt.query_map([], |row| { + (0..count) + .map(|i| row.get::<_, rusqlite::types::Value>(i)) + .collect::>>() + }) + .unwrap() + .collect::>>() + .unwrap() + }; + let tables = [ + "typed_tickets", + "workspace_tags", + "workspace_tag_operations", + "typed_ticket_tags", + ]; + let before: Vec<_> = tables.iter().map(|t| signature(t)).collect(); + verify_legacy_sqlite_ticket_schema(&connection).unwrap(); + migrate_sqlite_ticket_schema(&connection).unwrap(); + for (table, expected) in tables.iter().zip(before) { + assert_eq!(signature(table), expected, "lost host FK on {table}"); + } + assert!( + connection + .execute( + "INSERT INTO workspace_tags VALUES('foreign','tag','open','open','create',0)", + [] + ) + .is_err() + ); + assert!( + connection + .execute( + "INSERT INTO typed_ticket_tags VALUES('foreign','ticket','tag')", + [] + ) + .is_err() + ); + let failures: i64 = connection + .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(failures, 0); +} diff --git a/crates/worker/src/feature/builtin/resource_projection.rs b/crates/worker/src/feature/builtin/resource_projection.rs index 9834335fe..ae18dccbd 100644 --- a/crates/worker/src/feature/builtin/resource_projection.rs +++ b/crates/worker/src/feature/builtin/resource_projection.rs @@ -17,6 +17,7 @@ struct ModelTicketQueryItem { closure_operation_key: Option, current_closure: Option, legacy_handoff: Option, + tags: Vec, readiness: Option, priority: Option, created_at: Option, @@ -45,6 +46,7 @@ pub(super) struct ModelTicketDetail { closure_operation_key: Option, current_closure: Option, legacy_handoff: Option, + tags: Vec, readiness: Option, priority: Option, created_at: Option, @@ -101,8 +103,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, @@ -256,6 +266,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") { @@ -640,6 +672,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) @@ -720,7 +770,7 @@ mod tests { fn mrless_evidence_is_inapplicable_not_missing_or_vacuously_approved() { for state in ["open", "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", "closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null, "state": state, "events": [{"sequence": 1, "kind": "completed", "body": "No Git needed"}], "linked_objectives": [], "assignments": [], "implementation_reports": [], @@ -762,6 +812,7 @@ mod tests { "queued_by": "workspace-user:alice", "queued_at": "2025-12-31T00:00:00Z", "resolution": null }); let response = json!({ + "tags": [{"workspace_id":"ws", "id":"tag-1", "name":"closed", "last_operation_key":"tag-create", "deleted":false}], "resource_key": "T-739", "content_digest": "content-digest", "title": "Case", "body": "Requirements", "state": "open", "closure_operation_key": "close-1", "current_closure": null, "closure_history": history, "legacy_handoff": legacy, @@ -775,6 +826,8 @@ mod tests { assert_eq!(projected["current_closure"], Value::Null); assert_eq!(projected["closure_history"], history); assert_eq!(projected["legacy_handoff"], legacy); + assert_eq!(projected["tags"], response["tags"]); + assert_eq!(projected["content_digest"], "content-digest"); // Missing observations must not silently become null: the model must be // able to distinguish an observed never-closed case from unknown data. @@ -804,9 +857,9 @@ mod tests { "linked_tickets": ["00001M0E82D1V"], "linked_ticket_summaries": [{ "id": "00001M0E82D1V", - "resource_key": "T-496", + "resource_key": "T-496", "tags": [], "title": "Ticket", - "state": "done", + "state": "closed", "updated_at": "2026-01-02T00:00:00Z" }], "events": [{ @@ -835,7 +888,7 @@ mod tests { "record_authority": "workspace_sqlite", "items": [{ "id": "00001TICKETINTERNAL", - "resource_key": "T-543", + "resource_key": "T-543", "tags": [], "title": "Ticket", "state": "open", "closure_operation_key": null, "current_closure": null, "legacy_handoff": null, @@ -907,7 +960,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", "closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null, "title": "Queued Submit", @@ -927,7 +980,7 @@ mod tests { "reason_kind": "depends_on", "relation_kind": "depends_on", "note": "required foundation", - "blocking_state": "planning" + "blocking_state": "open" }], "notices": [] }, @@ -966,7 +1019,7 @@ mod tests { json!([{ "ticket": "T-584", "kind": "depends_on", - "state": "planning" + "state": "open" }]) ); assert!(!projected.to_string().contains("internal-blocker")); @@ -1011,7 +1064,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", "closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null, "title": "Source evidence", @@ -1192,4 +1245,123 @@ mod tests { .expect_err("missing W-key must fail"); assert!(error.contains("W-")); } + #[test] + fn ticket_query_and_detail_preserve_classification_separately_from_closure_judgment() { + let tag = json!({"workspace_id":"ws", "id":"tag-1", "name":"closed", "last_operation_key":"tag-create", "deleted":false}); + let closure = json!({"operation_key":"close-1", "reason":"not_planned", "explanation":"Not proceeding", "references":[{"kind":"ticket", "target":"T-12"}], "author":"workspace-user:alice", "at":"2026-01-01T00:00:00Z"}); + let history = json!([{"operation_key":"close-1", "previous_closure_operation_key":null, "action":{"action":"close", "reason":"not_planned"}, "explanation":"Not proceeding", "references":[{"kind":"ticket", "target":"T-12"}], "author":"workspace-user:alice", "at":"2026-01-01T00:00:00Z"}]); + let detail = json!({ + "resource_key":"T-739", "title":"Case", "body":"Requirements", "content_digest":"content-1", + "state":"closed", "tags":[tag], "closure_operation_key":"close-1", "current_closure":closure, + "legacy_handoff":null, "closure_history":history, "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[] + }); + let mut item = detail.clone(); + item["matched_fields"] = json!([]); + item["linked_objective_keys"] = json!([]); + for field in [ + "relation_count", + "blocker_count", + "unresolved_blocker_count", + "unresolved_review_count", + ] { + item[field] = json!(0); + } + let projected_detail = + serde_json::to_value(project_ticket_detail(detail.clone()).unwrap()).unwrap(); + let projected_query = serde_json::to_value( + project_ticket_query( + json!({"items":[item.clone()], "page":{"has_more":false, "next_cursor":null}}), + ) + .unwrap(), + ) + .unwrap(); + for field in [ + "tags", + "state", + "closure_operation_key", + "current_closure", + "legacy_handoff", + ] { + assert_eq!(projected_detail[field], detail[field], "detail {field}"); + assert_eq!( + projected_query["tickets"][0][field], detail[field], + "query {field}" + ); + } + assert_eq!(projected_detail["closure_history"], history); + assert_eq!(projected_detail["content_digest"], "content-1"); + // Unknown observations must not be replaced with empty/null values. + for field in [ + "tags", + "closure_operation_key", + "current_closure", + "legacy_handoff", + "state", + ] { + let mut missing_detail = detail.clone(); + missing_detail.as_object_mut().unwrap().remove(field); + assert!( + project_ticket_detail(missing_detail) + .unwrap_err() + .contains(field), + "detail {field}" + ); + let mut missing_item = item.clone(); + missing_item.as_object_mut().unwrap().remove(field); + assert!( + project_ticket_query( + json!({"items":[missing_item], "page":{"has_more":false, "next_cursor":null}}) + ) + .unwrap_err() + .contains(field), + "query {field}" + ); + } + for invalid_tags in [ + json!([{"id":"tag-1"}]), + json!(vec![detail["tags"][0].clone(); 101]), + { + let mut deleted = detail["tags"].clone(); + deleted[0]["deleted"] = json!(true); + deleted + }, + ] { + let mut malformed = detail.clone(); + malformed["tags"] = invalid_tags; + assert!(project_ticket_detail(malformed).is_err()); + } + for state in [ + "planning", + "ready", + "queued", + "inprogress", + "done", + "unknown", + ] { + let mut malformed = detail.clone(); + malformed["state"] = json!(state); + assert!( + project_ticket_detail(malformed) + .unwrap_err() + .contains("state"), + "{state}" + ); + } + } + + #[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 716a75a1c..1a3ced7c6 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}; @@ -78,13 +80,6 @@ impl WorkspaceTicketReadKind { } } -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -enum WorkspaceTicketStateFilter { - Open, - Closed, -} - #[derive(Debug, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] enum WorkspaceTicketEvidenceFilter { @@ -143,7 +138,7 @@ struct WorkspaceQueryTicketInput { query: Option, /// Exact case states (open or closed). Empty means every state. #[serde(default)] - states: Vec, + states: Vec, /// Exact typed event kinds that must occur in the bounded thread window. #[serde(default)] event_kinds: Vec, @@ -161,6 +156,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, @@ -204,6 +211,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) @@ -429,6 +443,7 @@ fn workspace_ticket_decision_definition( fn workspace_ticket_tool_description(name: &str) -> String { match name { "TicketCaseUpdate" => TICKET_CASE_UPDATE_DESCRIPTION.into(), + name if tags::TAG_TOOL_NAMES.contains(&name) => tags::description(name).into(), _ => ticket_tool_description(name, None), } } @@ -558,6 +573,9 @@ impl TicketFeatureAccess { .iter() .copied() .filter(|name| self.allows_tool(name)) + .chain(tags::TAG_TOOL_NAMES.iter().copied().filter(|name| { + *name == "WorkspaceTagList" || self.authoring || self.workflow || self.thread + })) .collect() } @@ -574,7 +592,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", @@ -602,6 +620,11 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketCaseUpdate", "TicketRelationRecord", "TicketRelationRemove", + "WorkspaceTagList", + "WorkspaceTagCreate", + "WorkspaceTagRename", + "WorkspaceTagDelete", + "TicketTagsSet", ]; #[cfg(test)] @@ -615,6 +638,11 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[ "TicketRelationRemove", "TicketOrchestrationPlanRecord", "TicketOrchestrationPlanQuery", + "WorkspaceTagList", + "WorkspaceTagCreate", + "WorkspaceTagRename", + "WorkspaceTagDelete", + "TicketTagsSet", ]; const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[ @@ -900,6 +928,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, @@ -1153,6 +1207,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(), @@ -1354,6 +1437,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, @@ -1458,7 +1571,7 @@ pub(crate) fn enabled_ticket_definitions( ) -> Vec { let backend = TicketToolBackend::new(WorkspaceHttpTicketBackend::new(client.clone())); let allowed = access.tool_names(); - ticket_tools(backend) + let mut definitions: Vec = ticket_tools(backend) .into_iter() .filter_map(|definition| { let (meta, _) = definition(); @@ -1479,7 +1592,19 @@ pub(crate) fn enabled_ticket_definitions( _ => definition, }) }) - .collect() + .collect(); + 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 } fn native_ticket_tools( @@ -1713,13 +1838,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( @@ -2583,6 +2711,11 @@ mod tests { "case_update", "record_relation", "remove_relation", + "tag_list", + "tag_create", + "tag_rename", + "tag_delete", + "tags_set", ] ); let item = ticket_descriptor( @@ -2630,6 +2763,11 @@ mod tests { "remove_relation", "record_orchestration_plan", "query_orchestration_plans", + "tag_list", + "tag_create", + "tag_rename", + "tag_delete", + "tags_set", ] ); assert_eq!( @@ -2638,7 +2776,7 @@ mod tests { .iter() .map(|tool| tool.projection.operation) .collect::>(), - ["query", "read"] + ["query", "read", "tag_list"] ); } @@ -2933,17 +3071,12 @@ mod tests { assert!(show.input_schema["properties"]["event_limit"].is_object()); let tool_names = TicketFeatureAccess::workspace_authoring().tool_names(); assert_eq!(tool_names, WORKSPACE_AUTHORING_TOOL_NAMES); - assert!( - tool_names.len() < 13, - "authoring catalog must stay below the prior broad catalog" - ); let workflow_names = TicketFeatureAccess::workflow().tool_names(); assert_eq!(workflow_names, WORKFLOW_TOOL_NAMES); - assert!( - workflow_names.len() < 12, - "workflow catalog must stay below the prior broad catalog" + 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..792ca3790 --- /dev/null +++ b/crates/worker/src/feature/builtin/ticket/tags/tests.rs @@ -0,0 +1,561 @@ +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":"open", "closure_operation_key":null, "current_closure":null, "legacy_handoff":null, "closure_history":[], "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"], "states":["open"], "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()])); + assert_eq!(value["tickets"][0]["state"], "open"); + for field in ["closure_operation_key", "current_closure", "legacy_handoff"] { + assert!( + value["tickets"][0].as_object().unwrap().contains_key(field), + "{field}" + ); + assert_eq!(value["tickets"][0][field], Value::Null); + } + { + let requests = client.requests.lock().unwrap(); + let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap(); + for field in ["states", "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":"open", "closure_operation_key":null, "current_closure":null, "legacy_handoff":null, "closure_history":[], "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"); + assert_eq!(value["state"], "open"); + assert_eq!(value["closure_history"], json!([])); + for field in ["closure_operation_key", "current_closure", "legacy_handoff"] { + assert!(value.as_object().unwrap().contains_key(field), "{field}"); + assert_eq!(value[field], Value::Null); + } + 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(_)) + )); + } + for state in [ + "planning", + "ready", + "queued", + "inprogress", + "done", + "invalid", + ] { + let input = json!({"states":[state], "tag_ids":["tag-1"]}); + assert!( + matches!( + tool.execute(&input.to_string(), ToolExecutionContext::direct()) + .await, + Err(ToolError::InvalidArgument(_)) + ), + "{state}" + ); + } + 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, TicketState::Open); + assert!(ticket.current_closure.is_none()); + assert!(ticket.closure_operation_key.is_none()); + assert!(ticket.case_history.is_empty()); + 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 8c0b176c5..dd4d30ebb 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!( @@ -908,6 +923,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(), @@ -1954,6 +1970,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 evidence in &query.evidence { if !matches!( evidence.as_str(), @@ -2142,7 +2165,19 @@ fn ticket_matches_query( authoritative_events: &[TicketEvent], query: &TicketQueryRequest, ) -> bool { - if !query.states.is_empty() && !query.states.iter().any(|state| state == &summary.state) { + 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| server_api::BrowserTicketState::from(*state) == summary.state) + { return false; } if query @@ -2300,6 +2335,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, @@ -2659,6 +2695,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, @@ -2690,6 +2727,7 @@ fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> Result Rou .merge(server_api::server_api_axum::ticket_case_update( 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(), )) @@ -10297,6 +10306,7 @@ impl server_api::ServerApi for ServerApiContractService { kind: meta.kind, priority: meta.priority, labels: meta.labels, + tags: updated.tags, readiness: meta.readiness, workflow_state: meta.workflow_state, workflow_state_explicit: meta.workflow_state_explicit, @@ -10306,6 +10316,81 @@ impl server_api::ServerApi for ServerApiContractService { })) } + 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_context( + self.workspace_api()?, + &workspace_id, + contract_request_headers(&context)?, + Some(&context), + 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_context( + self.workspace_api()?, + &workspace_id, + contract_request_headers(&context)?, + Some(&context), + 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_context( + self.workspace_api()?, + &workspace_id, + contract_request_headers(&context)?, + Some(&context), + 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, @@ -14351,6 +14436,16 @@ async fn execute_ticket_rest_operation( api: &WorkspaceApi, workspace_id: &str, headers: HeaderMap, + operation: TicketBackendOperation, +) -> ApiResult { + execute_ticket_rest_operation_with_context(api, workspace_id, headers, None, operation).await +} + +async fn execute_ticket_rest_operation_with_context( + api: &WorkspaceApi, + workspace_id: &str, + headers: HeaderMap, + context: Option<&server_api::ServerRequestContext>, mut operation: TicketBackendOperation, ) -> ApiResult { validate_workspace_scope(api, workspace_id)?; @@ -14379,7 +14474,31 @@ async fn execute_ticket_rest_operation( // Human clients are authorized by the Workspace route boundary. Runtime-forwarded Worker // calls carry source headers; when either header is present, the complete pair is required // and authenticated before source attribution is attached. - let source = optional_worker_mutation_source(api, workspace_id, &headers)?; + let hinted_source = optional_worker_mutation_source(api, workspace_id, &headers)?; + // The signed Workspace request carries verified identity in the middleware + // extension, independently of optional legacy source hints. Classification + // receipts and audit must not collapse distinct Workers into a human/no-source + // identity just because those redundant headers are absent. + let verified_source = context + .and_then(|context| context.runtime_source.as_ref()) + .map(|source| { + let worker_id = source.worker_id.as_ref().ok_or_else(|| { + Error::WorkspacePermissionDenied( + "Classification requests require a verified Runtime Worker identity".into(), + ) + })?; + Ok::<_, Error>(RuntimeWorkerRef::new(&source.runtime_id, worker_id)) + }) + .transpose()?; + if let (Some(verified), Some(hinted)) = (&verified_source, &hinted_source) + && verified != hinted + { + return Err(Error::WorkspacePermissionDenied( + "Classification source hints do not match the verified Runtime Worker".into(), + ) + .into()); + } + let source = verified_source.or(hinted_source); validate_ticket_repository_operation(api, &operation)?; let before = target.as_ref().and_then(|id| backend.show(id.clone()).ok()); if let Some(source) = source.as_ref() @@ -14408,6 +14527,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) = context.and_then(|context| context.actor.as_ref()) + { + 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( @@ -14495,7 +14620,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( @@ -16558,6 +16684,7 @@ fn ticket_mutation_target(operation: &TicketBackendOperation) -> Option<&TicketI | TicketBackendOperation::AddIntakeSummary { id, .. } | TicketBackendOperation::MarkReady { id, .. } | TicketBackendOperation::QueueReady { id, .. } + | TicketBackendOperation::SetTicketTags { id, .. } | TicketBackendOperation::AddTicketRelation { id, .. } | TicketBackendOperation::RemoveTicketRelation { id, .. } | TicketBackendOperation::AddOrchestrationPlanRecord { id, .. } => Some(id), @@ -16621,6 +16748,8 @@ fn ticket_mutation_operation_kind(operation: &TicketBackendOperation) -> &'stati TicketBackendOperation::AddIntakeSummary { .. } => "add_intake_summary", TicketBackendOperation::MarkReady { .. } => "mark_ready", TicketBackendOperation::QueueReady { .. } => "queue_ready", + TicketBackendOperation::MutateWorkspaceTag { .. } => "mutate_workspace_tag", + TicketBackendOperation::SetTicketTags { .. } => "set_ticket_tags", TicketBackendOperation::AddTicketRelation { .. } => "add_relation", TicketBackendOperation::RemoveTicketRelation { .. } => "remove_relation", TicketBackendOperation::AddOrchestrationPlanRecord { .. } => "add_plan_record", @@ -32252,6 +32381,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..b636a8356 --- /dev/null +++ b/crates/workspace-server/src/server_ticket_tags_tests.rs @@ -0,0 +1,971 @@ +use super::*; + +fn tag_test_case_update( + backend: &ticket::SqliteTicketBackend, + id: &str, + key: &str, + action: ticket::TicketCaseAction, +) -> ticket::Ticket { + let current = backend.show(id.into()).unwrap(); + backend + .update_case( + id, + ticket::TicketCaseUpdate { + expected_content_digest: ticket::ticket_content_digest(¤t), + expected_state: current.meta.workflow_state, + expected_closure_operation_key: current.closure_operation_key, + operation_key: key.into(), + action, + explanation: "Independent case judgment".into(), + references: vec![], + author: Some("case-fixture".into()), + }, + ) + .unwrap() +} + +// Exercise the generated tag routes behind real Runtime authentication. Each +// operation retry gets a fresh proof; an HTTP proof itself is single-use. +async fn runtime_tag_response( + app: Router, + request: Request, + expected: StatusCode, +) -> serde_json::Value { + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(status, expected, "{}", String::from_utf8_lossy(&body)); + if body.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&body).unwrap_or_else(|error| { + panic!( + "invalid JSON response: {error}: {}", + String::from_utf8_lossy(&body) + ) + }) + } +} + +async fn runtime_tag_json( + app: Router, + identity: &RuntimeIdentityMaterial, + worker_id: &str, + method: &str, + path: &str, + input: serde_json::Value, + expected: StatusCode, +) -> serde_json::Value { + runtime_tag_response( + app, + runtime_source_request( + identity, + Some(worker_id), + method, + path, + serde_json::to_vec(&input).unwrap(), + ), + expected, + ) + .await +} + +fn tag_execution_authority_snapshot(api: &WorkspaceApi) -> Vec>> { + let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); + [ + "worker_registry", + "worker_control_grants", + "ticket_current_worker_assignments", + "ticket_assignment_operations", + "backend_jobs", + ] + .into_iter() + .map(|table| { + let mut statement = conn + .prepare(&format!( + "SELECT * FROM {table} WHERE workspace_id=?1 ORDER BY rowid" + )) + .unwrap(); + let columns = statement.column_count(); + statement + .query_map([TEST_WORKSPACE_ID], |row| { + (0..columns) + .map(|column| row.get(column)) + .collect::>>() + }) + .unwrap() + .collect::>>() + .unwrap() + }) + .collect() +} + +#[tokio::test] +async fn runtime_tag_mutations_replay_original_receipts_and_preserve_execution_authority() { + let dir = tempfile::tempdir().unwrap(); + let (mut api, execution) = test_api_with_recording_backend(dir.path()).await; + let identity = RuntimeIdentityMaterial::generate("runtime-tags").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-tags"); + let source = RuntimeWorkerRef::new("runtime-tags", "classifier"); + let recipient = RuntimeWorkerRef::new("runtime-tags", "recipient"); + seed_worker_source_member(&api, &source.runtime_id, &source.worker_id); + seed_worker_source_member(&api, &recipient.runtime_id, &recipient.worker_id); + seed_worker_control_grant(&api, &source, &recipient, "existing-control"); + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend + .create(ticket::NewTicket::new("Classification is not a decision")) + .unwrap(); + // Routing is an immutable receipt on an open case, not a six-state value. + let Json(queue) = scoped_queue_ticket_record( + State(api.clone()), + AxumPath((TEST_WORKSPACE_ID.into(), reference.id.clone())), + HeaderMap::new(), + ) + .await + .unwrap(); + assert_eq!(queue.queued_tickets, vec![reference.id.clone()]); + assert_eq!( + backend + .show(reference.id.clone().into()) + .unwrap() + .meta + .workflow_state, + ticket::TicketState::Open + ); + api.store + .set_current_ticket_role_assignment( + &TicketRoleAssignmentRecord { + workspace_id: TEST_WORKSPACE_ID.into(), + ticket_id: reference.id.clone(), + assignment_id: "existing-assignment".into(), + role: TicketAssignmentRole::Worker, + principal: TicketAssignmentPrincipal::Worker { + runtime_id: recipient.runtime_id.clone(), + worker_id: recipient.worker_id.clone(), + }, + assigned_by: "test".into(), + assigned_at: TEST_CREATED_AT.into(), + }, + None, + "existing-assignment-event", + "existing-assignment-operation", + false, + ) + .unwrap(); + let authority_before = tag_execution_authority_snapshot(&api); + let ticket_before = backend.show(reference.id.clone().into()).unwrap(); + let app = build_router(api.clone()); + let tags_path = format!("/api/w/{TEST_WORKSPACE_ID}/ticket-tags"); + let membership_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}/tags", reference.id); + let create = + json!({"operation_key":"runtime-create", "action":{"kind":"create", "name":" ready "}}); + let tag = runtime_tag_json( + app.clone(), + &identity, + &source.worker_id, + "POST", + &tags_path, + create.clone(), + StatusCode::OK, + ) + .await; + assert_eq!(tag["name"], "ready"); + assert_eq!(tag["workspace_id"], TEST_WORKSPACE_ID); + let attach = + json!({"operation_key":"runtime-attach", "expected_tag_ids":[], "tag_ids":[tag["id"]]}); + let attached = runtime_tag_json( + app.clone(), + &identity, + &source.worker_id, + "PUT", + &membership_path, + attach.clone(), + StatusCode::OK, + ) + .await; + assert_eq!(attached, json!([tag.clone()])); + assert_eq!( + runtime_tag_json( + app.clone(), + &identity, + &source.worker_id, + "PUT", + &membership_path, + attach.clone(), + StatusCode::OK + ) + .await, + attached + ); + let rename = json!({"operation_key":"runtime-rename", "action":{"kind":"rename", "tag_id":tag["id"], "expected_last_operation_key":"runtime-create", "name":"Ready now"}}); + let renamed = runtime_tag_json( + app.clone(), + &identity, + &source.worker_id, + "POST", + &tags_path, + rename.clone(), + StatusCode::OK, + ) + .await; + assert_eq!(renamed["id"], tag["id"]); + assert_eq!(renamed["name"], "Ready now"); + assert_eq!( + serde_json::to_value(&backend.show(reference.id.clone().into()).unwrap().tags).unwrap(), + json!([renamed.clone()]) + ); + let delete = json!({"operation_key":"runtime-delete", "action":{"kind":"delete", "tag_id":tag["id"], "expected_last_operation_key":"runtime-rename"}}); + let deleted = runtime_tag_json( + app.clone(), + &identity, + &source.worker_id, + "POST", + &tags_path, + delete.clone(), + StatusCode::OK, + ) + .await; + assert_eq!(deleted["deleted"], true); + assert!( + backend + .show(reference.id.clone().into()) + .unwrap() + .tags + .is_empty() + ); + // Receipts return their original snapshots, even after rename/delete changed + // live state. Retries must not recreate membership or duplicate audit events. + for (input, expected) in [(create, tag), (rename, renamed), (delete, deleted)] { + assert_eq!( + runtime_tag_json( + app.clone(), + &identity, + &source.worker_id, + "POST", + &tags_path, + input, + StatusCode::OK + ) + .await, + expected + ); + } + assert_eq!( + runtime_tag_json( + app.clone(), + &identity, + &source.worker_id, + "PUT", + &membership_path, + attach, + StatusCode::OK + ) + .await, + attached + ); + runtime_tag_json(app.clone(), &identity, &source.worker_id, "PUT", &membership_path, + json!({"operation_key":"runtime-stale-set", "expected_tag_ids":[attached[0]["id"]], "tag_ids":[]}), StatusCode::CONFLICT).await; + let empty = runtime_tag_json( + app, + &identity, + &source.worker_id, + "PUT", + &membership_path, + json!({"operation_key":"runtime-empty-set", "expected_tag_ids":[], "tag_ids":[]}), + StatusCode::OK, + ) + .await; + assert_eq!(empty, json!([])); + let ticket_after = backend.show(reference.id.into()).unwrap(); + assert_eq!( + ticket::ticket_content_digest(&ticket_before), + ticket::ticket_content_digest(&ticket_after) + ); + assert_eq!( + ticket_before.meta.workflow_state, + ticket_after.meta.workflow_state + ); + assert_eq!(ticket_before.current_closure, ticket_after.current_closure); + assert_eq!(ticket_before.case_history, ticket_after.case_history); + assert_eq!(ticket_before.meta.queued_by, ticket_after.meta.queued_by); + assert_eq!(ticket_before.meta.queued_at, ticket_after.meta.queued_at); + assert_eq!( + ticket_before + .events + .iter() + .filter(|event| event.kind != ticket::TicketEventKind::TagsChanged) + .collect::>(), + ticket_after + .events + .iter() + .filter(|event| event.kind != ticket::TicketEventKind::TagsChanged) + .collect::>() + ); + assert!(ticket_after.tags.is_empty()); + assert_eq!(tag_execution_authority_snapshot(&api), authority_before); + assert!( + execution.protocol_methods().is_empty(), + "classification dispatched execution/routing" + ); + let tag_events: Vec<_> = ticket_after + .events + .iter() + .filter(|event| event.kind == ticket::TicketEventKind::TagsChanged) + .collect(); + assert_eq!( + tag_events.len(), + 2, + "attach/delete retries duplicated membership audit" + ); + let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); + let mut statement = conn.prepare("SELECT operation_key,source_context_json FROM workspace_tag_operations WHERE workspace_id=?1 ORDER BY operation_key").unwrap(); + let receipts = statement + .query_map([TEST_WORKSPACE_ID], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .unwrap() + .collect::>>() + .unwrap(); + assert_eq!(receipts.len(), 5); + for (key, context) in receipts { + let context: serde_json::Value = serde_json::from_str(&context).unwrap(); + assert_eq!( + context["source_runtime_id"], source.runtime_id, + "receipt {key}: {context}" + ); + assert_eq!( + context["source_worker_id"], source.worker_id, + "receipt {key}: {context}" + ); + assert!( + context.get("source_account_id").is_none(), + "signed source became a human actor: {context}" + ); + } + for event in tag_events { + assert_eq!( + event.attributes.get("source_runtime_id"), + Some(&source.runtime_id) + ); + assert_eq!( + event.attributes.get("source_worker_id"), + Some(&source.worker_id) + ); + } +} + +#[tokio::test] +async fn runtime_tag_receipt_replay_is_bound_to_verified_worker_not_transport_proof() { + let dir = tempfile::tempdir().unwrap(); + let mut api = test_api(dir.path()).await; + let identity = RuntimeIdentityMaterial::generate("runtime-tags").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-tags"); + for worker in ["classifier", "other"] { + seed_worker_source_member(&api, "runtime-tags", worker); + } + let app = build_router(api.clone()); + let path = format!("/api/w/{TEST_WORKSPACE_ID}/ticket-tags"); + let input = + json!({"operation_key":"worker-receipt", "action":{"kind":"create", "name":"ready"}}); + let request = runtime_source_request( + &identity, + Some("classifier"), + "POST", + &path, + serde_json::to_vec(&input).unwrap(), + ); + let proof = + request.headers()[worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER].clone(); + let tag = runtime_tag_response(app.clone(), request, StatusCode::OK).await; + runtime_tag_response( + app.clone(), + Request::builder() + .method("POST") + .uri(&path) + .header(CONTENT_TYPE, "application/json") + .header( + worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER, + proof, + ) + .body(Body::from(serde_json::to_vec(&input).unwrap())) + .unwrap(), + StatusCode::UNAUTHORIZED, + ) + .await; + assert_eq!( + runtime_tag_json( + app.clone(), + &identity, + "classifier", + "POST", + &path, + input.clone(), + StatusCode::OK + ) + .await, + tag + ); + runtime_tag_json( + app.clone(), + &identity, + "classifier", + "POST", + &path, + json!({"operation_key":"worker-receipt", "action":{"kind":"create", "name":"changed"}}), + StatusCode::CONFLICT, + ) + .await; + runtime_tag_json( + app, + &identity, + "other", + "POST", + &path, + input, + StatusCode::CONFLICT, + ) + .await; + assert_eq!( + browser_ticket_backend(&api) + .unwrap() + .list_workspace_tags(ticket::TagListRequest::default()) + .unwrap() + .len(), + 1 + ); +} + +#[tokio::test] +async fn runtime_tag_mutations_reject_invalid_proofs_and_changed_binding_authority() { + let dir = tempfile::tempdir().unwrap(); + let (mut api, execution) = test_api_with_recording_backend(dir.path()).await; + let identity = RuntimeIdentityMaterial::generate("runtime-tags").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-tags"); + seed_worker_source_member(&api, "runtime-tags", "classifier"); + let backend = browser_ticket_backend(&api).unwrap(); + let ticket = backend + .create(ticket::NewTicket::new("Rejected classification")) + .unwrap(); + let ticket_before = backend.show(ticket.id.clone().into()).unwrap(); + let authority_before = tag_execution_authority_snapshot(&api); + let app = build_router(api.clone()); + let tags_path = format!("/api/w/{TEST_WORKSPACE_ID}/ticket-tags"); + let membership_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}/tags", ticket.id); + let attacker = RuntimeIdentityMaterial::generate("runtime-tags").unwrap(); + // Both mutation routes must reject before writing tags, memberships, receipts + // or audit events. There is no persisted verification status in this fixture. + for (method, path, input) in [ + ( + "POST", + tags_path.as_str(), + json!({"operation_key":"denied-create", "action":{"kind":"create", "name":"denied"}}), + ), + ( + "PUT", + membership_path.as_str(), + json!({"operation_key":"denied-set", "expected_tag_ids":[], "tag_ids":[]}), + ), + ] { + let body = serde_json::to_vec(&input).unwrap(); + let unsigned = Request::builder() + .method(method) + .uri(path) + .header(CONTENT_TYPE, "application/json") + .header("x-yoi-runtime-id", "runtime-tags") + .header("x-yoi-worker-id", "classifier") + .body(Body::from(body.clone())) + .unwrap(); + runtime_tag_response(app.clone(), unsigned, StatusCode::UNAUTHORIZED).await; + runtime_tag_json( + app.clone(), + &attacker, + "classifier", + method, + path, + input.clone(), + StatusCode::UNAUTHORIZED, + ) + .await; + runtime_tag_json( + app.clone(), + &identity, + "not-a-member", + method, + path, + input.clone(), + StatusCode::UNAUTHORIZED, + ) + .await; + let proof = worker_runtime::auth::RuntimeRequestSourceSigner::from_identity(&identity) + .issue( + "server-test", + "another-workspace", + Some("classifier"), + worker_runtime::auth::WORKSPACE_REQUEST_PERMISSION, + method, + path, + &body, + i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap(), + 30, + ) + .unwrap(); + runtime_tag_response( + app.clone(), + Request::builder() + .method(method) + .uri(path) + .header(CONTENT_TYPE, "application/json") + .header( + worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER, + proof, + ) + .body(Body::from(body.clone())) + .unwrap(), + StatusCode::UNAUTHORIZED, + ) + .await; + let mut tampered = + runtime_source_request(&identity, Some("classifier"), method, path, body); + *tampered.body_mut() = Body::from(format!("{} ", input)); + runtime_tag_response(app.clone(), tampered, StatusCode::UNAUTHORIZED).await; + } + let store = SqliteWorkspaceStore::open(&api.config.database_path).unwrap(); + let original = api + .store + .get_workspace_runtime_binding(TEST_WORKSPACE_ID, "runtime-tags") + .await + .unwrap() + .unwrap(); + let mut replacement = original.clone(); + replacement.public_key = attacker.public_key.clone(); + replacement.public_key_fingerprint = String::new(); + store + .upsert_workspace_runtime_binding(replacement, true) + .unwrap(); + for (method, path, input) in [ + ( + "POST", + tags_path.as_str(), + json!({"operation_key":"stale-key-create", "action":{"kind":"create", "name":"stale"}}), + ), + ( + "PUT", + membership_path.as_str(), + json!({"operation_key":"stale-key-set", "expected_tag_ids":[], "tag_ids":[]}), + ), + ] { + runtime_tag_json( + app.clone(), + &identity, + "classifier", + method, + path, + input, + StatusCode::UNAUTHORIZED, + ) + .await; + } + store + .upsert_workspace_runtime_binding(original, true) + .unwrap(); + store + .revoke_workspace_runtime_binding(TEST_WORKSPACE_ID, "runtime-tags", TEST_CREATED_AT) + .unwrap(); + for (method, path, input) in [ + ( + "POST", + tags_path.as_str(), + json!({"operation_key":"revoked-create", "action":{"kind":"create", "name":"revoked"}}), + ), + ( + "PUT", + membership_path.as_str(), + json!({"operation_key":"revoked-set", "expected_tag_ids":[], "tag_ids":[]}), + ), + ] { + runtime_tag_json( + app.clone(), + &identity, + "classifier", + method, + path, + input, + StatusCode::UNAUTHORIZED, + ) + .await; + } + assert!( + backend + .list_workspace_tags(ticket::TagListRequest::default()) + .unwrap() + .is_empty() + ); + assert_eq!( + serde_json::to_value(backend.show(ticket.id.into()).unwrap()).unwrap(), + serde_json::to_value(ticket_before).unwrap() + ); + assert_eq!(tag_execution_authority_snapshot(&api), authority_before); + assert!(execution.protocol_methods().is_empty()); + let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); + let receipts: i64 = conn + .query_row( + "SELECT COUNT(*) FROM workspace_tag_operations WHERE workspace_id=?1", + [TEST_WORKSPACE_ID], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(receipts, 0); +} + +#[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 reasoned = tag_test_case_update( + &backend, + &reference.id, + "reasoned-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::NotPlanned, + }, + ); + 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":"closed"} + }), + ) + .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", + "current_closure", + "closure_operation_key", + "legacy_handoff", + "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"]); + let classified = backend.show(reference.id.clone().into()).unwrap(); + assert_eq!(classified.current_closure, reasoned.current_closure); + assert_eq!(classified.case_history, reasoned.case_history); + let tag_history: Vec<_> = classified + .events + .iter() + .filter(|event| event.kind == ticket::TicketEventKind::TagsChanged) + .cloned() + .collect(); + let corrected = tag_test_case_update( + &backend, + &reference.id, + "correct-close", + ticket::TicketCaseAction::CorrectClosure { + reason: ticket::TicketClosureReason::Completed, + }, + ); + assert_eq!(corrected.tags, classified.tags); + assert_eq!(corrected.case_history[0], reasoned.case_history[0]); + let reopened = tag_test_case_update( + &backend, + &reference.id, + "reopen-case", + ticket::TicketCaseAction::Reopen, + ); + assert_eq!(reopened.tags, classified.tags); + assert_eq!( + reopened + .events + .iter() + .filter(|event| event.kind == ticket::TicketEventKind::TagsChanged) + .cloned() + .collect::>(), + tag_history + ); + assert!(execution.protocol_methods().is_empty()); +} + +#[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 f6b264061..013b0a4e2 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -13059,8 +13059,13 @@ fn apply_ticket_targets_and_workdir_capabilities_schema(conn: &Connection) -> Re TICKET_SCHEMA_BASELINE_NAME ], )?; - // This retained step composes the historical Ticket schema 7. - // Current-domain verification follows the shared case migration at schema 91. + // Verify the retained six-state v7 boundary here; the shared domain + // migrator installs classification and cases only after Workspace auth90. + ticket::verify_pre_classification_ticket_schema(db).map_err(|error| { + Error::Store(format!( + "schema-66 Ticket schema verification failed: {error}" + )) + })?; let foreign_key_violations = db.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { @@ -14547,6 +14552,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 IF EXISTS typed_ticket_tags; DROP TABLE IF EXISTS workspace_tag_operations; DROP TABLE IF EXISTS workspace_tags;")?; conn.execute_batch("DROP TABLE IF EXISTS worker_restore_intents;")?; conn.execute_batch( "DROP TABLE backend_job_deliveries; @@ -15586,7 +15594,7 @@ mod tests { migrate_ticket_targets_and_workdir_capabilities_v65_to_v66(&conn).unwrap(); assert_eq!(current_schema_version(&conn).unwrap(), 66); - // The retained step is Ticket-7; current verification runs after schema 91. + 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!( @@ -15899,7 +15907,10 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx fn create_frozen_schema_v85(conn: &Connection) -> Result<()> { // Compose the frozen Ticket-7 baseline, not current Ticket case tables. conn.execute_batch( - "DROP TABLE IF EXISTS typed_ticket_case_metadata; + "DROP TABLE IF EXISTS typed_ticket_tags; + DROP TABLE IF EXISTS workspace_tag_operations; + DROP TABLE IF EXISTS workspace_tags; + DROP TABLE IF EXISTS typed_ticket_case_metadata; DROP TABLE IF EXISTS typed_ticket_case_history; DROP TABLE IF EXISTS typed_ticket_legacy_workflow_snapshot; DELETE FROM ticket_schema_migrations; @@ -15984,11 +15995,12 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx "#, )?; conn.execute_batch(include_str!("assignment_work.sql"))?; - // Verification uses current schema 9 only after the real retained chain. + // Current composed Ticket verification follows the real retained chain. Ok(()) } include!("schema_equivalence_tests.rs"); + include!("ticket_tag_upgrade_tests.rs"); include!("migration_entrypoint_tests.rs"); include!("legacy_evidence_column_tests.rs"); @@ -18954,7 +18966,18 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx VALUES('case-workspace','case-runtime','pending-worker','Pending','normal','1','1')", []).unwrap(); conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,runtime_id,worker_id,created_at,request_fingerprint,claim_state) VALUES('case-workspace','pending-claim','assign','pending-queue','worker','case-runtime','pending-worker','1','pending-fingerprint','pending')", []).unwrap(); + // Compose real retained Ticket8 classification data across the full + // six-state × MR matrix, before either auth90 or case91 runs. + install_retained_ticket_tags8(&conn); + let retained_tags = seed_retained_classification(&conn, "case-workspace", "done-true"); + conn.execute("INSERT OR IGNORE INTO typed_ticket_tags(workspace_id,ticket_id,tag_id) + SELECT workspace_id,ticket_id,?1 FROM typed_tickets WHERE workspace_id='case-workspace'", + [&retained_tags.active_ids[0]]).unwrap(); let unchanged = [ + "workspace_tags", + "typed_ticket_tags", + "workspace_tag_operations", + "typed_ticket_labels", "typed_ticket_events", "typed_ticket_event_attributes", "typed_ticket_event_references", @@ -19049,6 +19072,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx for has_mr in [false, true] { let id = format!("{state}-{has_mr}"); let ticket = backend.show(id.clone().into()).unwrap(); + assert!(!ticket.tags.is_empty(), "matrix classification {id}"); assert_eq!(ticket.document.body.as_str(), "Original requirements"); let snapshot = ticket.legacy_workflow_snapshot.as_ref().unwrap(); assert_eq!(snapshot.workflow_state, state); @@ -19082,6 +19106,19 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx assert_eq!(reopened.meta.workflow_state, ticket::TicketState::Open); assert_eq!(reopened.case_history.len(), 2); assert_eq!(reopened.case_history[0], ticket.case_history[0]); + assert_eq!(reopened.tags, ticket.tags); + assert_eq!( + reopened + .events + .iter() + .filter(|event| event.kind == ticket::TicketEventKind::TagsChanged) + .collect::>(), + ticket + .events + .iter() + .filter(|event| event.kind == ticket::TicketEventKind::TagsChanged) + .collect::>() + ); assert!( store .get_active_ticket_worker_assignment("case-workspace", &id) @@ -19193,11 +19230,15 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx VALUES('case-workspace','terminal','terminal','Terminal','closed','task','P2','','done',1); DROP VIEW ticket_active_worker_assignments; CREATE TABLE ticket_active_worker_assignments(collision TEXT);").unwrap(); + install_retained_ticket_tags8(&conn); + let _tags = seed_retained_classification(&conn, "case-workspace", "terminal"); + let tags_before = classification_rows(&conn); let auth_before = fixture_auth_rows(&conn); let error = migrate_ticket_cases_v90_to_v91(&conn).unwrap_err(); assert!(error.to_string().contains("DROP TABLE")); assert_eq!(current_schema_version(&conn).unwrap(), 90); assert_eq!(fixture_auth_rows(&conn), auth_before); + assert_eq!(classification_rows(&conn), tags_before); // The shared transaction completed, but Workspace did not advertise a // composed schema. Its immutable evidence remains the retry authority. let before = stored_fixture_rows(&conn, "typed_ticket_case_history"); @@ -19207,6 +19248,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx migrate_ticket_cases_v90_to_v91(&conn).unwrap(); assert_eq!(current_schema_version(&conn).unwrap(), 91); assert_eq!(fixture_auth_rows(&conn), auth_before); + assert_eq!(classification_rows(&conn), tags_before); assert_eq!( stored_fixture_rows(&conn, "typed_ticket_case_history"), before @@ -19349,6 +19391,9 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx let upgraded = SqliteWorkspaceStore::open(&path).unwrap(); let fresh = SqliteWorkspaceStore::in_memory().unwrap(); for object in [ + "workspace_tags", + "typed_ticket_tags", + "workspace_tag_operations", "typed_tickets", "typed_ticket_targets", "typed_ticket_case_metadata", diff --git a/crates/workspace-server/src/ticket_tag_upgrade_tests.rs b/crates/workspace-server/src/ticket_tag_upgrade_tests.rs new file mode 100644 index 000000000..72c97c6d8 --- /dev/null +++ b/crates/workspace-server/src/ticket_tag_upgrade_tests.rs @@ -0,0 +1,550 @@ +// Included by store::tests. Genuine retained six-state Workspace89/90 fixtures +// compose auth90, classification and cases; no lowered current-schema markers. + +// Frozen classification schema from develop b1fe562e (Ticket component 8). +// Deliberate persisted compatibility input, independent of the latest baseline. +fn install_retained_ticket_tags8(conn: &Connection) { + ticket::verify_pre_classification_ticket_schema(conn).unwrap(); + conn.execute_batch(r#" + 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); + 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)); + UPDATE ticket_schema_migrations SET version=8; + "#).unwrap(); +} + +#[derive(Clone)] +struct RetainedClassification { + source: std::collections::BTreeMap, + request: ticket::TicketTagSetRequest, + receipt: Vec, + active_ids: Vec, + deleted_id: String, +} + +// Seed actual v8 persisted boundaries rather than invoking a latest-only API on +// a legacy database. Full source context is audit, only authenticated identity +// belongs to the replay fingerprint. It is deliberately not the assigned Worker. +fn seed_retained_classification( + conn: &Connection, + workspace: &str, + id: &str, +) -> RetainedClassification { + let source = std::collections::BTreeMap::from([ + ("source_runtime_id".into(), "case-runtime".into()), + ("source_worker_id".into(), "classifier".into()), + ("assigned_runtime_id".into(), "case-runtime".into()), + ("assigned_worker_id".into(), id.into()), + ("source_actor_role".into(), "ticket_worker".into()), + ( + "source_assignment_id".into(), + format!("old-assignment-{id}"), + ), + ]); + let identity = std::collections::BTreeMap::from([ + ("source_runtime_id", "case-runtime"), + ("source_worker_id", "classifier"), + ]); + let mut receipt = Vec::new(); + for (suffix, name, deleted) in [ + ("ready", "ready", false), + ("closed", "closed", false), + ("old", "Bug", true), + ("replacement", "Bug", false), + ] { + let tag = ticket::WorkspaceTag { + workspace_id: workspace.into(), + id: format!("{id}-{suffix}"), + name: name.into(), + last_operation_key: format!("{id}-{suffix}-create"), + deleted: false, + }; + let request = ticket::WorkspaceTagMutation { + operation_key: tag.last_operation_key.clone(), + action: ticket::WorkspaceTagAction::Create { name: name.into() }, + }; + conn.execute( + "INSERT INTO workspace_tags VALUES(?1,?2,?3,?4,?5,?6)", + params![ + workspace, + tag.id, + name, + name.to_lowercase(), + if deleted { + format!("{id}-delete") + } else { + tag.last_operation_key.clone() + }, + deleted + ], + ) + .unwrap(); + let input = + serde_json::to_string(&("mutate_workspace_tag", "", &request, &identity)).unwrap(); + conn.execute("INSERT INTO workspace_tag_operations VALUES(?1,?2,'create',?3,?4,?5,'null',?6,'original-time')", + params![workspace, request.operation_key, tag.id, input, serde_json::to_string(&source).unwrap(), serde_json::to_string(&tag).unwrap()]).unwrap(); + if suffix != "replacement" { + receipt.push(tag); + } + } + receipt.sort_by(|a, b| a.id.cmp(&b.id)); + let request = ticket::TicketTagSetRequest { + operation_key: format!("{id}-attach"), + expected_tag_ids: vec![], + tag_ids: receipt.iter().map(|tag| tag.id.clone()).collect(), + }; + let selector: ticket::TicketIdOrSlug = id.into(); + let input = + serde_json::to_string(&("set_ticket_tags", "", &(&selector, &request), &identity)).unwrap(); + conn.execute("INSERT INTO workspace_tag_operations VALUES(?1,?2,'set_ticket_tags',?3,?4,?5,'[]',?6,'original-time')", + params![workspace, request.operation_key, id, input, serde_json::to_string(&source).unwrap(), serde_json::to_string(&receipt).unwrap()]).unwrap(); + let active: Vec<_> = receipt + .iter() + .filter(|tag| !tag.id.ends_with("-old")) + .cloned() + .collect(); + let old = receipt.iter().find(|tag| tag.id.ends_with("-old")).unwrap(); + let mut deleted = old.clone(); + deleted.deleted = true; + deleted.last_operation_key = format!("{id}-delete"); + let delete_request = ticket::WorkspaceTagMutation { + operation_key: deleted.last_operation_key.clone(), + action: ticket::WorkspaceTagAction::Delete { + tag_id: old.id.clone(), + expected_last_operation_key: old.last_operation_key.clone(), + }, + }; + let input = + serde_json::to_string(&("mutate_workspace_tag", "", &delete_request, &identity)).unwrap(); + conn.execute("INSERT INTO workspace_tag_operations VALUES(?1,?2,'delete',?3,?4,?5,?6,?7,'original-time')", + params![workspace, delete_request.operation_key, old.id, input, serde_json::to_string(&source).unwrap(), serde_json::to_string(old).unwrap(), serde_json::to_string(&deleted).unwrap()]).unwrap(); + for tag in &active { + conn.execute( + "INSERT INTO typed_ticket_tags VALUES(?1,?2,?3)", + params![workspace, id, tag.id], + ) + .unwrap(); + } + conn.execute( + "INSERT INTO typed_ticket_labels VALUES(?1,?2,0,'ready')", + params![workspace, id], + ) + .unwrap(); + let next: i64 = conn.query_row("SELECT COALESCE(MAX(event_index)+1,0) FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",params![workspace,id],|row|row.get(0)).unwrap(); + for (offset, key, before, after) in [ + ( + 0, + request.operation_key.as_str(), + Vec::new(), + receipt.clone(), + ), + ( + 1, + delete_request.operation_key.as_str(), + receipt.clone(), + active.clone(), + ), + ] { + let body = + serde_json::json!({"operation_key":key,"before":before,"after":after}).to_string(); + conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,body) VALUES(?1,?2,?3,'tags_changed','classifier','original-time',?4)",params![workspace,id,next+offset,body]).unwrap(); + for (key, value) in &source { + conn.execute( + "INSERT INTO typed_ticket_event_attributes VALUES(?1,?2,?3,?4,?5)", + params![workspace, id, next + offset, key, value], + ) + .unwrap(); + } + } + let deleted_id = old.id.clone(); + RetainedClassification { + source, + request, + receipt, + active_ids: active.iter().map(|tag| tag.id.clone()).collect(), + deleted_id, + } +} + +fn classification_rows(conn: &Connection) -> Vec>> { + [ + "workspace_tags", + "typed_ticket_tags", + "workspace_tag_operations", + "typed_ticket_events", + "typed_ticket_event_attributes", + "typed_ticket_labels", + ] + .map(|table| stored_fixture_rows(conn, table)) + .to_vec() +} + +#[test] +fn workspace90_ticket7_and_tagged8_upgrade_to_cases10_preserving_history_and_auth() { + for component in [7, 8] { + assert_retained_classification_upgrade(90, component); + } +} + +#[test] +fn workspace89_auth_then_cases_preserve_tags_receipts_source_context_and_runtime_archives() { + assert_retained_classification_upgrade(89, 8); +} + +fn assert_retained_classification_upgrade(workspace_version: i64, component: i64) { + use ticket::TicketBackend; + let (_dir, path) = legacy_case_workspace(workspace_version); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + // Real six-state parent rows: neither a fresh open/closed schema nor API close. + conn.execute_batch("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit,resolution,created_at,updated_at) + VALUES('case-workspace','closed-legacy','closed-legacy','Retained decision','closed','task','P2','Original requirements','done',1,'Original closure text','1','2'); + INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,reason,state_field,body) + VALUES('case-workspace','closed-legacy',0,'state_changed','original-author','1','inprogress','done','Original decision','state','Original thread'); + INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) + VALUES('case-workspace','case-runtime','recipient','Recipient','normal','1','1'); + INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,runtime_id,worker_id,assigned_by,assigned_at) + VALUES('case-workspace','closed-legacy','assignment','worker','worker','case-runtime','recipient','original-actor','1'); + INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,runtime_id,worker_id,updated_at) + VALUES('case-workspace','closed-legacy','assignment','worker','worker','case-runtime','recipient','1');").unwrap(); + ticket::verify_pre_classification_ticket_schema(&conn).unwrap(); + let retained = if component == 8 { + install_retained_ticket_tags8(&conn); + Some(seed_retained_classification( + &conn, + "case-workspace", + "closed-legacy", + )) + } else { + conn.execute( + "INSERT INTO typed_ticket_labels VALUES('case-workspace','closed-legacy',0,'ready')", + [], + ) + .unwrap(); + None + }; + assert_eq!( + conn.query_row( + "SELECT MAX(version) FROM ticket_schema_migrations", + [], + |row| row.get::<_, i64>(0) + ) + .unwrap(), + component + ); + assert!(!table_exists(&conn, "typed_ticket_case_history").unwrap()); + let preserved_tables = [ + "typed_ticket_events", + "typed_ticket_event_attributes", + "typed_ticket_labels", + "ticket_current_worker_assignments", + "ticket_worker_assignments", + "workspace_signing_identities", + "workspace_signing_identity_audit", + "workspace_signing_identity_provisioning_operations", + "workspace_runtime_binding_audit", + ]; + let preserved = preserved_tables.map(|table| stored_fixture_rows(&conn, table)); + let tags_before = retained.as_ref().map(|_| classification_rows(&conn)); + let auth_before = fixture_auth_rows(&conn); + drop(conn); + let store = SqliteWorkspaceStore::open(&path).unwrap(); + store.with_conn(|conn| { + assert_eq!(current_schema_version(conn)?,91); + assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations",[],|row|row.get::<_,i64>(0))?,10); + assert_eq!(fixture_auth_rows(conn),auth_before); + for (table, rows) in preserved_tables.iter().zip(&preserved) { assert_eq!(&stored_fixture_rows(conn,table),rows,"preserve {table}"); } + if let Some(rows)=&tags_before {assert_eq!(&classification_rows(conn),rows);} + assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_assignment_work_releases WHERE assignment_id='assignment'",[],|row|row.get::<_,i64>(0))?,1); + assert_eq!(conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check",[],|row|row.get::<_,i64>(0))?,0); + assert!(!column_exists(conn,"workspace_runtime_bindings","state")?); + ticket::verify_sqlite_ticket_schema(conn).unwrap(); + Ok(()) + }).unwrap(); + let backend = ticket::SqliteTicketBackend::open_verified(&path, "case-workspace").unwrap(); + let before = backend.show("closed-legacy".into()).unwrap(); + assert_eq!(before.meta.workflow_state, ticket::TicketState::Closed); + assert_eq!( + before.current_closure.as_ref().unwrap().reason, + ticket::TicketClosureReason::Unknown + ); + assert_eq!(before.case_history.len(), 1); + assert_eq!( + before + .legacy_workflow_snapshot + .as_ref() + .unwrap() + .resolution + .as_deref(), + Some("Original closure text") + ); + if let Some(retained) = retained { + let mut changed_context = retained.source.clone(); + changed_context.insert("source_assignment_id".into(), "new-assignment".into()); + changed_context.insert("assigned_worker_id".into(), "new-recipient".into()); + let actor = backend + .clone() + .with_event_attributes(changed_context.clone()); + assert_eq!( + actor + .set_ticket_tags("closed-legacy".into(), retained.request.clone()) + .unwrap(), + retained.receipt + ); + changed_context.insert("source_worker_id".into(), "other-classifier".into()); + assert!( + backend + .clone() + .with_event_attributes(changed_context) + .set_ticket_tags("closed-legacy".into(), retained.request) + .is_err() + ); + store + .with_conn(|conn| { + assert_eq!(classification_rows(conn), tags_before.clone().unwrap()); + assert!( + conn.execute( + "INSERT INTO typed_ticket_tags VALUES('other','missing',?1)", + [&retained.active_ids[0]] + ) + .is_err() + ); + Ok(()) + }) + .unwrap(); + assert!( + backend + .set_ticket_tags( + "closed-legacy".into(), + ticket::TicketTagSetRequest { + operation_key: "deleted-reference".into(), + expected_tag_ids: retained.active_ids.clone(), + tag_ids: vec![retained.deleted_id] + } + ) + .is_err() + ); + } + // Arbitrary lifecycle-looking tag names cannot alter a reasoned closure, + // digest, assignment or prior immutable case history. + let reasoned = update_test_case( + &backend, + "closed-legacy", + "correct-reason", + ticket::TicketCaseAction::CorrectClosure { + reason: ticket::TicketClosureReason::NotPlanned, + }, + ); + let tag = backend + .mutate_workspace_tag(ticket::WorkspaceTagMutation { + operation_key: "new-ready".into(), + action: ticket::WorkspaceTagAction::Create { + name: "ready after upgrade".into(), + }, + }) + .unwrap(); + backend + .set_ticket_tags( + "closed-legacy".into(), + ticket::TicketTagSetRequest { + operation_key: "new-attach".into(), + expected_tag_ids: reasoned.tags.iter().map(|tag| tag.id.clone()).collect(), + tag_ids: vec![tag.id], + }, + ) + .unwrap(); + let classified = backend.show("closed-legacy".into()).unwrap(); + assert_eq!(classified.meta, reasoned.meta); + assert_eq!(classified.current_closure, reasoned.current_closure); + assert_eq!(classified.case_history, reasoned.case_history); + assert_eq!( + ticket::ticket_content_digest(&classified), + ticket::ticket_content_digest(&reasoned) + ); + let tag_history: Vec<_> = classified + .events + .iter() + .filter(|event| event.kind == ticket::TicketEventKind::TagsChanged) + .cloned() + .collect(); + let reopened = update_test_case( + &backend, + "closed-legacy", + "reopen", + ticket::TicketCaseAction::Reopen, + ); + assert_eq!(reopened.tags, classified.tags); + assert_eq!( + reopened.case_history[..classified.case_history.len()], + classified.case_history + ); + assert_eq!( + reopened + .events + .iter() + .filter(|event| event.kind == ticket::TicketEventKind::TagsChanged) + .cloned() + .collect::>(), + tag_history + ); + assert!( + store + .get_active_ticket_worker_assignment("case-workspace", "closed-legacy") + .unwrap() + .is_none() + ); +} + +// Frozen cases-only component9 DDL from a558, not a stripped current DB. +const RETAINED_CASES9_SQL: &str = r#" +-- Ticket case authority. Legacy workflow data is a handoff snapshot, never current authority. +CREATE TABLE typed_ticket_case_metadata ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, closure_operation_key TEXT, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_case_history ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, + operation_key TEXT NOT NULL, request_fingerprint TEXT NOT NULL, judgment_json TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, operation_key), + UNIQUE (workspace_id, ticket_id, ordinal), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_legacy_workflow_snapshot ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, snapshot_json TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TRIGGER typed_ticket_case_history_no_update BEFORE UPDATE ON typed_ticket_case_history +BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END; +CREATE TRIGGER typed_ticket_case_history_no_delete BEFORE DELETE ON typed_ticket_case_history +BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END; +CREATE TRIGGER typed_ticket_legacy_workflow_no_update BEFORE UPDATE ON typed_ticket_legacy_workflow_snapshot +BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END; +CREATE TRIGGER typed_ticket_legacy_workflow_no_delete BEFORE DELETE ON typed_ticket_legacy_workflow_snapshot +BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END; +CREATE TRIGGER typed_ticket_case_state_insert BEFORE INSERT ON typed_tickets +WHEN NEW.workflow_state NOT IN ('open', 'closed') +BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END; +CREATE TRIGGER typed_ticket_case_state_update BEFORE UPDATE OF workflow_state ON typed_tickets +WHEN NEW.workflow_state NOT IN ('open', 'closed') +BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END; +"#; + +#[test] +fn workspace90_cases9_add_classification_without_reinterpreting_reasoned_history() { + use ticket::TicketBackend; + let (_dir, path) = legacy_case_workspace(90); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + // Build a genuine cases-only boundary from retained7 and frozen9 DDL. + // No latest schema is relabelled and no six-state row is called current. + conn.execute_batch(RETAINED_CASES9_SQL).unwrap(); + conn.execute_batch("UPDATE ticket_schema_migrations SET version=9; + INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) + VALUES('case-workspace','reasoned','reasoned','Existing judgment','closed','task','P2','Original requirements','closed',1); + INSERT INTO typed_ticket_case_metadata VALUES('case-workspace','reasoned','original-closure');").unwrap(); + let judgment = ticket::TicketCaseHistoryEntry { + operation_key: "original-closure".into(), + previous_closure_operation_key: None, + action: ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + explanation: "Original reasoned conclusion".into(), + references: vec![ticket::TicketReference { + kind: "commit".into(), + target: "original-source".into(), + }], + author: "original-author".into(), + at: "original-time".into(), + }; + conn.execute("INSERT INTO typed_ticket_case_history VALUES('case-workspace','reasoned',0,'original-closure','original-fingerprint',?1)", + [serde_json::to_string(&judgment).unwrap()]).unwrap(); + assert!(!table_exists(&conn, "workspace_tags").unwrap()); + let history_before = stored_fixture_rows(&conn, "typed_ticket_case_history"); + let metadata_before = stored_fixture_rows(&conn, "typed_ticket_case_metadata"); + let auth_before = fixture_auth_rows(&conn); + drop(conn); + let store = SqliteWorkspaceStore::open(&path).unwrap(); + store + .with_conn(|conn| { + assert_eq!(current_schema_version(conn)?, 91); + assert_eq!( + conn.query_row( + "SELECT MAX(version) FROM ticket_schema_migrations", + [], + |row| row.get::<_, i64>(0) + )?, + 10 + ); + assert_eq!( + stored_fixture_rows(conn, "typed_ticket_case_history"), + history_before + ); + assert_eq!( + stored_fixture_rows(conn, "typed_ticket_case_metadata"), + metadata_before + ); + assert_eq!(fixture_auth_rows(conn), auth_before); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get::<_, i64>(0) + })?, + 0 + ); + ticket::verify_sqlite_ticket_schema(conn).unwrap(); + Ok(()) + }) + .unwrap(); + let backend = ticket::SqliteTicketBackend::open_verified(&path, "case-workspace").unwrap(); + let before = backend.show("reasoned".into()).unwrap(); + assert_eq!(before.case_history, vec![judgment]); + assert_eq!( + before.current_closure.as_ref().unwrap().reason, + ticket::TicketClosureReason::Completed + ); + for name in ["ready", "closed"] { + let tag = backend + .mutate_workspace_tag(ticket::WorkspaceTagMutation { + operation_key: format!("create-{name}"), + action: ticket::WorkspaceTagAction::Create { name: name.into() }, + }) + .unwrap(); + let current = backend.show("reasoned".into()).unwrap(); + let mut desired: Vec<_> = current.tags.iter().map(|tag| tag.id.clone()).collect(); + desired.push(tag.id); + backend + .set_ticket_tags( + "reasoned".into(), + ticket::TicketTagSetRequest { + operation_key: format!("attach-{name}"), + expected_tag_ids: current.tags.iter().map(|tag| tag.id.clone()).collect(), + tag_ids: desired, + }, + ) + .unwrap(); + } + let after = backend.show("reasoned".into()).unwrap(); + assert_eq!(after.meta, before.meta); + assert_eq!(after.current_closure, before.current_closure); + assert_eq!(after.case_history, before.case_history); + assert_eq!( + ticket::ticket_content_digest(&after), + ticket::ticket_content_digest(&before) + ); +} diff --git a/crates/workspace-server/src/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index 0e584417d..0bb170c59 100644 --- a/crates/workspace-server/src/workspace_deletion.rs +++ b/crates/workspace-server/src/workspace_deletion.rs @@ -65,6 +65,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", @@ -96,6 +97,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/crates/yoi/src/ticket_cli.rs b/crates/yoi/src/ticket_cli.rs index 1f53b016c..a8dd9d9d1 100644 --- a/crates/yoi/src/ticket_cli.rs +++ b/crates/yoi/src/ticket_cli.rs @@ -1336,6 +1336,7 @@ mod tests { current_closure: None, case_history: vec![], legacy_workflow_snapshot: None, + tags: vec![], } } @@ -1447,6 +1448,9 @@ mod tests { Ok(current.clone()) } unused_backend_methods! { + fn list_workspace_tags(&self, request: ticket::TagListRequest) -> ticket::Result>; + fn mutate_workspace_tag(&self, request: ticket::WorkspaceTagMutation) -> ticket::Result; + fn set_ticket_tags(&self, id: TicketIdOrSlug, request: ticket::TicketTagSetRequest) -> ticket::Result>; fn default_intake_ready_state_change_body(&self, from: &str) -> String; fn create(&self, input: NewTicket) -> ticket::Result; fn edit_item(&self, id: TicketIdOrSlug, edit: ticket::TicketItemEdit) -> ticket::Result; 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/development/ticket-case-state.md b/docs/development/ticket-case-state.md index 543538c54..c1f7dbbd1 100644 --- a/docs/development/ticket-case-state.md +++ b/docs/development/ticket-case-state.md @@ -31,7 +31,7 @@ closeは案件の実作業拘束を終える一方、T-715の終端担当責任 ## 旧保存データの移行 -T-739 sourceではTicket component schema7→9を使用する。Workspaceは現developのauth/runtime connection移行89→90をそのまま保持し、案件移行90→91を追加する。旧89からはauth→案件の順に進み、現90からは案件移行だけを行う。component6からの既存移行も維持する。 +T-739/T-740合成sourceのTicket componentはschema10(タグと案件判断)とする。旧schema7(六状態)・8(六状態とタグ)を明示的な移行入力として扱い、番号だけで旧保存形式を推測しない。Workspaceはdevelopのauth/runtime connection移行89→90をそのまま保持し、案件移行90→91を追加する。旧89からはauth→案件の順に進み、現90からは案件移行だけを行う。component6からの既存移行も維持する。案件のみのcomponent9は、表・列・FKとimmutable triggerの保存形式を検証したうえでタグだけを追加し、既存の終了判断・receipt・migration actor/key・handoffを再生成しない。偽のversion markerや欠けたschema objectは拒否する。 | 旧workflow state | 現行案件状態 | 終了理由 | | --- | --- | --- | @@ -40,14 +40,14 @@ T-739 sourceではTicket component schema7→9を使用する。Workspaceは現d 旧doneを成功の証拠に昇格しない。移行による終了判断はmigration actorを明示し、旧resolutionの説明を保持する。旧thread、completion/close receipt、担当履歴、MR証跡を改ざんしない。旧phase、explicit flag、status、queued_by/queued_at、resolutionをimmutable `typed_ticket_legacy_workflow_snapshot` に保存し、`legacy_handoff` として後続追跡/routing移行へ渡す。これは現行案件状態の第二正本ではない。移行だけで過去のWorkerや終了済み作業を起動しない。 -component schema8は並行T-740のタグ契約が所有するため、この独立sourceは8を推測して9へ変換せず安全に拒否する。T-740との統合時には、タグschemaと案件schemaの明示的な合成、7およびタグあり8からの移行、再実行、タグ/監査/終了履歴保持を検証する必要がある。番号を分けただけでは合成完了と扱わない。 +T-740のタグschemaを案件schemaへ合成する際、タグのID・名前・tombstone・membership、operation receiptの入力・認証済みsource identity・応答、audit/threadの観測時snapshotを保持する。タグ変化からclose/reopen/訂正やeditable content digestの更新を起こさない。旧7およびタグあり8からの移行、再実行、タグ/監査/終了履歴、FKとauth archiveの保持を合成fixtureで検証する。番号を分けただけでは合成完了と扱わない。 ## 検証境界 - `crates/ticket/tests/case.rs`: Repository/MRなしの理由、再開/訂正、actor/ref/history、CAS/ABA、exact replay/異入力拒否、並行close、Workspace境界、transaction rollback。 -- `crates/ticket/tests/migration.rs`: 六状態、旧thread/receipt/担当/MR参照の保持、理由不明、再open、schema8の安全な拒否。 +- `crates/ticket/tests/migration.rs`: 六状態、旧thread/receipt/担当/MR参照の保持、理由不明、再open、タグあり8との合成と再実行。 - Workspace Store: 六状態×MRあり/なし、終端担当、旧queue/handoff、migration resumption、Workerを起動しない移行。 - Workspace API/authority/lifecycle: 偽装actor・無権限・他Workspace・旧route拒否、作業releaseと表示責任、reopen時の新routing identity、独立削除guardとWorkspace purge rollback。 - Worker通常Tool/WIP、Client、CLI、Web parser: 同じcase入力、モデルに見えるnullable観測keyと履歴、Backend以外のfallback禁止、stale失敗の伝達。 -実経路統合・Orchestrator追跡の置換は後続単位の責務であり、以上の単独source検証をT-740合成や実環境更新の検証と混同しない。 +実経路統合・Orchestrator追跡の置換は後続単位の責務であり、以上の合成source検証を実環境のmigration・更新や全経路統合検証と混同しない。 diff --git a/docs/report/T-740-develop-composition.md b/docs/report/T-740-develop-composition.md new file mode 100644 index 000000000..cb71fa64e --- /dev/null +++ b/docs/report/T-740-develop-composition.md @@ -0,0 +1,49 @@ +# T-740: composition with develop 71ab4c06 + +## Source reconciliation + +The existing `work/T-740-workspace-ticket-tags` source at `e9397fa0` was clean. The requested develop commit `71ab4c06b9ae24ca4daa88516d1e436c5a31c779` was fetched and merged normally into this same branch (no replacement MR or integration branch). Generated conflicts in OpenAPI and companion/repository/runtime/ticket/worker-launch types were resolved by regeneration, not by selecting one side's contract. Workdir types were regenerated as well because the shared contract digest changed. + +The merged Rust contract retains both classification operations/projections and the develop Runtime binding/per-request authentication model. Workspace schema remains 90; Ticket classification schema remains 8. The Runtime migration archives historical verification metadata, does not make it a grant, and does not rebuild Ticket/tag tables. Shared server/store/schema-equivalence/purge inventory changes were inspected against develop: both Runtime archives and all three classification tables are included. + +Official generators rerun: + +- canonical `export_openapi`; +- repository/runtime/ticket/workdir/worker-launch/companion OpenAPI schema-closure generators; +- feature-gated memory/skill/auth/repository-access/Drive/legacy Server type catalogs. Their checked-in representation conventions are preserved (auth uses the raw normalized generator output, not Deno reformatting). + +## Composition defect fixed + +New signed-Runtime router tests found that classification handlers discarded the verified Runtime Worker identity when optional transport source-hint headers were absent. This produced empty attribution in durable tag receipts and allowed another authenticated Worker to replay the same operation key. + +Classification now takes the verified Runtime source from `ServerRequestContext`, retains the existing source-hint verification path, rejects mismatched identities, and carries the actual Worker identity into event/audit and exact-replay input. Runtime-only claims without a Worker identity cannot impersonate a human classification actor. Request proof tokens are not operation identity; exact replay with a new valid signature from the same Worker is accepted, while another Worker conflicts. Other Ticket operation contracts are not respecified by this correction. + +## Executed validation + +All fixtures are hermetic, temporary, in-process boundaries following `docs/development/rust-testing-strategy.md`. + +- `cargo test -p yoi-workspace-server --lib ticket_tags_tests`: 6 passed (original 3 plus signed Runtime lifecycle/replay, identity-bound replay, and rejection matrix). +- Rejection coverage: unsigned hints, forged signing key, unknown Worker, other Workspace claim, body tampering, stale pinned key, and administratively revoked binding, for both catalog mutation and Ticket membership endpoints; no rejected-operation writes or execution effects. +- `current_workspace90_without_classification_upgrades_ticket7_without_rewriting_ticket_history`: passed. A current Workspace-90/pre-classification Ticket-7 fixture upgrades to Ticket-8 without rewriting an existing closed Ticket, labels, decision/history, or Workspace auth schema; arbitrary `ready` classification preserves the decision. +- `workspace89_to90_runtime_rebuild_preserves_tag_links_tombstones_receipts_and_audit`: passed. Real retained migration plus tagged data preserves exact current links, tombstones/recreated identities, receipt/context/audit rows and thread snapshots; replay and FK/deleted-reference rejection remain intact. +- `fresh_and_upgraded_state_contract_schemas_enforce_the_same_constraints`: passed, including classification tables and Runtime archives. +- `cargo test -p ticket --lib tags`: 14 passed. +- `cargo test -p worker --lib feature::builtin::ticket::tags`: 7 passed, covering normal tools/native WIP, permission, route binding and durable mutation/replay contracts after composition. +- `cargo test -p worker-runtime --lib workspace_issuer`: 12 passed. +- Full `cargo test -p ticket -p server-api -p client -p worker -p yoi-workspace-server`: passed including integration/doc targets. Unit suites: client 57, server-api 92, ticket 120, worker 975, Server 830 passed/1 existing ignored. The compaction integration target's 25 tests also passed. +- `cargo test -p server-api --features typescript`: 101 passed; all feature-catalog artifact tests passed. +- Root `cargo check`: passed, including normal TUI/Server closure. +- Canonical OpenAPI and all six OpenAPI-based generator `--check` runs: passed. +- `cargo fmt --all -- --check`, `git diff --check HEAD`: passed. + +Initial signed-source tests failed on attribution/replay and passed after the fix. An attempted Deno format of auth catalog output failed its exact generated-artifact test; regenerating it in the documented raw form restored the artifact and all 101 feature tests passed. + +## Naming audit limitation inherited from target + +The required state-contract-name command was executed. It reports **30 unapproved lines and 9 stale exception hashes** in develop's frozen SSH migration/fixtures, migration-entrypoint fixture and timestamp-mismatch report. Every affected file and the exception inventory are byte-for-byte unchanged from `71ab4c06` (`git diff --exit-code 71ab4c06 -- ` passed). The T-740 integration additions introduce no such finding. The inventory was not expanded, hashes were not silently refreshed, and no upstream historical contracts were renamed merely to make the checker green. This audit is **not reported as passing** and remains an Orchestrator/user follow-up outside the classification correction. + +## Limits / handoff + +T-739 is not integrated: its future case-state/close/migration composition is **not verified** by these tests. T-743 final Web UI, live production DB migration and full end-to-end UI/Runtime execution are not claimed. The existing ignored Server Drive Web-adapter roundtrip was not executed. The current-DB upgrade test covers a seeded schema-90 fixture, not live user data. + +Only the existing source branch is to be published non-force. Source movement requires fresh trusted review on the same explicit MR `01a126a0-b4b1-7a13-ba61-5b2c06a3bca4` and current Ticket digest. Target publication, MR integration and Ticket completion/close are not performed here. 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 418347928..65f364853 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": { @@ -10419,6 +10425,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": { @@ -10484,6 +10511,13 @@ "type": "null" } ] + }, + "tags": { + "default": [], + "items": { + "$ref": "#/components/schemas/WorkspaceTag" + }, + "type": "array" } }, "required": [ @@ -11082,6 +11116,12 @@ "state": { "$ref": "#/components/schemas/BrowserTicketState" }, + "tags": { + "items": { + "$ref": "#/components/schemas/WorkspaceTag" + }, + "type": "array" + }, "targets": { "items": { "$ref": "#/components/schemas/TicketTarget" @@ -11100,6 +11140,7 @@ }, "required": [ "id", + "tags", "resource_key", "title", "state", @@ -11537,6 +11578,7 @@ "decision", "implementation_report", "state_changed", + "tags_changed", "intake_summary", "status_changed", "close" @@ -11885,6 +11927,13 @@ "state": { "$ref": "#/components/schemas/BrowserTicketState" }, + "tags": { + "default": [], + "items": { + "$ref": "#/components/schemas/WorkspaceTag" + }, + "type": "array" + }, "title": { "type": "string" }, @@ -12327,6 +12376,12 @@ "state": { "$ref": "#/components/schemas/BrowserTicketState" }, + "tags": { + "items": { + "$ref": "#/components/schemas/WorkspaceTag" + }, + "type": "array" + }, "title": { "type": "string" }, @@ -12354,6 +12409,7 @@ }, "required": [ "id", + "tags", "resource_key", "title", "state", @@ -12450,7 +12506,31 @@ "states": { "default": [], "items": { - "$ref": "#/components/schemas/BrowserTicketState" + "$ref": "#/components/schemas/TicketState" + }, + "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" }, @@ -12984,6 +13064,13 @@ "status": { "$ref": "#/components/schemas/ExtensibleTicketStatus" }, + "tags": { + "default": [], + "items": { + "$ref": "#/components/schemas/WorkspaceTag" + }, + "type": "array" + }, "title": { "type": "string" }, @@ -13035,6 +13122,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": { @@ -16126,6 +16240,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.", @@ -16219,7 +16444,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:2e8dcd75d5d7b70c5d820999afcabf3d1377896732c7a3e8c3839e73a994974c" + "x-yoi-source-digest": "sha256:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3" }, "openapi": "3.1.0", "paths": { @@ -32797,6 +33022,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", @@ -35574,6 +36001,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 2801d4c4a..1d5aacf2f 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:2e8dcd75d5d7b70c5d820999afcabf3d1377896732c7a3e8c3839e73a994974c) +// Input contract: 0.1.0 (sha256:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3) // 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 3dab94650..59abffb71 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:2e8dcd75d5d7b70c5d820999afcabf3d1377896732c7a3e8c3839e73a994974c) +// Input contract: 0.1.0 (sha256:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3) // 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 f1224e56e..3acfbbd50 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:2e8dcd75d5d7b70c5d820999afcabf3d1377896732c7a3e8c3839e73a994974c) +// Input contract: 0.1.0 (sha256:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3) // 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 d8700209e..0983808b9 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:2e8dcd75d5d7b70c5d820999afcabf3d1377896732c7a3e8c3839e73a994974c) +// Input contract: 0.1.0 (sha256:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3) // 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 BrowserEditTicketRequest = { author?: string | null; body?: string | null; new_string?: string | null; old_string?: string | null; replace_all?: boolean; target?: BrowserTicketTargetEdit | null; title?: string | null; }; @@ -89,6 +91,8 @@ 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 TicketAttention = "unresolved_review" | "missing_commit" | "blocked" | "unblocked" | "awaiting_review" | "unresolved_changes" | "stale_after_rescope" | "missing_evidence"; + export type TicketCaseAction = { action: "close"; reason: TicketClosureReason; } | { action: "reopen"; } | { action: "correct_closure"; reason: TicketClosureReason; }; export type TicketCaseHistoryEntry = { action: TicketCaseAction; at: string; author: string; explanation: string; operation_key: string; previous_closure_operation_key?: string | null; references: Array; }; @@ -99,7 +103,7 @@ export type TicketClosureJudgment = { at: string; author: string; explanation: s export type TicketClosureReason = "completed" | "not_planned" | "duplicate" | "unknown" | { other: string; }; -export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array; assignment_diagnostics: Array; assignments: Array; body: string; body_truncated: boolean; closure_history: Array; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; 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: BrowserTicketState; 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; closure_history: Array; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; 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: BrowserTicketState; 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; }; @@ -121,7 +125,7 @@ export type TicketIntakeSummary = { author?: string | null; body: MarkdownText; export type TicketLegacyWorkflowSnapshot = { queued_at?: string | null; queued_by?: string | null; resolution?: string | null; status: string; workflow_state: string; workflow_state_explicit: boolean; }; -export type TicketListItemSummary = { closure_operation_key?: string | null; current_closure?: TicketClosureJudgment | null; id: string; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; priority: string; queued_at?: string | null; queued_by?: string | null; record_source: string; resource_key: string; state: BrowserTicketState; title: string; updated_at?: string | null; workspace_action_priority: string; }; +export type TicketListItemSummary = { closure_operation_key?: string | null; current_closure?: TicketClosureJudgment | null; id: string; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; priority: string; queued_at?: string | null; queued_by?: string | null; record_source: string; resource_key: string; state: BrowserTicketState; 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; }; @@ -129,6 +133,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; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; evidence: TicketEvidenceSummary; id: string; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; 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: BrowserTicketState; 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; }; @@ -147,10 +157,18 @@ export type TicketState = "open" | "closed"; export type TicketStatus = "Open" | "Closed"; -export type TicketSummary = { id: string; kind: string; labels: Array; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; resource_key?: string | null; slug: string; status: ExtensibleTicketStatus; title: string; updated_at?: string | null; workflow_state: TicketState; workflow_state_explicit: boolean; }; +export type TicketSummary = { id: string; kind: string; labels: Array; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; resource_key?: string | null; slug: string; status: ExtensibleTicketStatus; tags?: Array; title: string; updated_at?: string | null; workflow_state: TicketState; workflow_state_explicit: boolean; }; + +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"; export type TicketWorkerAttachmentBinding = { alias: string; connection_id: string; working_directory_id: string; }; + +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 cfcc87ab9..19d7b3edb 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:2e8dcd75d5d7b70c5d820999afcabf3d1377896732c7a3e8c3839e73a994974c) +// Input contract: 0.1.0 (sha256:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3) // 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 dce09b8bf..d250b63c9 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:2e8dcd75d5d7b70c5d820999afcabf3d1377896732c7a3e8c3839e73a994974c) +// Input contract: 0.1.0 (sha256:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3) // 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 diff --git a/web/workspace/src/lib/workspace/api/ticket-browser.test.ts b/web/workspace/src/lib/workspace/api/ticket-browser.test.ts index 000a066c5..1069c0a2f 100644 --- a/web/workspace/src/lib/workspace/api/ticket-browser.test.ts +++ b/web/workspace/src/lib/workspace/api/ticket-browser.test.ts @@ -10,6 +10,7 @@ import { parseTicketListResponse, parseTicketRecordRef, parseTicketRoleAssignmentMutationResponse, + parseTicketSummary, } from "./ticket-browser.ts"; function assertEquals(actual: unknown, expected: unknown): void { @@ -48,6 +49,7 @@ Deno.test("Ticket Browser parser accepts the generated Ticket list shape", () => id: "ticket-id", resource_key: "T-1", title: "Ticket", + tags: [], state: "open", priority: "P2", updated_at: null, @@ -176,6 +178,7 @@ function ticketDetailFixture() { resource_key: "T-1", risk_flags: [], state: "open", + tags: [], targets: [ { repository_key: "docs", @@ -452,6 +455,14 @@ Deno.test("Ticket Browser parser validates the canonical Merge Request thread", ); }); +const tags = ["ready", "closed"].map((name) => ({ + workspace_id: "workspace-a", + id: `tag-${name}`, + name, + last_operation_key: `create-${name}`, + deleted: false, +})); + const closure = { operation_key: "close-1", reason: "not_planned", @@ -499,10 +510,11 @@ const legacyHandoff = { resolution: "Historical result, not a current case state", }; -Deno.test("case projections retain reasons, actors, references and migration history after reopening", () => { +Deno.test("tags named ready and closed coexist with case judgment and retained reopen history without inferring state", () => { const fixture = { ...ticketDetailFixture(), state: "open", + tags, closure_operation_key: "correct-1", current_closure: null, closure_history: closureHistory, @@ -513,11 +525,16 @@ Deno.test("case projections retain reasons, actors, references and migration his assertEquals(parsed.closure_operation_key, "correct-1"); assertEquals(parsed.closure_history, closureHistory); assertEquals(parsed.legacy_handoff, legacyHandoff); - assertEquals( - parseTicketDetail({ ...fixture, state: "closed", current_closure: closure }) - .current_closure, - closure, - ); + assertEquals(parsed.tags, tags); + const closed = parseTicketDetail({ + ...fixture, + state: "closed", + current_closure: closure, + }); + assertEquals(closed.state, "closed"); + assertEquals(closed.current_closure, closure); + assertEquals(closed.closure_history, closureHistory); + assertEquals(closed.tags, tags); const list = parseTicketListResponse({ workspace_id: "workspace-a", limit: 30, @@ -529,6 +546,7 @@ Deno.test("case projections retain reasons, actors, references and migration his resource_key: "T-1", title: "Ticket", state: "closed", + tags, priority: "P2", record_source: "sqlite", workspace_action_priority: "background", @@ -537,10 +555,73 @@ Deno.test("case projections retain reasons, actors, references and migration his legacy_handoff: legacyHandoff, }], }); + assertEquals(list.items[0].state, "closed"); + assertEquals(list.items[0].tags, tags); assertEquals(list.items[0].current_closure, closure); assertEquals(list.items[0].legacy_handoff, legacyHandoff); }); +Deno.test("Ticket tag projections reject missing collections and malformed identities, types and unknown fields", () => { + const parsers = [ + (tags: unknown) => parseTicketDetail({ ...ticketDetailFixture(), tags }), + (tags: unknown) => + parseTicketSummary({ + id: "ticket-id", + slug: "ticket", + title: "Ticket", + kind: "task", + labels: [], + priority: "P2", + status: "Open", + workflow_state: "open", + workflow_state_explicit: true, + tags, + }), + (tags: unknown) => + parseTicketListResponse({ + workspace_id: "workspace-a", + limit: 30, + page, + invalid_records: [], + record_authority: "sqlite", + items: [{ + id: "ticket-id", + resource_key: "T-1", + title: "Ticket", + state: "open", + priority: "P2", + record_source: "sqlite", + workspace_action_priority: "background", + tags, + }], + }), + ]; + const tag = tags[0]; + for (const parse of parsers) { + for (const value of [undefined, null, {}, "ready"]) { + assertThrows(() => parse(value), "tags must be an array"); + } + for ( + const [value, message] of [ + [{ ...tag, id: undefined }, "id must be a string"], + [{ ...tag, id: 1 }, "id must be a string"], + [{ ...tag, id: " " }, "id must be nonempty"], + [{ ...tag, id: "é".repeat(65) }, "at most 128 UTF-8 bytes"], + [{ ...tag, workspace_id: false }, "workspace_id must be a string"], + [{ ...tag, name: null }, "name must be a string"], + [ + { ...tag, last_operation_key: 1 }, + "last_operation_key must be a string", + ], + [{ ...tag, deleted: "false" }, "deleted must be a boolean"], + [{ ...tag, state: "closed" }, "unknown field state"], + ] as const + ) { + assertThrows(() => parse([value]), message); + } + } +}); + Deno.test("case decoders reject legacy current states and malformed closure judgments", () => { const fixture = ticketDetailFixture(); for (const state of ["planning", "ready", "queued", "inprogress", "done"]) { diff --git a/web/workspace/src/lib/workspace/api/ticket-browser.ts b/web/workspace/src/lib/workspace/api/ticket-browser.ts index 95bc699f9..778be9a84 100644 --- a/web/workspace/src/lib/workspace/api/ticket-browser.ts +++ b/web/workspace/src/lib/workspace/api/ticket-browser.ts @@ -46,6 +46,7 @@ import type { TicketRoleAssignmentSummary, TicketSummary, TicketTarget, + WorkspaceTag, } from "#lib/generated/ticket-api.ts"; export const TICKET_BROWSER_API_MAX_RESPONSE_BYTES = 4 * 1024 * 1024; @@ -331,6 +332,31 @@ function caseProjection(item: Record, label: string) { }; } +function parseWorkspaceTag(value: unknown, label: string): WorkspaceTag { + const item = object(value, label); + exact(item, [ + "workspace_id", + "id", + "name", + "last_operation_key", + "deleted", + ], label); + const id = string(item.id, `${label}.id`); + if (id.trim().length === 0 || new TextEncoder().encode(id).length > 128) { + throw new Error(`${label}.id must be nonempty and at most 128 UTF-8 bytes`); + } + return { + workspace_id: string(item.workspace_id, `${label}.workspace_id`), + id, + name: string(item.name, `${label}.name`), + last_operation_key: string( + item.last_operation_key, + `${label}.last_operation_key`, + ), + deleted: boolean(item.deleted, `${label}.deleted`), + }; +} + export function parseTicketSummary(value: unknown): TicketSummary { const label = "Ticket summary"; const item = object(value, label); @@ -345,6 +371,7 @@ export function parseTicketSummary(value: unknown): TicketSummary { "resource_key", "slug", "status", + "tags", "title", "updated_at", "workflow_state", @@ -371,6 +398,7 @@ export function parseTicketSummary(value: unknown): TicketSummary { ), slug: string(item.slug, `${label}.slug`), status, + tags: array(item.tags, `${label}.tags`, parseWorkspaceTag), title: string(item.title, `${label}.title`), updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`), workflow_state: parseTicketState( @@ -400,6 +428,7 @@ function parseTicketListItem( "record_source", "resource_key", "state", + "tags", "title", "updated_at", "workspace_action_priority", @@ -413,6 +442,7 @@ function parseTicketListItem( record_source: string(item.record_source, `${label}.record_source`), resource_key: string(item.resource_key, `${label}.resource_key`), state: parseTicketState(item.state, `${label}.state`), + tags: array(item.tags, `${label}.tags`, parseWorkspaceTag), title: string(item.title, `${label}.title`), updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`), workspace_action_priority: string( @@ -960,6 +990,7 @@ export function parseTicketDetail(value: unknown): TicketDetail { "resource_key", "risk_flags", "state", + "tags", "targets", "title", "updated_at", @@ -1036,6 +1067,7 @@ export function parseTicketDetail(value: unknown): TicketDetail { resource_key: string(item.resource_key, `${label}.resource_key`), risk_flags: strings(item.risk_flags, `${label}.risk_flags`), state: parseTicketState(item.state, `${label}.state`), + tags: array(item.tags, `${label}.tags`, parseWorkspaceTag), targets: array(item.targets, `${label}.targets`, parseTicketTarget), title: string(item.title, `${label}.title`), updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`), diff --git a/web/workspace/src/lib/workspace/api/ticket-case.test.ts b/web/workspace/src/lib/workspace/api/ticket-case.test.ts index 1cb7d2601..c1d25105b 100644 --- a/web/workspace/src/lib/workspace/api/ticket-case.test.ts +++ b/web/workspace/src/lib/workspace/api/ticket-case.test.ts @@ -34,6 +34,13 @@ const summary = { title: "Case", kind: "task", labels: [], + tags: [{ + workspace_id: "workspace one", + id: "tag-closed", + name: "closed", + last_operation_key: "create-tag", + deleted: false, + }], priority: "P2", status: "Closed", workflow_state: "closed", @@ -73,11 +80,9 @@ Deno.test("case actions carry content/state/last-closure CAS without browser aut assertEquals(JSON.parse(String(options.body)), request); return Promise.resolve(Response.json(summary)); }; - assertEquals( - (await updateTicketCase("workspace one", "T/1", request)) - .workflow_state, - "closed", - ); + const result = await updateTicketCase("workspace one", "T/1", request); + assertEquals(result.workflow_state, "closed"); + assertEquals(result.tags, summary.tags); } } finally { globalThis.fetch = original; diff --git a/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts b/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts index 242695ef2..501890012 100644 --- a/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts +++ b/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts @@ -25,6 +25,7 @@ export function fixtureTicket( current_closure: null, legacy_handoff: null, state, + tags: [], priority: "normal", queued_at: null, queued_by: null, diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts index e472b4076..da95d49f6 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts @@ -29,15 +29,35 @@ Deno.test("ticketLanes separates open and closed cases and orders updates determ id: "old", state: "open", title: "Open old", + tags: [{ + workspace_id: "workspace-a", + id: "tag-closed", + name: "closed", + last_operation_key: "create-closed", + deleted: false, + }], updated_at: "2026-07-29T12:00:00Z", }, { id: "new", state: "open", title: "Open new", + tags: [], updated_at: "2026-07-30T12:00:00Z", }, - { id: "closed", state: "closed", title: "Closed", updated_at: null }, + { + id: "closed", + state: "closed", + title: "Closed", + tags: [{ + workspace_id: "workspace-a", + id: "tag-ready", + name: "ready", + last_operation_key: "create-ready", + deleted: false, + }], + updated_at: null, + }, ] as TicketSummary[]; const lanes = ticketLanes(tickets); assertEquals(lanes.map((lane) => lane.id), ["open", "closed"]);