diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index f54e72ff..d7926c8c 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -15645,6 +15645,38 @@ mod tests { } } + #[tokio::test] + async fn role_assignment_endpoint_replays_same_operation_result() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let ticket = browser_ticket_backend(&api) + .unwrap() + .create(ticket::NewTicket::new("Idempotent assignment")) + .unwrap(); + let request = || SetTicketRoleAssignmentRequest { + operation_id: "same-role-operation".to_string(), + principal: TicketAssignmentPrincipal::WorkspaceAgent { + agent_key: "workspace-orchestrator".to_string(), + }, + expected_assignment_id: None, + }; + let path = || { + AxumPath(( + TEST_WORKSPACE_ID.to_string(), + ticket.id.clone(), + "orchestrator".to_string(), + )) + }; + + let Json(first) = scoped_set_ticket_assignment(State(api.clone()), path(), Json(request())) + .await + .unwrap(); + let Json(replay) = scoped_set_ticket_assignment(State(api), path(), Json(request())) + .await + .unwrap(); + assert_eq!(replay.assignment, first.assignment); + } + #[tokio::test] async fn ticket_assignment_endpoints_read_and_clear_current_assignment() { let dir = tempfile::tempdir().unwrap(); @@ -15765,6 +15797,21 @@ mod tests { .await .unwrap(); assert_eq!(cleared.assignment, None); + let Json(replayed_clear) = scoped_clear_ticket_assignment( + State(api), + AxumPath(( + TEST_WORKSPACE_ID.to_string(), + ticket_id, + "coder".to_string(), + )), + Query(ClearTicketRoleAssignmentQuery { + operation_id: Some("clear-current".to_string()), + assignment_id: Some("assignment-api-1".to_string()), + }), + ) + .await + .unwrap(); + assert_eq!(replayed_clear.assignment, None); } #[tokio::test] diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index f34856ac..5ac00953 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -3618,11 +3618,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore { for value in [ record.workspace_id.as_str(), record.ticket_id.as_str(), - record.assignment_id.as_str(), record.role.as_str(), principal_json.as_str(), record.assigned_by.as_str(), - record.assigned_at.as_str(), expected_assignment_id.unwrap_or(""), if allow_reassign { "reassign" } else { "assign" }, ] { @@ -3857,10 +3855,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore { "ticket-role-assignment:manual-start:v1", record.workspace_id.as_str(), record.ticket_id.as_str(), - record.assignment_id.as_str(), principal_json.as_str(), record.assigned_by.as_str(), - record.assigned_at.as_str(), ] { hasher.update(value.as_bytes()); hasher.update([0]); @@ -4069,7 +4065,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore { assignment_id, principal_json.as_str(), actor, - occurred_at, reason.unwrap_or(""), ] { hasher.update(value.as_bytes()); @@ -4124,6 +4119,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore { "operation `{operation_id}` was already used for different Ticket assignment input" ))); } + tx.commit()?; + return Ok(true); } let deleted = tx.execute( "DELETE FROM ticket_current_worker_assignments @@ -10645,6 +10642,19 @@ INSERT INTO worker_registry ( .start_ready_ticket_with_coder_assignment(&coder, "event-coder", "op-coder") .unwrap(); assert_eq!(started, coder); + let replay_input = TicketRoleAssignmentRecord { + assignment_id: "coder-regenerated-result".to_string(), + assigned_at: "2026-09-01T00:01:59Z".to_string(), + ..coder.clone() + }; + let replayed = store + .start_ready_ticket_with_coder_assignment( + &replay_input, + "event-coder-regenerated", + "op-coder", + ) + .unwrap(); + assert_eq!(replayed, coder); let ticket = ticket::TicketBackend::show(&backend, ticket.id.clone().into()).unwrap(); assert_eq!( ticket.meta.workflow_state,