merge: compose classification tags with Ticket case migration
This commit is contained in:
@@ -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<Vec<ticket::WorkspaceTag>, 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<ticket::WorkspaceTag, BackendWorkspaceClientError> {
|
||||
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<Vec<ticket::WorkspaceTag>, 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<Vec<ticket::WorkspaceTag>> {
|
||||
BackendWorkspaceProductClient::list_workspace_tags(self, &request)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn mutate_workspace_tag(
|
||||
&self,
|
||||
request: ticket::WorkspaceTagMutation,
|
||||
) -> ticket::Result<ticket::WorkspaceTag> {
|
||||
BackendWorkspaceProductClient::mutate_workspace_tag(self, &request)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn set_ticket_tags(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
request: ticket::TicketTagSetRequest,
|
||||
) -> ticket::Result<Vec<ticket::WorkspaceTag>> {
|
||||
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(),
|
||||
|
||||
@@ -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<TicketRecordRef, RepositoryApiError>;
|
||||
|
||||
#[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<WorkspaceTicketTagList, RepositoryApiError>;
|
||||
|
||||
#[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<WorkspaceTicketTag, RepositoryApiError>;
|
||||
|
||||
#[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<WorkspaceTicketTagList, RepositoryApiError>;
|
||||
|
||||
#[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<WorkspaceTag>"))]
|
||||
pub tags: Vec<ticket::WorkspaceTag>,
|
||||
pub id: String,
|
||||
pub resource_key: String,
|
||||
pub title: String,
|
||||
@@ -7536,7 +7569,8 @@ impl TicketAttention {
|
||||
pub struct TicketQueryRequest {
|
||||
pub query: Option<String>,
|
||||
#[serde(default)]
|
||||
pub states: Vec<BrowserTicketState>,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "Array<BrowserTicketState>"))]
|
||||
pub states: Vec<ticket::TicketState>,
|
||||
#[serde(default)]
|
||||
pub event_kinds: Vec<String>,
|
||||
#[serde(default)]
|
||||
@@ -7544,6 +7578,15 @@ pub struct TicketQueryRequest {
|
||||
pub review_status: Option<String>,
|
||||
#[serde(default)]
|
||||
pub attention: Vec<TicketAttention>,
|
||||
/// Match tickets containing every supplied tag ID (AND).
|
||||
#[serde(default)]
|
||||
pub tag_ids: Vec<String>,
|
||||
/// Match tickets containing at least one supplied tag ID (OR).
|
||||
#[serde(default)]
|
||||
pub tag_ids_any: Vec<String>,
|
||||
/// Exclude tickets containing any supplied tag ID (NOT).
|
||||
#[serde(default)]
|
||||
pub tag_ids_none: Vec<String>,
|
||||
pub related_ticket_id: Option<String>,
|
||||
pub relation_kind: Option<String>,
|
||||
pub linked_objective_id: Option<String>,
|
||||
@@ -7560,6 +7603,8 @@ pub struct TicketQueryRequest {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TicketQueryItem {
|
||||
pub id: String,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "Array<WorkspaceTag>"))]
|
||||
pub tags: Vec<ticket::WorkspaceTag>,
|
||||
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<WorkspaceTag>"))]
|
||||
pub tags: Vec<ticket::WorkspaceTag>,
|
||||
pub resource_key: String,
|
||||
pub title: String,
|
||||
pub state: BrowserTicketState,
|
||||
@@ -7730,6 +7777,14 @@ pub struct TicketCaseUpdateRequest {
|
||||
#[serde(default)]
|
||||
pub references: Vec<ticket::TicketReference>,
|
||||
}
|
||||
transparent_ticket_dto!(WorkspaceTicketTagListRequest, ticket::TagListRequest);
|
||||
transparent_ticket_dto!(
|
||||
WorkspaceTicketTagMutationRequest,
|
||||
ticket::WorkspaceTagMutation
|
||||
);
|
||||
transparent_ticket_dto!(TicketTagsSetRequest, ticket::TicketTagSetRequest);
|
||||
transparent_ticket_dto!(WorkspaceTicketTagList, Vec<ticket::WorkspaceTag>);
|
||||
transparent_ticket_dto!(WorkspaceTicketTag, ticket::WorkspaceTag);
|
||||
transparent_ticket_dto!(TicketRecordRef, ticket::TicketRef);
|
||||
transparent_ticket_dto!(TicketRecordSummaryList, Vec<ticket::TicketSummary>);
|
||||
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::<WorkspaceTicketTagMutationRequest>(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::<WorkspaceTicketTagMutationRequest>(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::<TicketTagsSetRequest>(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::<WorkspaceTicketTagMutationRequest>(invalid_mutation)
|
||||
.is_err(),
|
||||
"mutation {field}"
|
||||
);
|
||||
let mut invalid_set = set.clone();
|
||||
invalid_set[field] = value;
|
||||
assert!(
|
||||
serde_json::from_value::<TicketTagsSetRequest>(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::<TicketQueryRequest>(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::<TicketQueryRequest>(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<WorkspaceTag>"));
|
||||
assert!(TicketQueryItem::decl(&config).contains("tags: Array<WorkspaceTag>"));
|
||||
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<WorkspaceTag>"));
|
||||
}
|
||||
|
||||
#[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",
|
||||
|
||||
@@ -423,6 +423,34 @@ fn validate_ticket_browser_operations(
|
||||
paths: &Map<String, Value>,
|
||||
) -> Result<BTreeSet<String>, 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",
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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<TicketState
|
||||
fn read_ticket_summary_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<TicketSummary> {
|
||||
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<WorkspaceTag>,
|
||||
pub id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resource_key: Option<String>,
|
||||
@@ -1668,6 +1683,8 @@ pub struct TicketArtifactRef {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct Ticket {
|
||||
#[serde(default)]
|
||||
pub tags: Vec<WorkspaceTag>,
|
||||
pub meta: TicketMeta,
|
||||
pub document: TicketDocument,
|
||||
pub events: Vec<TicketEvent>,
|
||||
@@ -1729,6 +1746,13 @@ impl TicketDoctorReport {
|
||||
}
|
||||
|
||||
pub trait TicketBackend {
|
||||
fn list_workspace_tags(&self, request: TagListRequest) -> Result<Vec<WorkspaceTag>>;
|
||||
fn mutate_workspace_tag(&self, request: WorkspaceTagMutation) -> Result<WorkspaceTag>;
|
||||
fn set_ticket_tags(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
request: TicketTagSetRequest,
|
||||
) -> Result<Vec<WorkspaceTag>>;
|
||||
fn default_intake_ready_state_change_body(&self, from: &str) -> String;
|
||||
fn list(&self, filter: TicketListQuery) -> Result<Vec<TicketSummary>>;
|
||||
fn show(&self, id: TicketIdOrSlug) -> Result<Ticket>;
|
||||
@@ -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(WorkspaceTag),
|
||||
Text(String),
|
||||
Unit,
|
||||
Tickets(Vec<TicketSummary>),
|
||||
@@ -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<R>(conn: &Connection, result: Result<R>) -> Result<
|
||||
}
|
||||
|
||||
impl TicketBackend for SqliteTicketBackend {
|
||||
fn list_workspace_tags(&self, request: TagListRequest) -> Result<Vec<WorkspaceTag>> {
|
||||
self.list_tags(request)
|
||||
}
|
||||
fn mutate_workspace_tag(&self, request: WorkspaceTagMutation) -> Result<WorkspaceTag> {
|
||||
self.mutate_tag(request)
|
||||
}
|
||||
fn set_ticket_tags(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
request: TicketTagSetRequest,
|
||||
) -> Result<Vec<WorkspaceTag>> {
|
||||
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(),
|
||||
|
||||
+532
-150
@@ -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<String> = 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::<String>()
|
||||
.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<String> = 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::<String>()
|
||||
.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<BTreeMap<i64, Stri
|
||||
fn validate_applied_migrations(applied: &BTreeMap<i64, String>) -> 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<String>>(3)?
|
||||
.map(|sql| sql.split_whitespace().collect::<String>()),
|
||||
))
|
||||
})
|
||||
.unwrap()
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.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());
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
#[schemars(range(min = 0, max = 200))]
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub tag_ids: Vec<String>,
|
||||
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<Vec<String>> {
|
||||
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<WorkspaceTag> {
|
||||
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<Vec<WorkspaceTag>> {
|
||||
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::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(sqlite_err)
|
||||
}
|
||||
|
||||
fn load_tag(&self, conn: &Connection, id: &str) -> Result<WorkspaceTag> {
|
||||
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<String> {
|
||||
// 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<T: serde::de::DeserializeOwned>(
|
||||
&self,
|
||||
conn: &Connection,
|
||||
key: &str,
|
||||
input: &str,
|
||||
) -> Result<Option<T>> {
|
||||
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<Vec<WorkspaceTag>> {
|
||||
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::<std::result::Result<Vec<_>,_>>().map_err(sqlite_err)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn mutate_tag(&self, request: WorkspaceTagMutation) -> Result<WorkspaceTag> {
|
||||
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::<std::result::Result<Vec<_>,_>>().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<Vec<WorkspaceTag>> {
|
||||
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::<Vec<_>>() != 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::<Result<Vec<_>>>()?;
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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<WorkspaceTag> =
|
||||
serde_json::from_str(&events[0].attributes["tags_after"]).unwrap();
|
||||
assert_eq!(first, vec![tag.clone()]);
|
||||
let before_delete: Vec<WorkspaceTag> =
|
||||
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::<Ticket>(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::<TicketSummary>(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::<BTreeMap<String, String>>(&stored).unwrap(),
|
||||
original_context
|
||||
);
|
||||
assert_eq!(audit_count(&backend), 1);
|
||||
}
|
||||
@@ -242,6 +242,28 @@ impl From<crate::SqliteTicketBackend> for TicketToolBackend {
|
||||
}
|
||||
|
||||
impl TicketBackend for TicketToolBackend {
|
||||
fn list_workspace_tags(
|
||||
&self,
|
||||
request: crate::TagListRequest,
|
||||
) -> TicketResult<Vec<crate::WorkspaceTag>> {
|
||||
self.backend.list_workspace_tags(request)
|
||||
}
|
||||
|
||||
fn mutate_workspace_tag(
|
||||
&self,
|
||||
request: crate::WorkspaceTagMutation,
|
||||
) -> TicketResult<crate::WorkspaceTag> {
|
||||
self.backend.mutate_workspace_tag(request)
|
||||
}
|
||||
|
||||
fn set_ticket_tags(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
request: crate::TicketTagSetRequest,
|
||||
) -> TicketResult<Vec<crate::WorkspaceTag>> {
|
||||
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)
|
||||
}
|
||||
|
||||
+38
@@ -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)
|
||||
);
|
||||
+33
@@ -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;
|
||||
@@ -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::<std::result::Result<_, _>>().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<Vec<rusqlite::types::Value>> {
|
||||
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::<std::result::Result<_, _>>()
|
||||
.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::<rusqlite::Result<Vec<_>>>()
|
||||
})
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ struct ModelTicketQueryItem {
|
||||
closure_operation_key: Option<String>,
|
||||
current_closure: Option<ticket::TicketClosureJudgment>,
|
||||
legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
|
||||
tags: Vec<ticket::WorkspaceTag>,
|
||||
readiness: Option<String>,
|
||||
priority: Option<String>,
|
||||
created_at: Option<String>,
|
||||
@@ -45,6 +46,7 @@ pub(super) struct ModelTicketDetail {
|
||||
closure_operation_key: Option<String>,
|
||||
current_closure: Option<ticket::TicketClosureJudgment>,
|
||||
legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
|
||||
tags: Vec<ticket::WorkspaceTag>,
|
||||
readiness: Option<String>,
|
||||
priority: Option<String>,
|
||||
created_at: Option<String>,
|
||||
@@ -101,8 +103,16 @@ struct ModelWorkerSummary {
|
||||
worker: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelTicketTagChange {
|
||||
before: Vec<ticket::WorkspaceTag>,
|
||||
after: Vec<ticket::WorkspaceTag>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelTicketEvent {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tag_changes: Option<ModelTicketTagChange>,
|
||||
sequence: usize,
|
||||
kind: String,
|
||||
body: Option<String>,
|
||||
@@ -256,6 +266,7 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
|
||||
closure_operation_key: typed_field(item, "closure_operation_key")?,
|
||||
current_closure: typed_field(item, "current_closure")?,
|
||||
legacy_handoff: typed_field(item, "legacy_handoff")?,
|
||||
tags: project_tags(item)?,
|
||||
readiness: optional_string(item, "readiness")?,
|
||||
priority: optional_string(item, "priority")?,
|
||||
created_at: optional_string(item, "created_at")?,
|
||||
@@ -311,6 +322,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
|
||||
current_closure: typed_field(root, "current_closure")?,
|
||||
legacy_handoff: typed_field(root, "legacy_handoff")?,
|
||||
closure_history: typed_field(root, "closure_history")?,
|
||||
tags: project_tags(root)?,
|
||||
readiness: optional_string(root, "readiness")?,
|
||||
priority: optional_string(root, "priority")?,
|
||||
created_at: optional_string(root, "created_at")?,
|
||||
@@ -418,7 +430,27 @@ fn project_worker(value: &Value) -> Result<ModelWorkerSummary, String> {
|
||||
|
||||
fn project_ticket_event(value: &Value) -> Result<ModelTicketEvent, String> {
|
||||
let event = object(value, "Ticket event")?;
|
||||
let tag_changes = if event.get("kind").and_then(Value::as_str) == Some("tags_changed") {
|
||||
let attributes = event
|
||||
.get("attributes")
|
||||
.ok_or("missing tag audit attributes")?;
|
||||
let snapshot = |field| -> Result<Vec<ticket::WorkspaceTag>, String> {
|
||||
let encoded = attributes
|
||||
.get(field)
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("missing tag audit snapshot")?;
|
||||
serde_json::from_str(encoded)
|
||||
.map_err(|error| format!("invalid tag audit snapshot: {error}"))
|
||||
};
|
||||
Some(ModelTicketTagChange {
|
||||
before: snapshot("tags_before")?,
|
||||
after: snapshot("tags_after")?,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(ModelTicketEvent {
|
||||
tag_changes,
|
||||
sequence: usize_field(event, "sequence")?,
|
||||
kind: string_field(event, "kind")?,
|
||||
body: match event.get("body") {
|
||||
@@ -640,6 +672,24 @@ fn object_field<'a>(
|
||||
.ok_or_else(|| format!("missing or invalid {key}"))
|
||||
}
|
||||
|
||||
fn project_tags(object: &Map<String, Value>) -> Result<Vec<ticket::WorkspaceTag>, String> {
|
||||
let values = array_field(object, "tags")?;
|
||||
if values.len() > 100 {
|
||||
return Err("Ticket projection exceeds 100 tags".into());
|
||||
}
|
||||
values
|
||||
.iter()
|
||||
.map(|value| {
|
||||
let tag: ticket::WorkspaceTag = serde_json::from_value(value.clone())
|
||||
.map_err(|error| format!("invalid Workspace tag: {error}"))?;
|
||||
if tag.deleted {
|
||||
return Err("current Ticket projection contains a deleted tag".into());
|
||||
}
|
||||
Ok(tag)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn array_field<'a>(object: &'a Map<String, Value>, key: &str) -> Result<&'a [Value], String> {
|
||||
object
|
||||
.get(key)
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
/// Exact case states (open or closed). Empty means every state.
|
||||
#[serde(default)]
|
||||
states: Vec<WorkspaceTicketStateFilter>,
|
||||
states: Vec<TicketState>,
|
||||
/// Exact typed event kinds that must occur in the bounded thread window.
|
||||
#[serde(default)]
|
||||
event_kinds: Vec<String>,
|
||||
@@ -161,6 +156,18 @@ struct WorkspaceQueryTicketInput {
|
||||
/// Related Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
|
||||
related_ticket_id: Option<String>,
|
||||
relation_kind: Option<WorkspaceTicketRelationFilter>,
|
||||
/// All stable tag IDs must be attached (AND); at most 100 unique IDs.
|
||||
#[serde(default)]
|
||||
#[schemars(length(max = 100))]
|
||||
tag_ids: Vec<String>,
|
||||
/// At least one stable tag ID must be attached (OR), unless empty.
|
||||
#[serde(default)]
|
||||
#[schemars(length(max = 100))]
|
||||
tag_ids_any: Vec<String>,
|
||||
/// None of these stable tag IDs may be attached (NOT).
|
||||
#[serde(default)]
|
||||
#[schemars(length(max = 100))]
|
||||
tag_ids_none: Vec<String>,
|
||||
/// Linked Objective reference. Prefer `O-*`; canonical internal ids remain accepted for compatibility.
|
||||
linked_objective_id: Option<String>,
|
||||
updated_after: Option<String>,
|
||||
@@ -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<TicketBackendOperationResult> {
|
||||
let base = format!("/api/w/{workspace_id}/tickets");
|
||||
match operation {
|
||||
TicketBackendOperation::ListWorkspaceTags { request } => {
|
||||
let mut endpoint = format!(
|
||||
"/api/w/{workspace_id}/ticket-tags?limit={}",
|
||||
request.limit.unwrap_or(200).min(200)
|
||||
);
|
||||
if let Some(after) = request.after {
|
||||
endpoint.push_str("&after=");
|
||||
endpoint.push_str(&Self::ticket_path(&TicketIdOrSlug::Id(after)));
|
||||
}
|
||||
Self::request(client, WorkspaceRequestMethod::Get, endpoint, None)
|
||||
.map(TicketBackendOperationResult::WorkspaceTags)
|
||||
}
|
||||
TicketBackendOperation::MutateWorkspaceTag { request } => Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("/api/w/{workspace_id}/ticket-tags"),
|
||||
Some(json!(request)),
|
||||
)
|
||||
.map(TicketBackendOperationResult::WorkspaceTag),
|
||||
TicketBackendOperation::SetTicketTags { id, request } => Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Put,
|
||||
format!("{base}/{}/tags", Self::ticket_path(&id)),
|
||||
Some(json!(request)),
|
||||
)
|
||||
.map(TicketBackendOperationResult::WorkspaceTags),
|
||||
TicketBackendOperation::DefaultIntakeReadyStateChangeBody { from } => {
|
||||
let value = Self::request::<String>(
|
||||
client,
|
||||
@@ -1153,6 +1207,35 @@ macro_rules! expect_ticket_result {
|
||||
}
|
||||
|
||||
impl TicketBackend for WorkspaceHttpTicketBackend {
|
||||
fn list_workspace_tags(
|
||||
&self,
|
||||
request: ticket::TagListRequest,
|
||||
) -> TicketResult<Vec<ticket::WorkspaceTag>> {
|
||||
expect_ticket_result!(
|
||||
self.invoke(TicketBackendOperation::ListWorkspaceTags { request }),
|
||||
TicketBackendOperationResult::WorkspaceTags
|
||||
)
|
||||
}
|
||||
fn mutate_workspace_tag(
|
||||
&self,
|
||||
request: ticket::WorkspaceTagMutation,
|
||||
) -> TicketResult<ticket::WorkspaceTag> {
|
||||
expect_ticket_result!(
|
||||
self.invoke(TicketBackendOperation::MutateWorkspaceTag { request }),
|
||||
TicketBackendOperationResult::WorkspaceTag
|
||||
)
|
||||
}
|
||||
fn set_ticket_tags(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
request: ticket::TicketTagSetRequest,
|
||||
) -> TicketResult<Vec<ticket::WorkspaceTag>> {
|
||||
expect_ticket_result!(
|
||||
self.invoke(TicketBackendOperation::SetTicketTags { id, request }),
|
||||
TicketBackendOperationResult::WorkspaceTags
|
||||
)
|
||||
}
|
||||
|
||||
fn default_intake_ready_state_change_body(&self, from: &str) -> String {
|
||||
match self.invoke(TicketBackendOperation::DefaultIntakeReadyStateChangeBody {
|
||||
from: from.to_string(),
|
||||
@@ -1354,6 +1437,36 @@ struct NativeTicketOperation {
|
||||
|
||||
fn native_ticket_operation(tool_name: &str) -> Option<NativeTicketOperation> {
|
||||
let operation = match tool_name {
|
||||
"WorkspaceTagList" => NativeTicketOperation {
|
||||
operation: "tag_list",
|
||||
surface: NativeTicketSurface::Collection,
|
||||
identity_field: None,
|
||||
mutating: false,
|
||||
},
|
||||
"WorkspaceTagCreate" => NativeTicketOperation {
|
||||
operation: "tag_create",
|
||||
surface: NativeTicketSurface::Collection,
|
||||
identity_field: None,
|
||||
mutating: true,
|
||||
},
|
||||
"WorkspaceTagRename" => NativeTicketOperation {
|
||||
operation: "tag_rename",
|
||||
surface: NativeTicketSurface::Collection,
|
||||
identity_field: None,
|
||||
mutating: true,
|
||||
},
|
||||
"WorkspaceTagDelete" => NativeTicketOperation {
|
||||
operation: "tag_delete",
|
||||
surface: NativeTicketSurface::Collection,
|
||||
identity_field: None,
|
||||
mutating: true,
|
||||
},
|
||||
"TicketTagsSet" => NativeTicketOperation {
|
||||
operation: "tags_set",
|
||||
surface: NativeTicketSurface::Item,
|
||||
identity_field: Some("ticket"),
|
||||
mutating: true,
|
||||
},
|
||||
"QueryTicket" => NativeTicketOperation {
|
||||
operation: "query",
|
||||
surface: NativeTicketSurface::Collection,
|
||||
@@ -1458,7 +1571,7 @@ pub(crate) fn enabled_ticket_definitions(
|
||||
) -> Vec<ToolDefinition> {
|
||||
let backend = TicketToolBackend::new(WorkspaceHttpTicketBackend::new(client.clone()));
|
||||
let allowed = access.tool_names();
|
||||
ticket_tools(backend)
|
||||
let mut definitions: Vec<ToolDefinition> = 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::<Vec<_>>(),
|
||||
["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"));
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Classification tools share the Ticket feature's scoped Backend transport.
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
use super::*;
|
||||
|
||||
pub(super) const TAG_TOOL_NAMES: &[&str] = &[
|
||||
"WorkspaceTagList",
|
||||
"WorkspaceTagCreate",
|
||||
"WorkspaceTagRename",
|
||||
"WorkspaceTagDelete",
|
||||
"TicketTagsSet",
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum TagToolKind {
|
||||
List,
|
||||
Create,
|
||||
Rename,
|
||||
Delete,
|
||||
Set,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ListInput {
|
||||
/// Continue strictly after this stable tag ID. Continue until an empty page.
|
||||
after: Option<String>,
|
||||
/// Maximum results, 0..=200; defaults to 200.
|
||||
#[schemars(range(max = 200))]
|
||||
limit: Option<usize>,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CreateInput {
|
||||
/// Display name: nonempty after Unicode trimming, no controls, at most 128 UTF-8 bytes.
|
||||
name: String,
|
||||
/// Retry an uncertain request with this same key and exact input.
|
||||
operation_key: String,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RenameInput {
|
||||
tag_id: String,
|
||||
/// Last operation key observed on this exact identity; rename-back is still a change.
|
||||
expected_last_operation_key: String,
|
||||
name: String,
|
||||
operation_key: String,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct DeleteInput {
|
||||
tag_id: String,
|
||||
expected_last_operation_key: String,
|
||||
operation_key: String,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SetInput {
|
||||
/// Ticket reference, preferably T-*; WIP binds it from the item route.
|
||||
ticket: String,
|
||||
/// Entire active ID set observed in ShowTicket/QueryTicket; never silently refreshed.
|
||||
#[schemars(length(max = 100))]
|
||||
expected_tag_ids: Vec<String>,
|
||||
/// Desired entire ID set. Attach/remove multiple tags atomically; [] removes all.
|
||||
#[schemars(length(max = 100))]
|
||||
tag_ids: Vec<String>,
|
||||
operation_key: String,
|
||||
}
|
||||
|
||||
impl TagToolKind {
|
||||
pub(super) fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::List => "WorkspaceTagList",
|
||||
Self::Create => "WorkspaceTagCreate",
|
||||
Self::Rename => "WorkspaceTagRename",
|
||||
Self::Delete => "WorkspaceTagDelete",
|
||||
Self::Set => "TicketTagsSet",
|
||||
}
|
||||
}
|
||||
fn schema(self) -> Value {
|
||||
match self {
|
||||
Self::List => json!(schemars::schema_for!(ListInput)),
|
||||
Self::Create => json!(schemars::schema_for!(CreateInput)),
|
||||
Self::Rename => json!(schemars::schema_for!(RenameInput)),
|
||||
Self::Delete => json!(schemars::schema_for!(DeleteInput)),
|
||||
Self::Set => json!(schemars::schema_for!(SetInput)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn description(name: &str) -> &'static str {
|
||||
match name {
|
||||
"WorkspaceTagList" => {
|
||||
"List active Workspace classification tags in stable ID order, bounded to 200. Continue after the last ID until exhausted; a full page does not promise another page."
|
||||
}
|
||||
"WorkspaceTagCreate" => {
|
||||
"Create a Workspace classification tag with a stable identity and replay key. Names are ordinary classification, never Ticket states, routing, or authority grants."
|
||||
}
|
||||
"WorkspaceTagRename" => {
|
||||
"Rename a Workspace classification tag by stable ID and observed last_operation_key. Links retain identity; history is not rewritten. Retry uncertain requests with the same key/input, then reread before further edits."
|
||||
}
|
||||
"WorkspaceTagDelete" => {
|
||||
"Tombstone a Workspace classification tag using its observed last_operation_key and atomically remove current Ticket membership. Prior history remains; recreating the name creates a new identity."
|
||||
}
|
||||
"TicketTagsSet" => {
|
||||
"Atomically compare and replace a Ticket's entire active classification tag ID set. Compute desired tag_ids from observed expected_tag_ids to attach/remove multiple tags without overwriting unrelated changes. At most 100 unique IDs. Classification does not edit state, content digest, assignment, or grants. A replay is the original receipt, not a fresh observation; reread before further edits."
|
||||
}
|
||||
_ => unreachable!("unknown classification tool"),
|
||||
}
|
||||
}
|
||||
|
||||
fn required(name: &str, value: &str) -> Result<(), ToolError> {
|
||||
if value.trim().is_empty() {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"{name} must not be empty"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn validate_name(name: &str) -> Result<(), ToolError> {
|
||||
if name.trim().is_empty() || name.trim().len() > 128 || name.chars().any(char::is_control) {
|
||||
return Err(ToolError::InvalidArgument("name must be nonempty, contain no control characters, and be at most 128 UTF-8 bytes after trimming".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn validate_ids(name: &str, ids: &[String]) -> Result<(), ToolError> {
|
||||
if ids.len() > 100 {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"{name} must contain at most 100 IDs"
|
||||
)));
|
||||
}
|
||||
let mut unique = BTreeSet::new();
|
||||
for id in ids {
|
||||
required(name, id)?;
|
||||
if !unique.insert(id) {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"{name} must contain unique IDs"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse<T: serde::de::DeserializeOwned>(input: &str) -> Result<T, ToolError> {
|
||||
serde_json::from_str(input).map_err(|error| ToolError::InvalidArgument(error.to_string()))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TagTool {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
kind: TagToolKind,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TagTool {
|
||||
async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
|
||||
self.client
|
||||
.workspace_id()
|
||||
.filter(|id| !id.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidArgument(
|
||||
"Classification tools require Backend Workspace identity; no local fallback"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
let backend = WorkspaceHttpTicketBackend::new(self.client.clone());
|
||||
let content = match self.kind {
|
||||
TagToolKind::List => {
|
||||
let input: ListInput = parse(input)?;
|
||||
if input.limit.is_some_and(|limit| limit > 200) {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"limit must be at most 200".into(),
|
||||
));
|
||||
}
|
||||
if let Some(after) = &input.after {
|
||||
required("after", after)?;
|
||||
}
|
||||
let limit = input.limit.unwrap_or(200);
|
||||
let mut tags = backend
|
||||
.list_workspace_tags(ticket::TagListRequest {
|
||||
after: input.after,
|
||||
limit: Some(limit),
|
||||
})
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
tags.truncate(limit);
|
||||
json!(tags)
|
||||
}
|
||||
TagToolKind::Set => {
|
||||
let input: SetInput = parse(input)?;
|
||||
required("ticket", &input.ticket)?;
|
||||
required("operation_key", &input.operation_key)?;
|
||||
validate_ids("expected_tag_ids", &input.expected_tag_ids)?;
|
||||
validate_ids("tag_ids", &input.tag_ids)?;
|
||||
let tags = backend
|
||||
.set_ticket_tags(
|
||||
TicketIdOrSlug::from(input.ticket.as_str()),
|
||||
ticket::TicketTagSetRequest {
|
||||
expected_tag_ids: input.expected_tag_ids,
|
||||
tag_ids: input.tag_ids,
|
||||
operation_key: input.operation_key,
|
||||
},
|
||||
)
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
if tags.len() > 100 {
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
"Backend returned more than 100 Ticket tags".into(),
|
||||
));
|
||||
}
|
||||
json!(tags)
|
||||
}
|
||||
kind => {
|
||||
let request = match kind {
|
||||
TagToolKind::Create => {
|
||||
let input: CreateInput = parse(input)?;
|
||||
validate_name(&input.name)?;
|
||||
ticket::WorkspaceTagMutation {
|
||||
operation_key: input.operation_key,
|
||||
action: ticket::WorkspaceTagAction::Create { name: input.name },
|
||||
}
|
||||
}
|
||||
TagToolKind::Rename => {
|
||||
let input: RenameInput = parse(input)?;
|
||||
required("tag_id", &input.tag_id)?;
|
||||
required(
|
||||
"expected_last_operation_key",
|
||||
&input.expected_last_operation_key,
|
||||
)?;
|
||||
validate_name(&input.name)?;
|
||||
ticket::WorkspaceTagMutation {
|
||||
operation_key: input.operation_key,
|
||||
action: ticket::WorkspaceTagAction::Rename {
|
||||
tag_id: input.tag_id,
|
||||
expected_last_operation_key: input.expected_last_operation_key,
|
||||
name: input.name,
|
||||
},
|
||||
}
|
||||
}
|
||||
TagToolKind::Delete => {
|
||||
let input: DeleteInput = parse(input)?;
|
||||
required("tag_id", &input.tag_id)?;
|
||||
required(
|
||||
"expected_last_operation_key",
|
||||
&input.expected_last_operation_key,
|
||||
)?;
|
||||
ticket::WorkspaceTagMutation {
|
||||
operation_key: input.operation_key,
|
||||
action: ticket::WorkspaceTagAction::Delete {
|
||||
tag_id: input.tag_id,
|
||||
expected_last_operation_key: input.expected_last_operation_key,
|
||||
},
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
required("operation_key", &request.operation_key)?;
|
||||
json!(
|
||||
backend
|
||||
.mutate_workspace_tag(request)
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?
|
||||
)
|
||||
}
|
||||
};
|
||||
Ok(ToolOutput {
|
||||
summary: self.kind.name().into(),
|
||||
content: Some(content.to_string()),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn definition(client: Arc<dyn WorkspaceClient>, kind: TagToolKind) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new(kind.name())
|
||||
.description(description(kind.name()))
|
||||
.input_schema(kind.schema()),
|
||||
Arc::new(TagTool {
|
||||
client: client.clone(),
|
||||
kind,
|
||||
}) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
use super::*;
|
||||
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RecordingClient {
|
||||
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||
body: String,
|
||||
identity: bool,
|
||||
status: u16,
|
||||
}
|
||||
impl WorkspaceClient for RecordingClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
self.identity.then_some("workspace")
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"classification-test"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
Ok(WorkspaceResponse {
|
||||
status: self.status,
|
||||
body: self.body.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
fn recording(body: Value) -> Arc<RecordingClient> {
|
||||
Arc::new(RecordingClient {
|
||||
requests: Mutex::new(Vec::new()),
|
||||
body: body.to_string(),
|
||||
identity: true,
|
||||
status: 200,
|
||||
})
|
||||
}
|
||||
fn tag() -> Value {
|
||||
json!({"workspace_id":"workspace", "id":"tag-1", "name":"Ready", "last_operation_key":"create-1", "deleted":false})
|
||||
}
|
||||
fn cases() -> Vec<(TagToolKind, Value, &'static str)> {
|
||||
vec![
|
||||
(TagToolKind::List, json!({"limit":5}), "WorkspaceTagList"),
|
||||
(
|
||||
TagToolKind::Create,
|
||||
json!({"name":"Ready", "operation_key":"create-1"}),
|
||||
"WorkspaceTagCreate",
|
||||
),
|
||||
(
|
||||
TagToolKind::Rename,
|
||||
json!({"tag_id":"tag-1", "expected_last_operation_key":"create-1", "name":"New", "operation_key":"rename-1"}),
|
||||
"WorkspaceTagRename",
|
||||
),
|
||||
(
|
||||
TagToolKind::Delete,
|
||||
json!({"tag_id":"tag-1", "expected_last_operation_key":"create-1", "operation_key":"delete-1"}),
|
||||
"WorkspaceTagDelete",
|
||||
),
|
||||
(
|
||||
TagToolKind::Set,
|
||||
json!({"ticket":"T-740", "expected_tag_ids":[], "tag_ids":["tag-1", "tag-2"], "operation_key":"set-1"}),
|
||||
"TicketTagsSet",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classification_tool_schemas_require_observations_and_reject_actor_claims() {
|
||||
for (kind, input, name) in cases() {
|
||||
let client = recording(json!([]));
|
||||
let (meta, tool) = definition(client.clone(), kind)();
|
||||
assert_eq!(meta.name, name);
|
||||
let validator = jsonschema::validator_for(&meta.input_schema).unwrap();
|
||||
assert!(validator.is_valid(&input), "{name}: {}", meta.input_schema);
|
||||
for field in meta.input_schema["required"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let field = field.as_str().unwrap();
|
||||
let mut invalid = input.clone();
|
||||
invalid.as_object_mut().unwrap().remove(field);
|
||||
assert!(!validator.is_valid(&invalid), "{name}.{field}");
|
||||
assert!(matches!(
|
||||
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
}
|
||||
let mut invalid = input.clone();
|
||||
invalid["author"] = json!("orchestrator");
|
||||
assert!(!validator.is_valid(&invalid));
|
||||
assert!(matches!(
|
||||
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
let schema = TagToolKind::Set.schema();
|
||||
assert_eq!(schema["properties"]["tag_ids"]["maxItems"], 100);
|
||||
assert_eq!(schema["properties"]["expected_tag_ids"]["maxItems"], 100);
|
||||
assert_eq!(
|
||||
TagToolKind::List.schema()["properties"]["limit"]["maximum"],
|
||||
200
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classification_reads_forward_id_filters_and_preserve_tag_observations() {
|
||||
let item = json!({"resource_key":"T-740", "title":"Classify", "state":"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::<Vec<_>>()});
|
||||
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::<Vec<_>>()),
|
||||
] {
|
||||
let (_, tool) = definition(client.clone(), TagToolKind::Set)();
|
||||
for field in ["tag_ids", "expected_tag_ids"] {
|
||||
let mut input = json!({"ticket":"T-740", "tag_ids":[], "expected_tag_ids":[], "operation_key":"set"});
|
||||
input[field] = ids.clone();
|
||||
assert!(matches!(
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
for (kind, mut input, _) in cases()
|
||||
.into_iter()
|
||||
.filter(|(kind, _, _)| !matches!(kind, TagToolKind::List))
|
||||
{
|
||||
input["operation_key"] = json!(" ");
|
||||
let (_, tool) = definition(client.clone(), kind)();
|
||||
assert!(matches!(
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
}
|
||||
let (_, tool) = definition(client.clone(), TagToolKind::List)();
|
||||
assert!(
|
||||
tool.execute(r#"{"limit":201}"#, ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classification_tools_require_backend_workspace_and_bound_list_results() {
|
||||
let missing = Arc::new(RecordingClient {
|
||||
requests: Mutex::new(Vec::new()),
|
||||
body: "[]".into(),
|
||||
identity: false,
|
||||
status: 200,
|
||||
});
|
||||
for (kind, input, _) in cases() {
|
||||
let (_, tool) = definition(missing.clone(), kind)();
|
||||
assert!(matches!(
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
}
|
||||
assert!(missing.requests.lock().unwrap().is_empty());
|
||||
let client = recording(json!(vec![tag(); 201]));
|
||||
let (_, tool) = definition(client.clone(), TagToolKind::List)();
|
||||
for (input, expected) in [
|
||||
(json!({}), 200),
|
||||
(json!({"limit":2, "after":"a&b"}), 2),
|
||||
(json!({"limit":0}), 0),
|
||||
] {
|
||||
let output = tool
|
||||
.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
let value: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(value.as_array().unwrap().len(), expected);
|
||||
}
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(
|
||||
requests[1].path,
|
||||
"/api/w/workspace/ticket-tags?limit=2&after=a%26b"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classification_wip_uses_normal_permissions_and_route_bound_ticket_identity() {
|
||||
let client = recording(json!([tag()]));
|
||||
let tools =
|
||||
native_ticket_tools(client.clone(), TicketFeatureAccess::workspace_authoring()).unwrap();
|
||||
let set = tools
|
||||
.iter()
|
||||
.find(|tool| tool.name == "TicketTagsSet")
|
||||
.unwrap();
|
||||
let (_, input, _) = cases().pop().unwrap();
|
||||
let permissions = Some(ToolPermissionConfig {
|
||||
default_action: ToolPermissionAction::Allow,
|
||||
rules: vec![manifest::ToolPermissionRule {
|
||||
tool: "TicketTagsSet".into(),
|
||||
action: ToolPermissionAction::Deny,
|
||||
pattern: input.to_string(),
|
||||
}],
|
||||
});
|
||||
let error = execute_native_ticket_tool(
|
||||
set,
|
||||
&permissions,
|
||||
input,
|
||||
WipCallContext {
|
||||
execution: ToolExecutionContext::direct(),
|
||||
security_context: "worker".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
WipOperationError::Protocol(ProtocolError {
|
||||
code: ProtocolErrorCode::PermissionDenied,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
let handler = TicketItemWipHandler {
|
||||
tools: operation_map(
|
||||
tools
|
||||
.into_iter()
|
||||
.filter(|tool| tool.projection.surface == NativeTicketSurface::Item)
|
||||
.collect(),
|
||||
),
|
||||
permissions: None,
|
||||
ticket_reference: "T-740".into(),
|
||||
observations: Arc::new(Mutex::new(TicketObservationState::default())),
|
||||
};
|
||||
handler
|
||||
.observations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.content_digests
|
||||
.insert("T-740".into(), "content-1".into());
|
||||
let mut args = BTreeMap::from([
|
||||
("operation_key".into(), WipValue::String("set-1".into())),
|
||||
("tag_ids".into(), json_to_wip(&json!(["tag-1"])).unwrap()),
|
||||
("expected_tag_ids".into(), json_to_wip(&json!([])).unwrap()),
|
||||
]);
|
||||
assert!(
|
||||
handler
|
||||
.call(
|
||||
"tags_set",
|
||||
&args,
|
||||
WipCallContext {
|
||||
execution: ToolExecutionContext::direct(),
|
||||
security_context: "worker".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
assert_eq!(
|
||||
client.requests.lock().unwrap()[0].path,
|
||||
"/api/w/workspace/tickets/T-740/tags"
|
||||
);
|
||||
assert_eq!(
|
||||
handler
|
||||
.observations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.content_digests
|
||||
.get("T-740")
|
||||
.map(String::as_str),
|
||||
Some("content-1")
|
||||
);
|
||||
args.insert("ticket".into(), WipValue::String("T-foreign".into()));
|
||||
assert!(
|
||||
handler
|
||||
.call(
|
||||
"tags_set",
|
||||
&args,
|
||||
WipCallContext {
|
||||
execution: ToolExecutionContext::direct(),
|
||||
security_context: "worker".into()
|
||||
}
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
let read_only = TicketFeatureAccess::read_only().tool_names();
|
||||
assert!(read_only.contains(&"WorkspaceTagList"));
|
||||
assert!(!read_only.contains(&"TicketTagsSet"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classification_tools_preserve_backend_permission_and_stale_set_failures() {
|
||||
for status in [403, 409] {
|
||||
let client = Arc::new(RecordingClient {
|
||||
requests: Mutex::new(Vec::new()),
|
||||
body: "{}".into(),
|
||||
identity: true,
|
||||
status,
|
||||
});
|
||||
let (_, tool) = definition(client.clone(), TagToolKind::Set)();
|
||||
let (_, input, _) = cases().pop().unwrap();
|
||||
let error = tool
|
||||
.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains(&status.to_string()));
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classification_tools_http_mutations_survive_backend_reopen_and_stale_compare() {
|
||||
use std::io::{Read, Write};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join("tags.db");
|
||||
let backend = ticket::SqliteTicketBackend::open(&db, "workspace").unwrap();
|
||||
let reference = backend.create(NewTicket::new("Classify")).unwrap();
|
||||
let initial_content_digest =
|
||||
ticket::ticket_content_digest(&backend.show(reference.id.clone().into()).unwrap());
|
||||
let ticket_id = reference.id.clone();
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server_db = db.clone();
|
||||
let server = std::thread::spawn(move || {
|
||||
for _ in 0..10 {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(10)))
|
||||
.unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
let (headers, body) = loop {
|
||||
let mut chunk = [0u8; 8192];
|
||||
let count = stream.read(&mut chunk).unwrap();
|
||||
assert!(count > 0);
|
||||
bytes.extend_from_slice(&chunk[..count]);
|
||||
if let Some(end) = bytes.windows(4).position(|w| w == b"\r\n\r\n") {
|
||||
let headers = String::from_utf8(bytes[..end].to_vec()).unwrap();
|
||||
let length = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
line.to_lowercase()
|
||||
.strip_prefix("content-length:")
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if bytes.len() >= end + 4 + length {
|
||||
break (headers, bytes[end + 4..end + 4 + length].to_vec());
|
||||
}
|
||||
}
|
||||
};
|
||||
let backend =
|
||||
ticket::SqliteTicketBackend::open_verified(&server_db, "workspace").unwrap();
|
||||
let line = headers.lines().next().unwrap();
|
||||
let result = if line.starts_with("POST /api/w/workspace/ticket-tags ") {
|
||||
backend
|
||||
.mutate_workspace_tag(serde_json::from_slice(&body).unwrap())
|
||||
.map(|tag| json!(tag))
|
||||
} else if line.starts_with(&format!("PUT /api/w/workspace/tickets/{ticket_id}/tags ")) {
|
||||
backend
|
||||
.set_ticket_tags(
|
||||
ticket_id.clone().into(),
|
||||
serde_json::from_slice(&body).unwrap(),
|
||||
)
|
||||
.map(|tags| json!(tags))
|
||||
} else {
|
||||
assert!(
|
||||
line.starts_with("GET /api/w/workspace/ticket-tags?"),
|
||||
"{line}"
|
||||
);
|
||||
backend
|
||||
.list_workspace_tags(ticket::TagListRequest::default())
|
||||
.map(|tags| json!(tags))
|
||||
};
|
||||
let (status, response) = match result {
|
||||
Ok(value) => ("200 OK", value.to_string()),
|
||||
Err(error) => (
|
||||
"409 Conflict",
|
||||
json!({"message":error.to_string()}).to_string(),
|
||||
),
|
||||
};
|
||||
write!(stream, "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}", response.len()).unwrap();
|
||||
}
|
||||
});
|
||||
let client: Arc<dyn WorkspaceClient> = Arc::new(crate::worker::TestWorkspaceHttpClient::new(
|
||||
"workspace",
|
||||
base,
|
||||
));
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let call = |kind, input: Value| {
|
||||
runtime.block_on(async {
|
||||
let (_, tool) = definition(client.clone(), kind)();
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
})
|
||||
};
|
||||
let first: ticket::WorkspaceTag = serde_json::from_str(
|
||||
call(
|
||||
TagToolKind::Create,
|
||||
json!({"name":"ready", "operation_key":"create-a"}),
|
||||
)
|
||||
.unwrap()
|
||||
.content
|
||||
.as_deref()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let second: ticket::WorkspaceTag = serde_json::from_str(
|
||||
call(
|
||||
TagToolKind::Create,
|
||||
json!({"name":"closed", "operation_key":"create-b"}),
|
||||
)
|
||||
.unwrap()
|
||||
.content
|
||||
.as_deref()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let set = json!({"ticket":reference.id, "expected_tag_ids":[], "tag_ids":[first.id, second.id], "operation_key":"set-both"});
|
||||
let receipt = call(TagToolKind::Set, set.clone()).unwrap();
|
||||
assert_eq!(
|
||||
call(TagToolKind::Set, set).unwrap().content,
|
||||
receipt.content
|
||||
);
|
||||
assert!(call(TagToolKind::Set, json!({"ticket":reference.id, "expected_tag_ids":[], "tag_ids":[], "operation_key":"stale"})).is_err());
|
||||
let removed = call(TagToolKind::Set, json!({"ticket":reference.id, "expected_tag_ids":[second.id, first.id], "tag_ids":[], "operation_key":"remove-both"})).unwrap();
|
||||
assert_eq!(removed.content.as_deref(), Some("[]"));
|
||||
assert!(
|
||||
backend
|
||||
.show(reference.id.clone().into())
|
||||
.unwrap()
|
||||
.tags
|
||||
.is_empty()
|
||||
);
|
||||
call(TagToolKind::Set, json!({"ticket":reference.id, "expected_tag_ids":[], "tag_ids":[first.id, second.id], "operation_key":"reattach-both"})).unwrap();
|
||||
call(TagToolKind::Rename, json!({"tag_id":first.id, "expected_last_operation_key":first.last_operation_key, "name":"renamed", "operation_key":"rename-a"})).unwrap();
|
||||
call(TagToolKind::Delete, json!({"tag_id":second.id, "expected_last_operation_key":second.last_operation_key, "operation_key":"delete-b"})).unwrap();
|
||||
let listed = call(TagToolKind::List, json!({})).unwrap();
|
||||
let tags: Vec<ticket::WorkspaceTag> =
|
||||
serde_json::from_str(listed.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(tags.len(), 1);
|
||||
assert_eq!(tags[0].name, "renamed");
|
||||
server.join().unwrap();
|
||||
let reopened = ticket::SqliteTicketBackend::open_verified(&db, "workspace").unwrap();
|
||||
let ticket = reopened.show(reference.id.into()).unwrap();
|
||||
assert_eq!(ticket.tags, tags);
|
||||
assert_eq!(
|
||||
ticket::ticket_content_digest(&ticket),
|
||||
initial_content_digest
|
||||
);
|
||||
assert_eq!(ticket.meta.workflow_state, 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"
|
||||
);
|
||||
}
|
||||
@@ -391,6 +391,21 @@ impl SqliteWorkspaceAuthority {
|
||||
.collect::<Vec<_>>();
|
||||
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::<Vec<_>>()
|
||||
.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<TicketSummary>
|
||||
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<TicketS
|
||||
state: item.summary.workflow_state.into(),
|
||||
priority: item.summary.priority,
|
||||
updated_at: item.summary.updated_at,
|
||||
tags: item.summary.tags,
|
||||
queued_by: item.summary.queued_by,
|
||||
queued_at: item.summary.queued_at,
|
||||
workspace_action_priority: workspace_action_priority_name(projection.priority).to_string(),
|
||||
@@ -3497,7 +3535,7 @@ VALUES ('workspace-test', 'ticket', 4);
|
||||
let ticket_query = authority
|
||||
.query_tickets(TicketQueryRequest {
|
||||
query: Some("Deep Ticket marker".to_string()),
|
||||
states: vec![server_api::BrowserTicketState::Open],
|
||||
states: vec![ticket::TicketState::Open],
|
||||
linked_objective_id: Some("00000000001J3".to_string()),
|
||||
attention: vec![server_api::TicketAttention::MissingCommit],
|
||||
limit: Some(1),
|
||||
|
||||
@@ -42,6 +42,16 @@ fn fresh_and_upgraded_state_contract_schemas_enforce_the_same_constraints() {
|
||||
let fresh = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
let mut mismatches = Vec::new();
|
||||
for table in [
|
||||
"workspace_tags",
|
||||
"typed_ticket_tags",
|
||||
"workspace_tag_operations",
|
||||
"typed_tickets",
|
||||
"typed_ticket_targets",
|
||||
"typed_ticket_case_metadata",
|
||||
"typed_ticket_case_history",
|
||||
"typed_ticket_legacy_workflow_snapshot",
|
||||
"ticket_current_worker_assignments",
|
||||
"ticket_assignment_work_releases",
|
||||
"artifacts",
|
||||
"workspace_config_trees",
|
||||
"workspace_config_tree_history",
|
||||
|
||||
@@ -6575,6 +6575,15 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> 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<server_api::WorkspaceTicketTagList, server_api::RepositoryApiError>
|
||||
{
|
||||
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<server_api::WorkspaceTicketTag, server_api::RepositoryApiError> {
|
||||
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<server_api::WorkspaceTicketTagList, server_api::RepositoryApiError>
|
||||
{
|
||||
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<TicketBackendOperationResult> {
|
||||
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<TicketBackendOperationResult> {
|
||||
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;
|
||||
|
||||
@@ -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<Body>,
|
||||
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<Vec<Vec<rusqlite::types::Value>>> {
|
||||
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::<rusqlite::Result<Vec<_>>>()
|
||||
})
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.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::<Vec<_>>(),
|
||||
ticket_after
|
||||
.events
|
||||
.iter()
|
||||
.filter(|event| event.kind != ticket::TicketEventKind::TagsChanged)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
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::<rusqlite::Result<Vec<_>>>()
|
||||
.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::<Vec<_>>(),
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::<Vec<_>>(),
|
||||
ticket
|
||||
.events
|
||||
.iter()
|
||||
.filter(|event| event.kind == ticket::TicketEventKind::TagsChanged)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
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",
|
||||
|
||||
@@ -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<String, String>,
|
||||
request: ticket::TicketTagSetRequest,
|
||||
receipt: Vec<ticket::WorkspaceTag>,
|
||||
active_ids: Vec<String>,
|
||||
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<Vec<Vec<rusqlite::types::Value>>> {
|
||||
[
|
||||
"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::<Vec<_>>(),
|
||||
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)
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Vec<ticket::WorkspaceTag>>;
|
||||
fn mutate_workspace_tag(&self, request: ticket::WorkspaceTagMutation) -> ticket::Result<ticket::WorkspaceTag>;
|
||||
fn set_ticket_tags(&self, id: TicketIdOrSlug, request: ticket::TicketTagSetRequest) -> ticket::Result<Vec<ticket::WorkspaceTag>>;
|
||||
fn default_intake_ready_state_change_body(&self, from: &str) -> String;
|
||||
fn create(&self, input: NewTicket) -> ticket::Result<ticket::TicketRef>;
|
||||
fn edit_item(&self, id: TicketIdOrSlug, edit: ticket::TicketItemEdit) -> ticket::Result<ticket::Ticket>;
|
||||
|
||||
Reference in New Issue
Block a user