feat: allow ticket implementation cancellation
This commit is contained in:
@@ -1827,6 +1827,10 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
|||||||
"/api/w/{workspace_id}/tickets/{id}/assignments/{role}",
|
"/api/w/{workspace_id}/tickets/{id}/assignments/{role}",
|
||||||
put(scoped_set_ticket_assignment).delete(scoped_clear_ticket_assignment),
|
put(scoped_set_ticket_assignment).delete(scoped_clear_ticket_assignment),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/w/{workspace_id}/tickets/{id}/implementation-cancellations",
|
||||||
|
post(scoped_cancel_ticket_implementation),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/tickets/{id}/state",
|
"/api/w/{workspace_id}/tickets/{id}/state",
|
||||||
post(scoped_transition_ticket_state),
|
post(scoped_transition_ticket_state),
|
||||||
@@ -3176,6 +3180,14 @@ struct SetTicketRoleAssignmentRequest {
|
|||||||
expected_assignment_id: Option<String>,
|
expected_assignment_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct CancelTicketImplementationRequest {
|
||||||
|
operation_id: String,
|
||||||
|
assignment_id: String,
|
||||||
|
reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
struct ClearTicketRoleAssignmentQuery {
|
struct ClearTicketRoleAssignmentQuery {
|
||||||
@@ -3373,6 +3385,113 @@ async fn scoped_clear_ticket_assignment(
|
|||||||
assignment: None,
|
assignment: None,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn scoped_cancel_ticket_implementation(
|
||||||
|
State(api): State<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedRecordPath>,
|
||||||
|
Json(request): Json<CancelTicketImplementationRequest>,
|
||||||
|
) -> ApiResult<Json<TicketDetail>> {
|
||||||
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
|
let ticket = api.authority.ticket(&path.id)?;
|
||||||
|
let operation_id = require_ticket_assignment_value("operation_id", request.operation_id)?;
|
||||||
|
let assignment_id = require_ticket_assignment_value("assignment_id", request.assignment_id)?;
|
||||||
|
let reason = require_ticket_assignment_value("reason", request.reason)?;
|
||||||
|
if reason.len() > 512 {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"implementation cancellation reason must be at most 512 bytes".to_string(),
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matches!(
|
||||||
|
ticket.state.as_str(),
|
||||||
|
state if state == TicketWorkflowState::InProgress.as_str()
|
||||||
|
|| state == TicketWorkflowState::Ready.as_str()
|
||||||
|
) {
|
||||||
|
return Err(Error::TicketAssignmentConflict(format!(
|
||||||
|
"implementation cancellation requires an inprogress Ticket; current state is {}",
|
||||||
|
ticket.state
|
||||||
|
))
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = api.store.get_current_ticket_role_assignment(
|
||||||
|
&path.workspace_id,
|
||||||
|
&ticket.id,
|
||||||
|
TicketAssignmentRole::Coder,
|
||||||
|
)?;
|
||||||
|
if let Some(assignment) = current.filter(|value| value.assignment_id == assignment_id)
|
||||||
|
&& let TicketAssignmentPrincipal::Worker {
|
||||||
|
runtime_id,
|
||||||
|
worker_id,
|
||||||
|
} = assignment.principal
|
||||||
|
{
|
||||||
|
if api
|
||||||
|
.store
|
||||||
|
.get_ticket_assignment_operation(&path.workspace_id, &operation_id)?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(Error::TicketAssignmentConflict(format!(
|
||||||
|
"operation `{operation_id}` was already used for another Ticket assignment mutation"
|
||||||
|
))
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let worker = RuntimeWorkerRef::new(runtime_id, worker_id);
|
||||||
|
cancel_ticket_coder_worker(&api, &worker, &reason).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = api.store.cancel_current_ticket_coder_assignment(
|
||||||
|
&path.workspace_id,
|
||||||
|
&ticket.id,
|
||||||
|
&assignment_id,
|
||||||
|
&new_id("tasev"),
|
||||||
|
&new_id("tev"),
|
||||||
|
&operation_id,
|
||||||
|
"workspace-web",
|
||||||
|
&Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||||
|
&reason,
|
||||||
|
)?;
|
||||||
|
if !cancelled {
|
||||||
|
return Err(Error::TicketAssignmentConflict(format!(
|
||||||
|
"assignment `{assignment_id}` is not the current Coder implementation"
|
||||||
|
))
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
browser_ticket_detail(&api, &ticket.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cancel_ticket_coder_worker(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
worker: &RuntimeWorkerRef,
|
||||||
|
reason: &str,
|
||||||
|
) -> ApiResult<()> {
|
||||||
|
let session_lock = current_worker_session_lock(api, worker);
|
||||||
|
let _session_guard = session_lock.lock().await;
|
||||||
|
match api.runtime.cancel_worker(
|
||||||
|
worker,
|
||||||
|
WorkerLifecycleRequest {
|
||||||
|
reason: Some(format!("Ticket implementation cancelled: {reason}")),
|
||||||
|
ticket_assignment: None,
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Ok(result) if result.state == WorkerOperationState::Accepted => {}
|
||||||
|
Ok(result) => {
|
||||||
|
return Err(ApiError::with_diagnostics(
|
||||||
|
Error::RuntimeOperationFailed {
|
||||||
|
runtime_id: worker.runtime_id.clone(),
|
||||||
|
code: "workspace_ticket_implementation_cancel_rejected".to_string(),
|
||||||
|
message: "Runtime did not cancel the assigned Coder Worker".to_string(),
|
||||||
|
},
|
||||||
|
result.diagnostics,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(RuntimeRegistryError::UnknownWorker { .. }) => {}
|
||||||
|
Err(error) => return Err(error.into_error().into()),
|
||||||
|
}
|
||||||
|
close_current_worker_session_locked(api, worker).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_ticket_assignment_state(
|
fn validate_ticket_assignment_state(
|
||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
assignment: &WorkerTicketAssignmentRequest,
|
assignment: &WorkerTicketAssignmentRequest,
|
||||||
@@ -16015,6 +16134,114 @@ mod tests {
|
|||||||
assert_eq!(replayed_clear.assignment, None);
|
assert_eq!(replayed_clear.assignment, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn implementation_cancellation_cancels_coder_and_returns_ticket_to_ready() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let api = test_api(dir.path()).await;
|
||||||
|
let worker = api
|
||||||
|
.runtime
|
||||||
|
.spawn_worker(
|
||||||
|
EMBEDDED_WORKER_RUNTIME_ID,
|
||||||
|
test_create_binding(),
|
||||||
|
WorkerSpawnRequest {
|
||||||
|
requested_worker_name: Some("cancelled-coder".to_string()),
|
||||||
|
intent: WorkerSpawnIntent::TicketRole {
|
||||||
|
ticket_id: "implementation-cancellation".to_string(),
|
||||||
|
role: TicketWorkerRole::Coder,
|
||||||
|
},
|
||||||
|
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
|
||||||
|
expected_segments: 0,
|
||||||
|
},
|
||||||
|
profile: ProfileSelector::Builtin("builtin:coder".to_string()),
|
||||||
|
ticket_assignment: None,
|
||||||
|
initial_submit: Vec::new(),
|
||||||
|
working_directory_request: None,
|
||||||
|
resolved_working_directory_request: None,
|
||||||
|
resolved_working_directory: None,
|
||||||
|
resolved_config_bundle: None,
|
||||||
|
resolved_worker_observation_enabled: false,
|
||||||
|
resolved_worker_observation_grants: Vec::new(),
|
||||||
|
resolved_workspace_api: Some(test_worker_workspace_api(
|
||||||
|
EMBEDDED_WORKER_RUNTIME_ID,
|
||||||
|
)),
|
||||||
|
resolved_memory_settings: Some(test_worker_memory_settings()),
|
||||||
|
resolved_control_operation: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.worker
|
||||||
|
.unwrap()
|
||||||
|
.worker;
|
||||||
|
let worker = RuntimeWorkerRef::new(EMBEDDED_WORKER_RUNTIME_ID, worker.worker_id);
|
||||||
|
api.store
|
||||||
|
.upsert_worker_registry(&WorkerRegistryRecord {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
|
worker: worker.clone(),
|
||||||
|
display_name: "Cancelled Coder".to_string(),
|
||||||
|
profile: Some("builtin:coder".to_string()),
|
||||||
|
retention_state: "normal".to_string(),
|
||||||
|
transcript_ref: None,
|
||||||
|
session_ref: None,
|
||||||
|
summary_ref: None,
|
||||||
|
diagnostics_ref: None,
|
||||||
|
created_at: TEST_CREATED_AT.to_string(),
|
||||||
|
updated_at: TEST_CREATED_AT.to_string(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let backend = browser_ticket_backend(&api).unwrap();
|
||||||
|
let mut input = ticket::NewTicket::new("Implementation cancellation");
|
||||||
|
input.workflow_state = Some(TicketWorkflowState::InProgress);
|
||||||
|
let ticket = backend.create(input).unwrap();
|
||||||
|
api.store
|
||||||
|
.set_current_ticket_coder_assignment(
|
||||||
|
&TicketCoderAssignmentRecord {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
|
ticket_id: ticket.id.clone(),
|
||||||
|
assignment_id: "cancelled-assignment".to_string(),
|
||||||
|
worker: worker.clone(),
|
||||||
|
assigned_by: "test-user".to_string(),
|
||||||
|
assigned_at: TEST_CREATED_AT.to_string(),
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
"cancelled-assignment-event",
|
||||||
|
"cancelled-assignment-operation",
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let path = || {
|
||||||
|
AxumPath(ScopedRecordPath {
|
||||||
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
|
id: ticket.id.clone(),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let request = || {
|
||||||
|
Json(CancelTicketImplementationRequest {
|
||||||
|
operation_id: "cancel-implementation-operation".to_string(),
|
||||||
|
assignment_id: "cancelled-assignment".to_string(),
|
||||||
|
reason: "redo with the corrected design".to_string(),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let Json(cancelled) =
|
||||||
|
scoped_cancel_ticket_implementation(State(api.clone()), path(), request())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cancelled.state, TicketWorkflowState::Ready.as_str());
|
||||||
|
assert!(cancelled.current_coder.is_none());
|
||||||
|
assert!(
|
||||||
|
!cancelled
|
||||||
|
.assignments
|
||||||
|
.iter()
|
||||||
|
.any(|assignment| assignment.role == "coder")
|
||||||
|
);
|
||||||
|
assert_eq!(api.runtime.worker(&worker).unwrap().state, "cancelled");
|
||||||
|
|
||||||
|
let Json(replayed) = scoped_cancel_ticket_implementation(State(api), path(), request())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(replayed.state, TicketWorkflowState::Ready.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn authenticated_worker_ticket_mutation_notifies_current_assignment() {
|
async fn authenticated_worker_ticket_mutation_notifies_current_assignment() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -1086,6 +1086,18 @@ pub trait ControlPlaneStore: Send + Sync {
|
|||||||
occurred_at: &str,
|
occurred_at: &str,
|
||||||
reason: Option<&str>,
|
reason: Option<&str>,
|
||||||
) -> Result<bool>;
|
) -> Result<bool>;
|
||||||
|
fn cancel_current_ticket_coder_assignment(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
ticket_id: &str,
|
||||||
|
assignment_id: &str,
|
||||||
|
assignment_event_id: &str,
|
||||||
|
state_event_id: &str,
|
||||||
|
operation_id: &str,
|
||||||
|
actor: &str,
|
||||||
|
occurred_at: &str,
|
||||||
|
reason: &str,
|
||||||
|
) -> Result<bool>;
|
||||||
fn get_current_ticket_coder_assignment(
|
fn get_current_ticket_coder_assignment(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -4084,106 +4096,110 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
self.with_conn_mut(|conn| {
|
self.with_conn_mut(|conn| {
|
||||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
let current = read_ticket_role_assignment_by_id(&tx, workspace_id, assignment_id)?;
|
let cleared = clear_current_ticket_role_assignment_in_tx(
|
||||||
let Some(current) = current.filter(|value| {
|
&tx,
|
||||||
value.ticket_id == ticket_id && value.role == role
|
|
||||||
}) else {
|
|
||||||
return Ok(false);
|
|
||||||
};
|
|
||||||
let principal_json = serde_json::to_string(¤t.principal)
|
|
||||||
.map_err(|error| Error::Store(format!("serialize Ticket assignment principal: {error}")))?;
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
for value in [
|
|
||||||
"ticket-role-assignment:clear:v1",
|
|
||||||
workspace_id,
|
workspace_id,
|
||||||
ticket_id,
|
ticket_id,
|
||||||
role.as_str(),
|
role,
|
||||||
assignment_id,
|
assignment_id,
|
||||||
principal_json.as_str(),
|
event_id,
|
||||||
actor,
|
|
||||||
reason.unwrap_or(""),
|
|
||||||
] {
|
|
||||||
hasher.update(value.as_bytes());
|
|
||||||
hasher.update([0]);
|
|
||||||
}
|
|
||||||
let fingerprint = hasher
|
|
||||||
.finalize()
|
|
||||||
.iter()
|
|
||||||
.map(|byte| format!("{byte:02x}"))
|
|
||||||
.collect::<String>();
|
|
||||||
let (principal_id, runtime_id, worker_id) = match ¤t.principal {
|
|
||||||
TicketAssignmentPrincipal::User { account_id } => {
|
|
||||||
(Some(account_id.as_str()), None, None)
|
|
||||||
}
|
|
||||||
TicketAssignmentPrincipal::Worker {
|
|
||||||
runtime_id,
|
|
||||||
worker_id,
|
|
||||||
} => (None, Some(runtime_id.as_str()), Some(worker_id.as_str())),
|
|
||||||
TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => {
|
|
||||||
(Some(agent_key.as_str()), None, None)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let inserted = tx.execute(
|
|
||||||
"INSERT OR IGNORE INTO ticket_assignment_operations (
|
|
||||||
workspace_id, operation_id, action, ticket_id, role, principal_kind,
|
|
||||||
principal_id, runtime_id, worker_id, assignment_id,
|
|
||||||
expected_assignment_id, created_at, request_fingerprint
|
|
||||||
) VALUES (?1, ?2, 'unassign', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9, ?10, ?11)",
|
|
||||||
params![
|
|
||||||
workspace_id,
|
|
||||||
operation_id,
|
operation_id,
|
||||||
ticket_id,
|
actor,
|
||||||
role.as_str(),
|
|
||||||
current.principal.kind(),
|
|
||||||
principal_id,
|
|
||||||
runtime_id,
|
|
||||||
worker_id,
|
|
||||||
assignment_id,
|
|
||||||
occurred_at,
|
occurred_at,
|
||||||
fingerprint,
|
reason,
|
||||||
],
|
"ticket-role-assignment:clear:v1",
|
||||||
)?;
|
)?;
|
||||||
if inserted == 0 {
|
tx.commit()?;
|
||||||
let persisted: String = tx.query_row(
|
Ok(cleared)
|
||||||
"SELECT request_fingerprint FROM ticket_assignment_operations
|
})
|
||||||
WHERE workspace_id = ?1 AND operation_id = ?2",
|
}
|
||||||
params![workspace_id, operation_id],
|
|
||||||
|
fn cancel_current_ticket_coder_assignment(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
ticket_id: &str,
|
||||||
|
assignment_id: &str,
|
||||||
|
assignment_event_id: &str,
|
||||||
|
state_event_id: &str,
|
||||||
|
operation_id: &str,
|
||||||
|
actor: &str,
|
||||||
|
occurred_at: &str,
|
||||||
|
reason: &str,
|
||||||
|
) -> Result<bool> {
|
||||||
|
self.with_conn_mut(|conn| {
|
||||||
|
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
|
let state: String = tx.query_row(
|
||||||
|
"SELECT workflow_state FROM typed_tickets
|
||||||
|
WHERE workspace_id = ?1 AND ticket_id = ?2",
|
||||||
|
params![workspace_id, ticket_id],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)?;
|
)?;
|
||||||
if persisted != fingerprint {
|
if !matches!(state.as_str(), "inprogress" | "ready") {
|
||||||
return Err(Error::TicketAssignmentConflict(format!(
|
return Err(Error::TicketAssignmentConflict(format!(
|
||||||
"operation `{operation_id}` was already used for different Ticket assignment input"
|
"implementation cancellation requires an inprogress Ticket; current state is `{state}`"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
tx.commit()?;
|
let cleared = clear_current_ticket_role_assignment_in_tx(
|
||||||
return Ok(true);
|
&tx,
|
||||||
}
|
|
||||||
let deleted = tx.execute(
|
|
||||||
"DELETE FROM ticket_current_worker_assignments
|
|
||||||
WHERE workspace_id = ?1 AND ticket_id = ?2 AND role = ?3 AND assignment_id = ?4",
|
|
||||||
params![workspace_id, ticket_id, role.as_str(), assignment_id],
|
|
||||||
)?;
|
|
||||||
if deleted != 0 {
|
|
||||||
tx.execute(
|
|
||||||
"INSERT INTO ticket_worker_assignment_events (
|
|
||||||
workspace_id, ticket_id, role, event_id, action, assignment_id,
|
|
||||||
previous_assignment_id, actor, created_at, operation_id, reason
|
|
||||||
) VALUES (?1, ?2, ?3, ?4, 'unassigned', NULL, ?5, ?6, ?7, ?8, ?9)",
|
|
||||||
params![
|
|
||||||
workspace_id,
|
workspace_id,
|
||||||
ticket_id,
|
ticket_id,
|
||||||
role.as_str(),
|
TicketAssignmentRole::Coder,
|
||||||
event_id,
|
|
||||||
assignment_id,
|
assignment_id,
|
||||||
|
assignment_event_id,
|
||||||
|
operation_id,
|
||||||
actor,
|
actor,
|
||||||
occurred_at,
|
occurred_at,
|
||||||
operation_id,
|
Some(reason),
|
||||||
reason,
|
"ticket-role-assignment:cancel-implementation:v1",
|
||||||
],
|
)?;
|
||||||
|
if !cleared {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
if state == "inprogress" {
|
||||||
|
let event_index: i64 = tx.query_row(
|
||||||
|
"SELECT COALESCE(MAX(event_index), -1) + 1 FROM typed_ticket_events
|
||||||
|
WHERE workspace_id = ?1 AND ticket_id = ?2",
|
||||||
|
params![workspace_id, ticket_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO typed_ticket_events (
|
||||||
|
workspace_id, ticket_id, event_index, kind, author, at,
|
||||||
|
from_state, to_state, reason, state_field, heading, body
|
||||||
|
) VALUES (?1, ?2, ?3, 'state_changed', ?4, ?5,
|
||||||
|
'inprogress', 'ready', ?6, 'state',
|
||||||
|
'Implementation cancelled', '')",
|
||||||
|
params![workspace_id, ticket_id, event_index, actor, occurred_at, reason],
|
||||||
|
)?;
|
||||||
|
for (key, value) in [
|
||||||
|
("event_id", state_event_id),
|
||||||
|
("assignment_id", assignment_id),
|
||||||
|
("assignment_role", "coder"),
|
||||||
|
("operation_id", operation_id),
|
||||||
|
] {
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO typed_ticket_event_attributes (
|
||||||
|
workspace_id, ticket_id, event_index, key, value
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||||
|
params![workspace_id, ticket_id, event_index, key, value],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
let updated = tx.execute(
|
||||||
|
"UPDATE typed_tickets SET workflow_state = 'ready',
|
||||||
|
workflow_state_explicit = 1,
|
||||||
|
queued_by = NULL, queued_at = NULL, updated_at = ?3
|
||||||
|
WHERE workspace_id = ?1 AND ticket_id = ?2
|
||||||
|
AND workflow_state = 'inprogress'",
|
||||||
|
params![workspace_id, ticket_id, occurred_at],
|
||||||
|
)?;
|
||||||
|
if updated != 1 {
|
||||||
|
return Err(Error::TicketAssignmentConflict(
|
||||||
|
"Ticket state changed during implementation cancellation".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
Ok(deleted != 0)
|
Ok(true)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5540,6 +5556,117 @@ fn validate_ticket_assignment_role_principal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn clear_current_ticket_role_assignment_in_tx(
|
||||||
|
tx: &rusqlite::Transaction<'_>,
|
||||||
|
workspace_id: &str,
|
||||||
|
ticket_id: &str,
|
||||||
|
role: TicketAssignmentRole,
|
||||||
|
assignment_id: &str,
|
||||||
|
event_id: &str,
|
||||||
|
operation_id: &str,
|
||||||
|
actor: &str,
|
||||||
|
occurred_at: &str,
|
||||||
|
reason: Option<&str>,
|
||||||
|
fingerprint_domain: &str,
|
||||||
|
) -> Result<bool> {
|
||||||
|
let current = read_ticket_role_assignment_by_id(tx, workspace_id, assignment_id)?;
|
||||||
|
let Some(current) = current.filter(|value| value.ticket_id == ticket_id && value.role == role)
|
||||||
|
else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let principal_json = serde_json::to_string(¤t.principal)
|
||||||
|
.map_err(|error| Error::Store(format!("serialize Ticket assignment principal: {error}")))?;
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
for value in [
|
||||||
|
fingerprint_domain,
|
||||||
|
workspace_id,
|
||||||
|
ticket_id,
|
||||||
|
role.as_str(),
|
||||||
|
assignment_id,
|
||||||
|
principal_json.as_str(),
|
||||||
|
actor,
|
||||||
|
reason.unwrap_or(""),
|
||||||
|
] {
|
||||||
|
hasher.update(value.as_bytes());
|
||||||
|
hasher.update([0]);
|
||||||
|
}
|
||||||
|
let fingerprint = hasher
|
||||||
|
.finalize()
|
||||||
|
.iter()
|
||||||
|
.map(|byte| format!("{byte:02x}"))
|
||||||
|
.collect::<String>();
|
||||||
|
let (principal_id, runtime_id, worker_id) = match ¤t.principal {
|
||||||
|
TicketAssignmentPrincipal::User { account_id } => (Some(account_id.as_str()), None, None),
|
||||||
|
TicketAssignmentPrincipal::Worker {
|
||||||
|
runtime_id,
|
||||||
|
worker_id,
|
||||||
|
} => (None, Some(runtime_id.as_str()), Some(worker_id.as_str())),
|
||||||
|
TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => {
|
||||||
|
(Some(agent_key.as_str()), None, None)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let inserted = tx.execute(
|
||||||
|
"INSERT OR IGNORE INTO ticket_assignment_operations (
|
||||||
|
workspace_id, operation_id, action, ticket_id, role, principal_kind,
|
||||||
|
principal_id, runtime_id, worker_id, assignment_id,
|
||||||
|
expected_assignment_id, created_at, request_fingerprint
|
||||||
|
) VALUES (?1, ?2, 'unassign', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9, ?10, ?11)",
|
||||||
|
params![
|
||||||
|
workspace_id,
|
||||||
|
operation_id,
|
||||||
|
ticket_id,
|
||||||
|
role.as_str(),
|
||||||
|
current.principal.kind(),
|
||||||
|
principal_id,
|
||||||
|
runtime_id,
|
||||||
|
worker_id,
|
||||||
|
assignment_id,
|
||||||
|
occurred_at,
|
||||||
|
fingerprint,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
if inserted == 0 {
|
||||||
|
let persisted: String = tx.query_row(
|
||||||
|
"SELECT request_fingerprint FROM ticket_assignment_operations
|
||||||
|
WHERE workspace_id = ?1 AND operation_id = ?2",
|
||||||
|
params![workspace_id, operation_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
if persisted != fingerprint {
|
||||||
|
return Err(Error::TicketAssignmentConflict(format!(
|
||||||
|
"operation `{operation_id}` was already used for different Ticket assignment input"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
let deleted = tx.execute(
|
||||||
|
"DELETE FROM ticket_current_worker_assignments
|
||||||
|
WHERE workspace_id = ?1 AND ticket_id = ?2 AND role = ?3 AND assignment_id = ?4",
|
||||||
|
params![workspace_id, ticket_id, role.as_str(), assignment_id],
|
||||||
|
)?;
|
||||||
|
if deleted != 0 {
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO ticket_worker_assignment_events (
|
||||||
|
workspace_id, ticket_id, role, event_id, action, assignment_id,
|
||||||
|
previous_assignment_id, actor, created_at, operation_id, reason
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, 'unassigned', NULL, ?5, ?6, ?7, ?8, ?9)",
|
||||||
|
params![
|
||||||
|
workspace_id,
|
||||||
|
ticket_id,
|
||||||
|
role.as_str(),
|
||||||
|
event_id,
|
||||||
|
assignment_id,
|
||||||
|
actor,
|
||||||
|
occurred_at,
|
||||||
|
operation_id,
|
||||||
|
reason,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(deleted != 0)
|
||||||
|
}
|
||||||
|
|
||||||
fn current_ticket_worker_assignment_select_sql() -> String {
|
fn current_ticket_worker_assignment_select_sql() -> String {
|
||||||
"SELECT a.workspace_id, a.ticket_id, a.assignment_id, a.runtime_id, a.worker_id, \
|
"SELECT a.workspace_id, a.ticket_id, a.assignment_id, a.runtime_id, a.worker_id, \
|
||||||
a.assigned_by, a.assigned_at \
|
a.assigned_by, a.assigned_at \
|
||||||
@@ -11052,18 +11179,58 @@ INSERT INTO worker_registry (
|
|||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
store
|
store
|
||||||
.clear_current_ticket_role_assignment(
|
.cancel_current_ticket_coder_assignment(
|
||||||
|
"workspace-role",
|
||||||
|
&ticket.meta.id,
|
||||||
|
"coder-manual-1",
|
||||||
|
"event-cancel-coder",
|
||||||
|
"event-cancel-state",
|
||||||
|
"op-cancel-coder",
|
||||||
|
"user",
|
||||||
|
"2026-09-01T00:03:00Z",
|
||||||
|
"implementation needs to be redone",
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.cancel_current_ticket_coder_assignment(
|
||||||
|
"workspace-role",
|
||||||
|
&ticket.meta.id,
|
||||||
|
"coder-manual-1",
|
||||||
|
"event-cancel-coder-replay",
|
||||||
|
"event-cancel-state-replay",
|
||||||
|
"op-cancel-coder",
|
||||||
|
"user",
|
||||||
|
"2026-09-01T00:03:30Z",
|
||||||
|
"implementation needs to be redone",
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
"same operation must be idempotent after the assignment is cleared"
|
||||||
|
);
|
||||||
|
let cancelled_ticket =
|
||||||
|
ticket::TicketBackend::show(&backend, ticket.meta.id.clone().into()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cancelled_ticket.meta.workflow_state,
|
||||||
|
ticket::TicketWorkflowState::Ready
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cancelled_ticket
|
||||||
|
.events
|
||||||
|
.last()
|
||||||
|
.and_then(|event| event.attributes.get("assignment_id"))
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("coder-manual-1")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.get_current_ticket_role_assignment(
|
||||||
"workspace-role",
|
"workspace-role",
|
||||||
&ticket.meta.id,
|
&ticket.meta.id,
|
||||||
TicketAssignmentRole::Coder,
|
TicketAssignmentRole::Coder,
|
||||||
"coder-manual-1",
|
|
||||||
"event-clear-coder",
|
|
||||||
"op-clear-coder",
|
|
||||||
"user",
|
|
||||||
"2026-09-01T00:03:00Z",
|
|
||||||
Some("test removal guard"),
|
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
store
|
store
|
||||||
|
|||||||
@@ -55,6 +55,10 @@
|
|||||||
let readyOperationKey = $state<string | null>(null);
|
let readyOperationKey = $state<string | null>(null);
|
||||||
let manualRuntimeId = $state("");
|
let manualRuntimeId = $state("");
|
||||||
let manualWorkerId = $state("");
|
let manualWorkerId = $state("");
|
||||||
|
let cancellationReason = $state("");
|
||||||
|
const coderAssignment = $derived(
|
||||||
|
ticket.assignments.find((assignment) => assignment.role === "coder") ?? null,
|
||||||
|
);
|
||||||
const selectedRepository = $derived(
|
const selectedRepository = $derived(
|
||||||
(loadedRepositories?.items ?? []).find((repository: RepositorySummary) => repository.id === repositoryId) ?? null,
|
(loadedRepositories?.items ?? []).find((repository: RepositorySummary) => repository.id === repositoryId) ?? null,
|
||||||
);
|
);
|
||||||
@@ -162,6 +166,18 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function cancelImplementation(event: SubmitEvent): Promise<void> {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!coderAssignment || !cancellationReason.trim()) return;
|
||||||
|
if (
|
||||||
|
await mutate("cancel-implementation", "/implementation-cancellations", {
|
||||||
|
operation_id: crypto.randomUUID(),
|
||||||
|
assignment_id: coderAssignment.assignment_id,
|
||||||
|
reason: cancellationReason.trim(),
|
||||||
|
})
|
||||||
|
) cancellationReason = "";
|
||||||
|
}
|
||||||
|
|
||||||
async function saveEdit(event: SubmitEvent) {
|
async function saveEdit(event: SubmitEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (
|
if (
|
||||||
@@ -416,6 +432,24 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if ticket.state === "inprogress" && coderAssignment}
|
||||||
|
<details class="ticket-cancel-implementation">
|
||||||
|
<summary>Cancel implementation</summary>
|
||||||
|
<form class="ticket-control-form" onsubmit={cancelImplementation}>
|
||||||
|
<p class="workspace-empty-copy">
|
||||||
|
Cancel the assigned Coder, remove its assignment, and return this Ticket to ready.
|
||||||
|
</p>
|
||||||
|
<label>Reason<textarea bind:value={cancellationReason} rows="3" required></textarea></label>
|
||||||
|
<button
|
||||||
|
class="workspace-danger-button"
|
||||||
|
type="submit"
|
||||||
|
disabled={busy !== null || !cancellationReason.trim()}
|
||||||
|
>
|
||||||
|
{busy === "cancel-implementation" ? "Cancelling…" : "Cancel and return to ready"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
{#if ticket.assignment_diagnostics.length > 0}
|
{#if ticket.assignment_diagnostics.length > 0}
|
||||||
{#each ticket.assignment_diagnostics as diagnostic}
|
{#each ticket.assignment_diagnostics as diagnostic}
|
||||||
<p class="workspace-callout">{diagnostic}</p>
|
<p class="workspace-callout">{diagnostic}</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user