feat(ticket): persist Orchestrator assignment tracking and audit

This commit is contained in:
2026-10-12 02:54:45 +09:00
parent 7f03db9d79
commit 2a55bef036
35 changed files with 7706 additions and 492 deletions
+125
View File
@@ -347,6 +347,12 @@ impl_openapi_schema!(
TicketDependencyCheckResponse,
TicketDetail,
TicketCaseUpdateRequest,
TicketAssignmentTrackingUpdateRequest,
TicketAssignmentTrackingRecord,
TicketAssignmentTrackingList,
TicketAssignmentTrackingQuery,
TicketAssignmentTrackingHistoryQuery,
TicketAssignmentTrackingHistoryPage,
TicketSummary,
TicketDoctorResponse,
TicketIntakeSummaryRequest,
@@ -3190,6 +3196,30 @@ pub trait ServerApi {
#[path] ticket_id: String,
#[body] request: TicketCaseUpdateRequest,
) -> Result<TicketSummary, RepositoryApiError>;
#[get("/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_assignment_tracking_get(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] ticket_id: String,
#[query] query: TicketAssignmentTrackingQuery,
) -> Result<TicketAssignmentTrackingList, RepositoryApiError>;
#[post("/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_assignment_tracking_update(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] ticket_id: String,
#[body] request: TicketAssignmentTrackingUpdateRequest,
) -> Result<TicketAssignmentTrackingRecord, RepositoryApiError>;
#[get("/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/history", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_assignment_tracking_history(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] ticket_id: String,
#[query] query: TicketAssignmentTrackingHistoryQuery,
) -> Result<TicketAssignmentTrackingHistoryPage, RepositoryApiError>;
#[get("/api/w/{workspace_id}/tickets/{id}/relation-view", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_relation_view(
&self,
@@ -3254,6 +3284,7 @@ pub trait ServerApi {
#[put("/api/w/{workspace_id}/tickets/{id}/assignments/{role}", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_assignment_set(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[path] role: String,
@@ -3263,6 +3294,7 @@ pub trait ServerApi {
#[delete("/api/w/{workspace_id}/tickets/{id}/assignments/{role}", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_assignment_clear(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[path] role: String,
@@ -3571,6 +3603,9 @@ pub struct ServerWorkerSource {
pub struct ServerRuntimeSource {
pub runtime_id: String,
pub worker_id: Option<String>,
/// Binding whose public key authenticated this request. Retain the verified
/// identity through mutation CAS/replay checks; a later lookup is not proof.
pub authentication_binding_id: String,
}
#[derive(Clone)]
@@ -7631,6 +7666,11 @@ pub struct TicketQueryItem {
#[cfg_attr(feature = "typescript", ts(type = "number"))]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
pub unresolved_review_count: usize,
#[cfg_attr(
feature = "typescript",
ts(type = "Array<TicketAssignmentTrackingProjection>")
)]
pub assignment_tracking: Vec<ticket::TicketAssignmentTrackingProjection>,
pub evidence: TicketEvidenceSummary,
pub merge_requests: Vec<TicketMergeRequestSummary>,
pub merge_request: Option<TicketMergeRequestSummary>,
@@ -7700,6 +7740,11 @@ pub struct TicketDetail {
pub linked_objectives: Vec<ObjectiveLinkSummary>,
pub implementation_reports: Vec<TicketEvidenceEvent>,
pub assignments: Vec<TicketRoleAssignmentSummary>,
#[cfg_attr(
feature = "typescript",
ts(type = "Array<TicketAssignmentTrackingProjection>")
)]
pub assignment_tracking: Vec<ticket::TicketAssignmentTrackingProjection>,
pub current_worker: Option<TicketAssignmentSummary>,
pub assignment_diagnostics: Vec<String>,
pub action_eligibility: TicketActionEligibility,
@@ -7777,6 +7822,42 @@ pub struct TicketCaseUpdateRequest {
#[serde(default)]
pub references: Vec<ticket::TicketReference>,
}
transparent_ticket_dto!(
TicketAssignmentTrackingUpdateRequest,
ticket::TicketAssignmentTrackingUpdate
);
transparent_ticket_dto!(
TicketAssignmentTrackingRecord,
ticket::TicketAssignmentTrackingProjection
);
transparent_ticket_dto!(
TicketAssignmentTrackingList,
Vec<ticket::TicketAssignmentTrackingProjection>
);
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TicketAssignmentTrackingQuery {
/// Omit for the current delegation; provide to inspect a retained assignment.
pub assignment_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TicketAssignmentTrackingHistoryQuery {
pub assignment_id: String,
pub after_event_id: Option<String>,
#[schemars(range(min = 1, max = 100))]
pub limit: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TicketAssignmentTrackingHistoryPage {
pub items: Vec<ticket::TicketAssignmentTrackingHistoryEntry>,
pub next_event_id: Option<String>,
}
transparent_ticket_dto!(WorkspaceTicketTagListRequest, ticket::TagListRequest);
transparent_ticket_dto!(
WorkspaceTicketTagMutationRequest,
@@ -13022,6 +13103,50 @@ mod tests {
}
}
#[test]
fn ticket_assignment_tracking_wire_has_explicit_event_cas_without_actor_claims() {
for status in ["waiting", "inprogress", "done"] {
let input = serde_json::json!({"assignment_id":"delegation", "expected_last_event_id":"observed-event", "operation_key":"handling", "tracking":{"status":status, "waiting_reason":null, "progress":"Investigated", "next_action":null, "result":null}});
let request: TicketAssignmentTrackingUpdateRequest =
serde_json::from_value(input.clone()).unwrap();
assert_eq!(serde_json::to_value(request).unwrap(), input);
for field in [
"assignment_id",
"expected_last_event_id",
"operation_key",
"tracking",
] {
let mut missing = input.clone();
missing.as_object_mut().unwrap().remove(field);
assert!(
serde_json::from_value::<TicketAssignmentTrackingUpdateRequest>(missing)
.is_err(),
"{field}"
);
}
for field in [
"author",
"role",
"source_context",
"runtime_id",
"worker_id",
] {
let mut invalid = input.clone();
invalid[field] = serde_json::json!("orchestrator");
assert!(
serde_json::from_value::<TicketAssignmentTrackingUpdateRequest>(invalid)
.is_err(),
"{field}"
);
}
let mut invalid = input.clone();
invalid["tracking"]["status"] = serde_json::json!("running");
assert!(
serde_json::from_value::<TicketAssignmentTrackingUpdateRequest>(invalid).is_err()
);
}
}
#[test]
fn ticket_query_accepts_only_case_states_and_current_attention_filters() {
let parsed: TicketQueryRequest = serde_json::from_value(serde_json::json!({
@@ -500,6 +500,27 @@ fn validate_ticket_browser_operations(
request: Some("TicketCaseUpdateRequest"),
successes: &[("200", "TicketSummary")],
},
OperationContract {
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking",
method: "get",
operation_id: "ticket_assignment_tracking_get",
request: None,
successes: &[("200", "Array_of_TicketAssignmentTrackingProjection")],
},
OperationContract {
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking",
method: "post",
operation_id: "ticket_assignment_tracking_update",
request: Some("TicketAssignmentTrackingUpdate"),
successes: &[("200", "TicketAssignmentTrackingProjection")],
},
OperationContract {
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/history",
method: "get",
operation_id: "ticket_assignment_tracking_history",
request: None,
successes: &[("200", "TicketAssignmentTrackingHistoryPage")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{id}/ready",
method: "post",