diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 4ea1d2c6..452ab0ae 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -679,7 +679,9 @@ fn validate_generic_state_change( to: TicketWorkflowState, ) -> Result<()> { if current == TicketWorkflowState::Planning && to == TicketWorkflowState::Ready + || current == TicketWorkflowState::Planning && to == TicketWorkflowState::InProgress || current == TicketWorkflowState::Ready && to == TicketWorkflowState::Queued + || current == TicketWorkflowState::Ready && to == TicketWorkflowState::InProgress { return Err(TicketError::InvalidWorkflowTransition { from: current.as_str().to_owned(), @@ -6998,6 +7000,24 @@ mod tests { assert!(projection.visible_overlay.is_some()); } + #[test] + fn generic_state_change_rejects_manual_start_bypass() { + assert!( + validate_generic_state_change( + TicketWorkflowState::Ready, + TicketWorkflowState::InProgress + ) + .is_err() + ); + assert!( + validate_generic_state_change( + TicketWorkflowState::Planning, + TicketWorkflowState::InProgress + ) + .is_err() + ); + } + #[test] fn workflow_state_rejects_legacy_intake_alias() { assert_eq!( diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index 0ed2aaf0..6e49a8d3 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -17,16 +17,17 @@ use ticket::{ use crate::records::{ ObjectiveDetail, ObjectiveEventDetail, ObjectiveLinkSummary, ObjectiveLinkedTicketSummary, ObjectiveQueryItem, ObjectiveQueryRequest, ObjectiveQueryResponse, ObjectiveResourceSummary, - ObjectiveShowRequest, ObjectiveSummary, ProjectRecordList, QueryPage, TicketAssignmentSummary, - TicketDetail, TicketEventDetail, TicketEvidenceEvent, TicketEvidenceSummary, - TicketListPageRequest, TicketMergeRequestSummary, TicketQueryItem, TicketQueryRequest, - TicketQueryResponse, TicketRelationView, TicketShowRequest, TicketSummary, TicketSummaryPage, + ObjectiveShowRequest, ObjectiveSummary, ProjectRecordList, QueryPage, TicketActionEligibility, + TicketAssignmentPrincipalSummary, TicketAssignmentSummary, TicketDetail, TicketEventDetail, + TicketEvidenceEvent, TicketEvidenceSummary, TicketListPageRequest, TicketMergeRequestSummary, + TicketQueryItem, TicketQueryRequest, TicketQueryResponse, TicketRelationView, + TicketRoleAssignmentSummary, TicketShowRequest, TicketSummary, TicketSummaryPage, summarize_body, truncate_body, validate_project_id, }; use crate::store::{ ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord, ObjectiveEventRecord, ObjectiveRecord, ObjectiveTicketLinkRecord, SqliteWorkspaceStore, - WorkspaceResourceKind, + TicketAssignmentPrincipal, TicketAssignmentRole, WorkspaceResourceKind, }; use crate::{Error, Result}; @@ -146,7 +147,7 @@ impl merge_request::AssignmentSource for AuthorityMergeRequestSource { ticket_id: &str, ) -> std::result::Result, String> { self.store - .get_current_ticket_worker_assignment(workspace_id, ticket_id) + .get_current_ticket_coder_assignment(workspace_id, ticket_id) .map(|assignment| { assignment.map(|assignment| merge_request::CurrentAssignment { assignment_id: assignment.assignment_id, @@ -762,23 +763,107 @@ impl SqliteWorkspaceAuthority { .filter(|(_, event)| event.kind.as_str() == "implementation_report") .map(|(sequence, event)| ticket_evidence_event(sequence, event)) .collect::>(); - let current_assignment = self + let role_assignments = self .store - .get_current_ticket_worker_assignment(&self.workspace_id, id)? - .map(|assignment| { + .list_current_ticket_role_assignments(&self.workspace_id, id)?; + let assignments = role_assignments + .iter() + .cloned() + .map(|assignment| TicketRoleAssignmentSummary { + assignment_id: assignment.assignment_id, + role: assignment.role.as_str().to_string(), + principal: match assignment.principal { + TicketAssignmentPrincipal::User { account_id } => { + TicketAssignmentPrincipalSummary::User { account_id } + } + TicketAssignmentPrincipal::Worker { + runtime_id, + worker_id, + } => TicketAssignmentPrincipalSummary::Worker { + runtime_id, + worker_id, + }, + TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => { + TicketAssignmentPrincipalSummary::WorkspaceAgent { agent_key } + } + }, + assigned_by: assignment.assigned_by, + assigned_at: assignment.assigned_at, + }) + .collect::>(); + let current_coder = role_assignments + .iter() + .find(|assignment| assignment.role == TicketAssignmentRole::Coder) + .and_then(|assignment| { + assignment + .principal + .worker() + .map(|worker| (assignment, worker)) + }) + .map(|(assignment, worker)| { let worker_resource_key = self.store.resource_key( &self.workspace_id, WorkspaceResourceKind::Worker, - &assignment.worker.worker_id, + &worker.worker_id, )?; Ok::<_, Error>(TicketAssignmentSummary { - assignment_id: assignment.assignment_id, - runtime_id: assignment.worker.runtime_id, - worker_id: assignment.worker.worker_id, + assignment_id: assignment.assignment_id.clone(), + runtime_id: worker.runtime_id, + worker_id: worker.worker_id, worker_resource_key, }) }) .transpose()?; + let has_orchestrator = role_assignments + .iter() + .any(|assignment| assignment.role == TicketAssignmentRole::Orchestrator); + let has_coder = role_assignments + .iter() + .any(|assignment| assignment.role == TicketAssignmentRole::Coder); + let has_target = ticket.meta.repository_id.is_some() && ticket.meta.ref_selector.is_some(); + let has_blockers = !ticket.relations.blockers.is_empty(); + let mut assignment_diagnostics = Vec::new(); + if let Some(legacy_assignee) = ticket + .meta + .assignee + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + assignment_diagnostics.push(format!( + "legacy Ticket assignee `{legacy_assignee}` is not assignment authority" + )); + } + let action_eligibility = TicketActionEligibility { + can_assign_orchestrator: matches!( + ticket.meta.workflow_state, + TicketWorkflowState::Planning | TicketWorkflowState::Ready + ) && !has_orchestrator + && !has_coder, + can_unassign_orchestrator: has_orchestrator + && matches!( + ticket.meta.workflow_state, + TicketWorkflowState::Planning | TicketWorkflowState::Ready + ), + can_queue: ticket.meta.workflow_state == TicketWorkflowState::Ready + && has_orchestrator + && !has_coder + && has_target + && !has_blockers, + can_start_manual_coder: ticket.meta.workflow_state == TicketWorkflowState::Ready + && !has_orchestrator + && !has_coder + && has_target + && !has_blockers, + blockers: [ + (!has_target).then_some("Ticket target is required".to_string()), + has_blockers.then_some("unresolved blocking relations remain".to_string()), + (has_orchestrator && has_coder) + .then_some("Orchestrator and manual Coder assignment conflict".to_string()), + ] + .into_iter() + .flatten() + .collect(), + }; let merge_request = match self.merge_request_store.get(&self.workspace_id, id) { Ok(request) => { let current_subject_ref = request.selector_from.as_deref().and_then(|selector| { @@ -847,7 +932,6 @@ impl SqliteWorkspaceAuthority { item_revision, queued_by: ticket.meta.queued_by, queued_at: ticket.meta.queued_at, - assignee: ticket.meta.assignee, repository_id: ticket.meta.repository_id, ref_selector: ticket.meta.ref_selector, risk_flags: ticket.meta.risk_flags, @@ -873,7 +957,10 @@ impl SqliteWorkspaceAuthority { relations, linked_objectives, implementation_reports, - current_assignment, + assignments, + current_coder, + assignment_diagnostics, + action_eligibility, merge_request, evidence, resolution: ticket diff --git a/crates/workspace-server/src/records.rs b/crates/workspace-server/src/records.rs index 0c18fd1f..e78022d2 100644 --- a/crates/workspace-server/src/records.rs +++ b/crates/workspace-server/src/records.rs @@ -81,7 +81,6 @@ pub struct TicketDetail { pub item_revision: String, pub queued_by: Option, pub queued_at: Option, - pub assignee: Option, pub repository_id: Option, pub ref_selector: Option, pub risk_flags: Vec, @@ -95,7 +94,10 @@ pub struct TicketDetail { pub relations: TicketRelationView, pub linked_objectives: Vec, pub implementation_reports: Vec, - pub current_assignment: Option, + pub assignments: Vec, + pub current_coder: Option, + pub assignment_diagnostics: Vec, + pub action_eligibility: TicketActionEligibility, pub merge_request: Option, pub evidence: TicketEvidenceSummary, pub resolution: Option, @@ -260,6 +262,43 @@ pub struct TicketAssignmentSummary { pub worker_resource_key: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketRoleAssignmentSummary { + pub assignment_id: String, + pub role: String, + pub principal: TicketAssignmentPrincipalSummary, + pub assigned_by: String, + pub assigned_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", ts(tag = "kind", rename_all = "snake_case"))] +pub enum TicketAssignmentPrincipalSummary { + User { + account_id: String, + }, + Worker { + runtime_id: String, + worker_id: String, + }, + WorkspaceAgent { + agent_key: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketActionEligibility { + pub can_assign_orchestrator: bool, + pub can_unassign_orchestrator: bool, + pub can_queue: bool, + pub can_start_manual_coder: bool, + pub blockers: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketMergeRequestSummary { @@ -424,6 +463,9 @@ pub fn ticket_api_typescript() -> String { ObjectiveLinkSummary::decl(&config), TicketEvidenceEvent::decl(&config), TicketAssignmentSummary::decl(&config), + TicketRoleAssignmentSummary::decl(&config), + TicketAssignmentPrincipalSummary::decl(&config), + TicketActionEligibility::decl(&config), TicketMergeRequestSummary::decl(&config), MergeRequestListItem::decl(&config), MergeRequestListResponse::decl(&config), diff --git a/crates/workspace-server/src/retention.rs b/crates/workspace-server/src/retention.rs index f75a3aa4..c11b39bf 100644 --- a/crates/workspace-server/src/retention.rs +++ b/crates/workspace-server/src/retention.rs @@ -1047,7 +1047,7 @@ fn parse_state(v: &str) -> rusqlite::Result { #[cfg(test)] mod tests { use super::*; - use crate::store::{ControlPlaneStore, TicketWorkerAssignmentRecord, WorkerRegistryRecord}; + use crate::store::{ControlPlaneStore, TicketCoderAssignmentRecord, WorkerRegistryRecord}; use worker_runtime::identity::WorkerId; fn worker_id() -> WorkerId { WorkerId::from_legacy_u64(1) @@ -1505,7 +1505,7 @@ mod tests { ).map_err(StoreError::from)).unwrap(); assert_eq!(revision, "rev1"); - let assignment = TicketWorkerAssignmentRecord { + let assignment = TicketCoderAssignmentRecord { workspace_id: "w".into(), ticket_id: "new-ticket".into(), assignment_id: "new-assignment".into(), @@ -1518,7 +1518,7 @@ mod tests { }; assert!( store - .set_current_ticket_worker_assignment( + .set_current_ticket_coder_assignment( &assignment, None, "event", diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index a190d819..61026fea 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -115,7 +115,8 @@ use crate::skills; use crate::store::{ AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore, DeviceLoginFlowRecord, FlowSourceRecord, PasskeyCredentialRecord, RepositoryRecord, - TicketWorkerAssignmentRecord, UserRecord, WorkdirRegistryRecord, WorkerControlGrantRecord, + TicketAssignmentPrincipal, TicketAssignmentRole, TicketCoderAssignmentRecord, + TicketRoleAssignmentRecord, UserRecord, WorkdirRegistryRecord, WorkerControlGrantRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord, WorkspaceResourceKind, }; use crate::workspace_catalog::{WorkspaceCatalogService, WorkspaceCreateRequest}; @@ -1819,14 +1820,12 @@ pub fn build_router(api: WorkspaceApi) -> Router { post(scoped_show_ticket), ) .route( - "/api/w/{workspace_id}/tickets/{id}/assignment", - get(scoped_get_ticket_worker_assignment) - .put(scoped_set_ticket_worker_assignment) - .delete(scoped_clear_ticket_worker_assignment), + "/api/w/{workspace_id}/tickets/{id}/assignments", + get(scoped_list_ticket_assignments), ) .route( - "/api/w/{workspace_id}/tickets/{id}/assignment/reassign", - post(scoped_reassign_ticket_worker_assignment), + "/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}/state", @@ -3156,131 +3155,174 @@ async fn scoped_show_ticket( } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] -struct TicketWorkerAssignmentResponse { +struct TicketRoleAssignmentsResponse { workspace_id: String, ticket_id: String, - assignment: Option, - worker: Option, + assignments: Vec, } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] -struct TicketWorkerAssignmentMutationResponse { +struct TicketRoleAssignmentMutationResponse { workspace_id: String, ticket_id: String, - assignment: Option, - previous_assignment_id: Option, + assignment: Option, } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] -struct SetTicketWorkerAssignmentRequest { +struct SetTicketRoleAssignmentRequest { operation_id: String, - #[serde(flatten)] - worker: RuntimeWorkerRef, + principal: TicketAssignmentPrincipal, expected_assignment_id: Option, - assigned_by: Option, } #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] -struct ClearTicketWorkerAssignmentQuery { +struct ClearTicketRoleAssignmentQuery { operation_id: Option, - expected_assignment_id: Option, - actor: Option, + assignment_id: Option, } -async fn scoped_get_ticket_worker_assignment( +fn parse_ticket_assignment_role(role: &str) -> ApiResult { + match role { + "orchestrator" => Ok(TicketAssignmentRole::Orchestrator), + "coder" => Ok(TicketAssignmentRole::Coder), + "owner" => Ok(TicketAssignmentRole::Owner), + "contributor" => Ok(TicketAssignmentRole::Contributor), + _ => Err(Error::InvalidInput(format!("unknown Ticket assignment role `{role}`")).into()), + } +} + +async fn scoped_list_ticket_assignments( State(api): State, AxumPath(path): AxumPath, -) -> ApiResult> { +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; let ticket = api.authority.ticket(&path.id)?; - let assignment = api + let assignments = api .store - .get_current_ticket_worker_assignment(&path.workspace_id, &ticket.id)?; - let worker = assignment - .as_ref() - .and_then(|assignment| api.runtime.worker(&assignment.worker).ok()); - Ok(Json(TicketWorkerAssignmentResponse { + .list_current_ticket_role_assignments(&path.workspace_id, &ticket.id)?; + Ok(Json(TicketRoleAssignmentsResponse { workspace_id: path.workspace_id, ticket_id: ticket.id, - assignment, - worker, + assignments, })) } -async fn scoped_set_ticket_worker_assignment( +async fn scoped_set_ticket_assignment( State(api): State, - AxumPath(path): AxumPath, - Json(request): Json, -) -> ApiResult> { - set_ticket_worker_assignment(api, path, request, false).await -} - -async fn scoped_reassign_ticket_worker_assignment( - State(api): State, - AxumPath(path): AxumPath, - Json(request): Json, -) -> ApiResult> { - set_ticket_worker_assignment(api, path, request, true).await -} - -async fn set_ticket_worker_assignment( - api: WorkspaceApi, - path: ScopedRecordPath, - request: SetTicketWorkerAssignmentRequest, - allow_reassign: bool, -) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let ticket = api.authority.ticket(&path.id)?; + AxumPath((workspace_id, id, role)): AxumPath<(String, String, String)>, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &workspace_id)?; + let ticket = api.authority.ticket(&id)?; + let role = parse_ticket_assignment_role(&role)?; let operation_id = require_ticket_assignment_value("operation_id", request.operation_id)?; - let runtime_id = require_ticket_assignment_value("runtime_id", request.worker.runtime_id)?; - let worker_id = require_ticket_assignment_value("worker_id", request.worker.worker_id)?; let expected_assignment_id = request .expected_assignment_id .map(|value| require_ticket_assignment_value("expected_assignment_id", value)) .transpose()?; - let assigned_by = request - .assigned_by - .map(|value| require_ticket_assignment_value("assigned_by", value)) - .transpose()? - .unwrap_or_else(|| "workspace-api".to_string()); - let requested_worker = RuntimeWorkerRef::new(runtime_id, worker_id); - let worker = api - .runtime - .worker(&requested_worker) - .map_err(|err| err.into_error())?; + if matches!(request.principal, TicketAssignmentPrincipal::User { .. }) { + return Err(Error::TicketAssignmentConflict( + "user-principal Ticket assignment requires an authenticated authoring boundary; weak Workspace Web access is not authority" + .to_string(), + ) + .into()); + } let assigned_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); - let record = TicketWorkerAssignmentRecord { - workspace_id: path.workspace_id.clone(), + let record = TicketRoleAssignmentRecord { + workspace_id: workspace_id.clone(), ticket_id: ticket.id.clone(), assignment_id: new_id("tasg"), - worker: worker.worker.clone(), - assigned_by, + role, + principal: request.principal, + assigned_by: "workspace-web".to_string(), assigned_at, }; - let update = api.store.set_current_ticket_worker_assignment( - &record, - expected_assignment_id.as_deref(), - &new_id("tasev"), - &operation_id, - allow_reassign, - )?; - Ok(Json(TicketWorkerAssignmentMutationResponse { - workspace_id: path.workspace_id, + let assignment = match role { + TicketAssignmentRole::Orchestrator => { + if !matches!( + ticket.state.as_str(), + state if state == TicketWorkflowState::Planning.as_str() + || state == TicketWorkflowState::Ready.as_str() + ) { + return Err(Error::TicketAssignmentConflict(format!( + "Orchestrator assignment requires planning or ready Ticket; current state is {}", + ticket.state + )) + .into()); + } + if api + .store + .get_current_ticket_role_assignment( + &workspace_id, + &ticket.id, + TicketAssignmentRole::Coder, + )? + .is_some() + { + return Err(Error::TicketAssignmentConflict( + "Orchestrator assignment conflicts with an active Coder assignment".to_string(), + ) + .into()); + } + api.store.set_current_ticket_role_assignment( + &record, + expected_assignment_id.as_deref(), + &new_id("tasev"), + &operation_id, + expected_assignment_id.is_some(), + )? + } + TicketAssignmentRole::Coder => { + if expected_assignment_id.is_some() { + return Err(Error::TicketAssignmentConflict( + "manual Coder start does not support reassign; clear through a guarded lifecycle operation first" + .to_string(), + ) + .into()); + } + if let TicketAssignmentPrincipal::Worker { + runtime_id, + worker_id, + } = &record.principal + { + api.runtime + .worker(&RuntimeWorkerRef::new( + runtime_id.clone(), + worker_id.clone(), + )) + .map_err(|error| error.into_error())?; + } + api.store.start_ready_ticket_with_coder_assignment( + &record, + &new_id("tasev"), + &operation_id, + )? + } + TicketAssignmentRole::Owner | TicketAssignmentRole::Contributor => { + return Err(Error::TicketAssignmentConflict( + "Owner and Contributor mutation requires an authenticated authoring boundary" + .to_string(), + ) + .into()); + } + }; + Ok(Json(TicketRoleAssignmentMutationResponse { + workspace_id, ticket_id: ticket.id, - assignment: Some(update.current), - previous_assignment_id: update.previous.map(|assignment| assignment.assignment_id), + assignment: Some(assignment), })) } -async fn scoped_clear_ticket_worker_assignment( +async fn scoped_clear_ticket_assignment( State(api): State, - AxumPath(path): AxumPath, - Query(query): Query, -) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let ticket = api.authority.ticket(&path.id)?; + AxumPath((workspace_id, id, role)): AxumPath<(String, String, String)>, + Query(query): Query, +) -> ApiResult> { + validate_workspace_scope(&api, &workspace_id)?; + let ticket = api.authority.ticket(&id)?; + let role = parse_ticket_assignment_role(&role)?; let operation_id = query .operation_id .map(|value| require_ticket_assignment_value("operation_id", value)) @@ -3288,32 +3330,49 @@ async fn scoped_clear_ticket_worker_assignment( .ok_or_else(|| { Error::TicketAssignmentConflict("unassign requires operation_id".to_string()) })?; - let expected_assignment_id = query - .expected_assignment_id - .map(|value| require_ticket_assignment_value("expected_assignment_id", value)) - .transpose()?; - let actor = query - .actor - .map(|value| require_ticket_assignment_value("actor", value)) + let assignment_id = query + .assignment_id + .map(|value| require_ticket_assignment_value("assignment_id", value)) .transpose()? - .unwrap_or_else(|| "workspace-api".to_string()); - let previous = api.store.clear_current_ticket_worker_assignment( - &path.workspace_id, + .ok_or_else(|| { + Error::TicketAssignmentConflict("unassign requires assignment_id".to_string()) + })?; + if matches!( + ticket.state.as_str(), + state if state == TicketWorkflowState::Queued.as_str() + || state == TicketWorkflowState::InProgress.as_str() + ) { + return Err(Error::TicketAssignmentConflict(format!( + "cannot unassign role `{}` while Ticket is {}; rescope through a guarded lifecycle operation", + role.as_str(), + ticket.state + )) + .into()); + } + let cleared = api.store.clear_current_ticket_role_assignment( + &workspace_id, &ticket.id, - expected_assignment_id.as_deref(), - &operation_id, + role, + &assignment_id, &new_id("tasev"), - &actor, + &operation_id, + "workspace-web", &Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + Some("role assignment removed from Ticket detail"), )?; - Ok(Json(TicketWorkerAssignmentMutationResponse { - workspace_id: path.workspace_id, + if !cleared { + return Err(Error::TicketAssignmentConflict(format!( + "assignment `{assignment_id}` is not current for role `{}`", + role.as_str() + )) + .into()); + } + Ok(Json(TicketRoleAssignmentMutationResponse { + workspace_id, ticket_id: ticket.id, assignment: None, - previous_assignment_id: previous.map(|assignment| assignment.assignment_id), })) } - fn validate_ticket_assignment_state( api: &WorkspaceApi, assignment: &WorkerTicketAssignmentRequest, @@ -3329,6 +3388,28 @@ fn validate_ticket_assignment_state( ticket.id, ticket.state ))); } + let Some(orchestrator_assignment) = + orchestrator_interested(api, &api.config.workspace_id, &ticket.id, &ticket.state)? + else { + return Err(Error::TicketAssignmentConflict(format!( + "Ticket {} cannot be assigned an orchestration Coder without an active Orchestrator role assignment", + ticket.id + ))); + }; + let queued = browser_ticket_backend(api)?.show(TicketIdOrSlug::Id(ticket.id.clone()))?; + let queued_assignment_id = queued + .events + .iter() + .rev() + .find_map(|event| event.attributes.get("orchestrator_assignment_id")); + if queued_assignment_id.map(String::as_str) + != Some(orchestrator_assignment.assignment_id.as_str()) + { + return Err(Error::TicketAssignmentConflict(format!( + "Ticket {} Queue fence does not match active Orchestrator assignment {}", + ticket.id, orchestrator_assignment.assignment_id + ))); + } Ok(()) } @@ -3374,7 +3455,7 @@ fn validate_ticket_assignment_spawn( if let Some(current) = api .store - .get_current_ticket_worker_assignment(&api.config.workspace_id, &assignment.ticket_id)? + .get_current_ticket_coder_assignment(&api.config.workspace_id, &assignment.ticket_id)? { let replay_matches = api .store @@ -3447,7 +3528,7 @@ fn assign_ticket_worker_from_lifecycle( assignment: &crate::hosts::WorkerTicketAssignmentRequest, runtime_id: &str, worker_id: &str, -) -> Result { +) -> Result { let ticket = api.authority.ticket(&assignment.ticket_id)?; let worker = RuntimeWorkerRef::new(runtime_id, worker_id); if let Some(operation) = api @@ -3458,7 +3539,7 @@ fn assign_ticket_worker_from_lifecycle( if operation.action == "assign" && operation.ticket_id == assignment.ticket_id && operation.worker.as_ref() == Some(&worker) - && let Some(current) = api.store.get_current_ticket_worker_assignment( + && let Some(current) = api.store.get_current_ticket_coder_assignment( &api.config.workspace_id, &assignment.ticket_id, )? @@ -3473,7 +3554,7 @@ fn assign_ticket_worker_from_lifecycle( ))); } let assigned_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); - let record = TicketWorkerAssignmentRecord { + let record = TicketCoderAssignmentRecord { workspace_id: api.config.workspace_id.clone(), ticket_id: ticket.id, assignment_id: new_id("tasg"), @@ -3483,7 +3564,7 @@ fn assign_ticket_worker_from_lifecycle( }; Ok(api .store - .set_current_ticket_worker_assignment( + .set_current_ticket_coder_assignment( &record, None, &new_id("tasev"), @@ -3873,11 +3954,52 @@ async fn execute_ticket_rest_operation( .as_ref() .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 matches!(operation, TicketBackendOperation::QueueReady { .. }) { + let ticket = before.as_ref().ok_or_else(|| { + Error::TicketAssignmentConflict( + "Queue requires an existing Ticket with an active Orchestrator assignment" + .to_string(), + ) + })?; + let assignment = active_orchestrator_assignment(api, workspace_id, &ticket.meta.id)? + .ok_or_else(|| { + Error::TicketAssignmentConflict( + "Queue requires role=orchestrator assignment to workspace-orchestrator" + .to_string(), + ) + })?; + let operation_id = new_id("tqueue"); + let fingerprint = Sha256::digest(format!( + "ticket-queue:v1\0{workspace_id}\0{}\0{}\0{}", + ticket.meta.id, + ticket.meta.workflow_state.as_str(), + assignment.assignment_id + )) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + event_attributes.extend([ + ( + "orchestrator_assignment_id".to_string(), + assignment.assignment_id, + ), + ( + "routing_principal".to_string(), + "workspace-orchestrator".to_string(), + ), + ("routing_operation_id".to_string(), operation_id), + ("routing_request_fingerprint".to_string(), fingerprint), + ]); + } if let Some(source) = source.as_ref() { bind_worker_ticket_operation_source(source, &mut operation); let source_context = worker_ticket_source_context(api, workspace_id, source, before.as_ref()); - backend = backend.with_event_attributes(source_context.attributes(operation_kind)); + event_attributes.extend(source_context.attributes(operation_kind)); + } + if !event_attributes.is_empty() { + backend = backend.with_event_attributes(event_attributes); } let result = execute_ticket_backend_operation(&backend, operation).map_err(Error::from)?; @@ -4223,7 +4345,7 @@ async fn scoped_queue_ticket_record( headers, TicketBackendOperation::QueueReady { id: TicketIdOrSlug::Query(id), - queued_by: String::new(), + queued_by: "workspace-web".to_string(), }, ) .await?; @@ -4316,7 +4438,7 @@ impl merge_request::AssignmentSource for MergeRequestAssignmentSource { ticket_id: &str, ) -> std::result::Result, String> { self.store - .get_current_ticket_worker_assignment(workspace_id, ticket_id) + .get_current_ticket_coder_assignment(workspace_id, ticket_id) .map(|value| { value.map(|assignment| merge_request::CurrentAssignment { assignment_id: assignment.assignment_id, @@ -4631,7 +4753,7 @@ async fn scoped_open_merge_request( let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; let assignment = api .store - .get_current_ticket_worker_assignment(&workspace_id, &ticket_id)? + .get_current_ticket_coder_assignment(&workspace_id, &ticket_id)? .ok_or_else(|| { Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into()) })?; @@ -4771,7 +4893,7 @@ async fn scoped_register_merge_request_review_capability( let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; let assignment = api .store - .get_current_ticket_worker_assignment(&workspace_id, &ticket_id)? + .get_current_ticket_coder_assignment(&workspace_id, &ticket_id)? .ok_or_else(|| { Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into()) })?; @@ -4857,7 +4979,7 @@ async fn scoped_revoke_merge_request_review( let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; let assignment = api .store - .get_current_ticket_worker_assignment(&workspace_id, &ticket_id)? + .get_current_ticket_coder_assignment(&workspace_id, &ticket_id)? .ok_or_else(|| { Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into()) })?; @@ -4925,7 +5047,7 @@ async fn scoped_complete_merge_request( } let assignment = api .store - .get_current_ticket_worker_assignment(&workspace_id, &ticket_id)? + .get_current_ticket_coder_assignment(&workspace_id, &ticket_id)? .ok_or_else(|| { Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into()) })?; @@ -5299,6 +5421,46 @@ fn worker_source_actor_role(is_current_assignment: bool, is_orchestrator: bool) } } +fn active_orchestrator_assignment( + api: &WorkspaceApi, + workspace_id: &str, + ticket_id: &str, +) -> Result> { + let assignment = api.store.get_current_ticket_role_assignment( + workspace_id, + ticket_id, + TicketAssignmentRole::Orchestrator, + )?; + match assignment { + Some(assignment) + if matches!( + assignment.principal, + TicketAssignmentPrincipal::WorkspaceAgent { ref agent_key } + if agent_key == "workspace-orchestrator" + ) => + { + Ok(Some(assignment)) + } + Some(_) => Err(Error::TicketAssignmentConflict( + "Orchestrator role must reference the registered workspace-orchestrator principal" + .to_string(), + )), + None => Ok(None), + } +} + +fn orchestrator_interested( + api: &WorkspaceApi, + workspace_id: &str, + ticket_id: &str, + state: &str, +) -> Result> { + if !matches!(state, "queued" | "inprogress") { + return Ok(None); + } + active_orchestrator_assignment(api, workspace_id, ticket_id) +} + fn worker_ticket_source_context( api: &WorkspaceApi, workspace_id: &str, @@ -5307,7 +5469,7 @@ fn worker_ticket_source_context( ) -> WorkerTicketSourceContext { let assignment = ticket.and_then(|ticket| { api.store - .get_current_ticket_worker_assignment(workspace_id, &ticket.meta.id) + .get_current_ticket_coder_assignment(workspace_id, &ticket.meta.id) .ok() .flatten() }); @@ -5315,9 +5477,19 @@ fn worker_ticket_source_context( let is_current_assignment = assignment .as_ref() .is_some_and(|assignment| &assignment.worker == source); - let is_orchestrator = orchestrator - .as_ref() - .is_some_and(|worker| worker.worker == *source); + let is_orchestrator = active_orchestrator_assignment( + api, + workspace_id, + ticket + .map(|ticket| ticket.meta.id.as_str()) + .unwrap_or_default(), + ) + .ok() + .flatten() + .is_some() + && orchestrator + .as_ref() + .is_some_and(|worker| worker.worker == *source); let actor_role = worker_source_actor_role(is_current_assignment, is_orchestrator); WorkerTicketSourceContext { worker: source.clone(), @@ -5338,21 +5510,23 @@ fn notify_ticket_recipients( api: &WorkspaceApi, workspace_id: &str, ticket_id: &str, - previous_state: &str, + _previous_state: &str, current_state: &str, source: Option, ) { let mut recipients = Vec::new(); if let Some(assignment) = api .store - .get_current_ticket_worker_assignment(workspace_id, ticket_id) + .get_current_ticket_coder_assignment(workspace_id, ticket_id) .ok() .flatten() { recipients.push(assignment.worker.clone()); } - if (matches!(previous_state, "queued" | "inprogress") - || matches!(current_state, "queued" | "inprogress")) + if orchestrator_interested(api, workspace_id, ticket_id, current_state) + .ok() + .flatten() + .is_some() && let Some(orchestrator) = find_workspace_orchestrator(api) { recipients.push(orchestrator.worker.clone()); @@ -5855,6 +6029,24 @@ fn dispatch_orchestrator_queue_attention(api: &WorkspaceApi) { ])) else { return; }; + queued.retain(|ticket| { + orchestrator_interested(api, &api.config.workspace_id, &ticket.id, "queued") + .ok() + .flatten() + .is_some() + }); + let Ok(mut inprogress) = backend.list(ticket::TicketListQuery::states([ + ticket::TicketListState::InProgress, + ])) else { + return; + }; + inprogress.retain(|ticket| { + orchestrator_interested(api, &api.config.workspace_id, &ticket.id, "inprogress") + .ok() + .flatten() + .is_some() + }); + queued.extend(inprogress); queued.sort_by(|left, right| left.id.cmp(&right.id)); if queued.is_empty() { *api.orchestrator_attention_fingerprint @@ -5862,15 +6054,6 @@ fn dispatch_orchestrator_queue_attention(api: &WorkspaceApi) { .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; return; } - let Ok(inprogress) = backend.list(ticket::TicketListQuery::states([ - ticket::TicketListState::InProgress, - ])) else { - return; - }; - if !inprogress.is_empty() { - return; - } - let fingerprint = queued .iter() .map(|ticket| ticket.id.as_str()) @@ -13668,6 +13851,7 @@ mod tests { let mut input = ticket::NewTicket::new("Assigned Ticket"); input.workflow_state = Some(TicketWorkflowState::Queued); let ticket = backend.create(input).unwrap(); + assign_test_orchestrator(&api, &ticket.id); let response = create_workspace_worker( State(api.clone()), HeaderMap::new(), @@ -13697,7 +13881,7 @@ mod tests { ); let current = api .store - .get_current_ticket_worker_assignment(&api.config.workspace_id, &ticket.id) + .get_current_ticket_coder_assignment(&api.config.workspace_id, &ticket.id) .unwrap() .unwrap(); assert_eq!(current.worker, response.worker_ref); @@ -13722,6 +13906,7 @@ mod tests { let mut input = ticket::NewTicket::new("Queued Ticket"); input.workflow_state = Some(TicketWorkflowState::Queued); let ticket = backend.create(input).unwrap(); + assign_test_orchestrator(&api, &ticket.id); let result = create_workspace_worker( State(api.clone()), @@ -13751,7 +13936,7 @@ mod tests { ); assert!( api.store - .get_current_ticket_worker_assignment(&api.config.workspace_id, &ticket.id) + .get_current_ticket_coder_assignment(&api.config.workspace_id, &ticket.id) .unwrap() .is_none() ); @@ -15354,6 +15539,7 @@ mod tests { input.repository_id = Some(TEST_REPOSITORY_ID.to_owned()); input.ref_selector = Some("develop".to_owned()); let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap(); + assign_test_orchestrator(&api, &ticket.id); let ticket_id = TicketIdOrSlug::Id(ticket.id.clone()); let operations = [ TicketBackendOperation::MarkReady { @@ -15442,7 +15628,7 @@ mod tests { updated_at: TEST_CREATED_AT.to_string(), }) .unwrap(); - let assignment = TicketWorkerAssignmentRecord { + let assignment = TicketCoderAssignmentRecord { workspace_id: TEST_WORKSPACE_ID.to_string(), ticket_id: ticket_id.clone(), assignment_id: "assignment-api-1".to_string(), @@ -15451,7 +15637,7 @@ mod tests { assigned_at: TEST_CREATED_AT.to_string(), }; api.store - .set_current_ticket_worker_assignment( + .set_current_ticket_coder_assignment( &assignment, None, "event-api-1", @@ -15464,18 +15650,23 @@ mod tests { id: ticket_id.clone(), }; - let Json(read) = scoped_get_ticket_worker_assignment(State(api.clone()), AxumPath(path())) + let Json(read) = scoped_list_ticket_assignments(State(api.clone()), AxumPath(path())) .await .unwrap(); - assert_eq!(read.assignment, Some(assignment)); + assert_eq!(read.assignments.len(), 1); + assert_eq!(read.assignments[0].assignment_id, assignment.assignment_id); + assert_eq!(read.assignments[0].role, TicketAssignmentRole::Coder); - let stale = scoped_clear_ticket_worker_assignment( + let stale = scoped_clear_ticket_assignment( State(api.clone()), - AxumPath(path()), - Query(ClearTicketWorkerAssignmentQuery { + AxumPath(( + TEST_WORKSPACE_ID.to_string(), + ticket_id.clone(), + "coder".to_string(), + )), + Query(ClearTicketRoleAssignmentQuery { operation_id: Some("clear-stale".to_string()), - expected_assignment_id: Some("stale-assignment".to_string()), - actor: Some("test-user".to_string()), + assignment_id: Some("stale-assignment".to_string()), }), ) .await @@ -15483,21 +15674,20 @@ mod tests { .into_response(); assert_eq!(stale.status(), StatusCode::CONFLICT); - let Json(cleared) = scoped_clear_ticket_worker_assignment( + let Json(cleared) = scoped_clear_ticket_assignment( State(api.clone()), - AxumPath(path()), - Query(ClearTicketWorkerAssignmentQuery { + AxumPath(( + TEST_WORKSPACE_ID.to_string(), + ticket_id.clone(), + "coder".to_string(), + )), + Query(ClearTicketRoleAssignmentQuery { operation_id: Some("clear-current".to_string()), - expected_assignment_id: Some("assignment-api-1".to_string()), - actor: Some("test-user".to_string()), + assignment_id: Some("assignment-api-1".to_string()), }), ) .await .unwrap(); - assert_eq!( - cleared.previous_assignment_id.as_deref(), - Some("assignment-api-1") - ); assert_eq!(cleared.assignment, None); } @@ -15588,8 +15778,8 @@ mod tests { .create(ticket::NewTicket::new("Notify assigned Worker")) .unwrap(); api.store - .set_current_ticket_worker_assignment( - &TicketWorkerAssignmentRecord { + .set_current_ticket_coder_assignment( + &TicketCoderAssignmentRecord { workspace_id: TEST_WORKSPACE_ID.to_string(), ticket_id: ticket_ref.id.clone(), assignment_id: "notify-assignment".to_string(), @@ -15691,8 +15881,8 @@ mod tests { ); api.store - .set_current_ticket_worker_assignment( - &TicketWorkerAssignmentRecord { + .set_current_ticket_coder_assignment( + &TicketCoderAssignmentRecord { workspace_id: TEST_WORKSPACE_ID.to_string(), ticket_id: ticket_ref.id.clone(), assignment_id: "source-assignment".to_string(), @@ -15759,6 +15949,56 @@ mod tests { assert_eq!(invalid_source.status(), StatusCode::BAD_REQUEST); } + #[tokio::test] + async fn queue_requires_orchestrator_role_and_records_assignment_fence() { + let dir = tempfile::tempdir().unwrap(); + init_clean_git_workspace(dir.path()); + let api = test_api(dir.path()).await; + let backend = browser_ticket_backend(&api).unwrap(); + let mut input = ticket::NewTicket::new("Queue role gate"); + input.workflow_state = Some(TicketWorkflowState::Ready); + input.repository_id = Some(TEST_REPOSITORY_ID.to_string()); + input.ref_selector = Some("develop".to_string()); + let ticket = backend.create(input).unwrap(); + let path = (TEST_WORKSPACE_ID.to_string(), ticket.id.clone()); + + let missing = scoped_queue_ticket_record( + State(api.clone()), + AxumPath(path.clone()), + HeaderMap::new(), + ) + .await + .unwrap_err() + .into_response(); + assert_eq!(missing.status(), StatusCode::CONFLICT); + assert_eq!( + backend + .show(ticket.id.clone().into()) + .unwrap() + .meta + .workflow_state, + TicketWorkflowState::Ready + ); + + assign_test_orchestrator(&api, &ticket.id); + scoped_queue_ticket_record(State(api.clone()), AxumPath(path), HeaderMap::new()) + .await + .unwrap(); + let queued = backend.show(ticket.id.into()).unwrap(); + assert_eq!(queued.meta.workflow_state, TicketWorkflowState::Queued); + let event = queued.events.last().unwrap(); + let expected_assignment_id = format!("orchestrator-{}", queued.meta.id); + assert_eq!( + event + .attributes + .get("orchestrator_assignment_id") + .map(String::as_str), + Some(expected_assignment_id.as_str()) + ); + assert!(event.attributes.contains_key("routing_operation_id")); + assert!(event.attributes.contains_key("routing_request_fingerprint")); + } + #[tokio::test] async fn queued_ticket_mutation_succeeds_without_orchestrator() { let dir = tempfile::tempdir().unwrap(); @@ -15834,6 +16074,7 @@ mod tests { input.repository_id = Some(TEST_REPOSITORY_ID.to_owned()); input.ref_selector = Some("HEAD".to_owned()); let ticket_ref = backend.create(input).unwrap(); + assign_test_orchestrator(&api, &ticket_ref.id); *api.orchestrator_attention_fingerprint.lock().unwrap() = Some(ticket_ref.id.clone()); let Json(started) = scoped_start_workspace_orchestrator( @@ -15890,6 +16131,7 @@ mod tests { let mut first_ticket_input = ticket::NewTicket::new("Spawn assignment"); first_ticket_input.workflow_state = Some(TicketWorkflowState::InProgress); let first_ticket = backend.create(first_ticket_input).unwrap(); + assign_test_orchestrator(&api, &first_ticket.id); let request = WorkerSpawnRequest { requested_worker_name: Some("assigned-spawn".to_string()), intent: WorkerSpawnIntent::TicketRole { @@ -15928,7 +16170,7 @@ mod tests { .await .unwrap(); let first_worker = first.worker.unwrap(); - let Json(projected) = scoped_get_ticket_worker_assignment( + let Json(projected) = scoped_list_ticket_assignments( State(api.clone()), AxumPath(ScopedRecordPath { workspace_id: TEST_WORKSPACE_ID.to_string(), @@ -15938,11 +16180,11 @@ mod tests { .await .unwrap(); assert_eq!( - projected - .worker - .as_ref() - .map(|worker| worker.worker.worker_id.as_str()), - Some(first_worker.worker.worker_id.as_str()) + projected.assignments[0] + .principal + .worker() + .map(|worker| worker.worker_id), + Some(first_worker.worker.worker_id.clone()) ); let Json(retried) = scoped_create_runtime_worker( State(api.clone()), @@ -15960,7 +16202,7 @@ mod tests { ); assert_eq!( api.store - .list_ticket_worker_assignment_events(TEST_WORKSPACE_ID, &first_ticket.id, 10,) + .list_ticket_coder_assignment_events(TEST_WORKSPACE_ID, &first_ticket.id, 10,) .unwrap() .len(), 1 @@ -15968,7 +16210,7 @@ mod tests { let current = api .store - .get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &first_ticket.id) + .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &first_ticket.id) .unwrap() .unwrap(); api.store @@ -15994,6 +16236,7 @@ mod tests { let mut second_ticket_input = ticket::NewTicket::new("Restore assignment"); second_ticket_input.workflow_state = Some(TicketWorkflowState::InProgress); let second_ticket = backend.create(second_ticket_input).unwrap(); + assign_test_orchestrator(&api, &second_ticket.id); let _ = scoped_restore_runtime_worker( State(api.clone()), AxumPath(ScopedRuntimeWorkerPath { @@ -16033,7 +16276,7 @@ mod tests { ); let restored_assignment = api .store - .get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &second_ticket.id) + .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &second_ticket.id) .unwrap() .unwrap(); assert_eq!( @@ -16154,6 +16397,7 @@ mod tests { .create(ticket_input) .unwrap() .id; + assign_test_orchestrator(&api, &ticket_id); let assignment = crate::hosts::WorkerTicketAssignmentRequest { ticket_id: ticket_id.clone(), operation_id: "compensation-test-operation".to_string(), @@ -16247,7 +16491,7 @@ mod tests { ); assert!( api.store - .get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket_id) + .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &ticket_id) .unwrap() .is_none() ); @@ -16346,7 +16590,8 @@ mod tests { assert_eq!(edited.body, "Updated from the Browser API."); assert_eq!(edited.repository_id.as_deref(), Some("main")); assert_eq!(edited.ref_selector.as_deref(), Some("develop")); - assert_eq!(edited.assignee, None); + assert!(edited.assignments.is_empty()); + assert!(edited.assignment_diagnostics.is_empty()); assert_eq!(edited.relations.outgoing.len(), 1); assert_eq!(edited.relations.outgoing[0].target, related_ticket_id); assert_eq!(edited.relations.outgoing[0].kind, "related"); @@ -16552,6 +16797,49 @@ mod tests { test_api_with_recording_backend(workspace_root).await.0 } + fn assign_test_orchestrator(api: &WorkspaceApi, ticket_id: &str) { + api.store + .set_current_ticket_role_assignment( + &TicketRoleAssignmentRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + ticket_id: ticket_id.to_string(), + assignment_id: format!("orchestrator-{ticket_id}"), + role: TicketAssignmentRole::Orchestrator, + principal: TicketAssignmentPrincipal::WorkspaceAgent { + agent_key: "workspace-orchestrator".to_string(), + }, + assigned_by: "test-user".to_string(), + assigned_at: "2026-09-01T00:00:00Z".to_string(), + }, + None, + &format!("orchestrator-event-{ticket_id}"), + &format!("orchestrator-op-{ticket_id}"), + false, + ) + .unwrap(); + if let Ok(ticket) = api.authority.ticket(ticket_id) + && matches!(ticket.state.as_str(), "queued" | "inprogress") + { + let assignment_id = format!("orchestrator-{ticket_id}"); + let backend = + browser_ticket_backend(api) + .unwrap() + .with_event_attributes(BTreeMap::from([( + "orchestrator_assignment_id".to_string(), + assignment_id, + )])); + backend + .add_event( + TicketIdOrSlug::Id(ticket.id), + ticket::NewTicketEvent::new( + ticket::TicketEventKind::Comment, + "test Queue assignment fence", + ), + ) + .unwrap(); + } + } + #[tokio::test] async fn memory_settings_handlers_reject_foreign_workspace_path_scope() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 4f3b48f8..aeaea00b 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -236,6 +236,11 @@ const MIGRATIONS: &[Migration] = &[ name: "create Workspace Memory settings authority", apply: create_workspace_memory_settings_authority, }, + Migration { + version: 43, + name: "generalize Ticket assignments to role principals", + apply: generalize_ticket_role_assignments, + }, ]; struct Migration { @@ -444,8 +449,92 @@ pub struct WorkerControlGrantRecord { pub revoked_at: Option, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum TicketAssignmentRole { + Orchestrator, + Coder, + Owner, + Contributor, +} + +impl TicketAssignmentRole { + pub fn as_str(self) -> &'static str { + match self { + Self::Orchestrator => "orchestrator", + Self::Coder => "coder", + Self::Owner => "owner", + Self::Contributor => "contributor", + } + } + + fn from_db(value: &str) -> rusqlite::Result { + match value { + "orchestrator" => Ok(Self::Orchestrator), + "coder" => Ok(Self::Coder), + "owner" => Ok(Self::Owner), + "contributor" => Ok(Self::Contributor), + _ => Err(rusqlite::Error::FromSqlConversionFailure( + 0, + rusqlite::types::Type::Text, + format!("unknown Ticket assignment role `{value}`").into(), + )), + } + } + + pub fn is_singleton(self) -> bool { + matches!(self, Self::Orchestrator | Self::Coder) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum TicketAssignmentPrincipal { + User { + account_id: String, + }, + Worker { + runtime_id: String, + worker_id: String, + }, + WorkspaceAgent { + agent_key: String, + }, +} + +impl TicketAssignmentPrincipal { + fn kind(&self) -> &'static str { + match self { + Self::User { .. } => "user", + Self::Worker { .. } => "worker", + Self::WorkspaceAgent { .. } => "workspace_agent", + } + } + + pub fn worker(&self) -> Option { + match self { + Self::Worker { + runtime_id, + worker_id, + } => Some(RuntimeWorkerRef::new(runtime_id.clone(), worker_id.clone())), + _ => None, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct TicketWorkerAssignmentRecord { +pub struct TicketRoleAssignmentRecord { + pub workspace_id: String, + pub ticket_id: String, + pub assignment_id: String, + pub role: TicketAssignmentRole, + pub principal: TicketAssignmentPrincipal, + pub assigned_by: String, + pub assigned_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TicketCoderAssignmentRecord { pub workspace_id: String, pub ticket_id: String, pub assignment_id: String, @@ -455,7 +544,7 @@ pub struct TicketWorkerAssignmentRecord { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct TicketWorkerAssignmentEventRecord { +pub struct TicketCoderAssignmentEventRecord { pub workspace_id: String, pub ticket_id: String, pub event_id: String, @@ -468,8 +557,8 @@ pub struct TicketWorkerAssignmentEventRecord { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct TicketWorkerAssignmentUpdate { - pub current: TicketWorkerAssignmentRecord, - pub previous: Option, + pub current: TicketCoderAssignmentRecord, + pub previous: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -955,14 +1044,51 @@ pub trait ControlPlaneStore: Send + Sync { workspace_id: &str, operation_id: &str, ) -> Result<()>; - fn get_current_ticket_worker_assignment( + fn list_current_ticket_role_assignments( &self, workspace_id: &str, ticket_id: &str, - ) -> Result>; - fn set_current_ticket_worker_assignment( + ) -> Result>; + fn get_current_ticket_role_assignment( &self, - record: &TicketWorkerAssignmentRecord, + workspace_id: &str, + ticket_id: &str, + role: TicketAssignmentRole, + ) -> Result>; + fn set_current_ticket_role_assignment( + &self, + record: &TicketRoleAssignmentRecord, + expected_assignment_id: Option<&str>, + event_id: &str, + operation_id: &str, + allow_reassign: bool, + ) -> Result; + fn start_ready_ticket_with_coder_assignment( + &self, + record: &TicketRoleAssignmentRecord, + event_id: &str, + operation_id: &str, + ) -> Result; + fn clear_current_ticket_role_assignment( + &self, + 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>, + ) -> Result; + fn get_current_ticket_coder_assignment( + &self, + workspace_id: &str, + ticket_id: &str, + ) -> Result>; + fn set_current_ticket_coder_assignment( + &self, + record: &TicketCoderAssignmentRecord, expected_assignment_id: Option<&str>, event_id: &str, operation_id: &str, @@ -977,13 +1103,13 @@ pub trait ControlPlaneStore: Send + Sync { event_id: &str, actor: &str, created_at: &str, - ) -> Result>; - fn list_ticket_worker_assignment_events( + ) -> Result>; + fn list_ticket_coder_assignment_events( &self, workspace_id: &str, ticket_id: &str, limit: usize, - ) -> Result>; + ) -> Result>; fn upsert_workdir_registry(&self, record: &WorkdirRegistryRecord) -> Result<()>; fn get_workdir_registry( @@ -3434,11 +3560,595 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } - fn get_current_ticket_worker_assignment( + fn list_current_ticket_role_assignments( &self, workspace_id: &str, ticket_id: &str, - ) -> Result> { + ) -> Result> { + self.with_conn(|conn| { + let sql = ticket_role_assignment_select_sql( + "WHERE current.workspace_id = ?1 AND current.ticket_id = ?2 \ + ORDER BY current.role, a.assigned_at, a.assignment_id", + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map( + params![workspace_id, ticket_id], + read_ticket_role_assignment_record, + )?; + rows.collect::>>() + .map_err(Into::into) + }) + } + + fn get_current_ticket_role_assignment( + &self, + workspace_id: &str, + ticket_id: &str, + role: TicketAssignmentRole, + ) -> Result> { + self.with_conn(|conn| { + let sql = ticket_role_assignment_select_sql( + "WHERE current.workspace_id = ?1 AND current.ticket_id = ?2 AND current.role = ?3 \ + ORDER BY a.assigned_at, a.assignment_id LIMIT 1", + ); + Ok(conn + .query_row( + &sql, + params![workspace_id, ticket_id, role.as_str()], + read_ticket_role_assignment_record, + ) + .optional()?) + }) + } + + fn set_current_ticket_role_assignment( + &self, + record: &TicketRoleAssignmentRecord, + expected_assignment_id: Option<&str>, + event_id: &str, + operation_id: &str, + allow_reassign: bool, + ) -> Result { + validate_ticket_assignment_role_principal(record.role, &record.principal)?; + let principal_json = serde_json::to_string(&record.principal).map_err(|error| { + Error::Store(format!("serialize Ticket assignment principal: {error}")) + })?; + let mut hasher = Sha256::new(); + hasher.update(b"ticket-role-assignment:set:v1\0"); + 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" }, + ] { + hasher.update(value.as_bytes()); + hasher.update([0]); + } + let request_fingerprint = hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let (principal_id, runtime_id, worker_id) = match &record.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) + } + }; + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + + let existing_operation: Option<(String, Option)> = tx + .query_row( + "SELECT request_fingerprint, assignment_id + FROM ticket_assignment_operations + WHERE workspace_id = ?1 AND operation_id = ?2", + params![record.workspace_id, operation_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + if let Some((persisted_fingerprint, assignment_id)) = existing_operation { + if persisted_fingerprint != request_fingerprint { + return Err(Error::TicketAssignmentConflict(format!( + "operation `{operation_id}` was already used for different Ticket assignment input" + ))); + } + let assignment_id = assignment_id.ok_or_else(|| { + Error::Store(format!( + "Ticket role assignment operation `{operation_id}` is missing assignment identity" + )) + })?; + let persisted = read_ticket_role_assignment_by_id(&tx, &record.workspace_id, &assignment_id)? + .ok_or_else(|| { + Error::TicketAssignmentConflict(format!( + "assignment `{assignment_id}` recorded by operation `{operation_id}` no longer exists" + )) + })?; + tx.commit()?; + return Ok(persisted); + } + + let current_sql = ticket_role_assignment_select_sql( + "WHERE current.workspace_id = ?1 AND current.ticket_id = ?2 AND current.role = ?3 \ + ORDER BY a.assigned_at, a.assignment_id", + ); + let mut current_stmt = tx.prepare(¤t_sql)?; + let current = current_stmt + .query_map( + params![record.workspace_id, record.ticket_id, record.role.as_str()], + read_ticket_role_assignment_record, + )? + .collect::>>()?; + drop(current_stmt); + + let replaced = if let Some(expected_assignment_id) = expected_assignment_id { + let Some(expected) = current + .iter() + .find(|assignment| assignment.assignment_id == expected_assignment_id) + else { + return Err(Error::TicketAssignmentConflict(format!( + "Ticket `{}` role `{}` expected assignment `{expected_assignment_id}`, current is {:?}", + record.ticket_id, + record.role.as_str(), + current.first().map(|value| value.assignment_id.as_str()) + ))); + }; + if !allow_reassign { + return Err(Error::TicketAssignmentConflict(format!( + "Ticket `{}` role `{}` is already assigned as `{}`", + record.ticket_id, + record.role.as_str(), + expected.assignment_id + ))); + } + Some(expected.clone()) + } else if record.role.is_singleton() && !current.is_empty() { + return Err(Error::TicketAssignmentConflict(format!( + "Ticket `{}` already has an active `{}` assignment `{}`", + record.ticket_id, + record.role.as_str(), + current[0].assignment_id + ))); + } else { + None + }; + + tx.execute( + "INSERT 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, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + params![ + record.workspace_id, + operation_id, + if replaced.is_some() { "reassign" } else { "assign" }, + record.ticket_id, + record.role.as_str(), + record.principal.kind(), + principal_id, + runtime_id, + worker_id, + record.assignment_id, + expected_assignment_id, + record.assigned_at, + request_fingerprint, + ], + )?; + tx.execute( + "INSERT INTO ticket_worker_assignments ( + workspace_id, ticket_id, assignment_id, role, principal_kind, + principal_id, runtime_id, worker_id, assigned_by, assigned_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![ + record.workspace_id, + record.ticket_id, + record.assignment_id, + record.role.as_str(), + record.principal.kind(), + principal_id, + runtime_id, + worker_id, + record.assigned_by, + record.assigned_at, + ], + )?; + if let Some(previous) = &replaced { + tx.execute( + "DELETE FROM ticket_current_worker_assignments + WHERE workspace_id = ?1 AND ticket_id = ?2 AND role = ?3 AND assignment_id = ?4", + params![ + record.workspace_id, + record.ticket_id, + record.role.as_str(), + previous.assignment_id, + ], + )?; + } + tx.execute( + "INSERT INTO ticket_current_worker_assignments ( + workspace_id, ticket_id, role, assignment_id, principal_kind, + principal_id, runtime_id, worker_id, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + record.workspace_id, + record.ticket_id, + record.role.as_str(), + record.assignment_id, + record.principal.kind(), + principal_id, + runtime_id, + worker_id, + record.assigned_at, + ], + )?; + 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 + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![ + record.workspace_id, + record.ticket_id, + record.role.as_str(), + event_id, + if replaced.is_some() { "reassigned" } else { "assigned" }, + record.assignment_id, + replaced.as_ref().map(|value| value.assignment_id.as_str()), + record.assigned_by, + record.assigned_at, + operation_id, + ], + )?; + tx.commit()?; + Ok(record.clone()) + }) + } + + fn start_ready_ticket_with_coder_assignment( + &self, + record: &TicketRoleAssignmentRecord, + event_id: &str, + operation_id: &str, + ) -> Result { + if record.role != TicketAssignmentRole::Coder + || !matches!( + record.principal, + TicketAssignmentPrincipal::Worker { .. } | TicketAssignmentPrincipal::User { .. } + ) + { + return Err(Error::TicketAssignmentConflict( + "manual Ticket start requires a Coder user or Worker principal".to_string(), + )); + } + let (principal_id, runtime_id, worker_id) = match &record.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 { .. } => unreachable!(), + }; + let principal_json = serde_json::to_string(&record.principal).map_err(|error| { + Error::Store(format!("serialize Ticket assignment principal: {error}")) + })?; + let mut hasher = Sha256::new(); + for value in [ + "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]); + } + let fingerprint = hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let existing_operation: Option<(String, Option)> = tx + .query_row( + "SELECT request_fingerprint, assignment_id FROM ticket_assignment_operations + WHERE workspace_id = ?1 AND operation_id = ?2", + params![record.workspace_id, operation_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + if let Some((persisted, assignment_id)) = existing_operation { + if persisted != fingerprint { + return Err(Error::TicketAssignmentConflict(format!( + "operation `{operation_id}` was already used for different manual Coder assignment input" + ))); + } + let assignment_id = assignment_id.ok_or_else(|| { + Error::TicketAssignmentConflict(format!( + "manual Coder operation `{operation_id}` has no result" + )) + })?; + return read_ticket_role_assignment_by_id(&tx, &record.workspace_id, &assignment_id)? + .ok_or_else(|| Error::TicketAssignmentConflict(format!( + "manual Coder assignment `{assignment_id}` no longer exists" + ))); + } + + let (state, repository_id, ref_selector): (String, Option, Option) = tx + .query_row( + "SELECT workflow_state, repository_id, ref_selector FROM typed_tickets + WHERE workspace_id = ?1 AND ticket_id = ?2", + params![record.workspace_id, record.ticket_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + if state != "ready" { + return Err(Error::TicketAssignmentConflict(format!( + "manual Coder assignment requires ready Ticket; current state is `{state}`" + ))); + } + if repository_id.as_deref().is_none_or(str::is_empty) + || ref_selector.as_deref().is_none_or(str::is_empty) + { + return Err(Error::TicketAssignmentConflict( + "manual Coder assignment requires a valid repository/ref target".to_string(), + )); + } + let unresolved_blockers: i64 = tx.query_row( + "SELECT COUNT(*) + FROM typed_ticket_relations AS relation + JOIN typed_tickets AS related + ON related.workspace_id = relation.workspace_id + AND related.ticket_id = CASE + WHEN relation.ticket_id = ?2 THEN relation.target + ELSE relation.ticket_id + END + WHERE relation.workspace_id = ?1 + AND ( + (relation.ticket_id = ?2 AND relation.kind = 'depends_on') + OR (relation.target = ?2 AND relation.kind = 'blocks') + ) + AND related.workflow_state NOT IN ('done', 'closed')", + params![record.workspace_id, record.ticket_id], + |row| row.get(0), + )?; + if unresolved_blockers != 0 { + return Err(Error::TicketAssignmentConflict( + "manual Coder assignment is blocked by unresolved Ticket relations".to_string(), + )); + } + let conflicting: i64 = tx.query_row( + "SELECT COUNT(*) FROM ticket_current_worker_assignments + WHERE workspace_id = ?1 AND ticket_id = ?2 + AND role IN ('orchestrator', 'coder')", + params![record.workspace_id, record.ticket_id], + |row| row.get(0), + )?; + if conflicting != 0 { + return Err(Error::TicketAssignmentConflict( + "manual Coder assignment requires no active Orchestrator or Coder assignment" + .to_string(), + )); + } + + tx.execute( + "INSERT INTO ticket_assignment_operations ( + workspace_id, operation_id, action, ticket_id, role, principal_kind, + principal_id, runtime_id, worker_id, assignment_id, created_at, + request_fingerprint + ) VALUES (?1, ?2, 'assign', ?3, 'coder', ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![record.workspace_id, operation_id, record.ticket_id, + record.principal.kind(), principal_id, runtime_id, worker_id, + record.assignment_id, record.assigned_at, fingerprint], + )?; + tx.execute( + "INSERT INTO ticket_worker_assignments ( + workspace_id, ticket_id, assignment_id, role, principal_kind, + principal_id, runtime_id, worker_id, assigned_by, assigned_at + ) VALUES (?1, ?2, ?3, 'coder', ?4, ?5, ?6, ?7, ?8, ?9)", + params![record.workspace_id, record.ticket_id, record.assignment_id, + record.principal.kind(), principal_id, runtime_id, worker_id, + record.assigned_by, record.assigned_at], + )?; + tx.execute( + "INSERT INTO ticket_current_worker_assignments ( + workspace_id, ticket_id, role, assignment_id, principal_kind, + principal_id, runtime_id, worker_id, updated_at + ) VALUES (?1, ?2, 'coder', ?3, ?4, ?5, ?6, ?7, ?8)", + params![record.workspace_id, record.ticket_id, record.assignment_id, + record.principal.kind(), principal_id, runtime_id, worker_id, + record.assigned_at], + )?; + tx.execute( + "INSERT INTO ticket_worker_assignment_events ( + workspace_id, ticket_id, role, event_id, action, assignment_id, + actor, created_at, operation_id + ) VALUES (?1, ?2, 'coder', ?3, 'assigned', ?4, ?5, ?6, ?7)", + params![record.workspace_id, record.ticket_id, event_id, record.assignment_id, + record.assigned_by, record.assigned_at, operation_id], + )?; + 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![record.workspace_id, record.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, 'ready', 'inprogress', + 'manual Coder assignment accepted', 'state', 'State changed', '')", + params![record.workspace_id, record.ticket_id, event_index, + record.assigned_by, record.assigned_at], + )?; + for (key, value) in [ + ("event_id", event_id), + ("assignment_id", record.assignment_id.as_str()), + ("assignment_role", "coder"), + ("operation_id", operation_id), + ("request_fingerprint", fingerprint.as_str()), + ] { + tx.execute( + "INSERT INTO typed_ticket_event_attributes ( + workspace_id, ticket_id, event_index, key, value + ) VALUES (?1, ?2, ?3, ?4, ?5)", + params![record.workspace_id, record.ticket_id, event_index, key, value], + )?; + } + let updated = tx.execute( + "UPDATE typed_tickets SET workflow_state = 'inprogress', + workflow_state_explicit = 1, updated_at = ?3 + WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = 'ready'", + params![record.workspace_id, record.ticket_id, record.assigned_at], + )?; + if updated != 1 { + return Err(Error::TicketAssignmentConflict( + "Ticket state changed during manual Coder assignment".to_string(), + )); + } + tx.commit()?; + Ok(record.clone()) + }) + } + + fn clear_current_ticket_role_assignment( + &self, + 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>, + ) -> 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, + occurred_at, + 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" + ))); + } + } + 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, + ], + )?; + } + tx.commit()?; + Ok(deleted != 0) + }) + } + + fn get_current_ticket_coder_assignment( + &self, + workspace_id: &str, + ticket_id: &str, + ) -> Result> { self.with_conn(|conn| { conn.query_row( current_ticket_worker_assignment_select_sql().as_str(), @@ -3450,9 +4160,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } - fn set_current_ticket_worker_assignment( + fn set_current_ticket_coder_assignment( &self, - record: &TicketWorkerAssignmentRecord, + record: &TicketCoderAssignmentRecord, expected_assignment_id: Option<&str>, event_id: &str, operation_id: &str, @@ -3579,7 +4289,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { tx.execute( r#"UPDATE ticket_current_worker_assignments SET assignment_id = ?3, runtime_id = ?4, worker_id = ?5, updated_at = ?6 - WHERE workspace_id = ?1 AND ticket_id = ?2"#, + WHERE workspace_id = ?1 AND ticket_id = ?2 AND role = 'coder'"#, params![ record.workspace_id, record.ticket_id, @@ -3681,7 +4391,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { event_id: &str, actor: &str, created_at: &str, - ) -> Result> { + ) -> Result> { self.with_conn(|conn| { let tx = conn.unchecked_transaction()?; if let Some(existing) = read_assignment_operation(&tx, workspace_id, operation_id)? { @@ -3722,7 +4432,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { return Ok(None); }; tx.execute( - "DELETE FROM ticket_current_worker_assignments WHERE workspace_id = ?1 AND ticket_id = ?2", + "DELETE FROM ticket_current_worker_assignments WHERE workspace_id = ?1 AND ticket_id = ?2 AND role = 'coder'", params![workspace_id, ticket_id], )?; tx.execute( @@ -3760,18 +4470,18 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } - fn list_ticket_worker_assignment_events( + fn list_ticket_coder_assignment_events( &self, workspace_id: &str, ticket_id: &str, limit: usize, - ) -> Result> { + ) -> Result> { self.with_conn(|conn| { let mut stmt = conn.prepare( r#"SELECT workspace_id, ticket_id, event_id, action, assignment_id, previous_assignment_id, actor, created_at FROM ticket_worker_assignment_events - WHERE workspace_id = ?1 AND ticket_id = ?2 + WHERE workspace_id = ?1 AND ticket_id = ?2 AND role = 'coder' ORDER BY created_at DESC, event_id DESC LIMIT ?3"#, )?; @@ -4664,6 +5374,103 @@ fn read_worker_control_grant_by_operation( .map_err(Error::from) } +fn read_ticket_role_assignment_record( + row: &rusqlite::Row<'_>, +) -> rusqlite::Result { + let role = TicketAssignmentRole::from_db(row.get::<_, String>(3)?.as_str())?; + let principal_kind: String = row.get(4)?; + let principal_id: Option = row.get(5)?; + let runtime_id: Option = row.get(6)?; + let worker_id: Option = row.get(7)?; + let principal = match principal_kind.as_str() { + "user" => TicketAssignmentPrincipal::User { + account_id: principal_id.ok_or_else(|| rusqlite::Error::InvalidQuery)?, + }, + "worker" => TicketAssignmentPrincipal::Worker { + runtime_id: runtime_id.ok_or_else(|| rusqlite::Error::InvalidQuery)?, + worker_id: worker_id.ok_or_else(|| rusqlite::Error::InvalidQuery)?, + }, + "workspace_agent" => TicketAssignmentPrincipal::WorkspaceAgent { + agent_key: principal_id.ok_or_else(|| rusqlite::Error::InvalidQuery)?, + }, + _ => { + return Err(rusqlite::Error::FromSqlConversionFailure( + 4, + rusqlite::types::Type::Text, + format!("unknown Ticket assignment principal kind `{principal_kind}`").into(), + )); + } + }; + Ok(TicketRoleAssignmentRecord { + workspace_id: row.get(0)?, + ticket_id: row.get(1)?, + assignment_id: row.get(2)?, + role, + principal, + assigned_by: row.get(8)?, + assigned_at: row.get(9)?, + }) +} + +fn read_ticket_role_assignment_by_id( + conn: &Connection, + workspace_id: &str, + assignment_id: &str, +) -> Result> { + Ok(conn + .query_row( + "SELECT workspace_id, ticket_id, assignment_id, role, principal_kind, + principal_id, runtime_id, worker_id, assigned_by, assigned_at + FROM ticket_worker_assignments + WHERE workspace_id = ?1 AND assignment_id = ?2", + params![workspace_id, assignment_id], + read_ticket_role_assignment_record, + ) + .optional()?) +} + +fn ticket_role_assignment_select_sql(where_clause: &str) -> String { + format!( + "SELECT a.workspace_id, a.ticket_id, a.assignment_id, a.role, \ + a.principal_kind, a.principal_id, a.runtime_id, a.worker_id, \ + a.assigned_by, a.assigned_at \ + FROM ticket_current_worker_assignments AS current \ + JOIN ticket_worker_assignments AS a \ + ON a.workspace_id = current.workspace_id \ + AND a.ticket_id = current.ticket_id \ + AND a.role = current.role \ + AND a.assignment_id = current.assignment_id \ + {where_clause}" + ) +} + +fn validate_ticket_assignment_role_principal( + role: TicketAssignmentRole, + principal: &TicketAssignmentPrincipal, +) -> Result<()> { + let valid = match role { + TicketAssignmentRole::Orchestrator => matches!( + principal, + TicketAssignmentPrincipal::WorkspaceAgent { agent_key } + if agent_key == "workspace-orchestrator" + ), + TicketAssignmentRole::Coder => { + matches!(principal, TicketAssignmentPrincipal::Worker { .. }) + } + TicketAssignmentRole::Owner => matches!(principal, TicketAssignmentPrincipal::User { .. }), + TicketAssignmentRole::Contributor => true, + }; + if valid { + Ok(()) + } else { + Err(Error::TicketAssignmentConflict(format!( + "principal kind `{}` is not valid for Ticket role `{}`", + principal.kind(), + role.as_str() + ))) + } +} + 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 \ @@ -4671,15 +5478,16 @@ fn current_ticket_worker_assignment_select_sql() -> String { JOIN ticket_worker_assignments AS a \ ON a.workspace_id = current.workspace_id \ AND a.ticket_id = current.ticket_id \ + AND a.role = current.role \ AND a.assignment_id = current.assignment_id \ - WHERE current.workspace_id = ?1 AND current.ticket_id = ?2" + WHERE current.workspace_id = ?1 AND current.ticket_id = ?2 AND current.role = 'coder'" .to_owned() } fn read_ticket_worker_assignment_record( row: &rusqlite::Row<'_>, -) -> rusqlite::Result { - Ok(TicketWorkerAssignmentRecord { +) -> rusqlite::Result { + Ok(TicketCoderAssignmentRecord { workspace_id: row.get(0)?, ticket_id: row.get(1)?, assignment_id: row.get(2)?, @@ -4691,8 +5499,8 @@ fn read_ticket_worker_assignment_record( fn read_ticket_worker_assignment_event_record( row: &rusqlite::Row<'_>, -) -> rusqlite::Result { - Ok(TicketWorkerAssignmentEventRecord { +) -> rusqlite::Result { + Ok(TicketCoderAssignmentEventRecord { workspace_id: row.get(0)?, ticket_id: row.get(1)?, event_id: row.get(2)?, @@ -4758,7 +5566,7 @@ fn map_assignment_constraint(error: rusqlite::Error, ticket_id: &str, worker_id: fn require_expected_ticket_assignment( ticket_id: &str, - current: Option<&TicketWorkerAssignmentRecord>, + current: Option<&TicketCoderAssignmentRecord>, expected_assignment_id: Option<&str>, ) -> Result<()> { let Some(expected_assignment_id) = expected_assignment_id else { @@ -6676,6 +7484,351 @@ fn create_workspace_memory_settings_authority(conn: &Connection) -> Result<()> { Ok(()) } +fn generalize_ticket_role_assignments(conn: &Connection) -> Result<()> { + let invalid_current: i64 = conn.query_row( + r#" + SELECT COUNT(*) + FROM ticket_current_worker_assignments AS current + LEFT JOIN typed_tickets AS ticket + ON ticket.workspace_id = current.workspace_id + AND ticket.ticket_id = current.ticket_id + LEFT JOIN worker_registry AS worker + ON worker.workspace_id = current.workspace_id + AND worker.runtime_id = current.runtime_id + AND worker.worker_id = current.worker_id + WHERE ticket.ticket_id IS NULL OR worker.worker_id IS NULL + "#, + [], + |row| row.get(0), + )?; + if invalid_current != 0 { + return Err(Error::Store(format!( + "migration 43 cannot classify {invalid_current} legacy current Ticket assignment(s) as valid Coder Worker principals" + ))); + } + + conn.execute_batch( + r#" + DROP INDEX IF EXISTS idx_ticket_worker_assignments_ticket; + DROP INDEX IF EXISTS idx_ticket_worker_assignments_worker; + DROP INDEX IF EXISTS idx_ticket_current_worker_assignments_worker; + DROP INDEX IF EXISTS idx_ticket_worker_assignment_events_ticket; + DROP INDEX IF EXISTS idx_ticket_assignment_operations_ticket; + DROP TRIGGER IF EXISTS ticket_assignment_ticket_parent_tombstone; + DROP TRIGGER IF EXISTS ticket_assignment_worker_parent_tombstone_delete; + DROP TRIGGER IF EXISTS ticket_assignment_worker_parent_tombstone_move; + DROP TRIGGER IF EXISTS ticket_worker_assignments_validate_insert; + DROP TRIGGER IF EXISTS ticket_worker_assignments_validate_update; + DROP TRIGGER IF EXISTS ticket_worker_assignment_events_validate_insert; + DROP TRIGGER IF EXISTS ticket_assignment_operations_validate_insert; + + ALTER TABLE ticket_worker_assignments RENAME TO ticket_worker_assignments_v43; + ALTER TABLE ticket_current_worker_assignments RENAME TO ticket_current_worker_assignments_v43; + ALTER TABLE ticket_worker_assignment_events RENAME TO ticket_worker_assignment_events_v43; + ALTER TABLE ticket_assignment_operations RENAME TO ticket_assignment_operations_v43; + + CREATE TABLE ticket_worker_assignments ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + assignment_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'coder' + CHECK(role IN ('orchestrator', 'coder', 'owner', 'contributor')), + principal_kind TEXT NOT NULL DEFAULT 'worker' + CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')), + principal_id TEXT, + runtime_id TEXT, + worker_id TEXT, + assigned_by TEXT NOT NULL, + assigned_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, assignment_id), + UNIQUE (workspace_id, ticket_id, role, assignment_id), + UNIQUE (workspace_id, ticket_id, role, assignment_id, principal_kind, principal_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + CHECK( + (principal_kind = 'worker' AND runtime_id IS NOT NULL AND worker_id IS NOT NULL AND principal_id IS NULL) + OR + (principal_kind != 'worker' AND runtime_id IS NULL AND worker_id IS NULL AND principal_id IS NOT NULL AND length(trim(principal_id)) > 0) + ) + ); + CREATE INDEX idx_ticket_worker_assignments_ticket + ON ticket_worker_assignments(workspace_id, ticket_id, role, assigned_at DESC); + CREATE INDEX idx_ticket_worker_assignments_principal + ON ticket_worker_assignments(workspace_id, role, principal_kind, principal_id, runtime_id, worker_id, assigned_at DESC); + + CREATE TABLE ticket_current_worker_assignments ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'coder' + CHECK(role IN ('orchestrator', 'coder', 'owner', 'contributor')), + assignment_id TEXT NOT NULL, + principal_kind TEXT NOT NULL DEFAULT 'worker' + CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')), + principal_id TEXT, + runtime_id TEXT, + worker_id TEXT, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, role, assignment_id), + UNIQUE (workspace_id, assignment_id), + FOREIGN KEY (workspace_id, ticket_id, role, assignment_id) + REFERENCES ticket_worker_assignments(workspace_id, ticket_id, role, assignment_id) + ON DELETE CASCADE, + FOREIGN KEY (workspace_id, ticket_id) + REFERENCES typed_tickets(workspace_id, ticket_id) + ON DELETE CASCADE, + FOREIGN KEY (workspace_id, runtime_id, worker_id) + REFERENCES worker_registry(workspace_id, runtime_id, worker_id) + ON DELETE RESTRICT, + CHECK( + (principal_kind = 'worker' AND runtime_id IS NOT NULL AND worker_id IS NOT NULL AND principal_id IS NULL) + OR + (principal_kind != 'worker' AND runtime_id IS NULL AND worker_id IS NULL AND principal_id IS NOT NULL AND length(trim(principal_id)) > 0) + ) + ); + CREATE UNIQUE INDEX ticket_current_singleton_role_idx + ON ticket_current_worker_assignments(workspace_id, ticket_id, role) + WHERE role IN ('orchestrator', 'coder'); + CREATE UNIQUE INDEX ticket_current_worker_role_idx + ON ticket_current_worker_assignments(workspace_id, role, runtime_id, worker_id) + WHERE principal_kind = 'worker'; + CREATE INDEX ticket_current_principal_idx + ON ticket_current_worker_assignments(workspace_id, principal_kind, principal_id, runtime_id, worker_id); + + CREATE TABLE ticket_worker_assignment_events ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'coder' + CHECK(role IN ('orchestrator', 'coder', 'owner', 'contributor')), + event_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('assigned', 'reassigned', 'unassigned')), + assignment_id TEXT, + previous_assignment_id TEXT, + actor TEXT NOT NULL, + created_at TEXT NOT NULL, + operation_id TEXT, + reason TEXT, + PRIMARY KEY (workspace_id, event_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + CREATE INDEX idx_ticket_worker_assignment_events_ticket + ON ticket_worker_assignment_events(workspace_id, ticket_id, role, created_at DESC); + + CREATE TABLE ticket_assignment_operations ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('assign', 'reassign', 'unassign')), + ticket_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'coder' + CHECK(role IN ('orchestrator', 'coder', 'owner', 'contributor')), + principal_kind TEXT NOT NULL DEFAULT 'worker' + CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')), + principal_id TEXT, + runtime_id TEXT, + worker_id TEXT, + assignment_id TEXT, + expected_assignment_id TEXT, + created_at TEXT NOT NULL, + request_fingerprint TEXT, + PRIMARY KEY (workspace_id, operation_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + CREATE INDEX idx_ticket_assignment_operations_ticket + ON ticket_assignment_operations(workspace_id, ticket_id, role, created_at DESC); + + INSERT INTO ticket_worker_assignments ( + workspace_id, ticket_id, assignment_id, role, principal_kind, + runtime_id, worker_id, assigned_by, assigned_at + ) + SELECT workspace_id, ticket_id, assignment_id, 'coder', 'worker', + runtime_id, worker_id, assigned_by, assigned_at + FROM ticket_worker_assignments_v43; + + INSERT INTO ticket_current_worker_assignments ( + workspace_id, ticket_id, role, assignment_id, principal_kind, + runtime_id, worker_id, updated_at + ) + SELECT workspace_id, ticket_id, 'coder', assignment_id, 'worker', + runtime_id, worker_id, updated_at + FROM ticket_current_worker_assignments_v43; + + INSERT INTO ticket_worker_assignment_events ( + workspace_id, ticket_id, role, event_id, action, assignment_id, + previous_assignment_id, actor, created_at + ) + SELECT workspace_id, ticket_id, 'coder', event_id, action, assignment_id, + previous_assignment_id, actor, created_at + FROM ticket_worker_assignment_events_v43; + + INSERT INTO ticket_assignment_operations ( + workspace_id, operation_id, action, ticket_id, role, principal_kind, + runtime_id, worker_id, assignment_id, expected_assignment_id, + created_at, request_fingerprint + ) + SELECT workspace_id, operation_id, action, ticket_id, 'coder', 'worker', + runtime_id, worker_id, assignment_id, expected_assignment_id, + created_at, request_fingerprint + FROM ticket_assignment_operations_v43; + + DROP TABLE ticket_current_worker_assignments_v43; + DROP TABLE ticket_worker_assignment_events_v43; + DROP TABLE ticket_assignment_operations_v43; + DROP TABLE ticket_worker_assignments_v43; + + CREATE TRIGGER ticket_assignment_ticket_parent_tombstone + BEFORE DELETE ON typed_tickets + WHEN EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = OLD.workspace_id + AND assignment.ticket_id = OLD.ticket_id + ) + BEGIN + INSERT OR IGNORE INTO ticket_assignment_ticket_tombstones ( + workspace_id, ticket_id, deleted_at + ) VALUES (OLD.workspace_id, OLD.ticket_id, CURRENT_TIMESTAMP); + END; + + CREATE TRIGGER ticket_assignment_worker_parent_tombstone_delete + BEFORE DELETE ON worker_registry + WHEN EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = OLD.workspace_id + AND assignment.principal_kind = 'worker' + AND assignment.runtime_id = OLD.runtime_id + AND assignment.worker_id = OLD.worker_id + ) + BEGIN + INSERT OR IGNORE INTO ticket_assignment_worker_tombstones ( + workspace_id, runtime_id, worker_id, deleted_at + ) VALUES (OLD.workspace_id, OLD.runtime_id, OLD.worker_id, CURRENT_TIMESTAMP); + END; + + CREATE TRIGGER ticket_assignment_worker_parent_tombstone_move + BEFORE UPDATE OF runtime_id ON worker_registry + WHEN OLD.runtime_id != NEW.runtime_id + AND EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = OLD.workspace_id + AND assignment.principal_kind = 'worker' + AND assignment.runtime_id = OLD.runtime_id + AND assignment.worker_id = OLD.worker_id + ) + BEGIN + INSERT OR IGNORE INTO ticket_assignment_worker_tombstones ( + workspace_id, runtime_id, worker_id, deleted_at + ) VALUES (OLD.workspace_id, OLD.runtime_id, OLD.worker_id, CURRENT_TIMESTAMP); + END; + + CREATE TRIGGER ticket_worker_assignments_validate_insert + BEFORE INSERT ON ticket_worker_assignments + WHEN NOT EXISTS ( + SELECT 1 FROM typed_tickets AS ticket + WHERE ticket.workspace_id = NEW.workspace_id AND ticket.ticket_id = NEW.ticket_id + ) + OR (NEW.principal_kind = 'worker' AND NOT EXISTS ( + SELECT 1 FROM worker_registry AS worker + WHERE worker.workspace_id = NEW.workspace_id + AND worker.runtime_id = NEW.runtime_id + AND worker.worker_id = NEW.worker_id + )) + OR (NEW.principal_kind = 'user' AND NOT EXISTS ( + SELECT 1 FROM accounts + WHERE account_id = NEW.principal_id AND kind = 'user' + )) + OR (NEW.principal_kind = 'workspace_agent' AND NEW.principal_id != 'workspace-orchestrator') + BEGIN + SELECT RAISE(ABORT, 'Ticket assignment principal is not valid in this Workspace'); + END; + + CREATE TRIGGER ticket_worker_assignments_validate_update + BEFORE UPDATE OF workspace_id, ticket_id, principal_kind, principal_id, runtime_id, worker_id + ON ticket_worker_assignments + WHEN NOT EXISTS ( + SELECT 1 FROM typed_tickets AS ticket + WHERE ticket.workspace_id = NEW.workspace_id AND ticket.ticket_id = NEW.ticket_id + ) + OR (NEW.principal_kind = 'worker' AND NOT EXISTS ( + SELECT 1 FROM worker_registry AS worker + WHERE worker.workspace_id = NEW.workspace_id + AND worker.runtime_id = NEW.runtime_id + AND worker.worker_id = NEW.worker_id + )) + OR (NEW.principal_kind = 'user' AND NOT EXISTS ( + SELECT 1 FROM accounts + WHERE account_id = NEW.principal_id AND kind = 'user' + )) + OR (NEW.principal_kind = 'workspace_agent' AND NEW.principal_id != 'workspace-orchestrator') + BEGIN + SELECT RAISE(ABORT, 'Ticket assignment principal is not valid in this Workspace'); + END; + + CREATE TRIGGER ticket_worker_assignment_events_validate_insert + BEFORE INSERT ON ticket_worker_assignment_events + WHEN (NEW.assignment_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = NEW.workspace_id + AND assignment.ticket_id = NEW.ticket_id + AND assignment.role = NEW.role + AND assignment.assignment_id = NEW.assignment_id + )) + OR (NEW.previous_assignment_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = NEW.workspace_id + AND assignment.ticket_id = NEW.ticket_id + AND assignment.role = NEW.role + AND assignment.assignment_id = NEW.previous_assignment_id + )) + BEGIN + SELECT RAISE(ABORT, 'Ticket assignment event must reference the same Ticket role'); + END; + + CREATE TRIGGER ticket_assignment_operations_validate_insert + BEFORE INSERT ON ticket_assignment_operations + WHEN NOT EXISTS ( + SELECT 1 FROM typed_tickets AS ticket + WHERE ticket.workspace_id = NEW.workspace_id AND ticket.ticket_id = NEW.ticket_id + ) + BEGIN + SELECT RAISE(ABORT, 'Ticket assignment operation must reference a Ticket in the same Workspace'); + END; + + CREATE TRIGGER ticket_assignment_user_principal_insert + BEFORE INSERT ON ticket_current_worker_assignments + WHEN NEW.principal_kind = 'user' + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM accounts + WHERE account_id = NEW.principal_id AND kind = 'user' + ) THEN RAISE(ABORT, 'Ticket assignment user principal is not a valid Account user') END; + END; + + CREATE TRIGGER ticket_assignment_user_principal_update + BEFORE UPDATE ON ticket_current_worker_assignments + WHEN NEW.principal_kind = 'user' + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM accounts + WHERE account_id = NEW.principal_id AND kind = 'user' + ) THEN RAISE(ABORT, 'Ticket assignment user principal is not a valid Account user') END; + END; + + CREATE TRIGGER ticket_assignment_workspace_agent_insert + BEFORE INSERT ON ticket_current_worker_assignments + WHEN NEW.principal_kind = 'workspace_agent' + BEGIN + SELECT CASE WHEN NEW.principal_id != 'workspace-orchestrator' + THEN RAISE(ABORT, 'Ticket assignment Workspace agent is not registered') END; + END; + + CREATE TRIGGER ticket_assignment_workspace_agent_update + BEFORE UPDATE ON ticket_current_worker_assignments + WHEN NEW.principal_kind = 'workspace_agent' + BEGIN + SELECT CASE WHEN NEW.principal_id != 'workspace-orchestrator' + THEN RAISE(ABORT, 'Ticket assignment Workspace agent is not registered') END; + END; + "#, + )?; + Ok(()) +} fn verify_workspace_resource_key_schema(conn: &Connection) -> Result<()> { ticket::migrate_sqlite_ticket_resource_key_schema_in_transaction(conn).map_err(|error| { Error::Store(format!( @@ -7985,7 +9138,7 @@ mod tests { let before = std::fs::read(&path).unwrap(); let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap(); assert_eq!(plan.current_schema_version, 36); - assert_eq!(plan.target_schema_version, 42); + assert_eq!(plan.target_schema_version, 43); assert!(plan.migration_required); assert_eq!(plan.worker_count, 1); assert_eq!(plan.mappings[0].legacy_worker_id, 7); @@ -7999,7 +9152,7 @@ mod tests { store .with_conn(|conn| { assert!(table_exists(conn, "worker_diagnostics_archives")?); - assert_eq!(current_schema_version(conn)?, 42); + assert_eq!(current_schema_version(conn)?, 43); Ok(()) }) .unwrap(); @@ -8078,7 +9231,7 @@ mod tests { ), ] ); - assert_eq!(current_schema_version(&conn).unwrap(), 42); + assert_eq!(current_schema_version(&conn).unwrap(), 43); let foreign_key_error: Option = conn .query_row("PRAGMA foreign_key_check", [], |row| row.get(0)) .optional() @@ -8207,7 +9360,7 @@ INSERT INTO worker_orphan_diagnostics ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 42); + assert_eq!(current_schema_version(&conn).unwrap(), 43); assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap()); let controller_worker_id: String = conn .query_row( @@ -8325,7 +9478,7 @@ INSERT INTO worker_orphan_diagnostics ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 42); + assert_eq!(current_schema_version(&conn).unwrap(), 43); assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap()); } @@ -8343,7 +9496,7 @@ INSERT INTO worker_orphan_diagnostics ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 42); + assert_eq!(current_schema_version(&conn).unwrap(), 43); let settings = conn .query_row( "SELECT settings_revision, language FROM workspace_memory_settings \ @@ -8384,7 +9537,7 @@ CREATE TABLE flow_events (event_id TEXT PRIMARY KEY); apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 42); + assert_eq!(current_schema_version(&conn).unwrap(), 43); assert!(table_exists(&conn, "flow_sources").unwrap()); assert!(table_exists(&conn, "flow_source_revisions").unwrap()); assert!(!table_exists(&conn, "flow_instances").unwrap()); @@ -8451,7 +9604,7 @@ INSERT INTO worker_workdir_attachment_reservations ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 42); + assert_eq!(current_schema_version(&conn).unwrap(), 43); let repositories_sql: String = conn .query_row( "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'", @@ -8629,7 +9782,7 @@ INSERT INTO workdir_registry ( let db = dir.path().join("control-plane.sqlite"); let store = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 42); + assert_eq!(store.schema_version().await.unwrap(), 43); assert!( !store .with_conn(|conn| table_exists(conn, "worker_workspace_credentials")) @@ -8646,7 +9799,7 @@ INSERT INTO workdir_registry ( store.upsert_workspace(&record).await.unwrap(); let reopened = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(reopened.schema_version().await.unwrap(), 42); + assert_eq!(reopened.schema_version().await.unwrap(), 43); assert_eq!( reopened.get_workspace("local-dev").await.unwrap(), Some(record) @@ -8976,7 +10129,7 @@ INSERT INTO worker_registry ( }) .unwrap(); - let first = TicketWorkerAssignmentRecord { + let first = TicketCoderAssignmentRecord { workspace_id: "workspace-a".to_string(), ticket_id: "ticket-1".to_string(), assignment_id: "assignment-1".to_string(), @@ -8985,13 +10138,13 @@ INSERT INTO worker_registry ( assigned_at: "2026-07-32T00:00:01Z".to_string(), }; let created = store - .set_current_ticket_worker_assignment(&first, None, "event-1", "operation-1", false) + .set_current_ticket_coder_assignment(&first, None, "event-1", "operation-1", false) .unwrap(); assert_eq!(created.current, first); assert_eq!(created.previous, None); let retried = store - .set_current_ticket_worker_assignment( - &TicketWorkerAssignmentRecord { + .set_current_ticket_coder_assignment( + &TicketCoderAssignmentRecord { assignment_id: "ignored-retry-assignment".to_string(), ..first.clone() }, @@ -9004,15 +10157,15 @@ INSERT INTO worker_registry ( assert_eq!(retried.current, first); assert_eq!( store - .list_ticket_worker_assignment_events("workspace-a", "ticket-1", 10) + .list_ticket_coder_assignment_events("workspace-a", "ticket-1", 10) .unwrap() .len(), 1, "idempotent retry must not append another assignment event" ); let implicit_reassign = store - .set_current_ticket_worker_assignment( - &TicketWorkerAssignmentRecord { + .set_current_ticket_coder_assignment( + &TicketCoderAssignmentRecord { assignment_id: "implicit-reassign".to_string(), worker: RuntimeWorkerRef::new("runtime-1", "worker-other"), ..first.clone() @@ -9028,8 +10181,8 @@ INSERT INTO worker_registry ( Error::TicketAssignmentConflict(_) )); let worker_conflict = store - .set_current_ticket_worker_assignment( - &TicketWorkerAssignmentRecord { + .set_current_ticket_coder_assignment( + &TicketCoderAssignmentRecord { ticket_id: "ticket-2".to_string(), assignment_id: "worker-conflict".to_string(), ..first.clone() @@ -9045,7 +10198,7 @@ INSERT INTO worker_registry ( Error::TicketAssignmentConflict(_) )); - let second = TicketWorkerAssignmentRecord { + let second = TicketCoderAssignmentRecord { assignment_id: "assignment-2".to_string(), worker: RuntimeWorkerRef::new("runtime-2", "worker-2"), assigned_by: "user-2".to_string(), @@ -9053,7 +10206,7 @@ INSERT INTO worker_registry ( ..first.clone() }; let replaced = store - .set_current_ticket_worker_assignment( + .set_current_ticket_coder_assignment( &second, Some("assignment-1"), "event-2", @@ -9064,7 +10217,7 @@ INSERT INTO worker_registry ( assert_eq!(replaced.current, second); assert_eq!(replaced.previous, Some(first.clone())); let replayed_reassignment = store - .set_current_ticket_worker_assignment( + .set_current_ticket_coder_assignment( &second, Some("assignment-1"), "ignored-reassign-event", @@ -9075,7 +10228,7 @@ INSERT INTO worker_registry ( assert_eq!(replayed_reassignment, replaced); assert_eq!( store - .get_current_ticket_worker_assignment("workspace-a", "ticket-1") + .get_current_ticket_coder_assignment("workspace-a", "ticket-1") .unwrap(), Some(second.clone()) ); @@ -9146,7 +10299,7 @@ INSERT INTO worker_registry ( "worker-3", ) .unwrap(); - let reserved_assignment = TicketWorkerAssignmentRecord { + let reserved_assignment = TicketCoderAssignmentRecord { workspace_id: "workspace-a".to_string(), ticket_id: "ticket-3".to_string(), assignment_id: "assignment-3".to_string(), @@ -9155,7 +10308,7 @@ INSERT INTO worker_registry ( assigned_at: "2026-07-32T00:00:06Z".to_string(), }; let completed_reservation = store - .set_current_ticket_worker_assignment( + .set_current_ticket_coder_assignment( &reserved_assignment, None, "reserved-event", @@ -9173,7 +10326,7 @@ INSERT INTO worker_registry ( ); assert_eq!( store - .get_current_ticket_worker_assignment("workspace-a", "ticket-1") + .get_current_ticket_coder_assignment("workspace-a", "ticket-1") .unwrap(), None ); @@ -9188,13 +10341,13 @@ INSERT INTO worker_registry ( ); assert_eq!( store - .get_current_ticket_worker_assignment("workspace-a", "ticket-3") + .get_current_ticket_coder_assignment("workspace-a", "ticket-3") .unwrap(), None ); assert!( store - .list_ticket_worker_assignment_events("workspace-a", "ticket-3", 10) + .list_ticket_coder_assignment_events("workspace-a", "ticket-3", 10) .unwrap() .is_empty() ); @@ -9203,7 +10356,7 @@ INSERT INTO worker_registry ( .unwrap(); let events = store - .list_ticket_worker_assignment_events("workspace-a", "ticket-1", 10) + .list_ticket_coder_assignment_events("workspace-a", "ticket-1", 10) .unwrap(); assert_eq!( events @@ -9219,19 +10372,281 @@ INSERT INTO worker_registry ( ); } + #[tokio::test] + async fn schema_v43_migrates_legacy_current_assignment_to_coder_worker_role() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("legacy-v42.db"); + let conn = Connection::open(&db_path).unwrap(); + configure_sqlite(&conn).unwrap(); + apply_migrations_through(&conn, 42).unwrap(); + let legacy = SqliteWorkspaceStore { + conn: Arc::new(Mutex::new(conn)), + }; + legacy + .upsert_workspace(&WorkspaceRecord { + workspace_id: "workspace-legacy".to_string(), + display_name: "Legacy".to_string(), + state: "active".to_string(), + owner_account_id: None, + created_at: "2026-09-01T00:00:00Z".to_string(), + updated_at: "2026-09-01T00:00:00Z".to_string(), + }) + .await + .unwrap(); + legacy + .upsert_worker_registry(&WorkerRegistryRecord { + workspace_id: "workspace-legacy".to_string(), + worker: RuntimeWorkerRef::new("runtime-legacy", "worker-legacy"), + display_name: "Legacy 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: "2026-09-01T00:00:00Z".to_string(), + updated_at: "2026-09-01T00:00:00Z".to_string(), + }) + .unwrap(); + let backend = + ticket::SqliteTicketBackend::open_verified(&db_path, "workspace-legacy").unwrap(); + let mut input = ticket::NewTicket::new("Legacy Ticket"); + input.workflow_state = Some(ticket::TicketWorkflowState::InProgress); + let ticket = ticket::TicketBackend::create(&backend, input).unwrap(); + legacy + .with_conn(|conn| { + conn.execute( + "INSERT INTO ticket_worker_assignments ( + workspace_id, ticket_id, assignment_id, runtime_id, worker_id, + assigned_by, assigned_at + ) VALUES ('workspace-legacy', ?1, 'assignment-legacy', + 'runtime-legacy', 'worker-legacy', 'legacy', + '2026-09-01T00:01:00Z')", + params![ticket.id], + )?; + conn.execute( + "INSERT INTO ticket_current_worker_assignments ( + workspace_id, ticket_id, assignment_id, runtime_id, worker_id, updated_at + ) VALUES ('workspace-legacy', ?1, 'assignment-legacy', + 'runtime-legacy', 'worker-legacy', '2026-09-01T00:01:00Z')", + params![ticket.id], + )?; + Ok(()) + }) + .unwrap(); + drop(legacy); + + let migrated = SqliteWorkspaceStore::open(&db_path).unwrap(); + assert_eq!( + migrated + .get_current_ticket_role_assignment( + "workspace-legacy", + &ticket.id, + TicketAssignmentRole::Coder, + ) + .unwrap(), + Some(TicketRoleAssignmentRecord { + workspace_id: "workspace-legacy".to_string(), + ticket_id: ticket.id, + assignment_id: "assignment-legacy".to_string(), + role: TicketAssignmentRole::Coder, + principal: TicketAssignmentPrincipal::Worker { + runtime_id: "runtime-legacy".to_string(), + worker_id: "worker-legacy".to_string(), + }, + assigned_by: "legacy".to_string(), + assigned_at: "2026-09-01T00:01:00Z".to_string(), + }) + ); + } + + #[tokio::test] + async fn role_assignment_routes_and_manual_start_are_state_fenced_atomically() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("workspace.db"); + let store = SqliteWorkspaceStore::open(&db_path).unwrap(); + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: "workspace-role".to_string(), + display_name: "Role Workspace".to_string(), + state: "active".to_string(), + owner_account_id: None, + created_at: "2026-09-01T00:00:00Z".to_string(), + updated_at: "2026-09-01T00:00:00Z".to_string(), + }) + .await + .unwrap(); + store + .upsert_repository(&RepositoryRecord { + workspace_id: "workspace-role".to_string(), + repository_id: "main".to_string(), + name: "Main".to_string(), + kind: "git".to_string(), + provider: Some("git".to_string()), + uri: "file:///tmp/main".to_string(), + default_ref: Some("develop".to_string()), + auth_ref_kind: None, + auth_ref_key: None, + created_at: "2026-09-01T00:00:00Z".to_string(), + updated_at: "2026-09-01T00:00:00Z".to_string(), + }) + .unwrap(); + let worker = WorkerRegistryRecord { + workspace_id: "workspace-role".to_string(), + worker: RuntimeWorkerRef::new("runtime-role", "worker-role"), + display_name: "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: "2026-09-01T00:00:00Z".to_string(), + updated_at: "2026-09-01T00:00:00Z".to_string(), + }; + store.upsert_worker_registry(&worker).unwrap(); + let backend = + ticket::SqliteTicketBackend::open_verified(&db_path, "workspace-role").unwrap(); + let mut input = ticket::NewTicket::new("Role assignment"); + input.body = ticket::MarkdownText::new("test"); + input.workflow_state = Some(ticket::TicketWorkflowState::Ready); + input.repository_id = Some("main".to_string()); + input.ref_selector = Some("develop".to_string()); + let ticket = ticket::TicketBackend::create(&backend, input).unwrap(); + + let orchestrator = TicketRoleAssignmentRecord { + workspace_id: "workspace-role".to_string(), + ticket_id: ticket.id.clone(), + assignment_id: "orchestrator-1".to_string(), + role: TicketAssignmentRole::Orchestrator, + principal: TicketAssignmentPrincipal::WorkspaceAgent { + agent_key: "workspace-orchestrator".to_string(), + }, + assigned_by: "user".to_string(), + assigned_at: "2026-09-01T00:00:00Z".to_string(), + }; + store + .set_current_ticket_role_assignment( + &orchestrator, + None, + "event-orchestrator", + "op-orchestrator", + false, + ) + .unwrap(); + assert_eq!( + store + .list_current_ticket_role_assignments("workspace-role", &ticket.id) + .unwrap(), + vec![orchestrator.clone()] + ); + + let replacement = TicketRoleAssignmentRecord { + assignment_id: "orchestrator-2".to_string(), + assigned_at: "2026-09-01T00:00:30Z".to_string(), + ..orchestrator.clone() + }; + store + .set_current_ticket_role_assignment( + &replacement, + Some("orchestrator-1"), + "event-orchestrator-reassign", + "op-orchestrator-reassign", + true, + ) + .unwrap(); + assert_eq!( + store + .list_current_ticket_role_assignments("workspace-role", &ticket.id) + .unwrap(), + vec![replacement] + ); + + let coder = TicketRoleAssignmentRecord { + workspace_id: "workspace-role".to_string(), + ticket_id: ticket.id.clone(), + assignment_id: "coder-manual-1".to_string(), + role: TicketAssignmentRole::Coder, + principal: TicketAssignmentPrincipal::Worker { + runtime_id: "runtime-role".to_string(), + worker_id: "worker-role".to_string(), + }, + assigned_by: "user".to_string(), + assigned_at: "2026-09-01T00:01:00Z".to_string(), + }; + assert!( + store + .start_ready_ticket_with_coder_assignment( + &coder, + "event-coder-conflict", + "op-coder-conflict", + ) + .is_err() + ); + assert_eq!( + ticket::TicketBackend::show(&backend, ticket.id.clone().into()) + .unwrap() + .meta + .workflow_state, + ticket::TicketWorkflowState::Ready + ); + assert!( + store + .clear_current_ticket_role_assignment( + "workspace-role", + &ticket.id, + TicketAssignmentRole::Orchestrator, + "orchestrator-2", + "event-clear-orchestrator", + "op-clear-orchestrator", + "user", + "2026-09-01T00:02:00Z", + Some("manual start"), + ) + .unwrap() + ); + let started = store + .start_ready_ticket_with_coder_assignment(&coder, "event-coder", "op-coder") + .unwrap(); + assert_eq!(started, coder); + let ticket = ticket::TicketBackend::show(&backend, ticket.id.clone().into()).unwrap(); + assert_eq!( + ticket.meta.workflow_state, + ticket::TicketWorkflowState::InProgress + ); + assert_eq!( + ticket + .events + .last() + .and_then(|event| event.attributes.get("assignment_id")) + .map(String::as_str), + Some("coder-manual-1") + ); + assert_eq!( + store + .get_current_ticket_role_assignment( + "workspace-role", + &ticket.meta.id, + TicketAssignmentRole::Coder, + ) + .unwrap(), + Some(coder) + ); + } + #[test] fn server_refuses_a_database_from_a_newer_schema_generation() { let conn = Connection::open_in_memory().unwrap(); configure_sqlite(&conn).unwrap(); apply_migrations(&conn).unwrap(); conn.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (43, 'future')", + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (44, 'future')", [], ) .unwrap(); let error = apply_migrations(&conn).unwrap_err().to_string(); - assert!(error.contains("schema version 43 is newer"), "{error}"); + assert!(error.contains("schema version 44 is newer"), "{error}"); assert!(error.contains("refusing to serve"), "{error}"); } @@ -9452,7 +10867,7 @@ VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-a', NULL, 'tester', '2026- apply_migrations(&mut conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 42); + assert_eq!(current_schema_version(&conn).unwrap(), 43); let workspace_id: Option = conn .query_row( "SELECT workspace_id FROM trusted_runtime_records WHERE runtime_id = 'runtime-a'", @@ -10069,7 +11484,7 @@ WHERE workspace_id = 'workspace-a' .unwrap(); let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 42); + assert_eq!(store.schema_version().await.unwrap(), 43); store .with_conn(|conn| { @@ -10258,7 +11673,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn repository_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 42); + assert_eq!(store.schema_version().await.unwrap(), 43); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -10324,7 +11739,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn memory_authority_records_round_trip_and_close_staging() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 42); + assert_eq!(store.schema_version().await.unwrap(), 43); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -10726,7 +12141,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn account_and_login_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 42); + assert_eq!(store.schema_version().await.unwrap(), 43); let now = "2026-07-22T00:00:00Z".to_string(); let account = AccountRecord { account_id: "acct-user-alice".to_string(), diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 2d85680e..ee66b0d6 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,241 +1,52 @@ // Generated from yoi-workspace-server. Do not edit by hand. // Regenerate: cargo run -q -p yoi-workspace-server --features typescript --example generate_ticket_api_types > web/workspace/src/lib/generated/ticket-api.ts -export type InvalidProjectRecord = { label: string; reason: string }; +export type InvalidProjectRecord = { label: string, reason: string, }; -export type TicketSummary = { - id: string; - resource_key: string; - title: string; - state: string; - priority: string; - updated_at: string | null; - queued_by: string | null; - queued_at: string | null; - workspace_action_priority: string; - record_source: string; -}; +export type TicketSummary = { id: string, resource_key: string, title: string, state: string, priority: string, updated_at: string | null, queued_by: string | null, queued_at: string | null, workspace_action_priority: string, record_source: string, }; -export type TicketListResponse = { - workspace_id: string; - limit: number; - items: Array; - page: QueryPage; - invalid_records: Array; - record_authority: string; -}; +export type TicketListResponse = { workspace_id: string, limit: number, items: Array, page: QueryPage, invalid_records: Array, record_authority: string, }; -export type QueryPage = { - limit: number; - returned: number; - has_more: boolean; - next_cursor: string | null; - sort: string; - source_limit: number | null; - source_truncated: boolean; -}; +export type QueryPage = { limit: number, returned: number, has_more: boolean, next_cursor: string | null, sort: string, source_limit: number | null, source_truncated: boolean, }; -export type TicketEventDetail = { - sequence: number; - event_ref: string; - kind: string; - author: string | null; - at: string | null; - status: string | null; - from: string | null; - to: string | null; - reason: string | null; - state_field: string | null; - heading: string | null; - body: string | null; - attributes: { [key in string]: string }; - references: Array; -}; +export type TicketEventDetail = { sequence: number, event_ref: string, kind: string, author: string | null, at: string | null, status: string | null, from: string | null, to: string | null, reason: string | null, state_field: string | null, heading: string | null, body: string | null, attributes: { [key in string]: string }, references: Array, }; -export type ObjectiveLinkSummary = { - id: string; - resource_key: string; - title: string; - state: string; -}; +export type ObjectiveLinkSummary = { id: string, resource_key: string, title: string, state: string, }; -export type TicketEvidenceEvent = { - event_ref: string; - sequence: number; - kind: string; - at: string | null; - author: string | null; - excerpt: string; -}; +export type TicketEvidenceEvent = { event_ref: string, sequence: number, kind: string, at: string | null, author: string | null, excerpt: string, }; -export type TicketAssignmentSummary = { - assignment_id: string; - runtime_id: string; - worker_id: string; - worker_resource_key?: string | null; -}; +export type TicketAssignmentSummary = { assignment_id: string, runtime_id: string, worker_id: string, worker_resource_key?: string | null, }; -export type TicketMergeRequestSummary = { - merge_request_id: string; - repository_id: string; - state: string; - review_status: string; - selector_from: string | null; - selector_to: string; - updated_at: string; - current_subject_ref: string | null; - review_subject_ref: string | null; - review_requested_at: string | null; - review_submitted_at: string | null; - review_excerpt: string | null; -}; +export type TicketRoleAssignmentSummary = { assignment_id: string, role: string, principal: TicketAssignmentPrincipalSummary, assigned_by: string, assigned_at: string, }; -export type MergeRequestListItem = { - summary: TicketMergeRequestSummary; - ticket_ids: Array; - thread_event_count: number; -}; +export type TicketAssignmentPrincipalSummary = { "kind": "user", account_id: string, } | { "kind": "worker", runtime_id: string, worker_id: string, } | { "kind": "workspace_agent", agent_key: string, }; -export type MergeRequestListResponse = { - items: Array; - next_cursor: string | null; -}; +export type TicketActionEligibility = { can_assign_orchestrator: boolean, can_unassign_orchestrator: boolean, can_queue: boolean, can_start_manual_coder: boolean, blockers: Array, }; -export type TicketEvidenceSummary = { - has_merge_request: boolean; - has_current_subject_ref: boolean; - has_review_request: boolean; - has_commit: boolean; - review_status: string | null; - approved_current_subject: boolean; - review_after_rescope: boolean; - unresolved_request_changes: boolean; - complete_for_integration: boolean; - missing: Array; -}; +export type TicketMergeRequestSummary = { merge_request_id: string, repository_id: string, state: string, review_status: string, selector_from: string | null, selector_to: string, updated_at: string, current_subject_ref: string | null, review_subject_ref: string | null, review_requested_at: string | null, review_submitted_at: string | null, review_excerpt: string | null, }; -export type TicketQueryRequest = { - query: string | null; - states: Array; - event_kinds: Array; - evidence: Array; - review_status: string | null; - attention: Array; - related_ticket_id: string | null; - relation_kind: string | null; - linked_objective_id: string | null; - updated_after: string | null; - updated_before: string | null; - sort: string | null; - limit: number | null; - cursor: string | null; -}; +export type MergeRequestListItem = { summary: TicketMergeRequestSummary, ticket_ids: Array, thread_event_count: number, }; -export type TicketQueryItem = { - id: string; - resource_key: string; - title: string; - state: string; - readiness: string | null; - priority: string; - created_at: string | null; - updated_at: string | null; - item_revision: string; - workspace_action_priority: string; - matched_fields: Array; - snippet: string | null; - matching_event: TicketEvidenceEvent | null; - linked_objective_ids: Array; - relation_count: number; - blocker_count: number; - unresolved_blocker_count: number; - unresolved_review_count: number; - evidence: TicketEvidenceSummary; - merge_request: TicketMergeRequestSummary | null; -}; +export type MergeRequestListResponse = { items: Array, next_cursor: string | null, }; -export type TicketQueryResponse = { - items: Array; - page: QueryPage; - record_authority: string; -}; +export type TicketEvidenceSummary = { has_merge_request: boolean, has_current_subject_ref: boolean, has_review_request: boolean, has_commit: boolean, review_status: string | null, approved_current_subject: boolean, review_after_rescope: boolean, unresolved_request_changes: boolean, complete_for_integration: boolean, missing: Array, }; -export type TicketShowRequest = { - event_limit: number | null; - event_cursor: string | null; -}; +export type TicketQueryRequest = { query: string | null, states: Array, event_kinds: Array, evidence: Array, review_status: string | null, attention: Array, related_ticket_id: string | null, relation_kind: string | null, linked_objective_id: string | null, updated_after: string | null, updated_before: string | null, sort: string | null, limit: number | null, cursor: string | null, }; -export type TicketRelation = { - ticket_id: string; - kind: string; - target: string; - target_resource_key?: string | null; - note: string | null; - author: string; - at: string; -}; +export type TicketQueryItem = { id: string, resource_key: string, title: string, state: string, readiness: string | null, priority: string, created_at: string | null, updated_at: string | null, item_revision: string, workspace_action_priority: string, matched_fields: Array, snippet: string | null, matching_event: TicketEvidenceEvent | null, linked_objective_ids: Array, relation_count: number, blocker_count: number, unresolved_blocker_count: number, unresolved_review_count: number, evidence: TicketEvidenceSummary, merge_request: TicketMergeRequestSummary | null, }; -export type DerivedTicketRelation = { - source_ticket: string; - source_resource_key?: string | null; - inverse_kind: string; - forward_kind: string; - note: string | null; - author: string; - at: string; -}; +export type TicketQueryResponse = { items: Array, page: QueryPage, record_authority: string, }; -export type TicketRelationBlocker = { - blocking_ticket: string; - blocking_resource_key?: string | null; - reason_kind: string; - relation_kind: string; - note: string | null; - blocking_state: string; -}; +export type TicketShowRequest = { event_limit: number | null, event_cursor: string | null, }; -export type TicketRelationNotice = { - related_ticket: string; - kind: string; - message: string; -}; +export type TicketRelation = { ticket_id: string, kind: string, target: string, target_resource_key?: string | null, note: string | null, author: string, at: string, }; -export type TicketRelationView = { - outgoing: Array; - incoming: Array; - blockers: Array; - notices: Array; -}; +export type DerivedTicketRelation = { source_ticket: string, source_resource_key?: string | null, inverse_kind: string, forward_kind: string, note: string | null, author: string, at: string, }; -export type TicketDetail = { - id: string; - resource_key: string; - title: string; - state: string; - readiness: string | null; - priority: string; - created_at: string | null; - updated_at: string | null; - item_revision: string; - queued_by: string | null; - queued_at: string | null; - assignee: string | null; - repository_id: string | null; - ref_selector: string | null; - risk_flags: Array; - body: string; - body_truncated: boolean; - event_count: number; - events: Array; - event_page: QueryPage; - artifact_count: number; - artifacts: Array; - relations: TicketRelationView; - linked_objectives: Array; - implementation_reports: Array; - current_assignment: TicketAssignmentSummary | null; - merge_request: TicketMergeRequestSummary | null; - evidence: TicketEvidenceSummary; - resolution: string | null; - record_source: string; -}; +export type TicketRelationBlocker = { blocking_ticket: string, blocking_resource_key?: string | null, reason_kind: string, relation_kind: string, note: string | null, blocking_state: string, }; + +export type TicketRelationNotice = { related_ticket: string, kind: string, message: string, }; + +export type TicketRelationView = { outgoing: Array, incoming: Array, blockers: Array, notices: Array, }; + +export type TicketDetail = { id: string, resource_key: string, title: string, state: string, readiness: string | null, priority: string, created_at: string | null, updated_at: string | null, item_revision: string, queued_by: string | null, queued_at: string | null, repository_id: string | null, ref_selector: string | null, risk_flags: Array, body: string, body_truncated: boolean, event_count: number, events: Array, event_page: QueryPage, artifact_count: number, artifacts: Array, relations: TicketRelationView, linked_objectives: Array, implementation_reports: Array, assignments: Array, current_coder: TicketAssignmentSummary | null, assignment_diagnostics: Array, action_eligibility: TicketActionEligibility, merge_request: TicketMergeRequestSummary | null, evidence: TicketEvidenceSummary, resolution: string | null, record_source: string, }; diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts index ad5c2de6..a58f507d 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts @@ -110,6 +110,25 @@ Deno.test("ticket worker launch uses the common Worker route and bounded Ticket ); }); +Deno.test("ticket detail uses server-derived role assignment actions", async () => { + const source = await Deno.readTextFile( + new URL( + "../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte", + import.meta.url, + ), + ); + + assertEquals(source.includes("ticket.action_eligibility.can_queue"), true); + assertEquals( + source.includes("ticket.action_eligibility.can_assign_orchestrator"), + true, + ); + assertEquals(source.includes("/assignments/${role}"), true); + assertEquals(source.includes('kind: "workspace_agent"'), true); + assertEquals(source.includes('kind: "worker"'), true); + assertEquals(source.includes("ticket.assignee"), false); +}); + Deno.test("ticket detail keeps the operation rail outside main content", async () => { const css = await Deno.readTextFile( new URL("../styles/tickets.css", import.meta.url), 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 3e4872d3..a0afba90 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte @@ -2,6 +2,7 @@ import { untrack } from "svelte"; import RichMarkdown from "$lib/workspace/console/RichMarkdown.svelte"; import { + workspaceApiJson, workspaceApiJsonWithBody, workspaceApiPath, } from "$lib/workspace/api/http"; @@ -9,7 +10,6 @@ import { relationLabel, TICKET_STATES, - ticketWorkerLaunchHref, type WorkspaceOrchestratorStatus, } from "$lib/workspace/tickets/ticket-panel"; import type { ApiResult } from "$lib/workspace/api/http"; @@ -37,7 +37,6 @@ const loadedTicket = initialData.ticket.data; if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed"); const loadedRepositories = initialData.repositories.data; - const orchestratorOnline = initialData.orchestrator.data?.online ?? false; let ticket = $state(loadedTicket); const mergeRequest = $derived(ticket.merge_request); @@ -54,6 +53,8 @@ let busy = $state(null); let errorMessage = $state(null); let readyOperationKey = $state(null); + let manualRuntimeId = $state(""); + let manualWorkerId = $state(""); const selectedRepository = $derived( (loadedRepositories?.items ?? []).find((repository: RepositorySummary) => repository.id === repositoryId) ?? null, ); @@ -72,7 +73,7 @@ ), ); const implementationStartEligible = $derived( - persistedTargetValid && ticket.state !== "planning" && ticket.state !== "closed", + ticket.action_eligibility.can_start_manual_coder, ); const ticketPath = $derived( @@ -116,6 +117,51 @@ } } + async function mutateAssignment( + action: string, + role: "orchestrator" | "coder", + principal: Record, + ): Promise { + if (busy) return; + busy = action; + errorMessage = null; + try { + await workspaceApiJsonWithBody( + `${ticketPath}/assignments/${role}`, + { + method: "PUT", + body: JSON.stringify({ + operation_id: crypto.randomUUID(), + principal, + expected_assignment_id: null, + }), + }, + ); + applyTicket(await workspaceApiJson(ticketPath)); + } catch (error) { + errorMessage = error instanceof Error ? error.message : String(error); + } finally { + busy = null; + } + } + + async function assignOrchestrator(): Promise { + await mutateAssignment("assign-orchestrator", "orchestrator", { + kind: "workspace_agent", + agent_key: "workspace-orchestrator", + }); + } + + async function startManualCoder(event: SubmitEvent): Promise { + event.preventDefault(); + if (!manualRuntimeId.trim() || !manualWorkerId.trim()) return; + await mutateAssignment("start-manual", "coder", { + kind: "worker", + runtime_id: manualRuntimeId.trim(), + worker_id: manualWorkerId.trim(), + }); + } + async function saveEdit(event: SubmitEvent) { event.preventDefault(); if ( @@ -326,24 +372,54 @@