merge: compose classification tags with Ticket case migration
This commit is contained in:
@@ -391,6 +391,21 @@ impl SqliteWorkspaceAuthority {
|
||||
.collect::<Vec<_>>();
|
||||
predicates.push(format!("t.workflow_state IN ({})", states.join(",")));
|
||||
}
|
||||
for id in &query.tag_ids {
|
||||
let id = bind(SqlValue::Text(id.clone()));
|
||||
predicates.push(format!("EXISTS (SELECT 1 FROM typed_ticket_tags l JOIN workspace_tags tag ON tag.workspace_id=l.workspace_id AND tag.tag_id=l.tag_id WHERE l.workspace_id=t.workspace_id AND l.ticket_id=t.ticket_id AND l.tag_id={id} AND tag.deleted=0)"));
|
||||
}
|
||||
for (ids, negate) in [(&query.tag_ids_any, false), (&query.tag_ids_none, true)] {
|
||||
if !ids.is_empty() {
|
||||
let ids = ids
|
||||
.iter()
|
||||
.map(|id| bind(SqlValue::Text(id.clone())))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let not = if negate { "NOT " } else { "" };
|
||||
predicates.push(format!("{not}EXISTS (SELECT 1 FROM typed_ticket_tags l JOIN workspace_tags tag ON tag.workspace_id=l.workspace_id AND tag.tag_id=l.tag_id WHERE l.workspace_id=t.workspace_id AND l.ticket_id=t.ticket_id AND l.tag_id IN ({ids}) AND tag.deleted=0)"));
|
||||
}
|
||||
}
|
||||
if let Some(text) = query.query.as_deref().filter(|text| !text.is_empty()) {
|
||||
let pattern = bind(SqlValue::Text(format!("%{}%", text.to_lowercase())));
|
||||
predicates.push(format!(
|
||||
@@ -908,6 +923,7 @@ impl SqliteWorkspaceAuthority {
|
||||
queued_at: ticket.meta.queued_at,
|
||||
targets,
|
||||
risk_flags: ticket.meta.risk_flags,
|
||||
tags: ticket.tags,
|
||||
body,
|
||||
body_truncated,
|
||||
event_count: ticket.events.len(),
|
||||
@@ -1954,6 +1970,13 @@ fn ticket_evidence_summary(
|
||||
}
|
||||
|
||||
fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> {
|
||||
for ids in [&query.tag_ids, &query.tag_ids_any, &query.tag_ids_none] {
|
||||
if ids.len() > 100 || ids.iter().any(|id| id.is_empty() || id.len() > 128) {
|
||||
return Err(Error::InvalidInput(
|
||||
"tag filters require at most 100 nonempty stable IDs of at most 128 bytes".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
for evidence in &query.evidence {
|
||||
if !matches!(
|
||||
evidence.as_str(),
|
||||
@@ -2142,7 +2165,19 @@ fn ticket_matches_query(
|
||||
authoritative_events: &[TicketEvent],
|
||||
query: &TicketQueryRequest,
|
||||
) -> bool {
|
||||
if !query.states.is_empty() && !query.states.iter().any(|state| state == &summary.state) {
|
||||
let has_tag = |id: &String| detail.tags.iter().any(|tag| &tag.id == id);
|
||||
if !query.tag_ids.iter().all(has_tag)
|
||||
|| (!query.tag_ids_any.is_empty() && !query.tag_ids_any.iter().any(has_tag))
|
||||
|| query.tag_ids_none.iter().any(has_tag)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !query.states.is_empty()
|
||||
&& !query
|
||||
.states
|
||||
.iter()
|
||||
.any(|state| server_api::BrowserTicketState::from(*state) == summary.state)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if query
|
||||
@@ -2300,6 +2335,7 @@ fn ticket_query_item(
|
||||
title: summary.title,
|
||||
state: summary.state,
|
||||
readiness: detail.readiness.clone(),
|
||||
tags: detail.tags.clone(),
|
||||
priority: summary.priority,
|
||||
created_at: detail.created_at.clone(),
|
||||
updated_at: summary.updated_at,
|
||||
@@ -2659,6 +2695,7 @@ fn ticket_summary_from_ticket(ticket: &ticket::Ticket) -> Result<TicketSummary>
|
||||
kind: ticket.meta.kind.clone(),
|
||||
priority: ticket.meta.priority.clone(),
|
||||
labels: ticket.meta.labels.clone(),
|
||||
tags: ticket.tags.clone(),
|
||||
readiness: ticket.meta.readiness.clone(),
|
||||
workflow_state: ticket.meta.workflow_state,
|
||||
workflow_state_explicit: ticket.meta.workflow_state_explicit,
|
||||
@@ -2690,6 +2727,7 @@ fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> Result<TicketS
|
||||
state: item.summary.workflow_state.into(),
|
||||
priority: item.summary.priority,
|
||||
updated_at: item.summary.updated_at,
|
||||
tags: item.summary.tags,
|
||||
queued_by: item.summary.queued_by,
|
||||
queued_at: item.summary.queued_at,
|
||||
workspace_action_priority: workspace_action_priority_name(projection.priority).to_string(),
|
||||
@@ -3497,7 +3535,7 @@ VALUES ('workspace-test', 'ticket', 4);
|
||||
let ticket_query = authority
|
||||
.query_tickets(TicketQueryRequest {
|
||||
query: Some("Deep Ticket marker".to_string()),
|
||||
states: vec![server_api::BrowserTicketState::Open],
|
||||
states: vec![ticket::TicketState::Open],
|
||||
linked_objective_id: Some("00000000001J3".to_string()),
|
||||
attention: vec![server_api::TicketAttention::MissingCommit],
|
||||
limit: Some(1),
|
||||
|
||||
@@ -42,6 +42,16 @@ fn fresh_and_upgraded_state_contract_schemas_enforce_the_same_constraints() {
|
||||
let fresh = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
let mut mismatches = Vec::new();
|
||||
for table in [
|
||||
"workspace_tags",
|
||||
"typed_ticket_tags",
|
||||
"workspace_tag_operations",
|
||||
"typed_tickets",
|
||||
"typed_ticket_targets",
|
||||
"typed_ticket_case_metadata",
|
||||
"typed_ticket_case_history",
|
||||
"typed_ticket_legacy_workflow_snapshot",
|
||||
"ticket_current_worker_assignments",
|
||||
"ticket_assignment_work_releases",
|
||||
"artifacts",
|
||||
"workspace_config_trees",
|
||||
"workspace_config_tree_history",
|
||||
|
||||
@@ -6575,6 +6575,15 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
|
||||
.merge(server_api::server_api_axum::ticket_case_update(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::workspace_ticket_tag_list(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::workspace_ticket_tag_mutate(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::ticket_tags_set(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::ticket_relation_view(
|
||||
service.clone(),
|
||||
))
|
||||
@@ -10297,6 +10306,7 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
kind: meta.kind,
|
||||
priority: meta.priority,
|
||||
labels: meta.labels,
|
||||
tags: updated.tags,
|
||||
readiness: meta.readiness,
|
||||
workflow_state: meta.workflow_state,
|
||||
workflow_state_explicit: meta.workflow_state_explicit,
|
||||
@@ -10306,6 +10316,81 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn workspace_ticket_tag_list(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
request: server_api::WorkspaceTicketTagListRequest,
|
||||
) -> std::result::Result<server_api::WorkspaceTicketTagList, server_api::RepositoryApiError>
|
||||
{
|
||||
let result = execute_ticket_rest_operation_with_context(
|
||||
self.workspace_api()?,
|
||||
&workspace_id,
|
||||
contract_request_headers(&context)?,
|
||||
Some(&context),
|
||||
TicketBackendOperation::ListWorkspaceTags { request: request.0 },
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::into_repository_api_error)?;
|
||||
let Json(value) = ticket_rest_result(result, |result| match result {
|
||||
TicketBackendOperationResult::WorkspaceTags(value) => Some(value),
|
||||
_ => None,
|
||||
})
|
||||
.map_err(ApiError::into_repository_api_error)?;
|
||||
Ok(server_api::WorkspaceTicketTagList(value))
|
||||
}
|
||||
|
||||
async fn workspace_ticket_tag_mutate(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
request: server_api::WorkspaceTicketTagMutationRequest,
|
||||
) -> std::result::Result<server_api::WorkspaceTicketTag, server_api::RepositoryApiError> {
|
||||
let result = execute_ticket_rest_operation_with_context(
|
||||
self.workspace_api()?,
|
||||
&workspace_id,
|
||||
contract_request_headers(&context)?,
|
||||
Some(&context),
|
||||
TicketBackendOperation::MutateWorkspaceTag { request: request.0 },
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::into_repository_api_error)?;
|
||||
let Json(value) = ticket_rest_result(result, |result| match result {
|
||||
TicketBackendOperationResult::WorkspaceTag(value) => Some(value),
|
||||
_ => None,
|
||||
})
|
||||
.map_err(ApiError::into_repository_api_error)?;
|
||||
Ok(server_api::WorkspaceTicketTag(value))
|
||||
}
|
||||
|
||||
async fn ticket_tags_set(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
id: String,
|
||||
request: server_api::TicketTagsSetRequest,
|
||||
) -> std::result::Result<server_api::WorkspaceTicketTagList, server_api::RepositoryApiError>
|
||||
{
|
||||
let result = execute_ticket_rest_operation_with_context(
|
||||
self.workspace_api()?,
|
||||
&workspace_id,
|
||||
contract_request_headers(&context)?,
|
||||
Some(&context),
|
||||
TicketBackendOperation::SetTicketTags {
|
||||
id: TicketIdOrSlug::Query(id),
|
||||
request: request.0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError::into_repository_api_error)?;
|
||||
let Json(value) = ticket_rest_result(result, |result| match result {
|
||||
TicketBackendOperationResult::WorkspaceTags(value) => Some(value),
|
||||
_ => None,
|
||||
})
|
||||
.map_err(ApiError::into_repository_api_error)?;
|
||||
Ok(server_api::WorkspaceTicketTagList(value))
|
||||
}
|
||||
|
||||
async fn ticket_relation_view(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
@@ -14351,6 +14436,16 @@ async fn execute_ticket_rest_operation(
|
||||
api: &WorkspaceApi,
|
||||
workspace_id: &str,
|
||||
headers: HeaderMap,
|
||||
operation: TicketBackendOperation,
|
||||
) -> ApiResult<TicketBackendOperationResult> {
|
||||
execute_ticket_rest_operation_with_context(api, workspace_id, headers, None, operation).await
|
||||
}
|
||||
|
||||
async fn execute_ticket_rest_operation_with_context(
|
||||
api: &WorkspaceApi,
|
||||
workspace_id: &str,
|
||||
headers: HeaderMap,
|
||||
context: Option<&server_api::ServerRequestContext>,
|
||||
mut operation: TicketBackendOperation,
|
||||
) -> ApiResult<TicketBackendOperationResult> {
|
||||
validate_workspace_scope(api, workspace_id)?;
|
||||
@@ -14379,7 +14474,31 @@ async fn execute_ticket_rest_operation(
|
||||
// Human clients are authorized by the Workspace route boundary. Runtime-forwarded Worker
|
||||
// calls carry source headers; when either header is present, the complete pair is required
|
||||
// and authenticated before source attribution is attached.
|
||||
let source = optional_worker_mutation_source(api, workspace_id, &headers)?;
|
||||
let hinted_source = optional_worker_mutation_source(api, workspace_id, &headers)?;
|
||||
// The signed Workspace request carries verified identity in the middleware
|
||||
// extension, independently of optional legacy source hints. Classification
|
||||
// receipts and audit must not collapse distinct Workers into a human/no-source
|
||||
// identity just because those redundant headers are absent.
|
||||
let verified_source = context
|
||||
.and_then(|context| context.runtime_source.as_ref())
|
||||
.map(|source| {
|
||||
let worker_id = source.worker_id.as_ref().ok_or_else(|| {
|
||||
Error::WorkspacePermissionDenied(
|
||||
"Classification requests require a verified Runtime Worker identity".into(),
|
||||
)
|
||||
})?;
|
||||
Ok::<_, Error>(RuntimeWorkerRef::new(&source.runtime_id, worker_id))
|
||||
})
|
||||
.transpose()?;
|
||||
if let (Some(verified), Some(hinted)) = (&verified_source, &hinted_source)
|
||||
&& verified != hinted
|
||||
{
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"Classification source hints do not match the verified Runtime Worker".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let source = verified_source.or(hinted_source);
|
||||
validate_ticket_repository_operation(api, &operation)?;
|
||||
let before = target.as_ref().and_then(|id| backend.show(id.clone()).ok());
|
||||
if let Some(source) = source.as_ref()
|
||||
@@ -14408,6 +14527,12 @@ async fn execute_ticket_rest_operation(
|
||||
.map(|ticket| ticket.meta.workflow_state.as_str().to_string())
|
||||
.unwrap_or_else(|| ticket_operation_initial_state(&operation));
|
||||
let mut event_attributes = BTreeMap::new();
|
||||
if source.is_none()
|
||||
&& let Some(actor) = context.and_then(|context| context.actor.as_ref())
|
||||
{
|
||||
event_attributes.insert("source_account_id".into(), actor.account_id.clone());
|
||||
event_attributes.insert("source_user_id".into(), actor.user_id.clone());
|
||||
}
|
||||
if matches!(operation, TicketBackendOperation::QueueReady { .. }) {
|
||||
let ticket = before.as_ref().ok_or_else(|| {
|
||||
Error::TicketAssignmentConflict(
|
||||
@@ -14495,7 +14620,8 @@ async fn execute_ticket_rest_operation(
|
||||
None,
|
||||
),
|
||||
};
|
||||
if is_mutation {
|
||||
// Classification has no routing or execution side effects.
|
||||
if is_mutation && !matches!(operation_kind, "mutate_workspace_tag" | "set_ticket_tags") {
|
||||
if let TicketBackendOperationResult::QueueOutcome(outcome) = &result {
|
||||
for ticket_id in &outcome.queued_tickets {
|
||||
notify_ticket_recipients(
|
||||
@@ -16558,6 +16684,7 @@ fn ticket_mutation_target(operation: &TicketBackendOperation) -> Option<&TicketI
|
||||
| TicketBackendOperation::AddIntakeSummary { id, .. }
|
||||
| TicketBackendOperation::MarkReady { id, .. }
|
||||
| TicketBackendOperation::QueueReady { id, .. }
|
||||
| TicketBackendOperation::SetTicketTags { id, .. }
|
||||
| TicketBackendOperation::AddTicketRelation { id, .. }
|
||||
| TicketBackendOperation::RemoveTicketRelation { id, .. }
|
||||
| TicketBackendOperation::AddOrchestrationPlanRecord { id, .. } => Some(id),
|
||||
@@ -16621,6 +16748,8 @@ fn ticket_mutation_operation_kind(operation: &TicketBackendOperation) -> &'stati
|
||||
TicketBackendOperation::AddIntakeSummary { .. } => "add_intake_summary",
|
||||
TicketBackendOperation::MarkReady { .. } => "mark_ready",
|
||||
TicketBackendOperation::QueueReady { .. } => "queue_ready",
|
||||
TicketBackendOperation::MutateWorkspaceTag { .. } => "mutate_workspace_tag",
|
||||
TicketBackendOperation::SetTicketTags { .. } => "set_ticket_tags",
|
||||
TicketBackendOperation::AddTicketRelation { .. } => "add_relation",
|
||||
TicketBackendOperation::RemoveTicketRelation { .. } => "remove_relation",
|
||||
TicketBackendOperation::AddOrchestrationPlanRecord { .. } => "add_plan_record",
|
||||
@@ -32252,6 +32381,10 @@ impl IntoResponse for ApiError {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
mod ticket_tags_tests {
|
||||
include!("server_ticket_tags_tests.rs");
|
||||
}
|
||||
|
||||
mod api_logging_tests;
|
||||
mod auth_logging_tests;
|
||||
mod drive_api_tests;
|
||||
|
||||
@@ -0,0 +1,971 @@
|
||||
use super::*;
|
||||
|
||||
fn tag_test_case_update(
|
||||
backend: &ticket::SqliteTicketBackend,
|
||||
id: &str,
|
||||
key: &str,
|
||||
action: ticket::TicketCaseAction,
|
||||
) -> ticket::Ticket {
|
||||
let current = backend.show(id.into()).unwrap();
|
||||
backend
|
||||
.update_case(
|
||||
id,
|
||||
ticket::TicketCaseUpdate {
|
||||
expected_content_digest: ticket::ticket_content_digest(¤t),
|
||||
expected_state: current.meta.workflow_state,
|
||||
expected_closure_operation_key: current.closure_operation_key,
|
||||
operation_key: key.into(),
|
||||
action,
|
||||
explanation: "Independent case judgment".into(),
|
||||
references: vec![],
|
||||
author: Some("case-fixture".into()),
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// Exercise the generated tag routes behind real Runtime authentication. Each
|
||||
// operation retry gets a fresh proof; an HTTP proof itself is single-use.
|
||||
async fn runtime_tag_response(
|
||||
app: Router,
|
||||
request: Request<Body>,
|
||||
expected: StatusCode,
|
||||
) -> serde_json::Value {
|
||||
let response = app.oneshot(request).await.unwrap();
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(status, expected, "{}", String::from_utf8_lossy(&body));
|
||||
if body.is_empty() {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
serde_json::from_slice(&body).unwrap_or_else(|error| {
|
||||
panic!(
|
||||
"invalid JSON response: {error}: {}",
|
||||
String::from_utf8_lossy(&body)
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn runtime_tag_json(
|
||||
app: Router,
|
||||
identity: &RuntimeIdentityMaterial,
|
||||
worker_id: &str,
|
||||
method: &str,
|
||||
path: &str,
|
||||
input: serde_json::Value,
|
||||
expected: StatusCode,
|
||||
) -> serde_json::Value {
|
||||
runtime_tag_response(
|
||||
app,
|
||||
runtime_source_request(
|
||||
identity,
|
||||
Some(worker_id),
|
||||
method,
|
||||
path,
|
||||
serde_json::to_vec(&input).unwrap(),
|
||||
),
|
||||
expected,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn tag_execution_authority_snapshot(api: &WorkspaceApi) -> Vec<Vec<Vec<rusqlite::types::Value>>> {
|
||||
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
[
|
||||
"worker_registry",
|
||||
"worker_control_grants",
|
||||
"ticket_current_worker_assignments",
|
||||
"ticket_assignment_operations",
|
||||
"backend_jobs",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|table| {
|
||||
let mut statement = conn
|
||||
.prepare(&format!(
|
||||
"SELECT * FROM {table} WHERE workspace_id=?1 ORDER BY rowid"
|
||||
))
|
||||
.unwrap();
|
||||
let columns = statement.column_count();
|
||||
statement
|
||||
.query_map([TEST_WORKSPACE_ID], |row| {
|
||||
(0..columns)
|
||||
.map(|column| row.get(column))
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.unwrap()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_tag_mutations_replay_original_receipts_and_preserve_execution_authority() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut api, execution) = test_api_with_recording_backend(dir.path()).await;
|
||||
let identity = RuntimeIdentityMaterial::generate("runtime-tags").unwrap();
|
||||
configure_runtime_request_auth(&mut api, &identity, "runtime-tags");
|
||||
let source = RuntimeWorkerRef::new("runtime-tags", "classifier");
|
||||
let recipient = RuntimeWorkerRef::new("runtime-tags", "recipient");
|
||||
seed_worker_source_member(&api, &source.runtime_id, &source.worker_id);
|
||||
seed_worker_source_member(&api, &recipient.runtime_id, &recipient.worker_id);
|
||||
seed_worker_control_grant(&api, &source, &recipient, "existing-control");
|
||||
let backend = browser_ticket_backend(&api).unwrap();
|
||||
let reference = backend
|
||||
.create(ticket::NewTicket::new("Classification is not a decision"))
|
||||
.unwrap();
|
||||
// Routing is an immutable receipt on an open case, not a six-state value.
|
||||
let Json(queue) = scoped_queue_ticket_record(
|
||||
State(api.clone()),
|
||||
AxumPath((TEST_WORKSPACE_ID.into(), reference.id.clone())),
|
||||
HeaderMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(queue.queued_tickets, vec![reference.id.clone()]);
|
||||
assert_eq!(
|
||||
backend
|
||||
.show(reference.id.clone().into())
|
||||
.unwrap()
|
||||
.meta
|
||||
.workflow_state,
|
||||
ticket::TicketState::Open
|
||||
);
|
||||
api.store
|
||||
.set_current_ticket_role_assignment(
|
||||
&TicketRoleAssignmentRecord {
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
ticket_id: reference.id.clone(),
|
||||
assignment_id: "existing-assignment".into(),
|
||||
role: TicketAssignmentRole::Worker,
|
||||
principal: TicketAssignmentPrincipal::Worker {
|
||||
runtime_id: recipient.runtime_id.clone(),
|
||||
worker_id: recipient.worker_id.clone(),
|
||||
},
|
||||
assigned_by: "test".into(),
|
||||
assigned_at: TEST_CREATED_AT.into(),
|
||||
},
|
||||
None,
|
||||
"existing-assignment-event",
|
||||
"existing-assignment-operation",
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let authority_before = tag_execution_authority_snapshot(&api);
|
||||
let ticket_before = backend.show(reference.id.clone().into()).unwrap();
|
||||
let app = build_router(api.clone());
|
||||
let tags_path = format!("/api/w/{TEST_WORKSPACE_ID}/ticket-tags");
|
||||
let membership_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}/tags", reference.id);
|
||||
let create =
|
||||
json!({"operation_key":"runtime-create", "action":{"kind":"create", "name":" ready "}});
|
||||
let tag = runtime_tag_json(
|
||||
app.clone(),
|
||||
&identity,
|
||||
&source.worker_id,
|
||||
"POST",
|
||||
&tags_path,
|
||||
create.clone(),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(tag["name"], "ready");
|
||||
assert_eq!(tag["workspace_id"], TEST_WORKSPACE_ID);
|
||||
let attach =
|
||||
json!({"operation_key":"runtime-attach", "expected_tag_ids":[], "tag_ids":[tag["id"]]});
|
||||
let attached = runtime_tag_json(
|
||||
app.clone(),
|
||||
&identity,
|
||||
&source.worker_id,
|
||||
"PUT",
|
||||
&membership_path,
|
||||
attach.clone(),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(attached, json!([tag.clone()]));
|
||||
assert_eq!(
|
||||
runtime_tag_json(
|
||||
app.clone(),
|
||||
&identity,
|
||||
&source.worker_id,
|
||||
"PUT",
|
||||
&membership_path,
|
||||
attach.clone(),
|
||||
StatusCode::OK
|
||||
)
|
||||
.await,
|
||||
attached
|
||||
);
|
||||
let rename = json!({"operation_key":"runtime-rename", "action":{"kind":"rename", "tag_id":tag["id"], "expected_last_operation_key":"runtime-create", "name":"Ready now"}});
|
||||
let renamed = runtime_tag_json(
|
||||
app.clone(),
|
||||
&identity,
|
||||
&source.worker_id,
|
||||
"POST",
|
||||
&tags_path,
|
||||
rename.clone(),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(renamed["id"], tag["id"]);
|
||||
assert_eq!(renamed["name"], "Ready now");
|
||||
assert_eq!(
|
||||
serde_json::to_value(&backend.show(reference.id.clone().into()).unwrap().tags).unwrap(),
|
||||
json!([renamed.clone()])
|
||||
);
|
||||
let delete = json!({"operation_key":"runtime-delete", "action":{"kind":"delete", "tag_id":tag["id"], "expected_last_operation_key":"runtime-rename"}});
|
||||
let deleted = runtime_tag_json(
|
||||
app.clone(),
|
||||
&identity,
|
||||
&source.worker_id,
|
||||
"POST",
|
||||
&tags_path,
|
||||
delete.clone(),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(deleted["deleted"], true);
|
||||
assert!(
|
||||
backend
|
||||
.show(reference.id.clone().into())
|
||||
.unwrap()
|
||||
.tags
|
||||
.is_empty()
|
||||
);
|
||||
// Receipts return their original snapshots, even after rename/delete changed
|
||||
// live state. Retries must not recreate membership or duplicate audit events.
|
||||
for (input, expected) in [(create, tag), (rename, renamed), (delete, deleted)] {
|
||||
assert_eq!(
|
||||
runtime_tag_json(
|
||||
app.clone(),
|
||||
&identity,
|
||||
&source.worker_id,
|
||||
"POST",
|
||||
&tags_path,
|
||||
input,
|
||||
StatusCode::OK
|
||||
)
|
||||
.await,
|
||||
expected
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
runtime_tag_json(
|
||||
app.clone(),
|
||||
&identity,
|
||||
&source.worker_id,
|
||||
"PUT",
|
||||
&membership_path,
|
||||
attach,
|
||||
StatusCode::OK
|
||||
)
|
||||
.await,
|
||||
attached
|
||||
);
|
||||
runtime_tag_json(app.clone(), &identity, &source.worker_id, "PUT", &membership_path,
|
||||
json!({"operation_key":"runtime-stale-set", "expected_tag_ids":[attached[0]["id"]], "tag_ids":[]}), StatusCode::CONFLICT).await;
|
||||
let empty = runtime_tag_json(
|
||||
app,
|
||||
&identity,
|
||||
&source.worker_id,
|
||||
"PUT",
|
||||
&membership_path,
|
||||
json!({"operation_key":"runtime-empty-set", "expected_tag_ids":[], "tag_ids":[]}),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(empty, json!([]));
|
||||
let ticket_after = backend.show(reference.id.into()).unwrap();
|
||||
assert_eq!(
|
||||
ticket::ticket_content_digest(&ticket_before),
|
||||
ticket::ticket_content_digest(&ticket_after)
|
||||
);
|
||||
assert_eq!(
|
||||
ticket_before.meta.workflow_state,
|
||||
ticket_after.meta.workflow_state
|
||||
);
|
||||
assert_eq!(ticket_before.current_closure, ticket_after.current_closure);
|
||||
assert_eq!(ticket_before.case_history, ticket_after.case_history);
|
||||
assert_eq!(ticket_before.meta.queued_by, ticket_after.meta.queued_by);
|
||||
assert_eq!(ticket_before.meta.queued_at, ticket_after.meta.queued_at);
|
||||
assert_eq!(
|
||||
ticket_before
|
||||
.events
|
||||
.iter()
|
||||
.filter(|event| event.kind != ticket::TicketEventKind::TagsChanged)
|
||||
.collect::<Vec<_>>(),
|
||||
ticket_after
|
||||
.events
|
||||
.iter()
|
||||
.filter(|event| event.kind != ticket::TicketEventKind::TagsChanged)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert!(ticket_after.tags.is_empty());
|
||||
assert_eq!(tag_execution_authority_snapshot(&api), authority_before);
|
||||
assert!(
|
||||
execution.protocol_methods().is_empty(),
|
||||
"classification dispatched execution/routing"
|
||||
);
|
||||
let tag_events: Vec<_> = ticket_after
|
||||
.events
|
||||
.iter()
|
||||
.filter(|event| event.kind == ticket::TicketEventKind::TagsChanged)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
tag_events.len(),
|
||||
2,
|
||||
"attach/delete retries duplicated membership audit"
|
||||
);
|
||||
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
let mut statement = conn.prepare("SELECT operation_key,source_context_json FROM workspace_tag_operations WHERE workspace_id=?1 ORDER BY operation_key").unwrap();
|
||||
let receipts = statement
|
||||
.query_map([TEST_WORKSPACE_ID], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.unwrap();
|
||||
assert_eq!(receipts.len(), 5);
|
||||
for (key, context) in receipts {
|
||||
let context: serde_json::Value = serde_json::from_str(&context).unwrap();
|
||||
assert_eq!(
|
||||
context["source_runtime_id"], source.runtime_id,
|
||||
"receipt {key}: {context}"
|
||||
);
|
||||
assert_eq!(
|
||||
context["source_worker_id"], source.worker_id,
|
||||
"receipt {key}: {context}"
|
||||
);
|
||||
assert!(
|
||||
context.get("source_account_id").is_none(),
|
||||
"signed source became a human actor: {context}"
|
||||
);
|
||||
}
|
||||
for event in tag_events {
|
||||
assert_eq!(
|
||||
event.attributes.get("source_runtime_id"),
|
||||
Some(&source.runtime_id)
|
||||
);
|
||||
assert_eq!(
|
||||
event.attributes.get("source_worker_id"),
|
||||
Some(&source.worker_id)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_tag_receipt_replay_is_bound_to_verified_worker_not_transport_proof() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut api = test_api(dir.path()).await;
|
||||
let identity = RuntimeIdentityMaterial::generate("runtime-tags").unwrap();
|
||||
configure_runtime_request_auth(&mut api, &identity, "runtime-tags");
|
||||
for worker in ["classifier", "other"] {
|
||||
seed_worker_source_member(&api, "runtime-tags", worker);
|
||||
}
|
||||
let app = build_router(api.clone());
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/ticket-tags");
|
||||
let input =
|
||||
json!({"operation_key":"worker-receipt", "action":{"kind":"create", "name":"ready"}});
|
||||
let request = runtime_source_request(
|
||||
&identity,
|
||||
Some("classifier"),
|
||||
"POST",
|
||||
&path,
|
||||
serde_json::to_vec(&input).unwrap(),
|
||||
);
|
||||
let proof =
|
||||
request.headers()[worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER].clone();
|
||||
let tag = runtime_tag_response(app.clone(), request, StatusCode::OK).await;
|
||||
runtime_tag_response(
|
||||
app.clone(),
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(&path)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header(
|
||||
worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
|
||||
proof,
|
||||
)
|
||||
.body(Body::from(serde_json::to_vec(&input).unwrap()))
|
||||
.unwrap(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
runtime_tag_json(
|
||||
app.clone(),
|
||||
&identity,
|
||||
"classifier",
|
||||
"POST",
|
||||
&path,
|
||||
input.clone(),
|
||||
StatusCode::OK
|
||||
)
|
||||
.await,
|
||||
tag
|
||||
);
|
||||
runtime_tag_json(
|
||||
app.clone(),
|
||||
&identity,
|
||||
"classifier",
|
||||
"POST",
|
||||
&path,
|
||||
json!({"operation_key":"worker-receipt", "action":{"kind":"create", "name":"changed"}}),
|
||||
StatusCode::CONFLICT,
|
||||
)
|
||||
.await;
|
||||
runtime_tag_json(
|
||||
app,
|
||||
&identity,
|
||||
"other",
|
||||
"POST",
|
||||
&path,
|
||||
input,
|
||||
StatusCode::CONFLICT,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
browser_ticket_backend(&api)
|
||||
.unwrap()
|
||||
.list_workspace_tags(ticket::TagListRequest::default())
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_tag_mutations_reject_invalid_proofs_and_changed_binding_authority() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut api, execution) = test_api_with_recording_backend(dir.path()).await;
|
||||
let identity = RuntimeIdentityMaterial::generate("runtime-tags").unwrap();
|
||||
configure_runtime_request_auth(&mut api, &identity, "runtime-tags");
|
||||
seed_worker_source_member(&api, "runtime-tags", "classifier");
|
||||
let backend = browser_ticket_backend(&api).unwrap();
|
||||
let ticket = backend
|
||||
.create(ticket::NewTicket::new("Rejected classification"))
|
||||
.unwrap();
|
||||
let ticket_before = backend.show(ticket.id.clone().into()).unwrap();
|
||||
let authority_before = tag_execution_authority_snapshot(&api);
|
||||
let app = build_router(api.clone());
|
||||
let tags_path = format!("/api/w/{TEST_WORKSPACE_ID}/ticket-tags");
|
||||
let membership_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}/tags", ticket.id);
|
||||
let attacker = RuntimeIdentityMaterial::generate("runtime-tags").unwrap();
|
||||
// Both mutation routes must reject before writing tags, memberships, receipts
|
||||
// or audit events. There is no persisted verification status in this fixture.
|
||||
for (method, path, input) in [
|
||||
(
|
||||
"POST",
|
||||
tags_path.as_str(),
|
||||
json!({"operation_key":"denied-create", "action":{"kind":"create", "name":"denied"}}),
|
||||
),
|
||||
(
|
||||
"PUT",
|
||||
membership_path.as_str(),
|
||||
json!({"operation_key":"denied-set", "expected_tag_ids":[], "tag_ids":[]}),
|
||||
),
|
||||
] {
|
||||
let body = serde_json::to_vec(&input).unwrap();
|
||||
let unsigned = Request::builder()
|
||||
.method(method)
|
||||
.uri(path)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header("x-yoi-runtime-id", "runtime-tags")
|
||||
.header("x-yoi-worker-id", "classifier")
|
||||
.body(Body::from(body.clone()))
|
||||
.unwrap();
|
||||
runtime_tag_response(app.clone(), unsigned, StatusCode::UNAUTHORIZED).await;
|
||||
runtime_tag_json(
|
||||
app.clone(),
|
||||
&attacker,
|
||||
"classifier",
|
||||
method,
|
||||
path,
|
||||
input.clone(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
)
|
||||
.await;
|
||||
runtime_tag_json(
|
||||
app.clone(),
|
||||
&identity,
|
||||
"not-a-member",
|
||||
method,
|
||||
path,
|
||||
input.clone(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
)
|
||||
.await;
|
||||
let proof = worker_runtime::auth::RuntimeRequestSourceSigner::from_identity(&identity)
|
||||
.issue(
|
||||
"server-test",
|
||||
"another-workspace",
|
||||
Some("classifier"),
|
||||
worker_runtime::auth::WORKSPACE_REQUEST_PERMISSION,
|
||||
method,
|
||||
path,
|
||||
&body,
|
||||
i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap(),
|
||||
30,
|
||||
)
|
||||
.unwrap();
|
||||
runtime_tag_response(
|
||||
app.clone(),
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(path)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header(
|
||||
worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
|
||||
proof,
|
||||
)
|
||||
.body(Body::from(body.clone()))
|
||||
.unwrap(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
)
|
||||
.await;
|
||||
let mut tampered =
|
||||
runtime_source_request(&identity, Some("classifier"), method, path, body);
|
||||
*tampered.body_mut() = Body::from(format!("{} ", input));
|
||||
runtime_tag_response(app.clone(), tampered, StatusCode::UNAUTHORIZED).await;
|
||||
}
|
||||
let store = SqliteWorkspaceStore::open(&api.config.database_path).unwrap();
|
||||
let original = api
|
||||
.store
|
||||
.get_workspace_runtime_binding(TEST_WORKSPACE_ID, "runtime-tags")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let mut replacement = original.clone();
|
||||
replacement.public_key = attacker.public_key.clone();
|
||||
replacement.public_key_fingerprint = String::new();
|
||||
store
|
||||
.upsert_workspace_runtime_binding(replacement, true)
|
||||
.unwrap();
|
||||
for (method, path, input) in [
|
||||
(
|
||||
"POST",
|
||||
tags_path.as_str(),
|
||||
json!({"operation_key":"stale-key-create", "action":{"kind":"create", "name":"stale"}}),
|
||||
),
|
||||
(
|
||||
"PUT",
|
||||
membership_path.as_str(),
|
||||
json!({"operation_key":"stale-key-set", "expected_tag_ids":[], "tag_ids":[]}),
|
||||
),
|
||||
] {
|
||||
runtime_tag_json(
|
||||
app.clone(),
|
||||
&identity,
|
||||
"classifier",
|
||||
method,
|
||||
path,
|
||||
input,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
store
|
||||
.upsert_workspace_runtime_binding(original, true)
|
||||
.unwrap();
|
||||
store
|
||||
.revoke_workspace_runtime_binding(TEST_WORKSPACE_ID, "runtime-tags", TEST_CREATED_AT)
|
||||
.unwrap();
|
||||
for (method, path, input) in [
|
||||
(
|
||||
"POST",
|
||||
tags_path.as_str(),
|
||||
json!({"operation_key":"revoked-create", "action":{"kind":"create", "name":"revoked"}}),
|
||||
),
|
||||
(
|
||||
"PUT",
|
||||
membership_path.as_str(),
|
||||
json!({"operation_key":"revoked-set", "expected_tag_ids":[], "tag_ids":[]}),
|
||||
),
|
||||
] {
|
||||
runtime_tag_json(
|
||||
app.clone(),
|
||||
&identity,
|
||||
"classifier",
|
||||
method,
|
||||
path,
|
||||
input,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
assert!(
|
||||
backend
|
||||
.list_workspace_tags(ticket::TagListRequest::default())
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(backend.show(ticket.id.into()).unwrap()).unwrap(),
|
||||
serde_json::to_value(ticket_before).unwrap()
|
||||
);
|
||||
assert_eq!(tag_execution_authority_snapshot(&api), authority_before);
|
||||
assert!(execution.protocol_methods().is_empty());
|
||||
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
let receipts: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM workspace_tag_operations WHERE workspace_id=?1",
|
||||
[TEST_WORKSPACE_ID],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(receipts, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_tags_round_trip_filter_and_preserve_execution_and_decision_projections() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (api, execution) = test_api_with_recording_backend(dir.path()).await;
|
||||
let backend = browser_ticket_backend(&api).unwrap();
|
||||
let reference = backend
|
||||
.create(ticket::NewTicket::new("Classification only"))
|
||||
.unwrap();
|
||||
let other = backend
|
||||
.create(ticket::NewTicket::new("Other ticket"))
|
||||
.unwrap();
|
||||
let reasoned = tag_test_case_update(
|
||||
&backend,
|
||||
&reference.id,
|
||||
"reasoned-close",
|
||||
ticket::TicketCaseAction::Close {
|
||||
reason: ticket::TicketClosureReason::NotPlanned,
|
||||
},
|
||||
);
|
||||
let app = build_inner_router(api.clone());
|
||||
let tags_path = format!("/api/w/{TEST_WORKSPACE_ID}/ticket-tags");
|
||||
let ticket_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}", reference.id);
|
||||
let before = get_json(app.clone(), &ticket_path).await;
|
||||
let ready = post_json(
|
||||
app.clone(),
|
||||
&tags_path,
|
||||
json!({
|
||||
"operation_key":"create-ready", "action":{"kind":"create", "name":" ready "}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let bug = post_json(
|
||||
app.clone(),
|
||||
&tags_path,
|
||||
json!({
|
||||
"operation_key":"create-bug", "action":{"kind":"create", "name":"closed"}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let ready_id = ready["id"].as_str().unwrap();
|
||||
let bug_id = bug["id"].as_str().unwrap();
|
||||
let assignment =
|
||||
json!({"operation_key":"classify", "expected_tag_ids":[], "tag_ids":[ready_id,bug_id]});
|
||||
let attached = request_json(
|
||||
app.clone(),
|
||||
"PUT",
|
||||
&format!("{ticket_path}/tags"),
|
||||
Some(assignment.clone()),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(attached.as_array().unwrap().len(), 2);
|
||||
let replay = request_json(
|
||||
app.clone(),
|
||||
"PUT",
|
||||
&format!("{ticket_path}/tags"),
|
||||
Some(assignment),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(attached, replay);
|
||||
let after = get_json(app.clone(), &ticket_path).await;
|
||||
for field in [
|
||||
"state",
|
||||
"current_closure",
|
||||
"closure_operation_key",
|
||||
"legacy_handoff",
|
||||
"content_digest",
|
||||
"current_worker",
|
||||
"assignments",
|
||||
"action_eligibility",
|
||||
"targets",
|
||||
"evidence",
|
||||
"merge_requests",
|
||||
"queued_by",
|
||||
"queued_at",
|
||||
] {
|
||||
assert_eq!(
|
||||
before[field], after[field],
|
||||
"classification changed {field}"
|
||||
);
|
||||
}
|
||||
assert_eq!(after["tags"].as_array().unwrap().len(), 2);
|
||||
assert!(execution.protocol_methods().is_empty());
|
||||
let list = get_json(app.clone(), &format!("/api/w/{TEST_WORKSPACE_ID}/tickets")).await;
|
||||
let item = list["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|item| item["id"] == reference.id)
|
||||
.unwrap();
|
||||
assert_eq!(item["tags"].as_array().unwrap().len(), 2);
|
||||
let query_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/query");
|
||||
for (filter, count) in [
|
||||
(json!({"tag_ids":[ready_id,bug_id]}), 1),
|
||||
(json!({"tag_ids_any":[ready_id,"absent"]}), 1),
|
||||
(json!({"tag_ids_none":[ready_id]}), 1),
|
||||
(json!({"tag_ids":[ready_id,"absent"]}), 0),
|
||||
] {
|
||||
let result = post_json(app.clone(), &query_path, filter.clone()).await;
|
||||
assert_eq!(
|
||||
result["items"].as_array().unwrap().len(),
|
||||
count,
|
||||
"filter {filter}"
|
||||
);
|
||||
if count == 1 && filter.get("tag_ids_none").is_none() {
|
||||
assert_eq!(result["items"][0]["tags"].as_array().unwrap().len(), 2);
|
||||
} else if filter.get("tag_ids_none").is_some() {
|
||||
assert_eq!(result["items"][0]["id"], other.id);
|
||||
}
|
||||
}
|
||||
let renamed = post_json(
|
||||
app.clone(),
|
||||
&tags_path,
|
||||
json!({
|
||||
"operation_key":"rename", "action":{"kind":"rename", "tag_id":bug_id,
|
||||
"expected_last_operation_key":"create-bug", "name":"Defect"}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(renamed["id"], bug["id"]);
|
||||
let shown = get_json(app.clone(), &ticket_path).await;
|
||||
assert!(
|
||||
shown["tags"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|tag| tag["name"] == "Defect")
|
||||
);
|
||||
request_json(
|
||||
app.clone(),
|
||||
"POST",
|
||||
&tags_path,
|
||||
Some(json!({
|
||||
"operation_key":"stale-rename", "action":{"kind":"rename", "tag_id":bug_id,
|
||||
"expected_last_operation_key":"create-bug", "name":"Wrong"}
|
||||
})),
|
||||
StatusCode::CONFLICT,
|
||||
)
|
||||
.await;
|
||||
post_json(
|
||||
app.clone(),
|
||||
&tags_path,
|
||||
json!({
|
||||
"operation_key":"delete", "action":{"kind":"delete", "tag_id":bug_id,
|
||||
"expected_last_operation_key":"rename"}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let deleted = get_json(app.clone(), &ticket_path).await;
|
||||
assert_eq!(deleted["tags"].as_array().unwrap().len(), 1);
|
||||
request_json(
|
||||
app.clone(),
|
||||
"PUT",
|
||||
&format!("{ticket_path}/tags"),
|
||||
Some(json!({
|
||||
"operation_key":"stale-set", "expected_tag_ids":[ready_id,bug_id], "tag_ids":[]
|
||||
})),
|
||||
StatusCode::CONFLICT,
|
||||
)
|
||||
.await;
|
||||
let untagged = request_json(
|
||||
app.clone(),
|
||||
"PUT",
|
||||
&format!("{ticket_path}/tags"),
|
||||
Some(json!({
|
||||
"operation_key":"detach", "expected_tag_ids":[ready_id], "tag_ids":[]
|
||||
})),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(untagged, json!([]));
|
||||
let final_detail = get_json(app, &ticket_path).await;
|
||||
assert_eq!(final_detail["state"], before["state"]);
|
||||
assert_eq!(final_detail["content_digest"], before["content_digest"]);
|
||||
let classified = backend.show(reference.id.clone().into()).unwrap();
|
||||
assert_eq!(classified.current_closure, reasoned.current_closure);
|
||||
assert_eq!(classified.case_history, reasoned.case_history);
|
||||
let tag_history: Vec<_> = classified
|
||||
.events
|
||||
.iter()
|
||||
.filter(|event| event.kind == ticket::TicketEventKind::TagsChanged)
|
||||
.cloned()
|
||||
.collect();
|
||||
let corrected = tag_test_case_update(
|
||||
&backend,
|
||||
&reference.id,
|
||||
"correct-close",
|
||||
ticket::TicketCaseAction::CorrectClosure {
|
||||
reason: ticket::TicketClosureReason::Completed,
|
||||
},
|
||||
);
|
||||
assert_eq!(corrected.tags, classified.tags);
|
||||
assert_eq!(corrected.case_history[0], reasoned.case_history[0]);
|
||||
let reopened = tag_test_case_update(
|
||||
&backend,
|
||||
&reference.id,
|
||||
"reopen-case",
|
||||
ticket::TicketCaseAction::Reopen,
|
||||
);
|
||||
assert_eq!(reopened.tags, classified.tags);
|
||||
assert_eq!(
|
||||
reopened
|
||||
.events
|
||||
.iter()
|
||||
.filter(|event| event.kind == ticket::TicketEventKind::TagsChanged)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>(),
|
||||
tag_history
|
||||
);
|
||||
assert!(execution.protocol_methods().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_tag_routes_require_workspace_authority_and_reject_forged_worker_source() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let owner_token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID);
|
||||
let collaborator_token = seed_test_api_token(api.store.as_ref(), "tags-collaborator");
|
||||
let app = build_router(api.clone());
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/ticket-tags");
|
||||
let input = json!({"operation_key":"authorized", "action":{"kind":"create", "name":"ready"}});
|
||||
request_json(
|
||||
app.clone(),
|
||||
"POST",
|
||||
&path,
|
||||
Some(input.clone()),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
)
|
||||
.await;
|
||||
|
||||
let tag = request_json_authenticated(
|
||||
app.clone(),
|
||||
"POST",
|
||||
&path,
|
||||
Some(input),
|
||||
&owner_token,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(tag["workspace_id"], TEST_WORKSPACE_ID);
|
||||
// Classification follows shared Ticket Workspace write authority, not the
|
||||
// owner-only repository/secrets-management policy. A distinct authenticated
|
||||
// actor may manage tags but cannot replay another actor's receipt.
|
||||
request_json_authenticated(
|
||||
app.clone(),
|
||||
"POST",
|
||||
&path,
|
||||
Some(json!({"operation_key":"authorized", "action":{"kind":"create", "name":"ready"}})),
|
||||
&collaborator_token,
|
||||
StatusCode::CONFLICT,
|
||||
)
|
||||
.await;
|
||||
request_json_authenticated(
|
||||
app.clone(),
|
||||
"POST",
|
||||
"/api/w/other-workspace/ticket-tags",
|
||||
Some(json!({"operation_key":"foreign", "action":{"kind":"create", "name":"foreign"}})),
|
||||
&collaborator_token,
|
||||
StatusCode::NOT_FOUND,
|
||||
)
|
||||
.await;
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(&path)
|
||||
.header(
|
||||
axum::http::header::AUTHORIZATION,
|
||||
format!("Bearer {owner_token}"),
|
||||
)
|
||||
.header("x-yoi-runtime-id", "untrusted-runtime")
|
||||
.header("x-yoi-worker-id", "untrusted-worker")
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
json!({"operation_key":"forged", "action":{"kind":"create", "name":"forged"}})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"{}",
|
||||
String::from_utf8_lossy(&body)
|
||||
);
|
||||
let tags = browser_ticket_backend(&api)
|
||||
.unwrap()
|
||||
.list_workspace_tags(ticket::TagListRequest::default())
|
||||
.unwrap();
|
||||
assert_eq!(tags.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tag_query_filters_precede_bounded_candidate_scan_and_bind_cursors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let backend = browser_ticket_backend(&api).unwrap();
|
||||
for i in 0..5 {
|
||||
backend
|
||||
.create(ticket::NewTicket::new(format!("a unrelated {i}")))
|
||||
.unwrap();
|
||||
}
|
||||
let tag = backend
|
||||
.mutate_workspace_tag(ticket::WorkspaceTagMutation {
|
||||
operation_key: "create".into(),
|
||||
action: ticket::WorkspaceTagAction::Create { name: "bug".into() },
|
||||
})
|
||||
.unwrap();
|
||||
for i in 0..3 {
|
||||
let id = backend
|
||||
.create(ticket::NewTicket::new(format!("z tagged {i}")))
|
||||
.unwrap()
|
||||
.id;
|
||||
backend
|
||||
.set_ticket_tags(
|
||||
id.into(),
|
||||
ticket::TicketTagSetRequest {
|
||||
expected_tag_ids: vec![],
|
||||
tag_ids: vec![tag.id.clone()],
|
||||
operation_key: format!("set-{i}"),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let app = build_inner_router(api);
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/query");
|
||||
let mut input = json!({"tag_ids":[tag.id], "sort":"title", "limit":1});
|
||||
for i in 0..3 {
|
||||
let page = post_json(app.clone(), &path, input.clone()).await;
|
||||
assert_eq!(page["items"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(page["items"][0]["title"], format!("z tagged {i}"));
|
||||
if i < 2 {
|
||||
let cursor = page["page"]["next_cursor"].clone();
|
||||
request_json(
|
||||
app.clone(),
|
||||
"POST",
|
||||
&path,
|
||||
Some(json!({"tag_ids_any":[tag.id], "sort":"title", "limit":1, "cursor":cursor})),
|
||||
StatusCode::NOT_FOUND,
|
||||
)
|
||||
.await;
|
||||
input["cursor"] = cursor;
|
||||
} else {
|
||||
assert_eq!(page["page"]["has_more"], false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13059,8 +13059,13 @@ fn apply_ticket_targets_and_workdir_capabilities_schema(conn: &Connection) -> Re
|
||||
TICKET_SCHEMA_BASELINE_NAME
|
||||
],
|
||||
)?;
|
||||
// This retained step composes the historical Ticket schema 7.
|
||||
// Current-domain verification follows the shared case migration at schema 91.
|
||||
// Verify the retained six-state v7 boundary here; the shared domain
|
||||
// migrator installs classification and cases only after Workspace auth90.
|
||||
ticket::verify_pre_classification_ticket_schema(db).map_err(|error| {
|
||||
Error::Store(format!(
|
||||
"schema-66 Ticket schema verification failed: {error}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let foreign_key_violations =
|
||||
db.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
|
||||
@@ -14547,6 +14552,9 @@ mod tests {
|
||||
}
|
||||
|
||||
fn downgrade_schema_66_ticket_and_workdir_authority(conn: &Connection) -> Result<()> {
|
||||
// A retained pre-target fixture must not carry today's classification
|
||||
// tables while resetting the component migration marker to v6.
|
||||
conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_tags; DROP TABLE IF EXISTS workspace_tag_operations; DROP TABLE IF EXISTS workspace_tags;")?;
|
||||
conn.execute_batch("DROP TABLE IF EXISTS worker_restore_intents;")?;
|
||||
conn.execute_batch(
|
||||
"DROP TABLE backend_job_deliveries;
|
||||
@@ -15586,7 +15594,7 @@ mod tests {
|
||||
migrate_ticket_targets_and_workdir_capabilities_v65_to_v66(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 66);
|
||||
// The retained step is Ticket-7; current verification runs after schema 91.
|
||||
ticket::verify_pre_classification_ticket_schema(&conn).unwrap();
|
||||
assert!(!column_exists(&conn, "typed_tickets", "repository_id").unwrap());
|
||||
assert!(!column_exists(&conn, "typed_tickets", "ref_selector").unwrap());
|
||||
assert_eq!(
|
||||
@@ -15899,7 +15907,10 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
fn create_frozen_schema_v85(conn: &Connection) -> Result<()> {
|
||||
// Compose the frozen Ticket-7 baseline, not current Ticket case tables.
|
||||
conn.execute_batch(
|
||||
"DROP TABLE IF EXISTS typed_ticket_case_metadata;
|
||||
"DROP TABLE IF EXISTS typed_ticket_tags;
|
||||
DROP TABLE IF EXISTS workspace_tag_operations;
|
||||
DROP TABLE IF EXISTS workspace_tags;
|
||||
DROP TABLE IF EXISTS typed_ticket_case_metadata;
|
||||
DROP TABLE IF EXISTS typed_ticket_case_history;
|
||||
DROP TABLE IF EXISTS typed_ticket_legacy_workflow_snapshot;
|
||||
DELETE FROM ticket_schema_migrations;
|
||||
@@ -15984,11 +15995,12 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
"#,
|
||||
)?;
|
||||
conn.execute_batch(include_str!("assignment_work.sql"))?;
|
||||
// Verification uses current schema 9 only after the real retained chain.
|
||||
// Current composed Ticket verification follows the real retained chain.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
include!("schema_equivalence_tests.rs");
|
||||
include!("ticket_tag_upgrade_tests.rs");
|
||||
include!("migration_entrypoint_tests.rs");
|
||||
include!("legacy_evidence_column_tests.rs");
|
||||
|
||||
@@ -18954,7 +18966,18 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
VALUES('case-workspace','case-runtime','pending-worker','Pending','normal','1','1')", []).unwrap();
|
||||
conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,runtime_id,worker_id,created_at,request_fingerprint,claim_state)
|
||||
VALUES('case-workspace','pending-claim','assign','pending-queue','worker','case-runtime','pending-worker','1','pending-fingerprint','pending')", []).unwrap();
|
||||
// Compose real retained Ticket8 classification data across the full
|
||||
// six-state × MR matrix, before either auth90 or case91 runs.
|
||||
install_retained_ticket_tags8(&conn);
|
||||
let retained_tags = seed_retained_classification(&conn, "case-workspace", "done-true");
|
||||
conn.execute("INSERT OR IGNORE INTO typed_ticket_tags(workspace_id,ticket_id,tag_id)
|
||||
SELECT workspace_id,ticket_id,?1 FROM typed_tickets WHERE workspace_id='case-workspace'",
|
||||
[&retained_tags.active_ids[0]]).unwrap();
|
||||
let unchanged = [
|
||||
"workspace_tags",
|
||||
"typed_ticket_tags",
|
||||
"workspace_tag_operations",
|
||||
"typed_ticket_labels",
|
||||
"typed_ticket_events",
|
||||
"typed_ticket_event_attributes",
|
||||
"typed_ticket_event_references",
|
||||
@@ -19049,6 +19072,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
for has_mr in [false, true] {
|
||||
let id = format!("{state}-{has_mr}");
|
||||
let ticket = backend.show(id.clone().into()).unwrap();
|
||||
assert!(!ticket.tags.is_empty(), "matrix classification {id}");
|
||||
assert_eq!(ticket.document.body.as_str(), "Original requirements");
|
||||
let snapshot = ticket.legacy_workflow_snapshot.as_ref().unwrap();
|
||||
assert_eq!(snapshot.workflow_state, state);
|
||||
@@ -19082,6 +19106,19 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
assert_eq!(reopened.meta.workflow_state, ticket::TicketState::Open);
|
||||
assert_eq!(reopened.case_history.len(), 2);
|
||||
assert_eq!(reopened.case_history[0], ticket.case_history[0]);
|
||||
assert_eq!(reopened.tags, ticket.tags);
|
||||
assert_eq!(
|
||||
reopened
|
||||
.events
|
||||
.iter()
|
||||
.filter(|event| event.kind == ticket::TicketEventKind::TagsChanged)
|
||||
.collect::<Vec<_>>(),
|
||||
ticket
|
||||
.events
|
||||
.iter()
|
||||
.filter(|event| event.kind == ticket::TicketEventKind::TagsChanged)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get_active_ticket_worker_assignment("case-workspace", &id)
|
||||
@@ -19193,11 +19230,15 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
VALUES('case-workspace','terminal','terminal','Terminal','closed','task','P2','','done',1);
|
||||
DROP VIEW ticket_active_worker_assignments;
|
||||
CREATE TABLE ticket_active_worker_assignments(collision TEXT);").unwrap();
|
||||
install_retained_ticket_tags8(&conn);
|
||||
let _tags = seed_retained_classification(&conn, "case-workspace", "terminal");
|
||||
let tags_before = classification_rows(&conn);
|
||||
let auth_before = fixture_auth_rows(&conn);
|
||||
let error = migrate_ticket_cases_v90_to_v91(&conn).unwrap_err();
|
||||
assert!(error.to_string().contains("DROP TABLE"));
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 90);
|
||||
assert_eq!(fixture_auth_rows(&conn), auth_before);
|
||||
assert_eq!(classification_rows(&conn), tags_before);
|
||||
// The shared transaction completed, but Workspace did not advertise a
|
||||
// composed schema. Its immutable evidence remains the retry authority.
|
||||
let before = stored_fixture_rows(&conn, "typed_ticket_case_history");
|
||||
@@ -19207,6 +19248,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
migrate_ticket_cases_v90_to_v91(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 91);
|
||||
assert_eq!(fixture_auth_rows(&conn), auth_before);
|
||||
assert_eq!(classification_rows(&conn), tags_before);
|
||||
assert_eq!(
|
||||
stored_fixture_rows(&conn, "typed_ticket_case_history"),
|
||||
before
|
||||
@@ -19349,6 +19391,9 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
let upgraded = SqliteWorkspaceStore::open(&path).unwrap();
|
||||
let fresh = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
for object in [
|
||||
"workspace_tags",
|
||||
"typed_ticket_tags",
|
||||
"workspace_tag_operations",
|
||||
"typed_tickets",
|
||||
"typed_ticket_targets",
|
||||
"typed_ticket_case_metadata",
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
// Included by store::tests. Genuine retained six-state Workspace89/90 fixtures
|
||||
// compose auth90, classification and cases; no lowered current-schema markers.
|
||||
|
||||
// Frozen classification schema from develop b1fe562e (Ticket component 8).
|
||||
// Deliberate persisted compatibility input, independent of the latest baseline.
|
||||
fn install_retained_ticket_tags8(conn: &Connection) {
|
||||
ticket::verify_pre_classification_ticket_schema(conn).unwrap();
|
||||
conn.execute_batch(r#"
|
||||
CREATE TABLE workspace_tags (
|
||||
workspace_id TEXT NOT NULL, tag_id TEXT NOT NULL, name TEXT NOT NULL,
|
||||
normalized_name TEXT NOT NULL, last_operation_key TEXT NOT NULL,
|
||||
deleted INTEGER NOT NULL CHECK (deleted IN (0,1)),
|
||||
PRIMARY KEY(workspace_id,tag_id));
|
||||
CREATE UNIQUE INDEX workspace_tags_active_name
|
||||
ON workspace_tags(workspace_id,normalized_name) WHERE deleted=0;
|
||||
CREATE TABLE typed_ticket_tags (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, tag_id TEXT NOT NULL,
|
||||
PRIMARY KEY(workspace_id,ticket_id,tag_id),
|
||||
FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(workspace_id,tag_id) REFERENCES workspace_tags(workspace_id,tag_id) ON DELETE RESTRICT);
|
||||
CREATE INDEX typed_ticket_tags_workspace_tag ON typed_ticket_tags(workspace_id,tag_id,ticket_id);
|
||||
CREATE TABLE workspace_tag_operations (
|
||||
workspace_id TEXT NOT NULL, operation_key TEXT NOT NULL, kind TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL, input_json TEXT NOT NULL, source_context_json TEXT NOT NULL,
|
||||
before_json TEXT NOT NULL, result_json TEXT NOT NULL, at TEXT NOT NULL,
|
||||
PRIMARY KEY(workspace_id,operation_key));
|
||||
UPDATE ticket_schema_migrations SET version=8;
|
||||
"#).unwrap();
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RetainedClassification {
|
||||
source: std::collections::BTreeMap<String, String>,
|
||||
request: ticket::TicketTagSetRequest,
|
||||
receipt: Vec<ticket::WorkspaceTag>,
|
||||
active_ids: Vec<String>,
|
||||
deleted_id: String,
|
||||
}
|
||||
|
||||
// Seed actual v8 persisted boundaries rather than invoking a latest-only API on
|
||||
// a legacy database. Full source context is audit, only authenticated identity
|
||||
// belongs to the replay fingerprint. It is deliberately not the assigned Worker.
|
||||
fn seed_retained_classification(
|
||||
conn: &Connection,
|
||||
workspace: &str,
|
||||
id: &str,
|
||||
) -> RetainedClassification {
|
||||
let source = std::collections::BTreeMap::from([
|
||||
("source_runtime_id".into(), "case-runtime".into()),
|
||||
("source_worker_id".into(), "classifier".into()),
|
||||
("assigned_runtime_id".into(), "case-runtime".into()),
|
||||
("assigned_worker_id".into(), id.into()),
|
||||
("source_actor_role".into(), "ticket_worker".into()),
|
||||
(
|
||||
"source_assignment_id".into(),
|
||||
format!("old-assignment-{id}"),
|
||||
),
|
||||
]);
|
||||
let identity = std::collections::BTreeMap::from([
|
||||
("source_runtime_id", "case-runtime"),
|
||||
("source_worker_id", "classifier"),
|
||||
]);
|
||||
let mut receipt = Vec::new();
|
||||
for (suffix, name, deleted) in [
|
||||
("ready", "ready", false),
|
||||
("closed", "closed", false),
|
||||
("old", "Bug", true),
|
||||
("replacement", "Bug", false),
|
||||
] {
|
||||
let tag = ticket::WorkspaceTag {
|
||||
workspace_id: workspace.into(),
|
||||
id: format!("{id}-{suffix}"),
|
||||
name: name.into(),
|
||||
last_operation_key: format!("{id}-{suffix}-create"),
|
||||
deleted: false,
|
||||
};
|
||||
let request = ticket::WorkspaceTagMutation {
|
||||
operation_key: tag.last_operation_key.clone(),
|
||||
action: ticket::WorkspaceTagAction::Create { name: name.into() },
|
||||
};
|
||||
conn.execute(
|
||||
"INSERT INTO workspace_tags VALUES(?1,?2,?3,?4,?5,?6)",
|
||||
params![
|
||||
workspace,
|
||||
tag.id,
|
||||
name,
|
||||
name.to_lowercase(),
|
||||
if deleted {
|
||||
format!("{id}-delete")
|
||||
} else {
|
||||
tag.last_operation_key.clone()
|
||||
},
|
||||
deleted
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let input =
|
||||
serde_json::to_string(&("mutate_workspace_tag", "", &request, &identity)).unwrap();
|
||||
conn.execute("INSERT INTO workspace_tag_operations VALUES(?1,?2,'create',?3,?4,?5,'null',?6,'original-time')",
|
||||
params![workspace, request.operation_key, tag.id, input, serde_json::to_string(&source).unwrap(), serde_json::to_string(&tag).unwrap()]).unwrap();
|
||||
if suffix != "replacement" {
|
||||
receipt.push(tag);
|
||||
}
|
||||
}
|
||||
receipt.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
let request = ticket::TicketTagSetRequest {
|
||||
operation_key: format!("{id}-attach"),
|
||||
expected_tag_ids: vec![],
|
||||
tag_ids: receipt.iter().map(|tag| tag.id.clone()).collect(),
|
||||
};
|
||||
let selector: ticket::TicketIdOrSlug = id.into();
|
||||
let input =
|
||||
serde_json::to_string(&("set_ticket_tags", "", &(&selector, &request), &identity)).unwrap();
|
||||
conn.execute("INSERT INTO workspace_tag_operations VALUES(?1,?2,'set_ticket_tags',?3,?4,?5,'[]',?6,'original-time')",
|
||||
params![workspace, request.operation_key, id, input, serde_json::to_string(&source).unwrap(), serde_json::to_string(&receipt).unwrap()]).unwrap();
|
||||
let active: Vec<_> = receipt
|
||||
.iter()
|
||||
.filter(|tag| !tag.id.ends_with("-old"))
|
||||
.cloned()
|
||||
.collect();
|
||||
let old = receipt.iter().find(|tag| tag.id.ends_with("-old")).unwrap();
|
||||
let mut deleted = old.clone();
|
||||
deleted.deleted = true;
|
||||
deleted.last_operation_key = format!("{id}-delete");
|
||||
let delete_request = ticket::WorkspaceTagMutation {
|
||||
operation_key: deleted.last_operation_key.clone(),
|
||||
action: ticket::WorkspaceTagAction::Delete {
|
||||
tag_id: old.id.clone(),
|
||||
expected_last_operation_key: old.last_operation_key.clone(),
|
||||
},
|
||||
};
|
||||
let input =
|
||||
serde_json::to_string(&("mutate_workspace_tag", "", &delete_request, &identity)).unwrap();
|
||||
conn.execute("INSERT INTO workspace_tag_operations VALUES(?1,?2,'delete',?3,?4,?5,?6,?7,'original-time')",
|
||||
params![workspace, delete_request.operation_key, old.id, input, serde_json::to_string(&source).unwrap(), serde_json::to_string(old).unwrap(), serde_json::to_string(&deleted).unwrap()]).unwrap();
|
||||
for tag in &active {
|
||||
conn.execute(
|
||||
"INSERT INTO typed_ticket_tags VALUES(?1,?2,?3)",
|
||||
params![workspace, id, tag.id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO typed_ticket_labels VALUES(?1,?2,0,'ready')",
|
||||
params![workspace, id],
|
||||
)
|
||||
.unwrap();
|
||||
let next: i64 = conn.query_row("SELECT COALESCE(MAX(event_index)+1,0) FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",params![workspace,id],|row|row.get(0)).unwrap();
|
||||
for (offset, key, before, after) in [
|
||||
(
|
||||
0,
|
||||
request.operation_key.as_str(),
|
||||
Vec::new(),
|
||||
receipt.clone(),
|
||||
),
|
||||
(
|
||||
1,
|
||||
delete_request.operation_key.as_str(),
|
||||
receipt.clone(),
|
||||
active.clone(),
|
||||
),
|
||||
] {
|
||||
let body =
|
||||
serde_json::json!({"operation_key":key,"before":before,"after":after}).to_string();
|
||||
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,body) VALUES(?1,?2,?3,'tags_changed','classifier','original-time',?4)",params![workspace,id,next+offset,body]).unwrap();
|
||||
for (key, value) in &source {
|
||||
conn.execute(
|
||||
"INSERT INTO typed_ticket_event_attributes VALUES(?1,?2,?3,?4,?5)",
|
||||
params![workspace, id, next + offset, key, value],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
let deleted_id = old.id.clone();
|
||||
RetainedClassification {
|
||||
source,
|
||||
request,
|
||||
receipt,
|
||||
active_ids: active.iter().map(|tag| tag.id.clone()).collect(),
|
||||
deleted_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn classification_rows(conn: &Connection) -> Vec<Vec<Vec<rusqlite::types::Value>>> {
|
||||
[
|
||||
"workspace_tags",
|
||||
"typed_ticket_tags",
|
||||
"workspace_tag_operations",
|
||||
"typed_ticket_events",
|
||||
"typed_ticket_event_attributes",
|
||||
"typed_ticket_labels",
|
||||
]
|
||||
.map(|table| stored_fixture_rows(conn, table))
|
||||
.to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace90_ticket7_and_tagged8_upgrade_to_cases10_preserving_history_and_auth() {
|
||||
for component in [7, 8] {
|
||||
assert_retained_classification_upgrade(90, component);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace89_auth_then_cases_preserve_tags_receipts_source_context_and_runtime_archives() {
|
||||
assert_retained_classification_upgrade(89, 8);
|
||||
}
|
||||
|
||||
fn assert_retained_classification_upgrade(workspace_version: i64, component: i64) {
|
||||
use ticket::TicketBackend;
|
||||
let (_dir, path) = legacy_case_workspace(workspace_version);
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
// Real six-state parent rows: neither a fresh open/closed schema nor API close.
|
||||
conn.execute_batch("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit,resolution,created_at,updated_at)
|
||||
VALUES('case-workspace','closed-legacy','closed-legacy','Retained decision','closed','task','P2','Original requirements','done',1,'Original closure text','1','2');
|
||||
INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,reason,state_field,body)
|
||||
VALUES('case-workspace','closed-legacy',0,'state_changed','original-author','1','inprogress','done','Original decision','state','Original thread');
|
||||
INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at)
|
||||
VALUES('case-workspace','case-runtime','recipient','Recipient','normal','1','1');
|
||||
INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,runtime_id,worker_id,assigned_by,assigned_at)
|
||||
VALUES('case-workspace','closed-legacy','assignment','worker','worker','case-runtime','recipient','original-actor','1');
|
||||
INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,runtime_id,worker_id,updated_at)
|
||||
VALUES('case-workspace','closed-legacy','assignment','worker','worker','case-runtime','recipient','1');").unwrap();
|
||||
ticket::verify_pre_classification_ticket_schema(&conn).unwrap();
|
||||
let retained = if component == 8 {
|
||||
install_retained_ticket_tags8(&conn);
|
||||
Some(seed_retained_classification(
|
||||
&conn,
|
||||
"case-workspace",
|
||||
"closed-legacy",
|
||||
))
|
||||
} else {
|
||||
conn.execute(
|
||||
"INSERT INTO typed_ticket_labels VALUES('case-workspace','closed-legacy',0,'ready')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
None
|
||||
};
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT MAX(version) FROM ticket_schema_migrations",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
component
|
||||
);
|
||||
assert!(!table_exists(&conn, "typed_ticket_case_history").unwrap());
|
||||
let preserved_tables = [
|
||||
"typed_ticket_events",
|
||||
"typed_ticket_event_attributes",
|
||||
"typed_ticket_labels",
|
||||
"ticket_current_worker_assignments",
|
||||
"ticket_worker_assignments",
|
||||
"workspace_signing_identities",
|
||||
"workspace_signing_identity_audit",
|
||||
"workspace_signing_identity_provisioning_operations",
|
||||
"workspace_runtime_binding_audit",
|
||||
];
|
||||
let preserved = preserved_tables.map(|table| stored_fixture_rows(&conn, table));
|
||||
let tags_before = retained.as_ref().map(|_| classification_rows(&conn));
|
||||
let auth_before = fixture_auth_rows(&conn);
|
||||
drop(conn);
|
||||
let store = SqliteWorkspaceStore::open(&path).unwrap();
|
||||
store.with_conn(|conn| {
|
||||
assert_eq!(current_schema_version(conn)?,91);
|
||||
assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations",[],|row|row.get::<_,i64>(0))?,10);
|
||||
assert_eq!(fixture_auth_rows(conn),auth_before);
|
||||
for (table, rows) in preserved_tables.iter().zip(&preserved) { assert_eq!(&stored_fixture_rows(conn,table),rows,"preserve {table}"); }
|
||||
if let Some(rows)=&tags_before {assert_eq!(&classification_rows(conn),rows);}
|
||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_assignment_work_releases WHERE assignment_id='assignment'",[],|row|row.get::<_,i64>(0))?,1);
|
||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check",[],|row|row.get::<_,i64>(0))?,0);
|
||||
assert!(!column_exists(conn,"workspace_runtime_bindings","state")?);
|
||||
ticket::verify_sqlite_ticket_schema(conn).unwrap();
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
let backend = ticket::SqliteTicketBackend::open_verified(&path, "case-workspace").unwrap();
|
||||
let before = backend.show("closed-legacy".into()).unwrap();
|
||||
assert_eq!(before.meta.workflow_state, ticket::TicketState::Closed);
|
||||
assert_eq!(
|
||||
before.current_closure.as_ref().unwrap().reason,
|
||||
ticket::TicketClosureReason::Unknown
|
||||
);
|
||||
assert_eq!(before.case_history.len(), 1);
|
||||
assert_eq!(
|
||||
before
|
||||
.legacy_workflow_snapshot
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.resolution
|
||||
.as_deref(),
|
||||
Some("Original closure text")
|
||||
);
|
||||
if let Some(retained) = retained {
|
||||
let mut changed_context = retained.source.clone();
|
||||
changed_context.insert("source_assignment_id".into(), "new-assignment".into());
|
||||
changed_context.insert("assigned_worker_id".into(), "new-recipient".into());
|
||||
let actor = backend
|
||||
.clone()
|
||||
.with_event_attributes(changed_context.clone());
|
||||
assert_eq!(
|
||||
actor
|
||||
.set_ticket_tags("closed-legacy".into(), retained.request.clone())
|
||||
.unwrap(),
|
||||
retained.receipt
|
||||
);
|
||||
changed_context.insert("source_worker_id".into(), "other-classifier".into());
|
||||
assert!(
|
||||
backend
|
||||
.clone()
|
||||
.with_event_attributes(changed_context)
|
||||
.set_ticket_tags("closed-legacy".into(), retained.request)
|
||||
.is_err()
|
||||
);
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
assert_eq!(classification_rows(conn), tags_before.clone().unwrap());
|
||||
assert!(
|
||||
conn.execute(
|
||||
"INSERT INTO typed_ticket_tags VALUES('other','missing',?1)",
|
||||
[&retained.active_ids[0]]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
backend
|
||||
.set_ticket_tags(
|
||||
"closed-legacy".into(),
|
||||
ticket::TicketTagSetRequest {
|
||||
operation_key: "deleted-reference".into(),
|
||||
expected_tag_ids: retained.active_ids.clone(),
|
||||
tag_ids: vec![retained.deleted_id]
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
// Arbitrary lifecycle-looking tag names cannot alter a reasoned closure,
|
||||
// digest, assignment or prior immutable case history.
|
||||
let reasoned = update_test_case(
|
||||
&backend,
|
||||
"closed-legacy",
|
||||
"correct-reason",
|
||||
ticket::TicketCaseAction::CorrectClosure {
|
||||
reason: ticket::TicketClosureReason::NotPlanned,
|
||||
},
|
||||
);
|
||||
let tag = backend
|
||||
.mutate_workspace_tag(ticket::WorkspaceTagMutation {
|
||||
operation_key: "new-ready".into(),
|
||||
action: ticket::WorkspaceTagAction::Create {
|
||||
name: "ready after upgrade".into(),
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
backend
|
||||
.set_ticket_tags(
|
||||
"closed-legacy".into(),
|
||||
ticket::TicketTagSetRequest {
|
||||
operation_key: "new-attach".into(),
|
||||
expected_tag_ids: reasoned.tags.iter().map(|tag| tag.id.clone()).collect(),
|
||||
tag_ids: vec![tag.id],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let classified = backend.show("closed-legacy".into()).unwrap();
|
||||
assert_eq!(classified.meta, reasoned.meta);
|
||||
assert_eq!(classified.current_closure, reasoned.current_closure);
|
||||
assert_eq!(classified.case_history, reasoned.case_history);
|
||||
assert_eq!(
|
||||
ticket::ticket_content_digest(&classified),
|
||||
ticket::ticket_content_digest(&reasoned)
|
||||
);
|
||||
let tag_history: Vec<_> = classified
|
||||
.events
|
||||
.iter()
|
||||
.filter(|event| event.kind == ticket::TicketEventKind::TagsChanged)
|
||||
.cloned()
|
||||
.collect();
|
||||
let reopened = update_test_case(
|
||||
&backend,
|
||||
"closed-legacy",
|
||||
"reopen",
|
||||
ticket::TicketCaseAction::Reopen,
|
||||
);
|
||||
assert_eq!(reopened.tags, classified.tags);
|
||||
assert_eq!(
|
||||
reopened.case_history[..classified.case_history.len()],
|
||||
classified.case_history
|
||||
);
|
||||
assert_eq!(
|
||||
reopened
|
||||
.events
|
||||
.iter()
|
||||
.filter(|event| event.kind == ticket::TicketEventKind::TagsChanged)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>(),
|
||||
tag_history
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get_active_ticket_worker_assignment("case-workspace", "closed-legacy")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
// Frozen cases-only component9 DDL from a558, not a stripped current DB.
|
||||
const RETAINED_CASES9_SQL: &str = r#"
|
||||
-- Ticket case authority. Legacy workflow data is a handoff snapshot, never current authority.
|
||||
CREATE TABLE typed_ticket_case_metadata (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, closure_operation_key TEXT,
|
||||
PRIMARY KEY (workspace_id, ticket_id),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_case_history (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL,
|
||||
operation_key TEXT NOT NULL, request_fingerprint TEXT NOT NULL, judgment_json TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, operation_key),
|
||||
UNIQUE (workspace_id, ticket_id, ordinal),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE typed_ticket_legacy_workflow_snapshot (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, snapshot_json TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id),
|
||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TRIGGER typed_ticket_case_history_no_update BEFORE UPDATE ON typed_ticket_case_history
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_case_history_no_delete BEFORE DELETE ON typed_ticket_case_history
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_legacy_workflow_no_update BEFORE UPDATE ON typed_ticket_legacy_workflow_snapshot
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_legacy_workflow_no_delete BEFORE DELETE ON typed_ticket_legacy_workflow_snapshot
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END;
|
||||
CREATE TRIGGER typed_ticket_case_state_insert BEFORE INSERT ON typed_tickets
|
||||
WHEN NEW.workflow_state NOT IN ('open', 'closed')
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END;
|
||||
CREATE TRIGGER typed_ticket_case_state_update BEFORE UPDATE OF workflow_state ON typed_tickets
|
||||
WHEN NEW.workflow_state NOT IN ('open', 'closed')
|
||||
BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END;
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn workspace90_cases9_add_classification_without_reinterpreting_reasoned_history() {
|
||||
use ticket::TicketBackend;
|
||||
let (_dir, path) = legacy_case_workspace(90);
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
// Build a genuine cases-only boundary from retained7 and frozen9 DDL.
|
||||
// No latest schema is relabelled and no six-state row is called current.
|
||||
conn.execute_batch(RETAINED_CASES9_SQL).unwrap();
|
||||
conn.execute_batch("UPDATE ticket_schema_migrations SET version=9;
|
||||
INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit)
|
||||
VALUES('case-workspace','reasoned','reasoned','Existing judgment','closed','task','P2','Original requirements','closed',1);
|
||||
INSERT INTO typed_ticket_case_metadata VALUES('case-workspace','reasoned','original-closure');").unwrap();
|
||||
let judgment = ticket::TicketCaseHistoryEntry {
|
||||
operation_key: "original-closure".into(),
|
||||
previous_closure_operation_key: None,
|
||||
action: ticket::TicketCaseAction::Close {
|
||||
reason: ticket::TicketClosureReason::Completed,
|
||||
},
|
||||
explanation: "Original reasoned conclusion".into(),
|
||||
references: vec![ticket::TicketReference {
|
||||
kind: "commit".into(),
|
||||
target: "original-source".into(),
|
||||
}],
|
||||
author: "original-author".into(),
|
||||
at: "original-time".into(),
|
||||
};
|
||||
conn.execute("INSERT INTO typed_ticket_case_history VALUES('case-workspace','reasoned',0,'original-closure','original-fingerprint',?1)",
|
||||
[serde_json::to_string(&judgment).unwrap()]).unwrap();
|
||||
assert!(!table_exists(&conn, "workspace_tags").unwrap());
|
||||
let history_before = stored_fixture_rows(&conn, "typed_ticket_case_history");
|
||||
let metadata_before = stored_fixture_rows(&conn, "typed_ticket_case_metadata");
|
||||
let auth_before = fixture_auth_rows(&conn);
|
||||
drop(conn);
|
||||
let store = SqliteWorkspaceStore::open(&path).unwrap();
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
assert_eq!(current_schema_version(conn)?, 91);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT MAX(version) FROM ticket_schema_migrations",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0)
|
||||
)?,
|
||||
10
|
||||
);
|
||||
assert_eq!(
|
||||
stored_fixture_rows(conn, "typed_ticket_case_history"),
|
||||
history_before
|
||||
);
|
||||
assert_eq!(
|
||||
stored_fixture_rows(conn, "typed_ticket_case_metadata"),
|
||||
metadata_before
|
||||
);
|
||||
assert_eq!(fixture_auth_rows(conn), auth_before);
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})?,
|
||||
0
|
||||
);
|
||||
ticket::verify_sqlite_ticket_schema(conn).unwrap();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let backend = ticket::SqliteTicketBackend::open_verified(&path, "case-workspace").unwrap();
|
||||
let before = backend.show("reasoned".into()).unwrap();
|
||||
assert_eq!(before.case_history, vec![judgment]);
|
||||
assert_eq!(
|
||||
before.current_closure.as_ref().unwrap().reason,
|
||||
ticket::TicketClosureReason::Completed
|
||||
);
|
||||
for name in ["ready", "closed"] {
|
||||
let tag = backend
|
||||
.mutate_workspace_tag(ticket::WorkspaceTagMutation {
|
||||
operation_key: format!("create-{name}"),
|
||||
action: ticket::WorkspaceTagAction::Create { name: name.into() },
|
||||
})
|
||||
.unwrap();
|
||||
let current = backend.show("reasoned".into()).unwrap();
|
||||
let mut desired: Vec<_> = current.tags.iter().map(|tag| tag.id.clone()).collect();
|
||||
desired.push(tag.id);
|
||||
backend
|
||||
.set_ticket_tags(
|
||||
"reasoned".into(),
|
||||
ticket::TicketTagSetRequest {
|
||||
operation_key: format!("attach-{name}"),
|
||||
expected_tag_ids: current.tags.iter().map(|tag| tag.id.clone()).collect(),
|
||||
tag_ids: desired,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let after = backend.show("reasoned".into()).unwrap();
|
||||
assert_eq!(after.meta, before.meta);
|
||||
assert_eq!(after.current_closure, before.current_closure);
|
||||
assert_eq!(after.case_history, before.case_history);
|
||||
assert_eq!(
|
||||
ticket::ticket_content_digest(&after),
|
||||
ticket::ticket_content_digest(&before)
|
||||
);
|
||||
}
|
||||
@@ -65,6 +65,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
||||
"typed_ticket_relations",
|
||||
"typed_ticket_risk_flags",
|
||||
"typed_ticket_targets",
|
||||
"typed_ticket_tags",
|
||||
"typed_tickets",
|
||||
"workdir_create_credential_candidates",
|
||||
"workdir_create_credential_retentions",
|
||||
@@ -96,6 +97,8 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
||||
"workspace_config_tree_history",
|
||||
"workspace_config_trees",
|
||||
"workspace_create_operations",
|
||||
"workspace_tag_operations",
|
||||
"workspace_tags",
|
||||
"workspace_memory_documents",
|
||||
"workspace_memory_settings",
|
||||
"workspace_resource_key_counters",
|
||||
|
||||
Reference in New Issue
Block a user