diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 8be3de6f..7017e637 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1827,6 +1827,10 @@ pub fn build_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/tickets/{id}/assignments/{role}", 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( "/api/w/{workspace_id}/tickets/{id}/state", post(scoped_transition_ticket_state), @@ -3176,6 +3180,14 @@ struct SetTicketRoleAssignmentRequest { expected_assignment_id: Option, } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct CancelTicketImplementationRequest { + operation_id: String, + assignment_id: String, + reason: String, +} + #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] struct ClearTicketRoleAssignmentQuery { @@ -3373,6 +3385,113 @@ async fn scoped_clear_ticket_assignment( assignment: None, })) } + +async fn scoped_cancel_ticket_implementation( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + 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( api: &WorkspaceApi, assignment: &WorkerTicketAssignmentRequest, @@ -16015,6 +16134,114 @@ mod tests { 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] async fn authenticated_worker_ticket_mutation_notifies_current_assignment() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index b09bcdac..1192bc83 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -1086,6 +1086,18 @@ pub trait ControlPlaneStore: Send + Sync { occurred_at: &str, reason: Option<&str>, ) -> Result; + 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; fn get_current_ticket_coder_assignment( &self, workspace_id: &str, @@ -4084,106 +4096,110 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ) -> Result { self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; - 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 [ - "ticket-role-assignment:clear:v1", - 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::(); - 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![ + let cleared = clear_current_ticket_role_assignment_in_tx( + &tx, workspace_id, - operation_id, ticket_id, - role.as_str(), - current.principal.kind(), - principal_id, - runtime_id, - worker_id, + role, assignment_id, + event_id, + operation_id, + actor, 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], + reason, + "ticket-role-assignment:clear:v1", + )?; + tx.commit()?; + Ok(cleared) + }) + } + + 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 { + 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), )?; - if persisted != fingerprint { + if !matches!(state.as_str(), "inprogress" | "ready") { 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()?; - 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, - ], + let cleared = clear_current_ticket_role_assignment_in_tx( + &tx, + workspace_id, + ticket_id, + TicketAssignmentRole::Coder, + assignment_id, + assignment_event_id, + operation_id, + actor, + occurred_at, + Some(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()?; - 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 { + 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::(); + 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 { "SELECT a.workspace_id, a.ticket_id, a.assignment_id, a.runtime_id, a.worker_id, \ a.assigned_by, a.assigned_at \ @@ -11052,18 +11179,58 @@ INSERT INTO worker_registry ( ); assert!( 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", &ticket.meta.id, TicketAssignmentRole::Coder, - "coder-manual-1", - "event-clear-coder", - "op-clear-coder", - "user", - "2026-09-01T00:03:00Z", - Some("test removal guard"), ) .unwrap() + .is_none() ); assert!( store diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte index a0afba90..d5add100 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte @@ -55,6 +55,10 @@ let readyOperationKey = $state(null); let manualRuntimeId = $state(""); let manualWorkerId = $state(""); + let cancellationReason = $state(""); + const coderAssignment = $derived( + ticket.assignments.find((assignment) => assignment.role === "coder") ?? null, + ); const selectedRepository = $derived( (loadedRepositories?.items ?? []).find((repository: RepositorySummary) => repository.id === repositoryId) ?? null, ); @@ -162,6 +166,18 @@ }); } + async function cancelImplementation(event: SubmitEvent): Promise { + 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) { event.preventDefault(); if ( @@ -416,6 +432,24 @@ {/if} + {#if ticket.state === "inprogress" && coderAssignment} +
+ Cancel implementation +
+

+ Cancel the assigned Coder, remove its assignment, and return this Ticket to ready. +

+ + +
+
+ {/if} {#if ticket.assignment_diagnostics.length > 0} {#each ticket.assignment_diagnostics as diagnostic}

{diagnostic}