feat: add shared Workspace Ticket classification tags

This commit is contained in:
2026-10-11 01:23:30 +09:00
parent 6a12b6cc34
commit e9397fa0dd
29 changed files with 3959 additions and 49 deletions
+181
View File
@@ -91,6 +91,56 @@ impl BackendWorkspaceProductClient {
.map_err(|error| crate::backend_workspace::server_client_error(&self.api, error))
}
pub fn list_workspace_tags(
&self,
request: &ticket::TagListRequest,
) -> Result<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,
@@ -602,6 +652,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();
@@ -996,6 +1071,112 @@ mod tests {
(format!("http://{address}"), receiver, handle)
}
#[test]
fn classification_client_uses_scoped_routes_and_preserves_cas_and_replay_input() {
let tag = serde_json::json!({"workspace_id":"workspace-a", "id":"tag-a", "name":"Ready", "last_operation_key":"create-1", "deleted":false});
let single = tag.to_string();
let list = serde_json::json!([tag]).to_string();
let (base_url, requests, handle) = response_sequence_server(vec![
("200 OK", &list),
("200 OK", &single),
("200 OK", &list),
]);
let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-token",
)
.unwrap();
let listed = client
.list_workspace_tags(&ticket::TagListRequest {
after: Some("tag-0".into()),
limit: Some(7),
})
.unwrap();
assert_eq!(listed[0].id, "tag-a");
let request = requests.recv().unwrap();
assert!(
request.starts_with("GET /api/w/workspace-a/ticket-tags?"),
"{request}"
);
assert!(
request.contains("after=tag-0") && request.contains("limit=7"),
"{request}"
);
assert!(
request
.to_lowercase()
.contains("authorization: bearer test-token")
);
let mutation = ticket::WorkspaceTagMutation {
operation_key: "rename-1".into(),
action: ticket::WorkspaceTagAction::Rename {
tag_id: "tag-a".into(),
expected_last_operation_key: "create-1".into(),
name: "New".into(),
},
};
TicketBackend::mutate_workspace_tag(&client, mutation.clone()).unwrap();
let request = requests.recv().unwrap();
assert!(
request.starts_with("POST /api/w/workspace-a/ticket-tags "),
"{request}"
);
let body: serde_json::Value =
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
assert_eq!(body, serde_json::json!(mutation));
let set = ticket::TicketTagSetRequest {
operation_key: "set-1".into(),
expected_tag_ids: vec!["tag-old".into()],
tag_ids: vec!["tag-a".into(), "tag-b".into()],
};
let receipt =
TicketBackend::set_ticket_tags(&client, TicketIdOrSlug::from("T-740"), set.clone())
.unwrap();
assert_eq!(receipt[0].name, "Ready");
let request = requests.recv().unwrap();
assert!(
request.starts_with("PUT /api/w/workspace-a/tickets/T-740/tags "),
"{request}"
);
let body: serde_json::Value =
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
assert_eq!(body, serde_json::json!(set));
handle.join().unwrap();
}
#[test]
fn classification_client_returns_backend_denials_without_local_fallback() {
for status in ["403 Forbidden", "409 Conflict"] {
let (base_url, requests, handle) = one_response_server(
status,
r#"{"error":"ticket_conflict","message":"stale or unauthorized","diagnostics":[]}"#,
);
let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"token",
)
.unwrap();
let result = client.set_ticket_tags(
&TicketIdOrSlug::from("T-740"),
&ticket::TicketTagSetRequest {
operation_key: "retry-key".into(),
expected_tag_ids: vec![],
tag_ids: vec!["tag-a".into()],
},
);
assert!(result.is_err());
assert!(
requests
.recv()
.unwrap()
.starts_with("PUT /api/w/workspace-a/tickets/T-740/tags ")
);
handle.join().unwrap();
}
}
#[test]
fn legacy_state_adapter_uses_snapshot_content_without_overriding_stale_expected_state() {
let temp = tempfile::tempdir().unwrap();
+203
View File
@@ -376,6 +376,11 @@ impl_openapi_schema!(
TicketStateUpdateRequest,
TicketSummarySearchQuery,
TicketThreadEventRequest,
WorkspaceTicketTagListRequest,
WorkspaceTicketTagMutationRequest,
TicketTagsSetRequest,
WorkspaceTicketTagList,
WorkspaceTicketTag,
RuntimeManagementApiError,
RuntimeWorkersQuery,
RuntimeWorkerSpawnRequest,
@@ -2976,6 +2981,31 @@ pub trait ServerApi {
#[body] request: CreateTicketRecordRequest,
) -> Result<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,
@@ -7260,6 +7290,9 @@ pub struct InvalidProjectRecord {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct TicketListItemSummary {
#[serde(default)]
#[cfg_attr(feature = "typescript", ts(type = "Array<WorkspaceTag>"))]
pub tags: Vec<ticket::WorkspaceTag>,
pub id: String,
pub resource_key: String,
pub title: String,
@@ -7567,6 +7600,15 @@ pub struct TicketQueryRequest {
pub review_status: Option<String>,
#[serde(default)]
pub attention: Vec<String>,
/// 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>,
@@ -7583,6 +7625,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: String,
@@ -7638,6 +7682,8 @@ pub struct TicketShowRequest {
#[serde(deny_unknown_fields)]
pub struct TicketDetail {
pub id: String,
#[cfg_attr(feature = "typescript", ts(type = "Array<WorkspaceTag>"))]
pub tags: Vec<ticket::WorkspaceTag>,
pub resource_key: String,
pub title: String,
pub state: String,
@@ -7703,6 +7749,14 @@ macro_rules! transparent_ticket_dto {
}
transparent_ticket_dto!(TicketRecord, ticket::Ticket);
transparent_ticket_dto!(WorkspaceTicketTagListRequest, ticket::TagListRequest);
transparent_ticket_dto!(
WorkspaceTicketTagMutationRequest,
ticket::WorkspaceTagMutation
);
transparent_ticket_dto!(TicketTagsSetRequest, ticket::TicketTagSetRequest);
transparent_ticket_dto!(WorkspaceTicketTagList, Vec<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);
@@ -11121,6 +11175,155 @@ mod skill_typescript_tests {
mod tests {
use super::*;
#[test]
fn ticket_tag_query_filters_default_empty_and_roundtrip() {
let request: TicketQueryRequest = serde_json::from_value(serde_json::json!({})).unwrap();
assert!(request.tag_ids.is_empty());
assert!(request.tag_ids_any.is_empty());
assert!(request.tag_ids_none.is_empty());
let request: TicketQueryRequest = serde_json::from_value(serde_json::json!({
"tag_ids": ["tag-a", "tag-b"],
"tag_ids_any": ["tag-c", "tag-d"],
"tag_ids_none": ["tag-e"]
}))
.unwrap();
let value = serde_json::to_value(request).unwrap();
assert_eq!(value["tag_ids"], serde_json::json!(["tag-a", "tag-b"]));
assert_eq!(value["tag_ids_any"], serde_json::json!(["tag-c", "tag-d"]));
assert_eq!(value["tag_ids_none"], serde_json::json!(["tag-e"]));
}
#[test]
fn ticket_tag_mutations_require_operation_keys_and_observed_cas() {
for action in [
serde_json::json!({"kind": "create", "name": "bug"}),
serde_json::json!({"kind": "rename", "tag_id": "tag-a", "expected_last_operation_key": "observed", "name": "defect"}),
serde_json::json!({"kind": "delete", "tag_id": "tag-a", "expected_last_operation_key": "observed"}),
] {
let value = serde_json::json!({"operation_key": "new-operation", "action": action});
let parsed: WorkspaceTicketTagMutationRequest =
serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(parsed).unwrap(), value);
let mut missing_operation = value.clone();
missing_operation
.as_object_mut()
.unwrap()
.remove("operation_key");
assert!(
serde_json::from_value::<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),
] {
let schema = serde_json::to_value(schema).unwrap();
assert_eq!(schema["properties"]["tags"]["type"], "array");
assert_eq!(
schema["properties"]["tags"]["items"]["$ref"],
"#/$defs/WorkspaceTag"
);
}
}
#[test]
fn ticket_tag_operations_are_typed_workspace_routes() {
for (name, method, path) in [
(
"workspace_ticket_tag_list",
HttpMethod::Get,
"/api/w/{workspace_id}/ticket-tags",
),
(
"workspace_ticket_tag_mutate",
HttpMethod::Post,
"/api/w/{workspace_id}/ticket-tags",
),
(
"ticket_tags_set",
HttpMethod::Put,
"/api/w/{workspace_id}/tickets/{id}/tags",
),
] {
let operation = ServerApiMetadata::OPERATIONS
.iter()
.find(|operation| operation.operation_id == name)
.unwrap();
assert_eq!(operation.method, method);
assert_eq!(operation.path, path);
}
let contract = canonical_openapi_document().unwrap().to_json().unwrap();
let contract: serde_json::Value = serde_json::from_str(&contract).unwrap();
for (path, method) in [
("/api/w/{workspace_id}/ticket-tags", "post"),
("/api/w/{workspace_id}/tickets/{id}/tags", "put"),
] {
let operation = &contract["paths"][path][method];
assert!(
operation["responses"].get("409").is_some(),
"CAS conflict must be documented"
);
assert!(operation["requestBody"]["required"].as_bool().unwrap());
}
}
#[cfg(feature = "typescript")]
#[test]
fn ticket_tag_typescript_references_are_registered() {
use ts_rs::TS;
let config = ts_rs::Config::default();
assert!(TicketDetail::decl(&config).contains("tags: Array<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()
{
@@ -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",
+38
View File
@@ -131,3 +131,41 @@ CREATE INDEX typed_tickets_workspace_title
ON typed_tickets(workspace_id, title COLLATE NOCASE, ticket_id);
CREATE INDEX typed_tickets_workspace_updated
ON typed_tickets(workspace_id, updated_at DESC, ticket_id);
-- Workspace tag foundation (v8): shared by fresh schema and v7 upgrade.
CREATE TABLE workspace_tags (
workspace_id TEXT NOT NULL,
tag_id TEXT NOT NULL,
name TEXT NOT NULL,
normalized_name TEXT NOT NULL,
last_operation_key TEXT NOT NULL,
deleted INTEGER NOT NULL CHECK (deleted IN (0, 1)),
PRIMARY KEY (workspace_id, tag_id)
);
CREATE UNIQUE INDEX workspace_tags_active_name
ON workspace_tags(workspace_id, normalized_name) WHERE deleted = 0;
CREATE TABLE typed_ticket_tags (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
tag_id TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, tag_id),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE,
FOREIGN KEY (workspace_id, tag_id) REFERENCES workspace_tags(workspace_id, tag_id) ON DELETE RESTRICT
);
CREATE INDEX typed_ticket_tags_workspace_tag
ON typed_ticket_tags(workspace_id, tag_id, ticket_id);
-- Immutable operation receipt and Workspace audit: exact input includes actor
-- attributes; before/result preserve stable identities and display-name snapshots.
-- No foreign keys to mutable rows: history survives deletion of current links.
CREATE TABLE workspace_tag_operations (
workspace_id TEXT NOT NULL,
operation_key TEXT NOT NULL,
kind TEXT NOT NULL,
target_id TEXT NOT NULL,
input_json TEXT NOT NULL,
source_context_json TEXT NOT NULL,
before_json TEXT NOT NULL,
result_json TEXT NOT NULL,
at TEXT NOT NULL,
PRIMARY KEY (workspace_id, operation_key)
);
+69 -3
View File
@@ -22,9 +22,16 @@ use thiserror::Error;
pub mod config;
mod sqlite_schema;
mod tags;
pub use tags::{
TagListRequest, TicketTagSetRequest, WorkspaceTag, WorkspaceTagAction, WorkspaceTagMutation,
};
pub mod tool;
pub use sqlite_schema::{migrate_sqlite_ticket_schema, verify_sqlite_ticket_schema};
pub use sqlite_schema::{
migrate_sqlite_ticket_schema, verify_pre_classification_ticket_schema,
verify_sqlite_ticket_schema,
};
const MAX_INTAKE_SUMMARY_BODY_BYTES: usize = 16 * 1024;
const MAX_ORCHESTRATION_PLAN_TEXT_BYTES: usize = 16 * 1024;
@@ -116,6 +123,7 @@ fn io_err(path: impl Into<PathBuf>, source: io::Error) -> TicketError {
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)?,
@@ -360,6 +368,7 @@ pub enum TicketEventKind {
Decision,
ImplementationReport,
StateChanged,
TagsChanged,
IntakeSummary,
StatusChanged,
Close,
@@ -375,6 +384,7 @@ impl TicketEventKind {
Self::Decision => "decision",
Self::ImplementationReport => "implementation_report",
Self::StateChanged => "state_changed",
Self::TagsChanged => "tags_changed",
Self::IntakeSummary => "intake_summary",
Self::StatusChanged => "status_changed",
Self::Close => "close",
@@ -390,6 +400,7 @@ impl TicketEventKind {
Self::Decision => "Decision".to_string(),
Self::ImplementationReport => "Implementation report".to_string(),
Self::StateChanged => "State changed".to_string(),
Self::TagsChanged => "Tags changed".to_string(),
Self::IntakeSummary => "Intake summary".to_string(),
Self::StatusChanged => "Status changed".to_string(),
Self::Close => "Closed".to_string(),
@@ -408,6 +419,7 @@ impl From<&str> for TicketEventKind {
"implementation_report" => Self::ImplementationReport,
"review" => Self::Comment,
"state_changed" => Self::StateChanged,
"tags_changed" => Self::TagsChanged,
"intake_summary" => Self::IntakeSummary,
"status_changed" => Self::StatusChanged,
"close" | "closed" => Self::Close,
@@ -1635,6 +1647,8 @@ pub struct TicketMeta {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct TicketSummary {
#[serde(default)]
pub tags: Vec<WorkspaceTag>,
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resource_key: Option<String>,
@@ -1718,6 +1732,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>,
@@ -1774,6 +1790,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>;
@@ -1848,6 +1871,16 @@ pub trait TicketBackend {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "operation", rename_all = "snake_case")]
pub enum TicketBackendOperation {
ListWorkspaceTags {
request: TagListRequest,
},
MutateWorkspaceTag {
request: WorkspaceTagMutation,
},
SetTicketTags {
id: TicketIdOrSlug,
request: TicketTagSetRequest,
},
DefaultIntakeReadyStateChangeBody {
from: String,
},
@@ -1938,6 +1971,8 @@ pub enum TicketBackendOperation {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "result", content = "value", rename_all = "snake_case")]
pub enum TicketBackendOperationResult {
WorkspaceTags(Vec<WorkspaceTag>),
WorkspaceTag(WorkspaceTag),
Text(String),
Unit,
Tickets(Vec<TicketSummary>),
@@ -1961,6 +1996,15 @@ where
B: TicketBackend + ?Sized,
{
Ok(match operation {
TicketBackendOperation::ListWorkspaceTags { request } => {
TicketBackendOperationResult::WorkspaceTags(backend.list_workspace_tags(request)?)
}
TicketBackendOperation::MutateWorkspaceTag { request } => {
TicketBackendOperationResult::WorkspaceTag(backend.mutate_workspace_tag(request)?)
}
TicketBackendOperation::SetTicketTags { id, request } => {
TicketBackendOperationResult::WorkspaceTags(backend.set_ticket_tags(id, request)?)
}
TicketBackendOperation::DefaultIntakeReadyStateChangeBody { from } => {
TicketBackendOperationResult::Text(
backend.default_intake_ready_state_change_body(&from),
@@ -2259,6 +2303,7 @@ impl SqliteTicketBackend {
.map_err(sqlite_err)?;
for summary in &mut summaries {
summary.resource_key = Self::resource_key_for(conn, &self.workspace_id, &summary.id)?;
summary.tags = self.load_ticket_tags(conn, &summary.id)?;
}
let has_more = summaries.len() > query.limit;
summaries.truncate(query.limit);
@@ -2343,6 +2388,7 @@ impl SqliteTicketBackend {
updated_at,
) = row.map_err(sqlite_err)?;
summaries.push(TicketSummary {
tags: self.load_ticket_tags(conn, &id)?,
resource_key: Self::resource_key_for(conn, &self.workspace_id, &id)?,
id,
slug,
@@ -2929,6 +2975,7 @@ impl SqliteTicketBackend {
let artifacts = self.load_artifacts(conn, ticket_id)?;
let relations = self.relation_view_for_meta(conn, &meta)?;
Ok(Ticket {
tags: self.load_ticket_tags(conn, ticket_id)?,
meta,
document: TicketDocument {
body: MarkdownText::new(body),
@@ -3098,7 +3145,9 @@ impl SqliteTicketBackend {
}
meta.labels =
self.load_ordered_values(conn, "typed_ticket_labels", "label", &meta.id)?;
summaries.push(ticket_summary_from_meta(meta));
let mut summary = ticket_summary_from_meta(meta);
summary.tags = self.load_ticket_tags(conn, &summary.id)?;
summaries.push(summary);
}
Ok(summaries)
}
@@ -3173,6 +3222,19 @@ fn finish_sqlite_transaction<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 planning が完了しました。state {from} -> ready。\n")
@@ -3253,6 +3315,7 @@ impl TicketBackend for SqliteTicketBackend {
.author
.unwrap_or_else(|| "SqliteTicketBackend".to_string());
let ticket = Ticket {
tags: Vec::new(),
meta,
document: TicketDocument {
body,
@@ -3400,7 +3463,8 @@ impl TicketBackend for SqliteTicketBackend {
let states = self.state_index(conn)?;
let relations = self.all_relations(conn)?;
let blockers = transitive_dependency_blockers(&ticket_id, &states, &relations)?;
let summary = ticket_summary_from_meta(ticket.meta);
let mut summary = ticket_summary_from_meta(ticket.meta);
summary.tags = ticket.tags;
let mut projection = project_ticket_workspace_item(&summary, &blockers, None);
projection.queue_guard = ticket_queue_guard(&summary, &blockers, None);
let queue_tickets = vec![ticket_id];
@@ -3854,6 +3918,7 @@ impl TicketBackend for SqliteTicketBackend {
fn ticket_summary_from_meta(meta: TicketMeta) -> TicketSummary {
TicketSummary {
tags: Vec::new(),
id: meta.id,
resource_key: meta.resource_key,
slug: meta.slug,
@@ -4664,6 +4729,7 @@ mod tests {
fn summary_with_state(state: TicketWorkflowState) -> TicketSummary {
TicketSummary {
tags: Vec::new(),
id: "000TEST".to_string(),
resource_key: Some("T-1".to_string()),
slug: "000TEST".to_string(),
+285 -9
View File
@@ -7,8 +7,8 @@ use crate::{Result, TicketError, sqlite_err};
const MIGRATION_TABLE: &str = "ticket_schema_migrations";
const MAX_SCHEMA_DIAGNOSTICS: usize = 32;
const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 7;
const PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION: i64 = 6;
const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 8;
const PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION: i64 = 7;
const BASELINE_MIGRATION_NAME: &str = "ticket schema baseline";
#[derive(Clone, Copy)]
@@ -41,6 +41,9 @@ struct ExpectedForeignKey {
}
const OWNED_TABLES: &[&str] = &[
"workspace_tags",
"typed_ticket_tags",
"workspace_tag_operations",
"typed_tickets",
"typed_ticket_targets",
"typed_ticket_labels",
@@ -96,6 +99,57 @@ const TICKET_COLUMNS: &[ExpectedColumn] = &[
column("resolution", "TEXT", false, 0),
];
const TAG_COLUMNS: &[ExpectedColumn] = &[
column("workspace_id", "TEXT", true, 1),
column("tag_id", "TEXT", true, 2),
column("name", "TEXT", true, 0),
column("normalized_name", "TEXT", true, 0),
column("last_operation_key", "TEXT", true, 0),
column("deleted", "INTEGER", true, 0),
];
const TICKET_TAG_COLUMNS: &[ExpectedColumn] = &[
column("workspace_id", "TEXT", true, 1),
column("ticket_id", "TEXT", true, 2),
column("tag_id", "TEXT", true, 3),
];
const TAG_OPERATION_COLUMNS: &[ExpectedColumn] = &[
column("workspace_id", "TEXT", true, 1),
column("operation_key", "TEXT", true, 2),
column("kind", "TEXT", true, 0),
column("target_id", "TEXT", true, 0),
column("input_json", "TEXT", true, 0),
column("source_context_json", "TEXT", true, 0),
column("before_json", "TEXT", true, 0),
column("result_json", "TEXT", true, 0),
column("at", "TEXT", true, 0),
];
const TICKET_TAG_FOREIGN_KEYS: &[ExpectedForeignKey] = &[
ExpectedForeignKey {
from: "workspace_id",
target_table: "typed_tickets",
to: "workspace_id",
on_delete: "CASCADE",
},
ExpectedForeignKey {
from: "ticket_id",
target_table: "typed_tickets",
to: "ticket_id",
on_delete: "CASCADE",
},
ExpectedForeignKey {
from: "workspace_id",
target_table: "workspace_tags",
to: "workspace_id",
on_delete: "RESTRICT",
},
ExpectedForeignKey {
from: "tag_id",
target_table: "workspace_tags",
to: "tag_id",
on_delete: "RESTRICT",
},
];
const TARGET_COLUMNS: &[ExpectedColumn] = &[
column("workspace_id", "TEXT", true, 1),
column("ticket_id", "TEXT", true, 2),
@@ -288,12 +342,17 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION,
BASELINE_MIGRATION_NAME.to_string(),
)])
|| applied == BTreeMap::from([(6, BASELINE_MIGRATION_NAME.to_string())])
{
migrate_v6_to_v7(connection)?;
let previous = *applied.keys().next().expect("one migration");
if previous == 6 {
migrate_v6_to_v7(connection)?;
}
migrate_v7_to_v8(connection)?;
connection
.execute(
"DELETE FROM ticket_schema_migrations WHERE version = ?1",
params![PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION],
params![previous],
)
.map_err(sqlite_err)?;
connection
@@ -325,6 +384,17 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
/// Verifies the current Ticket-owned SQLite schema without executing DDL.
pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
verify_ticket_schema_shape(connection, false)
}
/// Retained Workspace schema-66 migration produces the pre-classification v7
/// Ticket shape. Validate that intermediate contract before the normal startup
/// path upgrades it; never require future tag tables inside that old transaction.
pub fn verify_pre_classification_ticket_schema(connection: &Connection) -> Result<()> {
verify_ticket_schema_shape(connection, true)
}
fn verify_ticket_schema_shape(connection: &Connection, pre_classification: bool) -> Result<()> {
let mut diagnostics = Vec::new();
collect_table_diagnostics(
@@ -337,7 +407,18 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
match load_applied_migrations(connection) {
Ok(applied) => {
if let Err(error) = validate_applied_migrations(&applied) {
let history = if pre_classification {
if applied == BTreeMap::from([(7, BASELINE_MIGRATION_NAME.to_owned())]) {
Ok(())
} else {
Err(TicketError::Sqlite(
"pre-classification schema requires the retained v7 baseline".into(),
))
}
} else {
validate_applied_migrations(&applied)
};
if let Err(error) = history {
push_diagnostic(&mut diagnostics, error.to_string());
} else if applied.len() != MIGRATIONS.len() {
push_diagnostic(
@@ -354,6 +435,17 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
}
for (table, columns, foreign_keys) in [
("workspace_tags", TAG_COLUMNS, TICKET_FOREIGN_KEYS),
(
"typed_ticket_tags",
TICKET_TAG_COLUMNS,
TICKET_TAG_FOREIGN_KEYS,
),
(
"workspace_tag_operations",
TAG_OPERATION_COLUMNS,
TICKET_FOREIGN_KEYS,
),
("typed_tickets", TICKET_COLUMNS, TICKET_FOREIGN_KEYS),
("typed_ticket_targets", TARGET_COLUMNS, CHILD_FOREIGN_KEYS),
("typed_ticket_labels", LABEL_COLUMNS, CHILD_FOREIGN_KEYS),
@@ -394,9 +486,47 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
CHILD_FOREIGN_KEYS,
),
] {
if pre_classification
&& matches!(
table,
"workspace_tags" | "typed_ticket_tags" | "workspace_tag_operations"
)
{
continue;
}
collect_table_diagnostics(connection, table, columns, foreign_keys, &mut diagnostics);
}
if !pre_classification {
collect_index_diagnostics(
connection,
"workspace_tags",
Some("workspace_tags_active_name"),
true,
&["workspace_id", "normalized_name"],
&mut diagnostics,
);
collect_index_diagnostics(
connection,
"typed_ticket_tags",
Some("typed_ticket_tags_workspace_tag"),
false,
&["workspace_id", "tag_id", "ticket_id"],
&mut diagnostics,
);
let tag_name_index_sql: Option<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",
@@ -449,6 +579,14 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
}
for table in OWNED_TABLES {
if pre_classification
&& matches!(
*table,
"workspace_tags" | "typed_ticket_tags" | "workspace_tag_operations"
)
{
continue;
}
collect_foreign_key_check_diagnostics(connection, table, &mut diagnostics);
}
@@ -474,6 +612,14 @@ fn create_latest_ticket_schema(connection: &Connection) -> Result<()> {
.map_err(sqlite_err)
}
fn migrate_v7_to_v8(connection: &Connection) -> Result<()> {
// Use the exact canonical DDL rather than duplicating migration definitions.
let (_, tag_schema) = include_str!("latest_schema.sql")
.split_once("-- Workspace tag foundation (v8): shared by fresh schema and v7 upgrade.")
.expect("canonical tag schema marker");
connection.execute_batch(tag_schema).map_err(sqlite_err)
}
fn migrate_v6_to_v7(connection: &Connection) -> Result<()> {
connection
.execute_batch(
@@ -1029,7 +1175,10 @@ mod tests {
create_latest_ticket_schema(&connection).unwrap();
connection
.execute_batch(
"DROP TABLE typed_ticket_targets;
"DROP TABLE typed_ticket_tags;
DROP TABLE workspace_tag_operations;
DROP TABLE workspace_tags;
DROP TABLE typed_ticket_targets;
ALTER TABLE typed_tickets ADD COLUMN repository_id TEXT;
ALTER TABLE typed_tickets ADD COLUMN ref_selector TEXT;
CREATE TABLE ticket_schema_migrations (
@@ -1137,10 +1286,137 @@ mod tests {
assert!(!migrated_attributes.contains_key("ref_selector"));
assert_eq!(
load_applied_migrations(&connection).unwrap(),
BTreeMap::from([(7, BASELINE_MIGRATION_NAME.to_owned())])
BTreeMap::from([(8, BASELINE_MIGRATION_NAME.to_owned())])
);
}
#[test]
fn migrates_v7_to_identical_fresh_schema_without_touching_legacy_labels() {
let upgraded = Connection::open_in_memory().unwrap();
let legacy_sql = include_str!("latest_schema.sql")
.split_once("-- Workspace tag foundation (v8): shared by fresh schema and v7 upgrade.")
.unwrap()
.0;
upgraded.execute_batch(legacy_sql).unwrap();
upgraded.execute_batch(
"CREATE TABLE ticket_schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL);
INSERT INTO ticket_schema_migrations VALUES (7, 'ticket schema baseline', 'old');
INSERT INTO typed_tickets (workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit)
VALUES ('ws','ticket','ticket','Title','open','task','P2','body','ready',1);
INSERT INTO typed_ticket_labels VALUES ('ws','ticket',0,' Legacy Label ');"
).unwrap();
migrate_sqlite_ticket_schema(&upgraded).unwrap();
verify_sqlite_ticket_schema(&upgraded).unwrap();
let fresh = Connection::open_in_memory().unwrap();
migrate_sqlite_ticket_schema(&fresh).unwrap();
let schema = |conn: &Connection| {
let mut statement = conn.prepare("SELECT type,name,tbl_name,sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY type,name").unwrap();
statement
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<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','ready',1);
INSERT INTO workspace_tags VALUES ('two','tag','Name','name','create',0);"
).unwrap();
assert!(
connection
.execute(
"INSERT INTO typed_ticket_tags VALUES ('one','ticket','tag')",
[]
)
.is_err()
);
connection
.execute(
"INSERT INTO workspace_tags VALUES ('one','tag','Name','name','create',0)",
[],
)
.unwrap();
connection
.execute(
"INSERT INTO typed_ticket_tags VALUES ('one','ticket','tag')",
[],
)
.unwrap();
assert!(
connection
.execute("DELETE FROM workspace_tags WHERE workspace_id='one'", [])
.is_err()
);
verify_sqlite_ticket_schema(&connection).unwrap();
}
#[test]
fn rejects_unknown_future_migration_history_without_changing_schema() {
let connection = Connection::open_in_memory().unwrap();
@@ -1155,7 +1431,7 @@ mod tests {
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
assert!(error.to_string().contains(
"migration history must contain only the canonical version 7 baseline marker"
"migration history must contain only the canonical version 8 baseline marker"
));
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2);
}
@@ -1177,7 +1453,7 @@ mod tests {
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
assert!(error.to_string().contains(
"migration history must contain only the canonical version 7 baseline marker"
"migration history must contain only the canonical version 8 baseline marker"
));
assert!(!table_exists(&connection, "typed_tickets").unwrap());
}
+323
View File
@@ -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)
})
}
}
+801
View File
@@ -0,0 +1,801 @@
use super::*;
use tempfile::tempdir;
fn create_tag(backend: &SqliteTicketBackend, key: &str, name: &str) -> WorkspaceTag {
backend
.mutate_workspace_tag(WorkspaceTagMutation {
operation_key: key.into(),
action: WorkspaceTagAction::Create { name: name.into() },
})
.unwrap()
}
fn rename_tag(
backend: &SqliteTicketBackend,
key: &str,
tag: &WorkspaceTag,
name: &str,
) -> WorkspaceTag {
backend
.mutate_workspace_tag(WorkspaceTagMutation {
operation_key: key.into(),
action: WorkspaceTagAction::Rename {
tag_id: tag.id.clone(),
expected_last_operation_key: tag.last_operation_key.clone(),
name: name.into(),
},
})
.unwrap()
}
fn delete_request(key: &str, tag: &WorkspaceTag) -> WorkspaceTagMutation {
WorkspaceTagMutation {
operation_key: key.into(),
action: WorkspaceTagAction::Delete {
tag_id: tag.id.clone(),
expected_last_operation_key: tag.last_operation_key.clone(),
},
}
}
fn set_request(
key: &str,
expected: &[WorkspaceTag],
desired: &[WorkspaceTag],
) -> TicketTagSetRequest {
TicketTagSetRequest {
expected_tag_ids: expected.iter().map(|t| t.id.clone()).collect(),
tag_ids: desired.iter().map(|t| t.id.clone()).collect(),
operation_key: key.into(),
}
}
fn audit_count(backend: &SqliteTicketBackend) -> i64 {
backend
.open_connection()
.unwrap()
.query_row(
"SELECT COUNT(*) FROM workspace_tag_operations WHERE workspace_id=?1",
params![backend.workspace_id],
|row| row.get(0),
)
.unwrap()
}
#[test]
fn tag_names_have_documented_unicode_lowercase_not_casefold_policy() {
let dir = tempdir().unwrap();
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
let tag = create_tag(&backend, "a", "\u{2003}Ä BC\u{2003}");
assert_eq!(tag.name, "Ä BC");
for name in ["ä bc", " Ä BC "] {
assert!(
backend
.mutate_workspace_tag(WorkspaceTagMutation {
operation_key: "duplicate".into(),
action: WorkspaceTagAction::Create { name: name.into() }
})
.is_err()
);
}
for name in [
"",
" \u{2003}",
"bad\nname",
"\tname",
"a\0",
&"é".repeat(65),
] {
assert!(
backend
.mutate_workspace_tag(WorkspaceTagMutation {
operation_key: "invalid".into(),
action: WorkspaceTagAction::Create { name: name.into() }
})
.is_err()
);
}
create_tag(&backend, "boundary", &"é".repeat(64));
create_tag(&backend, "sharp", "ß");
create_tag(&backend, "ss", "SS"); // Lowercase, not full case folding.
create_tag(&backend, "composed", "é");
create_tag(&backend, "decomposed", "e\u{301}"); // No Unicode normalization.
assert_eq!(audit_count(&backend), 6);
}
#[test]
fn tag_rename_delete_cas_and_name_conflicts_leave_no_audit_or_changes() {
let dir = tempdir().unwrap();
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
let a = create_tag(&backend, "a", "A");
let b = create_tag(&backend, "b", "B");
let renamed = rename_tag(&backend, "rename", &a, "New");
assert_eq!(a.id, renamed.id);
for action in [
WorkspaceTagAction::Rename {
tag_id: a.id.clone(),
expected_last_operation_key: "a".into(),
name: "Stale".into(),
},
WorkspaceTagAction::Delete {
tag_id: a.id.clone(),
expected_last_operation_key: "a".into(),
},
WorkspaceTagAction::Rename {
tag_id: renamed.id.clone(),
expected_last_operation_key: "rename".into(),
name: " b ".into(),
},
WorkspaceTagAction::Rename {
tag_id: renamed.id.clone(),
expected_last_operation_key: "rename".into(),
name: "\n".into(),
},
] {
assert!(
backend
.mutate_workspace_tag(WorkspaceTagMutation {
operation_key: "rejected".into(),
action
})
.is_err()
);
}
assert_eq!(audit_count(&backend), 3);
let active = backend
.list_workspace_tags(TagListRequest::default())
.unwrap();
assert!(active.contains(&renamed) && active.contains(&b));
}
#[test]
fn workspace_mutation_exact_replay_returns_stored_snapshot_before_cas() {
let dir = tempdir().unwrap();
let db = dir.path().join("tags.db");
let backend = SqliteTicketBackend::open(&db, "ws")
.unwrap()
.with_event_attributes(BTreeMap::from([(
"source_worker_id".into(),
"actor-a".into(),
)]));
let request = WorkspaceTagMutation {
operation_key: "create".into(),
action: WorkspaceTagAction::Create { name: " A ".into() },
};
let original = backend.mutate_workspace_tag(request.clone()).unwrap();
let renamed = rename_tag(&backend, "rename", &original, "B");
let deletion = delete_request("delete", &renamed);
let tombstone = backend.mutate_workspace_tag(deletion.clone()).unwrap();
assert!(tombstone.deleted);
assert_eq!(
backend.mutate_workspace_tag(request.clone()).unwrap(),
original
);
assert_eq!(
backend.mutate_workspace_tag(deletion.clone()).unwrap(),
tombstone
);
let restarted = SqliteTicketBackend::open_verified(&db, "ws")
.unwrap()
.with_event_attributes(backend.event_attributes.clone());
assert_eq!(
restarted.mutate_workspace_tag(request.clone()).unwrap(),
original
);
let other_actor = restarted.with_event_attributes(BTreeMap::from([(
"source_worker_id".into(),
"actor-b".into(),
)]));
assert!(matches!(
other_actor.mutate_workspace_tag(request),
Err(TicketError::OperationFingerprintMismatch { .. })
));
assert!(matches!(
backend.mutate_workspace_tag(WorkspaceTagMutation {
operation_key: "create".into(),
action: WorkspaceTagAction::Create { name: "A".into() }
}),
Err(TicketError::OperationFingerprintMismatch { .. })
));
assert_eq!(audit_count(&backend), 3);
assert!(
backend
.list_workspace_tags(TagListRequest::default())
.unwrap()
.is_empty()
);
}
#[test]
fn tag_deletion_detaches_every_ticket_keeps_audit_snapshots_and_recreate_new_id() {
let dir = tempdir().unwrap();
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
let tag = create_tag(&backend, "create", "Old name");
let mut ids = Vec::new();
for i in 0..2 {
let id = backend
.create(NewTicket::new(format!("ticket {i}")))
.unwrap()
.id;
backend
.set_ticket_tags(
id.clone().into(),
set_request(&format!("attach-{i}"), &[], std::slice::from_ref(&tag)),
)
.unwrap();
ids.push(id);
}
let renamed = rename_tag(&backend, "rename", &tag, "New name");
backend
.mutate_workspace_tag(delete_request("delete", &renamed))
.unwrap();
let replacement = create_tag(&backend, "recreate", "new NAME");
assert_ne!(replacement.id, tag.id);
for id in ids {
let ticket = backend.show(id.into()).unwrap();
assert!(ticket.tags.is_empty());
let events: Vec<_> = ticket
.events
.iter()
.filter(|e| e.kind == TicketEventKind::TagsChanged)
.collect();
assert_eq!(events.len(), 2);
let first: Vec<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", "Tag");
for (i, state) in [
TicketWorkflowState::Planning,
TicketWorkflowState::Ready,
TicketWorkflowState::Queued,
TicketWorkflowState::InProgress,
TicketWorkflowState::Done,
TicketWorkflowState::Closed,
]
.into_iter()
.enumerate()
{
let mut new = NewTicket::new(format!("ticket {i}"));
new.workflow_state = Some(state);
new.labels = vec!["legacy label".into()];
new.queued_by = Some("user".into());
new.queued_at = Some("yesterday".into());
let id = backend.create(new).unwrap().id;
let before = backend.show(id.clone().into()).unwrap();
backend
.set_ticket_tags(
id.clone().into(),
set_request(&format!("set-{i}"), &[], std::slice::from_ref(&tag)),
)
.unwrap();
let after = backend.show(id.clone().into()).unwrap();
assert_eq!(before.meta, after.meta);
assert_eq!(before.document, after.document);
assert_eq!(before.resolution, after.resolution);
assert_eq!(
ticket_content_digest(&before),
ticket_content_digest(&after)
);
assert_eq!(after.tags, vec![tag.clone()]);
let summary = backend
.list(TicketListQuery::all())
.unwrap()
.into_iter()
.find(|s| s.id == id)
.unwrap();
assert_eq!(summary.tags, after.tags);
let projection = backend.list_workspace_projection(200).unwrap();
assert_eq!(
projection
.items
.iter()
.find(|s| s.summary.id == id)
.unwrap()
.summary
.tags,
after.tags
);
let page = backend
.list_workspace_projection_page(SqliteTicketListPageQuery {
limit: 200,
..Default::default()
})
.unwrap();
assert_eq!(
page.items
.iter()
.find(|s| s.summary.id == id)
.unwrap()
.summary
.tags,
after.tags
);
}
}
#[test]
fn tag_list_has_bounded_id_keyset_order_and_excludes_deleted() {
let dir = tempdir().unwrap();
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
// Fixture insertion avoids spending this query test on 205 mutation transactions.
backend
.with_write(|conn| {
for i in 0..205 {
conn.execute(
"INSERT INTO workspace_tags VALUES ('ws',?1,?1,?1,'fixture',0)",
params![format!("tag-{i:03}")],
)
.map_err(sqlite_err)?;
}
Ok(())
})
.unwrap();
let first = backend
.list_workspace_tags(TagListRequest {
after: None,
limit: Some(usize::MAX),
})
.unwrap();
assert_eq!(first.len(), 200);
assert_eq!(first[0].id, "tag-000");
assert_eq!(first[199].id, "tag-199");
assert_eq!(
backend
.list_workspace_tags(TagListRequest::default())
.unwrap(),
first
);
backend
.mutate_workspace_tag(delete_request("delete", &first[0]))
.unwrap();
let tail = backend
.list_workspace_tags(TagListRequest {
after: Some("tag-199".into()),
limit: Some(200),
})
.unwrap();
assert_eq!(tail.len(), 5);
assert!(
backend
.list_workspace_tags(TagListRequest {
after: None,
limit: Some(0)
})
.unwrap()
.is_empty()
);
assert!(
backend
.list_workspace_tags(TagListRequest {
after: None,
limit: Some(1)
})
.unwrap()
.iter()
.all(|t| t.id == "tag-001")
);
}
#[test]
fn failed_tag_mutation_hook_rolls_back_links_tombstone_audit_and_receipt() {
let dir = tempdir().unwrap();
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
let tag = create_tag(&backend, "create", "Tag");
let id = backend.create(NewTicket::new("ticket")).unwrap().id;
let failing = backend.clone().with_mutation_hook(Arc::new(|_, _| {
Err(TicketError::Conflict("test hook".into()))
}));
let attach = set_request("attach", &[], std::slice::from_ref(&tag));
assert!(
failing
.set_ticket_tags(id.clone().into(), attach.clone())
.is_err()
);
assert!(backend.show(id.clone().into()).unwrap().tags.is_empty());
assert_eq!(audit_count(&backend), 1);
backend.set_ticket_tags(id.clone().into(), attach).unwrap();
let before = backend.show(id.clone().into()).unwrap();
let deletion = delete_request("delete", &tag);
assert!(failing.mutate_workspace_tag(deletion.clone()).is_err());
assert_eq!(backend.show(id.into()).unwrap(), before);
assert_eq!(
backend
.list_workspace_tags(TagListRequest::default())
.unwrap(),
vec![tag]
);
assert_eq!(audit_count(&backend), 2);
backend.mutate_workspace_tag(deletion).unwrap();
assert_eq!(audit_count(&backend), 3);
}
#[test]
fn concurrent_tag_replay_and_delete_attach_are_serialized() {
use std::sync::Barrier;
use std::thread;
let dir = tempdir().unwrap();
let db = dir.path().join("tags.db");
let backend = SqliteTicketBackend::open(&db, "ws").unwrap();
let barrier = Arc::new(Barrier::new(3));
let mut joins = Vec::new();
for _ in 0..2 {
let backend = backend.clone();
let barrier = barrier.clone();
joins.push(thread::spawn(move || {
barrier.wait();
create_tag(&backend, "create", "Tag")
}));
}
barrier.wait();
let tag = joins.remove(0).join().unwrap();
assert_eq!(joins.remove(0).join().unwrap(), tag);
assert_eq!(audit_count(&backend), 1);
let id = backend.create(NewTicket::new("ticket")).unwrap().id;
let barrier = Arc::new(Barrier::new(3));
let attach = {
let backend = backend.clone();
let barrier = barrier.clone();
let tag = tag.clone();
let id = id.clone();
thread::spawn(move || {
barrier.wait();
backend.set_ticket_tags(id.into(), set_request("attach", &[], &[tag]))
})
};
let delete = {
let backend = backend.clone();
let barrier = barrier.clone();
let tag = tag.clone();
thread::spawn(move || {
barrier.wait();
backend.mutate_workspace_tag(delete_request("delete", &tag))
})
};
barrier.wait();
let attached = attach.join().unwrap();
assert!(delete.join().unwrap().unwrap().deleted);
assert!(backend.show(id.into()).unwrap().tags.is_empty());
assert!(
backend
.list_workspace_tags(TagListRequest::default())
.unwrap()
.is_empty()
);
assert_eq!(audit_count(&backend), if attached.is_ok() { 3 } else { 2 });
let links: i64 = backend
.open_connection()
.unwrap()
.query_row("SELECT COUNT(*) FROM typed_ticket_tags", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(links, 0);
}
#[test]
fn ticket_tag_receipts_bind_selector_and_operation_kind_and_replay_before_assignment_fence() {
let dir = tempdir().unwrap();
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
let tag = create_tag(&backend, "create", "Tag");
let id = backend.create(NewTicket::new("ticket")).unwrap().id;
let request = set_request("attach", &[], std::slice::from_ref(&tag));
let mut reused = request.clone();
reused.operation_key = "create".into();
assert!(matches!(
backend.set_ticket_tags(id.clone().into(), reused),
Err(TicketError::OperationFingerprintMismatch { .. })
));
let conn = backend.open_connection().unwrap();
conn.execute_batch("CREATE TABLE ticket_active_worker_assignments (workspace_id TEXT,ticket_id TEXT,runtime_id TEXT,worker_id TEXT,assignment_id TEXT);").unwrap();
conn.execute("INSERT INTO ticket_active_worker_assignments VALUES ('ws',?1,'runtime','worker','assignment')",params![id]).unwrap();
let worker = backend.clone().with_event_attributes(BTreeMap::from([
("source_actor_role".into(), "ticket_worker".into()),
("source_runtime_id".into(), "runtime".into()),
("source_worker_id".into(), "worker".into()),
("source_assignment_id".into(), "assignment".into()),
]));
let stored = worker
.set_ticket_tags(TicketIdOrSlug::Id(id.clone()), request.clone())
.unwrap();
conn.execute("DELETE FROM ticket_active_worker_assignments", [])
.unwrap();
assert_eq!(
worker
.set_ticket_tags(TicketIdOrSlug::Id(id.clone()), request.clone())
.unwrap(),
stored
);
assert!(matches!(
worker.set_ticket_tags(TicketIdOrSlug::Query(id.clone()), request),
Err(TicketError::OperationFingerprintMismatch { .. })
));
assert!(
worker
.set_ticket_tags(
id.clone().into(),
set_request("new-operation", &stored, &[])
)
.is_err()
);
assert_eq!(backend.show(id.clone().into()).unwrap().tags, stored);
backend
.mutate_workspace_tag(delete_request("delete", &tag))
.unwrap();
assert!(
backend
.set_ticket_tags(
id.into(),
set_request("deleted-attach", &[], std::slice::from_ref(&tag))
)
.is_err()
);
assert_eq!(audit_count(&backend), 3);
}
#[test]
fn tag_domain_dispatch_and_wire_contracts_are_exact() {
let dir = tempdir().unwrap();
let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap();
let request = WorkspaceTagMutation {
operation_key: "create".into(),
action: WorkspaceTagAction::Create {
name: "Name".into(),
},
};
assert_eq!(
serde_json::to_value(&request).unwrap(),
serde_json::json!({"operation_key":"create","action":{"kind":"create","name":"Name"}})
);
let operation = TicketBackendOperation::MutateWorkspaceTag { request };
let operation = serde_json::from_value(serde_json::to_value(operation).unwrap()).unwrap();
let TicketBackendOperationResult::WorkspaceTag(tag) =
execute_ticket_backend_operation(&backend, operation).unwrap()
else {
panic!("tag result")
};
let id = backend.create(NewTicket::new("ticket")).unwrap().id;
let result = execute_ticket_backend_operation(
&backend,
TicketBackendOperation::SetTicketTags {
id: id.clone().into(),
request: set_request("set", &[], std::slice::from_ref(&tag)),
},
)
.unwrap();
assert!(
matches!(result,TicketBackendOperationResult::WorkspaceTags(tags) if tags==vec![tag.clone()])
);
let result = execute_ticket_backend_operation(
&backend,
TicketBackendOperation::ListWorkspaceTags {
request: TagListRequest::default(),
},
)
.unwrap();
assert!(matches!(result,TicketBackendOperationResult::WorkspaceTags(tags) if tags==vec![tag]));
let ticket = backend.show(id.into()).unwrap();
let mut wire = serde_json::to_value(&ticket).unwrap();
wire.as_object_mut().unwrap().remove("tags");
assert!(
serde_json::from_value::<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);
}
+22
View File
@@ -249,6 +249,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)
}
@@ -14,6 +14,7 @@ struct ModelTicketQueryItem {
ticket: String,
title: String,
state: String,
tags: Vec<ticket::WorkspaceTag>,
readiness: Option<String>,
priority: Option<String>,
created_at: Option<String>,
@@ -39,6 +40,7 @@ pub(super) struct ModelTicketDetail {
title: String,
body: String,
state: String,
tags: Vec<ticket::WorkspaceTag>,
readiness: Option<String>,
priority: Option<String>,
created_at: Option<String>,
@@ -94,8 +96,16 @@ struct ModelWorkerSummary {
worker: String,
}
#[derive(Debug, Serialize)]
struct ModelTicketTagChange {
before: Vec<ticket::WorkspaceTag>,
after: Vec<ticket::WorkspaceTag>,
}
#[derive(Debug, Serialize)]
struct ModelTicketEvent {
#[serde(skip_serializing_if = "Option::is_none")]
tag_changes: Option<ModelTicketTagChange>,
sequence: usize,
kind: String,
body: Option<String>,
@@ -236,6 +246,7 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
ticket: resource_ref(item, "resource_key", "T-")?,
title: string_field(item, "title")?,
state: string_field(item, "state")?,
tags: project_tags(item)?,
readiness: optional_string(item, "readiness")?,
priority: optional_string(item, "priority")?,
created_at: optional_string(item, "created_at")?,
@@ -287,6 +298,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
title: string_field(root, "title")?,
body: string_field(root, "body")?,
state: string_field(root, "state")?,
tags: project_tags(root)?,
readiness: optional_string(root, "readiness")?,
priority: optional_string(root, "priority")?,
created_at: optional_string(root, "created_at")?,
@@ -394,7 +406,27 @@ fn project_worker(value: &Value) -> Result<ModelWorkerSummary, String> {
fn project_ticket_event(value: &Value) -> Result<ModelTicketEvent, String> {
let event = object(value, "Ticket event")?;
let tag_changes = if event.get("kind").and_then(Value::as_str) == Some("tags_changed") {
let attributes = event
.get("attributes")
.ok_or("missing tag audit attributes")?;
let snapshot = |field| -> Result<Vec<ticket::WorkspaceTag>, String> {
let encoded = attributes
.get(field)
.and_then(Value::as_str)
.ok_or("missing tag audit snapshot")?;
serde_json::from_str(encoded)
.map_err(|error| format!("invalid tag audit snapshot: {error}"))
};
Some(ModelTicketTagChange {
before: snapshot("tags_before")?,
after: snapshot("tags_after")?,
})
} else {
None
};
Ok(ModelTicketEvent {
tag_changes,
sequence: usize_field(event, "sequence")?,
kind: string_field(event, "kind")?,
body: match event.get("body") {
@@ -616,6 +648,24 @@ fn object_field<'a>(
.ok_or_else(|| format!("missing or invalid {key}"))
}
fn project_tags(object: &Map<String, Value>) -> Result<Vec<ticket::WorkspaceTag>, String> {
let values = array_field(object, "tags")?;
if values.len() > 100 {
return Err("Ticket projection exceeds 100 tags".into());
}
values
.iter()
.map(|value| {
let tag: ticket::WorkspaceTag = serde_json::from_value(value.clone())
.map_err(|error| format!("invalid Workspace tag: {error}"))?;
if tag.deleted {
return Err("current Ticket projection contains a deleted tag".into());
}
Ok(tag)
})
.collect()
}
fn array_field<'a>(object: &'a Map<String, Value>, key: &str) -> Result<&'a [Value], String> {
object
.get(key)
@@ -703,7 +753,7 @@ mod tests {
"closed",
] {
let projected = project_ticket_detail(json!({
"resource_key": "T-718", "content_digest": "rev-1", "title": "No Git required", "body": "Decision",
"resource_key": "T-718", "tags": [], "content_digest": "rev-1", "title": "No Git required", "body": "Decision",
"state": state, "events": [{"sequence": 1, "kind": "completed", "body": "No Git needed"}],
"linked_objectives": [], "assignments": [], "implementation_reports": [],
"merge_requests": [], "merge_request": null,
@@ -738,7 +788,7 @@ mod tests {
"linked_tickets": ["00001M0E82D1V"],
"linked_ticket_summaries": [{
"id": "00001M0E82D1V",
"resource_key": "T-496",
"resource_key": "T-496", "tags": [],
"title": "Ticket",
"state": "done",
"updated_at": "2026-01-02T00:00:00Z"
@@ -769,7 +819,7 @@ mod tests {
"record_authority": "workspace_sqlite",
"items": [{
"id": "00001TICKETINTERNAL",
"resource_key": "T-543",
"resource_key": "T-543", "tags": [],
"title": "Ticket",
"state": "inprogress",
"readiness": null,
@@ -840,7 +890,7 @@ mod tests {
fn ticket_detail_projection_accepts_current_blocker_shape() {
let projected = project_ticket_detail(json!({
"id": "internal-ticket",
"resource_key": "T-588",
"resource_key": "T-588", "tags": [],
"content_digest": "ticket-content-digest",
"title": "Queued Submit",
"body": "Body",
@@ -943,7 +993,7 @@ mod tests {
"review_excerpt": "Reviewed source"
});
let projected = project_ticket_detail(json!({
"resource_key": "T-716",
"resource_key": "T-716", "tags": [],
"content_digest": "current-ticket-content-digest",
"title": "Source evidence",
"body": "Body",
@@ -1123,4 +1173,19 @@ mod tests {
.expect_err("missing W-key must fail");
assert!(error.contains("W-"));
}
#[test]
fn ticket_tag_history_projection_preserves_observed_names_and_identities() {
let tag = serde_json::json!({"workspace_id":"ws","id":"tag-1","name":"Old name","last_operation_key":"create","deleted":false});
let event = serde_json::json!({"sequence":3,"kind":"tags_changed","body":"Ticket tags changed","at":"then","attributes":{"tags_before":serde_json::json!([tag.clone()]).to_string(),"tags_after":"[]"}});
let projected = serde_json::to_value(project_ticket_event(&event).unwrap()).unwrap();
assert_eq!(projected["tag_changes"]["before"], serde_json::json!([tag]));
assert_eq!(projected["tag_changes"]["after"], serde_json::json!([]));
let ordinary = serde_json::json!({"sequence":0,"kind":"comment","body":"hello","at":null});
assert!(
serde_json::to_value(project_ticket_event(&ordinary).unwrap())
.unwrap()
.get("tag_changes")
.is_none()
);
}
}
+159 -20
View File
@@ -4,6 +4,8 @@
//! module binds an authority-scoped Workspace client, declares the built-in
//! feature, and contributes those tools through the normal registry path.
mod tags;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::{Arc, Mutex};
@@ -167,6 +169,18 @@ struct WorkspaceQueryTicketInput {
/// Related Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
related_ticket_id: Option<String>,
relation_kind: Option<WorkspaceTicketRelationFilter>,
/// All stable tag IDs must be attached (AND); at most 100 unique IDs.
#[serde(default)]
#[schemars(length(max = 100))]
tag_ids: Vec<String>,
/// At least one stable tag ID must be attached (OR), unless empty.
#[serde(default)]
#[schemars(length(max = 100))]
tag_ids_any: Vec<String>,
/// None of these stable tag IDs may be attached (NOT).
#[serde(default)]
#[schemars(length(max = 100))]
tag_ids_none: Vec<String>,
/// Linked Objective reference. Prefer `O-*`; canonical internal ids remain accepted for compatibility.
linked_objective_id: Option<String>,
updated_after: Option<String>,
@@ -210,6 +224,13 @@ impl Tool for WorkspaceTicketReadTool {
WorkspaceTicketReadKind::Query => {
let input: WorkspaceQueryTicketInput = serde_json::from_str(&input)
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
for (name, ids) in [
("tag_ids", &input.tag_ids),
("tag_ids_any", &input.tag_ids_any),
("tag_ids_none", &input.tag_ids_none),
] {
tags::validate_ids(name, ids)?;
}
(
format!("/api/w/{workspace_id}/tickets/query"),
serde_json::to_value(input)
@@ -502,6 +523,7 @@ fn workspace_ticket_tool_description(name: &str) -> String {
"CompleteTicket" => COMPLETE_TICKET_DESCRIPTION.into(),
"TicketWorkflowState" => TICKET_STATE_UPDATE_DESCRIPTION.into(),
"TicketClose" => TICKET_CLOSE_DESCRIPTION.into(),
name if tags::TAG_TOOL_NAMES.contains(&name) => tags::description(name).into(),
_ => ticket_tool_description(name, None),
}
}
@@ -632,6 +654,9 @@ impl TicketFeatureAccess {
.copied()
.filter(|name| self.allows_tool(name))
.chain((self.authoring || self.workflow || self.thread).then_some("CompleteTicket"))
.chain(tags::TAG_TOOL_NAMES.iter().copied().filter(|name| {
*name == "WorkspaceTagList" || self.authoring || self.workflow || self.thread
}))
.collect()
}
@@ -648,7 +673,7 @@ impl TicketFeatureAccess {
}
}
const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket"];
const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket", "WorkspaceTagList"];
const AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketCreate",
@@ -678,6 +703,11 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketRelationRecord",
"TicketRelationRemove",
"CompleteTicket",
"WorkspaceTagList",
"WorkspaceTagCreate",
"WorkspaceTagRename",
"WorkspaceTagDelete",
"TicketTagsSet",
];
#[cfg(test)]
@@ -693,6 +723,11 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[
"TicketOrchestrationPlanRecord",
"TicketOrchestrationPlanQuery",
"CompleteTicket",
"WorkspaceTagList",
"WorkspaceTagCreate",
"WorkspaceTagRename",
"WorkspaceTagDelete",
"TicketTagsSet",
];
const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[
@@ -1022,6 +1057,32 @@ impl WorkspaceHttpTicketBackend {
) -> TicketResult<TicketBackendOperationResult> {
let base = format!("/api/w/{workspace_id}/tickets");
match operation {
TicketBackendOperation::ListWorkspaceTags { request } => {
let mut endpoint = format!(
"/api/w/{workspace_id}/ticket-tags?limit={}",
request.limit.unwrap_or(200).min(200)
);
if let Some(after) = request.after {
endpoint.push_str("&after=");
endpoint.push_str(&Self::ticket_path(&TicketIdOrSlug::Id(after)));
}
Self::request(client, WorkspaceRequestMethod::Get, endpoint, None)
.map(TicketBackendOperationResult::WorkspaceTags)
}
TicketBackendOperation::MutateWorkspaceTag { request } => Self::request(
client,
WorkspaceRequestMethod::Post,
format!("/api/w/{workspace_id}/ticket-tags"),
Some(json!(request)),
)
.map(TicketBackendOperationResult::WorkspaceTag),
TicketBackendOperation::SetTicketTags { id, request } => Self::request(
client,
WorkspaceRequestMethod::Put,
format!("{base}/{}/tags", Self::ticket_path(&id)),
Some(json!(request)),
)
.map(TicketBackendOperationResult::WorkspaceTags),
TicketBackendOperation::DefaultIntakeReadyStateChangeBody { from } => {
let value = Self::request::<String>(
client,
@@ -1332,6 +1393,35 @@ macro_rules! expect_ticket_result {
}
impl TicketBackend for WorkspaceHttpTicketBackend {
fn list_workspace_tags(
&self,
request: ticket::TagListRequest,
) -> TicketResult<Vec<ticket::WorkspaceTag>> {
expect_ticket_result!(
self.invoke(TicketBackendOperation::ListWorkspaceTags { request }),
TicketBackendOperationResult::WorkspaceTags
)
}
fn mutate_workspace_tag(
&self,
request: ticket::WorkspaceTagMutation,
) -> TicketResult<ticket::WorkspaceTag> {
expect_ticket_result!(
self.invoke(TicketBackendOperation::MutateWorkspaceTag { request }),
TicketBackendOperationResult::WorkspaceTag
)
}
fn set_ticket_tags(
&self,
id: TicketIdOrSlug,
request: ticket::TicketTagSetRequest,
) -> TicketResult<Vec<ticket::WorkspaceTag>> {
expect_ticket_result!(
self.invoke(TicketBackendOperation::SetTicketTags { id, request }),
TicketBackendOperationResult::WorkspaceTags
)
}
fn default_intake_ready_state_change_body(&self, from: &str) -> String {
match self.invoke(TicketBackendOperation::DefaultIntakeReadyStateChangeBody {
from: from.to_string(),
@@ -1577,6 +1667,36 @@ struct NativeTicketOperation {
fn native_ticket_operation(tool_name: &str) -> Option<NativeTicketOperation> {
let operation = match tool_name {
"WorkspaceTagList" => NativeTicketOperation {
operation: "tag_list",
surface: NativeTicketSurface::Collection,
identity_field: None,
mutating: false,
},
"WorkspaceTagCreate" => NativeTicketOperation {
operation: "tag_create",
surface: NativeTicketSurface::Collection,
identity_field: None,
mutating: true,
},
"WorkspaceTagRename" => NativeTicketOperation {
operation: "tag_rename",
surface: NativeTicketSurface::Collection,
identity_field: None,
mutating: true,
},
"WorkspaceTagDelete" => NativeTicketOperation {
operation: "tag_delete",
surface: NativeTicketSurface::Collection,
identity_field: None,
mutating: true,
},
"TicketTagsSet" => NativeTicketOperation {
operation: "tags_set",
surface: NativeTicketSurface::Item,
identity_field: Some("ticket"),
mutating: true,
},
"QueryTicket" => NativeTicketOperation {
operation: "query",
surface: NativeTicketSurface::Collection,
@@ -1721,10 +1841,21 @@ pub(crate) fn enabled_ticket_definitions(
.collect();
if allowed.contains(&"CompleteTicket") {
definitions.push(workspace_ticket_decision_definition(
client,
client.clone(),
WorkspaceTicketDecisionKind::Complete,
));
}
for kind in [
tags::TagToolKind::List,
tags::TagToolKind::Create,
tags::TagToolKind::Rename,
tags::TagToolKind::Delete,
tags::TagToolKind::Set,
] {
if allowed.contains(&kind.name()) {
definitions.push(tags::definition(client.clone(), kind));
}
}
definitions
}
@@ -1959,13 +2090,16 @@ impl WipOperationHandler for TicketItemWipHandler {
)?;
let output = execute_native_ticket_tool(tool, &self.permissions, input, context).await?;
let response = ticket_tool_output_json(output)?;
record_ticket_observation(
&self.observations,
Some(&self.ticket_reference),
&response,
tool.projection.mutating,
);
if tool.projection.mutating {
// Classification has its own membership CAS and never changes item content.
if operation != "tags_set" {
record_ticket_observation(
&self.observations,
Some(&self.ticket_reference),
&response,
tool.projection.mutating,
);
}
if tool.projection.mutating && operation != "tags_set" {
record_affected_ticket_mutations(&self.observations, &self.ticket_reference, &response);
}
Ok(WipOperationOutput::native(
@@ -3053,6 +3187,11 @@ mod tests {
"record_relation",
"remove_relation",
"complete",
"tag_list",
"tag_create",
"tag_rename",
"tag_delete",
"tags_set",
]
);
let item = ticket_descriptor(
@@ -3102,6 +3241,11 @@ mod tests {
"record_orchestration_plan",
"query_orchestration_plans",
"complete",
"tag_list",
"tag_create",
"tag_rename",
"tag_delete",
"tags_set",
]
);
assert_eq!(
@@ -3110,7 +3254,7 @@ mod tests {
.iter()
.map(|tool| tool.projection.operation)
.collect::<Vec<_>>(),
["query", "read"]
["query", "read", "tag_list"]
);
}
@@ -3404,18 +3548,13 @@ mod tests {
assert_eq!(show.name, "ShowTicket");
assert!(show.input_schema["properties"]["event_limit"].is_object());
let tool_names = TicketFeatureAccess::workspace_authoring().tool_names();
assert_eq!(tool_names.len(), 12);
assert!(
tool_names.len() < 13,
"authoring catalog must stay below the prior broad catalog"
);
assert_eq!(tool_names, WORKSPACE_AUTHORING_TOOL_NAMES);
let workflow_names = TicketFeatureAccess::workflow().tool_names();
assert_eq!(workflow_names.len(), 11);
assert!(
workflow_names.len() < 12,
"workflow catalog must stay below the prior broad catalog"
assert_eq!(workflow_names, WORKFLOW_TOOL_NAMES);
assert_eq!(
TicketFeatureAccess::review().tool_names(),
READ_ONLY_TOOL_NAMES
);
assert_eq!(TicketFeatureAccess::review().tool_names().len(), 2);
assert!(tool_names.contains(&"QueryTicket"));
assert!(tool_names.contains(&"ShowTicket"));
assert!(!tool_names.contains(&"TicketList"));
@@ -0,0 +1,284 @@
//! Classification tools share the Ticket feature's scoped Backend transport.
#[cfg(test)]
mod tests;
use super::*;
pub(super) const TAG_TOOL_NAMES: &[&str] = &[
"WorkspaceTagList",
"WorkspaceTagCreate",
"WorkspaceTagRename",
"WorkspaceTagDelete",
"TicketTagsSet",
];
#[derive(Clone, Copy)]
pub(super) enum TagToolKind {
List,
Create,
Rename,
Delete,
Set,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ListInput {
/// Continue strictly after this stable tag ID. Continue until an empty page.
after: Option<String>,
/// Maximum results, 0..=200; defaults to 200.
#[schemars(range(max = 200))]
limit: Option<usize>,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct CreateInput {
/// Display name: nonempty after Unicode trimming, no controls, at most 128 UTF-8 bytes.
name: String,
/// Retry an uncertain request with this same key and exact input.
operation_key: String,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct RenameInput {
tag_id: String,
/// Last operation key observed on this exact identity; rename-back is still a change.
expected_last_operation_key: String,
name: String,
operation_key: String,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct DeleteInput {
tag_id: String,
expected_last_operation_key: String,
operation_key: String,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct SetInput {
/// Ticket reference, preferably T-*; WIP binds it from the item route.
ticket: String,
/// Entire active ID set observed in ShowTicket/QueryTicket; never silently refreshed.
#[schemars(length(max = 100))]
expected_tag_ids: Vec<String>,
/// Desired entire ID set. Attach/remove multiple tags atomically; [] removes all.
#[schemars(length(max = 100))]
tag_ids: Vec<String>,
operation_key: String,
}
impl TagToolKind {
pub(super) fn name(self) -> &'static str {
match self {
Self::List => "WorkspaceTagList",
Self::Create => "WorkspaceTagCreate",
Self::Rename => "WorkspaceTagRename",
Self::Delete => "WorkspaceTagDelete",
Self::Set => "TicketTagsSet",
}
}
fn schema(self) -> Value {
match self {
Self::List => json!(schemars::schema_for!(ListInput)),
Self::Create => json!(schemars::schema_for!(CreateInput)),
Self::Rename => json!(schemars::schema_for!(RenameInput)),
Self::Delete => json!(schemars::schema_for!(DeleteInput)),
Self::Set => json!(schemars::schema_for!(SetInput)),
}
}
}
pub(super) fn description(name: &str) -> &'static str {
match name {
"WorkspaceTagList" => {
"List active Workspace classification tags in stable ID order, bounded to 200. Continue after the last ID until exhausted; a full page does not promise another page."
}
"WorkspaceTagCreate" => {
"Create a Workspace classification tag with a stable identity and replay key. Names are ordinary classification, never Ticket states, routing, or authority grants."
}
"WorkspaceTagRename" => {
"Rename a Workspace classification tag by stable ID and observed last_operation_key. Links retain identity; history is not rewritten. Retry uncertain requests with the same key/input, then reread before further edits."
}
"WorkspaceTagDelete" => {
"Tombstone a Workspace classification tag using its observed last_operation_key and atomically remove current Ticket membership. Prior history remains; recreating the name creates a new identity."
}
"TicketTagsSet" => {
"Atomically compare and replace a Ticket's entire active classification tag ID set. Compute desired tag_ids from observed expected_tag_ids to attach/remove multiple tags without overwriting unrelated changes. At most 100 unique IDs. Classification does not edit state, content digest, assignment, or grants. A replay is the original receipt, not a fresh observation; reread before further edits."
}
_ => unreachable!("unknown classification tool"),
}
}
fn required(name: &str, value: &str) -> Result<(), ToolError> {
if value.trim().is_empty() {
return Err(ToolError::InvalidArgument(format!(
"{name} must not be empty"
)));
}
Ok(())
}
fn validate_name(name: &str) -> Result<(), ToolError> {
if name.trim().is_empty() || name.trim().len() > 128 || name.chars().any(char::is_control) {
return Err(ToolError::InvalidArgument("name must be nonempty, contain no control characters, and be at most 128 UTF-8 bytes after trimming".into()));
}
Ok(())
}
pub(super) fn validate_ids(name: &str, ids: &[String]) -> Result<(), ToolError> {
if ids.len() > 100 {
return Err(ToolError::InvalidArgument(format!(
"{name} must contain at most 100 IDs"
)));
}
let mut unique = BTreeSet::new();
for id in ids {
required(name, id)?;
if !unique.insert(id) {
return Err(ToolError::InvalidArgument(format!(
"{name} must contain unique IDs"
)));
}
}
Ok(())
}
fn parse<T: serde::de::DeserializeOwned>(input: &str) -> Result<T, ToolError> {
serde_json::from_str(input).map_err(|error| ToolError::InvalidArgument(error.to_string()))
}
#[derive(Clone)]
struct TagTool {
client: Arc<dyn WorkspaceClient>,
kind: TagToolKind,
}
#[async_trait]
impl Tool for TagTool {
async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
self.client
.workspace_id()
.filter(|id| !id.trim().is_empty())
.ok_or_else(|| {
ToolError::InvalidArgument(
"Classification tools require Backend Workspace identity; no local fallback"
.into(),
)
})?;
let backend = WorkspaceHttpTicketBackend::new(self.client.clone());
let content = match self.kind {
TagToolKind::List => {
let input: ListInput = parse(input)?;
if input.limit.is_some_and(|limit| limit > 200) {
return Err(ToolError::InvalidArgument(
"limit must be at most 200".into(),
));
}
if let Some(after) = &input.after {
required("after", after)?;
}
let limit = input.limit.unwrap_or(200);
let mut tags = backend
.list_workspace_tags(ticket::TagListRequest {
after: input.after,
limit: Some(limit),
})
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
tags.truncate(limit);
json!(tags)
}
TagToolKind::Set => {
let input: SetInput = parse(input)?;
required("ticket", &input.ticket)?;
required("operation_key", &input.operation_key)?;
validate_ids("expected_tag_ids", &input.expected_tag_ids)?;
validate_ids("tag_ids", &input.tag_ids)?;
let tags = backend
.set_ticket_tags(
TicketIdOrSlug::from(input.ticket.as_str()),
ticket::TicketTagSetRequest {
expected_tag_ids: input.expected_tag_ids,
tag_ids: input.tag_ids,
operation_key: input.operation_key,
},
)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if tags.len() > 100 {
return Err(ToolError::ExecutionFailed(
"Backend returned more than 100 Ticket tags".into(),
));
}
json!(tags)
}
kind => {
let request = match kind {
TagToolKind::Create => {
let input: CreateInput = parse(input)?;
validate_name(&input.name)?;
ticket::WorkspaceTagMutation {
operation_key: input.operation_key,
action: ticket::WorkspaceTagAction::Create { name: input.name },
}
}
TagToolKind::Rename => {
let input: RenameInput = parse(input)?;
required("tag_id", &input.tag_id)?;
required(
"expected_last_operation_key",
&input.expected_last_operation_key,
)?;
validate_name(&input.name)?;
ticket::WorkspaceTagMutation {
operation_key: input.operation_key,
action: ticket::WorkspaceTagAction::Rename {
tag_id: input.tag_id,
expected_last_operation_key: input.expected_last_operation_key,
name: input.name,
},
}
}
TagToolKind::Delete => {
let input: DeleteInput = parse(input)?;
required("tag_id", &input.tag_id)?;
required(
"expected_last_operation_key",
&input.expected_last_operation_key,
)?;
ticket::WorkspaceTagMutation {
operation_key: input.operation_key,
action: ticket::WorkspaceTagAction::Delete {
tag_id: input.tag_id,
expected_last_operation_key: input.expected_last_operation_key,
},
}
}
_ => unreachable!(),
};
required("operation_key", &request.operation_key)?;
json!(
backend
.mutate_workspace_tag(request)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?
)
}
};
Ok(ToolOutput {
summary: self.kind.name().into(),
content: Some(content.to_string()),
attachments: Vec::new(),
})
}
}
pub(super) fn definition(client: Arc<dyn WorkspaceClient>, kind: TagToolKind) -> ToolDefinition {
Arc::new(move || {
(
ToolMeta::new(kind.name())
.description(description(kind.name()))
.input_schema(kind.schema()),
Arc::new(TagTool {
client: client.clone(),
kind,
}) as Arc<dyn Tool>,
)
})
}
@@ -0,0 +1,526 @@
use super::*;
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
#[derive(Debug)]
struct RecordingClient {
requests: Mutex<Vec<WorkspaceRequest>>,
body: String,
identity: bool,
status: u16,
}
impl WorkspaceClient for RecordingClient {
fn workspace_id(&self) -> Option<&str> {
self.identity.then_some("workspace")
}
fn kind(&self) -> &str {
"classification-test"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.requests.lock().unwrap().push(request);
Ok(WorkspaceResponse {
status: self.status,
body: self.body.clone(),
})
}
}
fn recording(body: Value) -> Arc<RecordingClient> {
Arc::new(RecordingClient {
requests: Mutex::new(Vec::new()),
body: body.to_string(),
identity: true,
status: 200,
})
}
fn tag() -> Value {
json!({"workspace_id":"workspace", "id":"tag-1", "name":"Ready", "last_operation_key":"create-1", "deleted":false})
}
fn cases() -> Vec<(TagToolKind, Value, &'static str)> {
vec![
(TagToolKind::List, json!({"limit":5}), "WorkspaceTagList"),
(
TagToolKind::Create,
json!({"name":"Ready", "operation_key":"create-1"}),
"WorkspaceTagCreate",
),
(
TagToolKind::Rename,
json!({"tag_id":"tag-1", "expected_last_operation_key":"create-1", "name":"New", "operation_key":"rename-1"}),
"WorkspaceTagRename",
),
(
TagToolKind::Delete,
json!({"tag_id":"tag-1", "expected_last_operation_key":"create-1", "operation_key":"delete-1"}),
"WorkspaceTagDelete",
),
(
TagToolKind::Set,
json!({"ticket":"T-740", "expected_tag_ids":[], "tag_ids":["tag-1", "tag-2"], "operation_key":"set-1"}),
"TicketTagsSet",
),
]
}
#[tokio::test]
async fn classification_tool_schemas_require_observations_and_reject_actor_claims() {
for (kind, input, name) in cases() {
let client = recording(json!([]));
let (meta, tool) = definition(client.clone(), kind)();
assert_eq!(meta.name, name);
let validator = jsonschema::validator_for(&meta.input_schema).unwrap();
assert!(validator.is_valid(&input), "{name}: {}", meta.input_schema);
for field in meta.input_schema["required"]
.as_array()
.into_iter()
.flatten()
{
let field = field.as_str().unwrap();
let mut invalid = input.clone();
invalid.as_object_mut().unwrap().remove(field);
assert!(!validator.is_valid(&invalid), "{name}.{field}");
assert!(matches!(
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
let mut invalid = input.clone();
invalid["author"] = json!("orchestrator");
assert!(!validator.is_valid(&invalid));
assert!(matches!(
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
assert!(client.requests.lock().unwrap().is_empty());
}
let schema = TagToolKind::Set.schema();
assert_eq!(schema["properties"]["tag_ids"]["maxItems"], 100);
assert_eq!(schema["properties"]["expected_tag_ids"]["maxItems"], 100);
assert_eq!(
TagToolKind::List.schema()["properties"]["limit"]["maximum"],
200
);
}
#[tokio::test]
async fn classification_reads_forward_id_filters_and_preserve_tag_observations() {
let item = json!({"resource_key":"T-740", "title":"Classify", "state":"planning", "tags":[tag()], "matched_fields":[], "linked_objective_keys":[], "relation_count":0, "blocker_count":0, "unresolved_blocker_count":0, "unresolved_review_count":0, "merge_requests":[]});
let client = recording(json!({"items":[item], "page":{"has_more":false, "next_cursor":null}}));
let (meta, tool) =
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)();
for field in ["tag_ids", "tag_ids_any", "tag_ids_none"] {
assert_eq!(meta.input_schema["properties"][field]["maxItems"], 100);
}
let input = json!({"tag_ids":["tag-1"], "tag_ids_any":["tag-2", "tag-3"], "tag_ids_none":["tag-4"], "limit":7});
let output = tool
.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
let value: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
assert_eq!(value["tickets"][0]["tags"], json!([tag()]));
{
let requests = client.requests.lock().unwrap();
let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
for field in ["tag_ids", "tag_ids_any", "tag_ids_none"] {
assert_eq!(body[field], input[field]);
}
}
let detail = json!({"resource_key":"T-740", "content_digest":"content-1", "title":"Classify", "body":"Body", "state":"planning", "tags":[tag()], "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[]});
let client = recording(detail);
let (_, tool) =
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)();
let output = tool
.execute(r#"{"id":"T-740"}"#, ToolExecutionContext::direct())
.await
.unwrap();
let value: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
assert_eq!(value["tags"], json!([tag()]));
assert_eq!(value["content_digest"], "content-1");
let client = recording(json!({}));
let (_, tool) =
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)();
for field in ["tag_ids", "tag_ids_any", "tag_ids_none"] {
let input = json!({field: (0..101).map(|i| format!("tag-{i}")).collect::<Vec<_>>()});
assert!(matches!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
assert!(client.requests.lock().unwrap().is_empty());
}
#[tokio::test]
async fn classification_tools_reject_invalid_names_ids_and_keys_before_dispatch() {
let client = recording(json!([]));
for name in [" ".to_string(), "x\n".to_string(), "é".repeat(65)] {
let (_, tool) = definition(client.clone(), TagToolKind::Create)();
assert!(matches!(
tool.execute(
&json!({"name":name, "operation_key":"create"}).to_string(),
ToolExecutionContext::direct()
)
.await,
Err(ToolError::InvalidArgument(_))
));
}
for ids in [
json!(["tag-1", "tag-1"]),
json!([""]),
json!((0..101).map(|i| format!("tag-{i}")).collect::<Vec<_>>()),
] {
let (_, tool) = definition(client.clone(), TagToolKind::Set)();
for field in ["tag_ids", "expected_tag_ids"] {
let mut input = json!({"ticket":"T-740", "tag_ids":[], "expected_tag_ids":[], "operation_key":"set"});
input[field] = ids.clone();
assert!(matches!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
}
for (kind, mut input, _) in cases()
.into_iter()
.filter(|(kind, _, _)| !matches!(kind, TagToolKind::List))
{
input["operation_key"] = json!(" ");
let (_, tool) = definition(client.clone(), kind)();
assert!(matches!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
let (_, tool) = definition(client.clone(), TagToolKind::List)();
assert!(
tool.execute(r#"{"limit":201}"#, ToolExecutionContext::direct())
.await
.is_err()
);
assert!(client.requests.lock().unwrap().is_empty());
}
#[tokio::test]
async fn classification_tools_require_backend_workspace_and_bound_list_results() {
let missing = Arc::new(RecordingClient {
requests: Mutex::new(Vec::new()),
body: "[]".into(),
identity: false,
status: 200,
});
for (kind, input, _) in cases() {
let (_, tool) = definition(missing.clone(), kind)();
assert!(matches!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
assert!(missing.requests.lock().unwrap().is_empty());
let client = recording(json!(vec![tag(); 201]));
let (_, tool) = definition(client.clone(), TagToolKind::List)();
for (input, expected) in [
(json!({}), 200),
(json!({"limit":2, "after":"a&b"}), 2),
(json!({"limit":0}), 0),
] {
let output = tool
.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
let value: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
assert_eq!(value.as_array().unwrap().len(), expected);
}
let requests = client.requests.lock().unwrap();
assert_eq!(
requests[1].path,
"/api/w/workspace/ticket-tags?limit=2&after=a%26b"
);
}
#[tokio::test]
async fn classification_wip_uses_normal_permissions_and_route_bound_ticket_identity() {
let client = recording(json!([tag()]));
let tools =
native_ticket_tools(client.clone(), TicketFeatureAccess::workspace_authoring()).unwrap();
let set = tools
.iter()
.find(|tool| tool.name == "TicketTagsSet")
.unwrap();
let (_, input, _) = cases().pop().unwrap();
let permissions = Some(ToolPermissionConfig {
default_action: ToolPermissionAction::Allow,
rules: vec![manifest::ToolPermissionRule {
tool: "TicketTagsSet".into(),
action: ToolPermissionAction::Deny,
pattern: input.to_string(),
}],
});
let error = execute_native_ticket_tool(
set,
&permissions,
input,
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "worker".into(),
},
)
.await
.unwrap_err();
assert!(matches!(
error,
WipOperationError::Protocol(ProtocolError {
code: ProtocolErrorCode::PermissionDenied,
..
})
));
assert!(client.requests.lock().unwrap().is_empty());
let handler = TicketItemWipHandler {
tools: operation_map(
tools
.into_iter()
.filter(|tool| tool.projection.surface == NativeTicketSurface::Item)
.collect(),
),
permissions: None,
ticket_reference: "T-740".into(),
observations: Arc::new(Mutex::new(TicketObservationState::default())),
};
handler
.observations
.lock()
.unwrap()
.content_digests
.insert("T-740".into(), "content-1".into());
let mut args = BTreeMap::from([
("operation_key".into(), WipValue::String("set-1".into())),
("tag_ids".into(), json_to_wip(&json!(["tag-1"])).unwrap()),
("expected_tag_ids".into(), json_to_wip(&json!([])).unwrap()),
]);
assert!(
handler
.call(
"tags_set",
&args,
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "worker".into(),
},
)
.await
.is_ok()
);
assert_eq!(
client.requests.lock().unwrap()[0].path,
"/api/w/workspace/tickets/T-740/tags"
);
assert_eq!(
handler
.observations
.lock()
.unwrap()
.content_digests
.get("T-740")
.map(String::as_str),
Some("content-1")
);
args.insert("ticket".into(), WipValue::String("T-foreign".into()));
assert!(
handler
.call(
"tags_set",
&args,
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "worker".into()
}
)
.await
.is_err()
);
assert_eq!(client.requests.lock().unwrap().len(), 1);
let read_only = TicketFeatureAccess::read_only().tool_names();
assert!(read_only.contains(&"WorkspaceTagList"));
assert!(!read_only.contains(&"TicketTagsSet"));
}
#[tokio::test]
async fn classification_tools_preserve_backend_permission_and_stale_set_failures() {
for status in [403, 409] {
let client = Arc::new(RecordingClient {
requests: Mutex::new(Vec::new()),
body: "{}".into(),
identity: true,
status,
});
let (_, tool) = definition(client.clone(), TagToolKind::Set)();
let (_, input, _) = cases().pop().unwrap();
let error = tool
.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap_err();
assert!(error.to_string().contains(&status.to_string()));
assert_eq!(client.requests.lock().unwrap().len(), 1);
}
}
#[test]
fn classification_tools_http_mutations_survive_backend_reopen_and_stale_compare() {
use std::io::{Read, Write};
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("tags.db");
let backend = ticket::SqliteTicketBackend::open(&db, "workspace").unwrap();
let reference = backend.create(NewTicket::new("Classify")).unwrap();
let initial_content_digest =
ticket::ticket_content_digest(&backend.show(reference.id.clone().into()).unwrap());
let ticket_id = reference.id.clone();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let server_db = db.clone();
let server = std::thread::spawn(move || {
for _ in 0..10 {
let (mut stream, _) = listener.accept().unwrap();
stream
.set_read_timeout(Some(std::time::Duration::from_secs(10)))
.unwrap();
let mut bytes = Vec::new();
let (headers, body) = loop {
let mut chunk = [0u8; 8192];
let count = stream.read(&mut chunk).unwrap();
assert!(count > 0);
bytes.extend_from_slice(&chunk[..count]);
if let Some(end) = bytes.windows(4).position(|w| w == b"\r\n\r\n") {
let headers = String::from_utf8(bytes[..end].to_vec()).unwrap();
let length = headers
.lines()
.find_map(|line| {
line.to_lowercase()
.strip_prefix("content-length:")
.and_then(|v| v.trim().parse::<usize>().ok())
})
.unwrap_or(0);
if bytes.len() >= end + 4 + length {
break (headers, bytes[end + 4..end + 4 + length].to_vec());
}
}
};
let backend =
ticket::SqliteTicketBackend::open_verified(&server_db, "workspace").unwrap();
let line = headers.lines().next().unwrap();
let result = if line.starts_with("POST /api/w/workspace/ticket-tags ") {
backend
.mutate_workspace_tag(serde_json::from_slice(&body).unwrap())
.map(|tag| json!(tag))
} else if line.starts_with(&format!("PUT /api/w/workspace/tickets/{ticket_id}/tags ")) {
backend
.set_ticket_tags(
ticket_id.clone().into(),
serde_json::from_slice(&body).unwrap(),
)
.map(|tags| json!(tags))
} else {
assert!(
line.starts_with("GET /api/w/workspace/ticket-tags?"),
"{line}"
);
backend
.list_workspace_tags(ticket::TagListRequest::default())
.map(|tags| json!(tags))
};
let (status, response) = match result {
Ok(value) => ("200 OK", value.to_string()),
Err(error) => (
"409 Conflict",
json!({"message":error.to_string()}).to_string(),
),
};
write!(stream, "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}", response.len()).unwrap();
}
});
let client: Arc<dyn WorkspaceClient> = Arc::new(crate::worker::TestWorkspaceHttpClient::new(
"workspace",
base,
));
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let call = |kind, input: Value| {
runtime.block_on(async {
let (_, tool) = definition(client.clone(), kind)();
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
})
};
let first: ticket::WorkspaceTag = serde_json::from_str(
call(
TagToolKind::Create,
json!({"name":"ready", "operation_key":"create-a"}),
)
.unwrap()
.content
.as_deref()
.unwrap(),
)
.unwrap();
let second: ticket::WorkspaceTag = serde_json::from_str(
call(
TagToolKind::Create,
json!({"name":"closed", "operation_key":"create-b"}),
)
.unwrap()
.content
.as_deref()
.unwrap(),
)
.unwrap();
let set = json!({"ticket":reference.id, "expected_tag_ids":[], "tag_ids":[first.id, second.id], "operation_key":"set-both"});
let receipt = call(TagToolKind::Set, set.clone()).unwrap();
assert_eq!(
call(TagToolKind::Set, set).unwrap().content,
receipt.content
);
assert!(call(TagToolKind::Set, json!({"ticket":reference.id, "expected_tag_ids":[], "tag_ids":[], "operation_key":"stale"})).is_err());
let removed = call(TagToolKind::Set, json!({"ticket":reference.id, "expected_tag_ids":[second.id, first.id], "tag_ids":[], "operation_key":"remove-both"})).unwrap();
assert_eq!(removed.content.as_deref(), Some("[]"));
assert!(
backend
.show(reference.id.clone().into())
.unwrap()
.tags
.is_empty()
);
call(TagToolKind::Set, json!({"ticket":reference.id, "expected_tag_ids":[], "tag_ids":[first.id, second.id], "operation_key":"reattach-both"})).unwrap();
call(TagToolKind::Rename, json!({"tag_id":first.id, "expected_last_operation_key":first.last_operation_key, "name":"renamed", "operation_key":"rename-a"})).unwrap();
call(TagToolKind::Delete, json!({"tag_id":second.id, "expected_last_operation_key":second.last_operation_key, "operation_key":"delete-b"})).unwrap();
let listed = call(TagToolKind::List, json!({})).unwrap();
let tags: Vec<ticket::WorkspaceTag> =
serde_json::from_str(listed.content.as_deref().unwrap()).unwrap();
assert_eq!(tags.len(), 1);
assert_eq!(tags[0].name, "renamed");
server.join().unwrap();
let reopened = ticket::SqliteTicketBackend::open_verified(&db, "workspace").unwrap();
let ticket = reopened.show(reference.id.into()).unwrap();
assert_eq!(ticket.tags, tags);
assert_eq!(
ticket::ticket_content_digest(&ticket),
initial_content_digest
);
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Planning);
assert_eq!(
ticket
.events
.iter()
.filter(|event| event.kind == ticket::TicketEventKind::TagsChanged)
.count(),
4,
"replay and stale input must not append membership events"
);
}
+34
View File
@@ -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!(
@@ -926,6 +941,7 @@ impl SqliteWorkspaceAuthority {
queued_at: ticket.meta.queued_at,
targets,
risk_flags: ticket.meta.risk_flags,
tags: ticket.tags,
body,
body_truncated,
event_count: ticket.events.len(),
@@ -982,6 +998,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
state: item.summary.workflow_state.as_str().to_string(),
priority: item.summary.priority,
updated_at: item.summary.updated_at,
tags: item.summary.tags,
queued_by: item.summary.queued_by,
queued_at: item.summary.queued_at,
workspace_action_priority: workspace_action_priority_name(projection.priority)
@@ -1945,6 +1962,13 @@ fn ticket_evidence_summary(
}
fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> {
for ids in [&query.tag_ids, &query.tag_ids_any, &query.tag_ids_none] {
if ids.len() > 100 || ids.iter().any(|id| id.is_empty() || id.len() > 128) {
return Err(Error::InvalidInput(
"tag filters require at most 100 nonempty stable IDs of at most 128 bytes".into(),
));
}
}
for state in &query.states {
if ticket::TicketWorkflowState::parse(state).is_none() {
return Err(Error::InvalidRecordId(format!(
@@ -2161,6 +2185,13 @@ fn ticket_matches_query(
authoritative_events: &[TicketEvent],
query: &TicketQueryRequest,
) -> bool {
let has_tag = |id: &String| detail.tags.iter().any(|tag| &tag.id == id);
if !query.tag_ids.iter().all(has_tag)
|| (!query.tag_ids_any.is_empty() && !query.tag_ids_any.iter().any(has_tag))
|| query.tag_ids_none.iter().any(has_tag)
{
return false;
}
if !query.states.is_empty() && !query.states.iter().any(|state| state == &summary.state) {
return false;
}
@@ -2319,6 +2350,7 @@ fn ticket_query_item(
title: summary.title,
state: summary.state,
readiness: detail.readiness.clone(),
tags: detail.tags.clone(),
priority: summary.priority,
created_at: detail.created_at.clone(),
updated_at: summary.updated_at,
@@ -2675,6 +2707,7 @@ fn ticket_summary_from_ticket(ticket: &ticket::Ticket) -> Result<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,
@@ -2702,6 +2735,7 @@ fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> Result<TicketS
state: item.summary.workflow_state.as_str().to_string(),
priority: item.summary.priority,
updated_at: item.summary.updated_at,
tags: item.summary.tags,
queued_by: item.summary.queued_by,
queued_at: item.summary.queued_at,
workspace_action_priority: workspace_action_priority_name(projection.priority).to_string(),
@@ -42,6 +42,9 @@ 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",
"artifacts",
"workspace_config_trees",
"workspace_config_tree_history",
+109 -1
View File
@@ -6609,6 +6609,15 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
.merge(server_api::server_api_axum::ticket_close_record(
service.clone(),
))
.merge(server_api::server_api_axum::workspace_ticket_tag_list(
service.clone(),
))
.merge(server_api::server_api_axum::workspace_ticket_tag_mutate(
service.clone(),
))
.merge(server_api::server_api_axum::ticket_tags_set(
service.clone(),
))
.merge(server_api::server_api_axum::ticket_relation_view(
service.clone(),
))
@@ -10410,6 +10419,81 @@ impl server_api::ServerApi for ServerApiContractService {
Ok(())
}
async fn workspace_ticket_tag_list(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
request: server_api::WorkspaceTicketTagListRequest,
) -> std::result::Result<server_api::WorkspaceTicketTagList, server_api::RepositoryApiError>
{
let result = execute_ticket_rest_operation_with_actor(
self.workspace_api()?,
&workspace_id,
contract_request_headers(&context)?,
context.actor.as_ref(),
TicketBackendOperation::ListWorkspaceTags { request: request.0 },
)
.await
.map_err(ApiError::into_repository_api_error)?;
let Json(value) = ticket_rest_result(result, |result| match result {
TicketBackendOperationResult::WorkspaceTags(value) => Some(value),
_ => None,
})
.map_err(ApiError::into_repository_api_error)?;
Ok(server_api::WorkspaceTicketTagList(value))
}
async fn workspace_ticket_tag_mutate(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
request: server_api::WorkspaceTicketTagMutationRequest,
) -> std::result::Result<server_api::WorkspaceTicketTag, server_api::RepositoryApiError> {
let result = execute_ticket_rest_operation_with_actor(
self.workspace_api()?,
&workspace_id,
contract_request_headers(&context)?,
context.actor.as_ref(),
TicketBackendOperation::MutateWorkspaceTag { request: request.0 },
)
.await
.map_err(ApiError::into_repository_api_error)?;
let Json(value) = ticket_rest_result(result, |result| match result {
TicketBackendOperationResult::WorkspaceTag(value) => Some(value),
_ => None,
})
.map_err(ApiError::into_repository_api_error)?;
Ok(server_api::WorkspaceTicketTag(value))
}
async fn ticket_tags_set(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
id: String,
request: server_api::TicketTagsSetRequest,
) -> std::result::Result<server_api::WorkspaceTicketTagList, server_api::RepositoryApiError>
{
let result = execute_ticket_rest_operation_with_actor(
self.workspace_api()?,
&workspace_id,
contract_request_headers(&context)?,
context.actor.as_ref(),
TicketBackendOperation::SetTicketTags {
id: TicketIdOrSlug::Query(id),
request: request.0,
},
)
.await
.map_err(ApiError::into_repository_api_error)?;
let Json(value) = ticket_rest_result(result, |result| match result {
TicketBackendOperationResult::WorkspaceTags(value) => Some(value),
_ => None,
})
.map_err(ApiError::into_repository_api_error)?;
Ok(server_api::WorkspaceTicketTagList(value))
}
async fn ticket_relation_view(
&self,
context: server_api::ServerRequestContext,
@@ -14606,6 +14690,16 @@ async fn execute_ticket_rest_operation(
api: &WorkspaceApi,
workspace_id: &str,
headers: HeaderMap,
operation: TicketBackendOperation,
) -> ApiResult<TicketBackendOperationResult> {
execute_ticket_rest_operation_with_actor(api, workspace_id, headers, None, operation).await
}
async fn execute_ticket_rest_operation_with_actor(
api: &WorkspaceApi,
workspace_id: &str,
headers: HeaderMap,
actor: Option<&RequestActor>,
mut operation: TicketBackendOperation,
) -> ApiResult<TicketBackendOperationResult> {
validate_workspace_scope(api, workspace_id)?;
@@ -14654,6 +14748,12 @@ async fn execute_ticket_rest_operation(
.map(|ticket| ticket.meta.workflow_state.as_str().to_string())
.unwrap_or_else(|| ticket_operation_initial_state(&operation));
let mut event_attributes = BTreeMap::new();
if source.is_none()
&& let Some(actor) = actor
{
event_attributes.insert("source_account_id".into(), actor.account_id.clone());
event_attributes.insert("source_user_id".into(), actor.user_id.clone());
}
if matches!(operation, TicketBackendOperation::QueueReady { .. }) {
let ticket = before.as_ref().ok_or_else(|| {
Error::TicketAssignmentConflict(
@@ -14784,7 +14884,8 @@ async fn execute_ticket_rest_operation(
None,
),
};
if is_mutation {
// Classification has no routing or execution side effects.
if is_mutation && !matches!(operation_kind, "mutate_workspace_tag" | "set_ticket_tags") {
if let TicketBackendOperationResult::QueueOutcome(outcome) = &result {
for ticket_id in &outcome.queued_tickets {
notify_ticket_recipients(
@@ -16948,6 +17049,7 @@ fn ticket_mutation_target(operation: &TicketBackendOperation) -> Option<&TicketI
| TicketBackendOperation::MarkReady { id, .. }
| TicketBackendOperation::QueueReady { id, .. }
| TicketBackendOperation::Close { id, .. }
| TicketBackendOperation::SetTicketTags { id, .. }
| TicketBackendOperation::AddTicketRelation { id, .. }
| TicketBackendOperation::RemoveTicketRelation { id, .. }
| TicketBackendOperation::AddOrchestrationPlanRecord { id, .. } => Some(id),
@@ -17015,6 +17117,8 @@ fn ticket_mutation_operation_kind(operation: &TicketBackendOperation) -> &'stati
TicketBackendOperation::MarkReady { .. } => "mark_ready",
TicketBackendOperation::QueueReady { .. } => "queue_ready",
TicketBackendOperation::Close { .. } => "close",
TicketBackendOperation::MutateWorkspaceTag { .. } => "mutate_workspace_tag",
TicketBackendOperation::SetTicketTags { .. } => "set_ticket_tags",
TicketBackendOperation::AddTicketRelation { .. } => "add_relation",
TicketBackendOperation::RemoveTicketRelation { .. } => "remove_relation",
TicketBackendOperation::AddOrchestrationPlanRecord { .. } => "add_plan_record",
@@ -33006,6 +33110,10 @@ impl IntoResponse for ApiError {
#[cfg(test)]
mod tests {
mod ticket_tags_tests {
include!("server_ticket_tags_tests.rs");
}
mod api_logging_tests;
mod auth_logging_tests;
mod drive_api_tests;
@@ -0,0 +1,139 @@
use super::*;
#[tokio::test]
async fn ticket_tags_round_trip_filter_and_preserve_execution_and_decision_projections() {
let dir = tempfile::tempdir().unwrap();
let (api, execution) = test_api_with_recording_backend(dir.path()).await;
let backend = browser_ticket_backend(&api).unwrap();
let reference = backend.create(ticket::NewTicket::new("Classification only")).unwrap();
let other = backend.create(ticket::NewTicket::new("Other ticket")).unwrap();
let app = build_inner_router(api.clone());
let tags_path = format!("/api/w/{TEST_WORKSPACE_ID}/ticket-tags");
let ticket_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}", reference.id);
let before = get_json(app.clone(), &ticket_path).await;
let ready = post_json(app.clone(), &tags_path, json!({
"operation_key":"create-ready", "action":{"kind":"create", "name":" ready "}
})).await;
let bug = post_json(app.clone(), &tags_path, json!({
"operation_key":"create-bug", "action":{"kind":"create", "name":"Bug"}
})).await;
let ready_id = ready["id"].as_str().unwrap();
let bug_id = bug["id"].as_str().unwrap();
let assignment = json!({"operation_key":"classify", "expected_tag_ids":[], "tag_ids":[ready_id,bug_id]});
let attached = request_json(app.clone(), "PUT", &format!("{ticket_path}/tags"), Some(assignment.clone()), StatusCode::OK).await;
assert_eq!(attached.as_array().unwrap().len(), 2);
let replay = request_json(app.clone(), "PUT", &format!("{ticket_path}/tags"), Some(assignment), StatusCode::OK).await;
assert_eq!(attached, replay);
let after = get_json(app.clone(), &ticket_path).await;
for field in ["state", "resolution", "content_digest", "current_worker", "assignments", "action_eligibility", "targets", "evidence", "merge_requests", "queued_by", "queued_at"] {
assert_eq!(before[field], after[field], "classification changed {field}");
}
assert_eq!(after["tags"].as_array().unwrap().len(), 2);
assert!(execution.protocol_methods().is_empty());
let list = get_json(app.clone(), &format!("/api/w/{TEST_WORKSPACE_ID}/tickets")).await;
let item = list["items"].as_array().unwrap().iter().find(|item| item["id"] == reference.id).unwrap();
assert_eq!(item["tags"].as_array().unwrap().len(), 2);
let query_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/query");
for (filter, count) in [
(json!({"tag_ids":[ready_id,bug_id]}), 1),
(json!({"tag_ids_any":[ready_id,"absent"]}), 1),
(json!({"tag_ids_none":[ready_id]}), 1),
(json!({"tag_ids":[ready_id,"absent"]}), 0),
] {
let result = post_json(app.clone(), &query_path, filter.clone()).await;
assert_eq!(result["items"].as_array().unwrap().len(), count, "filter {filter}");
if count == 1 && filter.get("tag_ids_none").is_none() {
assert_eq!(result["items"][0]["tags"].as_array().unwrap().len(), 2);
} else if filter.get("tag_ids_none").is_some() {
assert_eq!(result["items"][0]["id"], other.id);
}
}
let renamed = post_json(app.clone(), &tags_path, json!({
"operation_key":"rename", "action":{"kind":"rename", "tag_id":bug_id,
"expected_last_operation_key":"create-bug", "name":"Defect"}
})).await;
assert_eq!(renamed["id"], bug["id"]);
let shown = get_json(app.clone(), &ticket_path).await;
assert!(shown["tags"].as_array().unwrap().iter().any(|tag| tag["name"] == "Defect"));
request_json(app.clone(), "POST", &tags_path, Some(json!({
"operation_key":"stale-rename", "action":{"kind":"rename", "tag_id":bug_id,
"expected_last_operation_key":"create-bug", "name":"Wrong"}
})), StatusCode::CONFLICT).await;
post_json(app.clone(), &tags_path, json!({
"operation_key":"delete", "action":{"kind":"delete", "tag_id":bug_id,
"expected_last_operation_key":"rename"}
})).await;
let deleted = get_json(app.clone(), &ticket_path).await;
assert_eq!(deleted["tags"].as_array().unwrap().len(), 1);
request_json(app.clone(), "PUT", &format!("{ticket_path}/tags"), Some(json!({
"operation_key":"stale-set", "expected_tag_ids":[ready_id,bug_id], "tag_ids":[]
})), StatusCode::CONFLICT).await;
let untagged = request_json(app.clone(), "PUT", &format!("{ticket_path}/tags"), Some(json!({
"operation_key":"detach", "expected_tag_ids":[ready_id], "tag_ids":[]
})), StatusCode::OK).await;
assert_eq!(untagged, json!([]));
let final_detail = get_json(app, &ticket_path).await;
assert_eq!(final_detail["state"], before["state"]);
assert_eq!(final_detail["content_digest"], before["content_digest"]);
}
#[tokio::test]
async fn ticket_tag_routes_require_workspace_authority_and_reject_forged_worker_source() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let owner_token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID);
let collaborator_token = seed_test_api_token(api.store.as_ref(), "tags-collaborator");
let app = build_router(api.clone());
let path = format!("/api/w/{TEST_WORKSPACE_ID}/ticket-tags");
let input = json!({"operation_key":"authorized", "action":{"kind":"create", "name":"ready"}});
request_json(app.clone(), "POST", &path, Some(input.clone()), StatusCode::UNAUTHORIZED).await;
let tag = request_json_authenticated(app.clone(), "POST", &path, Some(input), &owner_token, StatusCode::OK).await;
assert_eq!(tag["workspace_id"], TEST_WORKSPACE_ID);
// Classification follows shared Ticket Workspace write authority, not the
// owner-only repository/secrets-management policy. A distinct authenticated
// actor may manage tags but cannot replay another actor's receipt.
request_json_authenticated(app.clone(), "POST", &path, Some(json!({"operation_key":"authorized", "action":{"kind":"create", "name":"ready"}})), &collaborator_token, StatusCode::CONFLICT).await;
request_json_authenticated(app.clone(), "POST", "/api/w/other-workspace/ticket-tags", Some(json!({"operation_key":"foreign", "action":{"kind":"create", "name":"foreign"}})), &collaborator_token, StatusCode::NOT_FOUND).await;
let response = app.oneshot(Request::builder().method("POST").uri(&path)
.header(axum::http::header::AUTHORIZATION, format!("Bearer {owner_token}"))
.header("x-yoi-runtime-id", "untrusted-runtime")
.header("x-yoi-worker-id", "untrusted-worker")
.header(CONTENT_TYPE,"application/json")
.body(Body::from(json!({"operation_key":"forged", "action":{"kind":"create", "name":"forged"}}).to_string())).unwrap()).await.unwrap();
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(status, StatusCode::BAD_REQUEST, "{}", String::from_utf8_lossy(&body));
let tags = browser_ticket_backend(&api).unwrap().list_workspace_tags(ticket::TagListRequest::default()).unwrap();
assert_eq!(tags.len(), 1);
}
#[tokio::test]
async fn tag_query_filters_precede_bounded_candidate_scan_and_bind_cursors() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let backend = browser_ticket_backend(&api).unwrap();
for i in 0..5 { backend.create(ticket::NewTicket::new(format!("a unrelated {i}"))).unwrap(); }
let tag = backend.mutate_workspace_tag(ticket::WorkspaceTagMutation {
operation_key: "create".into(), action: ticket::WorkspaceTagAction::Create { name: "bug".into() },
}).unwrap();
for i in 0..3 {
let id = backend.create(ticket::NewTicket::new(format!("z tagged {i}"))).unwrap().id;
backend.set_ticket_tags(id.into(), ticket::TicketTagSetRequest {
expected_tag_ids: vec![], tag_ids: vec![tag.id.clone()], operation_key: format!("set-{i}"),
}).unwrap();
}
let app = build_inner_router(api);
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/query");
let mut input = json!({"tag_ids":[tag.id], "sort":"title", "limit":1});
for i in 0..3 {
let page = post_json(app.clone(), &path, input.clone()).await;
assert_eq!(page["items"].as_array().unwrap().len(),1);
assert_eq!(page["items"][0]["title"], format!("z tagged {i}"));
if i < 2 {
let cursor = page["page"]["next_cursor"].clone();
request_json(app.clone(), "POST", &path, Some(json!({"tag_ids_any":[tag.id], "sort":"title", "limit":1, "cursor":cursor})), StatusCode::NOT_FOUND).await;
input["cursor"] = cursor;
} else { assert_eq!(page["page"]["has_more"], false); }
}
}
+5 -2
View File
@@ -13512,7 +13512,7 @@ fn apply_ticket_targets_and_workdir_capabilities_schema(conn: &Connection) -> Re
TICKET_SCHEMA_BASELINE_NAME
],
)?;
ticket::verify_sqlite_ticket_schema(db).map_err(|error| {
ticket::verify_pre_classification_ticket_schema(db).map_err(|error| {
Error::Store(format!(
"schema-66 Ticket schema verification failed: {error}"
))
@@ -14896,6 +14896,9 @@ mod tests {
}
fn downgrade_schema_66_ticket_and_workdir_authority(conn: &Connection) -> Result<()> {
// A retained pre-target fixture must not carry today's classification
// tables while resetting the component migration marker to v6.
conn.execute_batch("DROP TABLE typed_ticket_tags; DROP TABLE workspace_tag_operations; DROP TABLE workspace_tags;")?;
conn.execute_batch("DROP TABLE IF EXISTS worker_restore_intents;")?;
conn.execute_batch(
"DROP TABLE backend_job_deliveries;
@@ -15935,7 +15938,7 @@ mod tests {
migrate_ticket_targets_and_workdir_capabilities_v65_to_v66(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 66);
ticket::verify_sqlite_ticket_schema(&conn).unwrap();
ticket::verify_pre_classification_ticket_schema(&conn).unwrap();
assert!(!column_exists(&conn, "typed_tickets", "repository_id").unwrap());
assert!(!column_exists(&conn, "typed_tickets", "ref_selector").unwrap());
assert_eq!(
@@ -62,6 +62,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"typed_ticket_relations",
"typed_ticket_risk_flags",
"typed_ticket_targets",
"typed_ticket_tags",
"typed_tickets",
"workdir_create_credential_candidates",
"workdir_create_credential_retentions",
@@ -93,6 +94,8 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"workspace_config_tree_history",
"workspace_config_trees",
"workspace_create_operations",
"workspace_tag_operations",
"workspace_tags",
"workspace_memory_documents",
"workspace_memory_settings",
"workspace_resource_key_counters",