feat: generalize ticket role assignments
This commit is contained in:
@@ -679,7 +679,9 @@ fn validate_generic_state_change(
|
|||||||
to: TicketWorkflowState,
|
to: TicketWorkflowState,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if current == TicketWorkflowState::Planning && to == TicketWorkflowState::Ready
|
if current == TicketWorkflowState::Planning && to == TicketWorkflowState::Ready
|
||||||
|
|| current == TicketWorkflowState::Planning && to == TicketWorkflowState::InProgress
|
||||||
|| current == TicketWorkflowState::Ready && to == TicketWorkflowState::Queued
|
|| current == TicketWorkflowState::Ready && to == TicketWorkflowState::Queued
|
||||||
|
|| current == TicketWorkflowState::Ready && to == TicketWorkflowState::InProgress
|
||||||
{
|
{
|
||||||
return Err(TicketError::InvalidWorkflowTransition {
|
return Err(TicketError::InvalidWorkflowTransition {
|
||||||
from: current.as_str().to_owned(),
|
from: current.as_str().to_owned(),
|
||||||
@@ -6998,6 +7000,24 @@ mod tests {
|
|||||||
assert!(projection.visible_overlay.is_some());
|
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]
|
#[test]
|
||||||
fn workflow_state_rejects_legacy_intake_alias() {
|
fn workflow_state_rejects_legacy_intake_alias() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -17,16 +17,17 @@ use ticket::{
|
|||||||
use crate::records::{
|
use crate::records::{
|
||||||
ObjectiveDetail, ObjectiveEventDetail, ObjectiveLinkSummary, ObjectiveLinkedTicketSummary,
|
ObjectiveDetail, ObjectiveEventDetail, ObjectiveLinkSummary, ObjectiveLinkedTicketSummary,
|
||||||
ObjectiveQueryItem, ObjectiveQueryRequest, ObjectiveQueryResponse, ObjectiveResourceSummary,
|
ObjectiveQueryItem, ObjectiveQueryRequest, ObjectiveQueryResponse, ObjectiveResourceSummary,
|
||||||
ObjectiveShowRequest, ObjectiveSummary, ProjectRecordList, QueryPage, TicketAssignmentSummary,
|
ObjectiveShowRequest, ObjectiveSummary, ProjectRecordList, QueryPage, TicketActionEligibility,
|
||||||
TicketDetail, TicketEventDetail, TicketEvidenceEvent, TicketEvidenceSummary,
|
TicketAssignmentPrincipalSummary, TicketAssignmentSummary, TicketDetail, TicketEventDetail,
|
||||||
TicketListPageRequest, TicketMergeRequestSummary, TicketQueryItem, TicketQueryRequest,
|
TicketEvidenceEvent, TicketEvidenceSummary, TicketListPageRequest, TicketMergeRequestSummary,
|
||||||
TicketQueryResponse, TicketRelationView, TicketShowRequest, TicketSummary, TicketSummaryPage,
|
TicketQueryItem, TicketQueryRequest, TicketQueryResponse, TicketRelationView,
|
||||||
|
TicketRoleAssignmentSummary, TicketShowRequest, TicketSummary, TicketSummaryPage,
|
||||||
summarize_body, truncate_body, validate_project_id,
|
summarize_body, truncate_body, validate_project_id,
|
||||||
};
|
};
|
||||||
use crate::store::{
|
use crate::store::{
|
||||||
ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord,
|
ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord,
|
||||||
ObjectiveEventRecord, ObjectiveRecord, ObjectiveTicketLinkRecord, SqliteWorkspaceStore,
|
ObjectiveEventRecord, ObjectiveRecord, ObjectiveTicketLinkRecord, SqliteWorkspaceStore,
|
||||||
WorkspaceResourceKind,
|
TicketAssignmentPrincipal, TicketAssignmentRole, WorkspaceResourceKind,
|
||||||
};
|
};
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
@@ -146,7 +147,7 @@ impl merge_request::AssignmentSource for AuthorityMergeRequestSource {
|
|||||||
ticket_id: &str,
|
ticket_id: &str,
|
||||||
) -> std::result::Result<Option<merge_request::CurrentAssignment>, String> {
|
) -> std::result::Result<Option<merge_request::CurrentAssignment>, String> {
|
||||||
self.store
|
self.store
|
||||||
.get_current_ticket_worker_assignment(workspace_id, ticket_id)
|
.get_current_ticket_coder_assignment(workspace_id, ticket_id)
|
||||||
.map(|assignment| {
|
.map(|assignment| {
|
||||||
assignment.map(|assignment| merge_request::CurrentAssignment {
|
assignment.map(|assignment| merge_request::CurrentAssignment {
|
||||||
assignment_id: assignment.assignment_id,
|
assignment_id: assignment.assignment_id,
|
||||||
@@ -762,23 +763,107 @@ impl SqliteWorkspaceAuthority {
|
|||||||
.filter(|(_, event)| event.kind.as_str() == "implementation_report")
|
.filter(|(_, event)| event.kind.as_str() == "implementation_report")
|
||||||
.map(|(sequence, event)| ticket_evidence_event(sequence, event))
|
.map(|(sequence, event)| ticket_evidence_event(sequence, event))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let current_assignment = self
|
let role_assignments = self
|
||||||
.store
|
.store
|
||||||
.get_current_ticket_worker_assignment(&self.workspace_id, id)?
|
.list_current_ticket_role_assignments(&self.workspace_id, id)?;
|
||||||
.map(|assignment| {
|
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::<Vec<_>>();
|
||||||
|
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(
|
let worker_resource_key = self.store.resource_key(
|
||||||
&self.workspace_id,
|
&self.workspace_id,
|
||||||
WorkspaceResourceKind::Worker,
|
WorkspaceResourceKind::Worker,
|
||||||
&assignment.worker.worker_id,
|
&worker.worker_id,
|
||||||
)?;
|
)?;
|
||||||
Ok::<_, Error>(TicketAssignmentSummary {
|
Ok::<_, Error>(TicketAssignmentSummary {
|
||||||
assignment_id: assignment.assignment_id,
|
assignment_id: assignment.assignment_id.clone(),
|
||||||
runtime_id: assignment.worker.runtime_id,
|
runtime_id: worker.runtime_id,
|
||||||
worker_id: assignment.worker.worker_id,
|
worker_id: worker.worker_id,
|
||||||
worker_resource_key,
|
worker_resource_key,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.transpose()?;
|
.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) {
|
let merge_request = match self.merge_request_store.get(&self.workspace_id, id) {
|
||||||
Ok(request) => {
|
Ok(request) => {
|
||||||
let current_subject_ref = request.selector_from.as_deref().and_then(|selector| {
|
let current_subject_ref = request.selector_from.as_deref().and_then(|selector| {
|
||||||
@@ -847,7 +932,6 @@ impl SqliteWorkspaceAuthority {
|
|||||||
item_revision,
|
item_revision,
|
||||||
queued_by: ticket.meta.queued_by,
|
queued_by: ticket.meta.queued_by,
|
||||||
queued_at: ticket.meta.queued_at,
|
queued_at: ticket.meta.queued_at,
|
||||||
assignee: ticket.meta.assignee,
|
|
||||||
repository_id: ticket.meta.repository_id,
|
repository_id: ticket.meta.repository_id,
|
||||||
ref_selector: ticket.meta.ref_selector,
|
ref_selector: ticket.meta.ref_selector,
|
||||||
risk_flags: ticket.meta.risk_flags,
|
risk_flags: ticket.meta.risk_flags,
|
||||||
@@ -873,7 +957,10 @@ impl SqliteWorkspaceAuthority {
|
|||||||
relations,
|
relations,
|
||||||
linked_objectives,
|
linked_objectives,
|
||||||
implementation_reports,
|
implementation_reports,
|
||||||
current_assignment,
|
assignments,
|
||||||
|
current_coder,
|
||||||
|
assignment_diagnostics,
|
||||||
|
action_eligibility,
|
||||||
merge_request,
|
merge_request,
|
||||||
evidence,
|
evidence,
|
||||||
resolution: ticket
|
resolution: ticket
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ pub struct TicketDetail {
|
|||||||
pub item_revision: String,
|
pub item_revision: String,
|
||||||
pub queued_by: Option<String>,
|
pub queued_by: Option<String>,
|
||||||
pub queued_at: Option<String>,
|
pub queued_at: Option<String>,
|
||||||
pub assignee: Option<String>,
|
|
||||||
pub repository_id: Option<String>,
|
pub repository_id: Option<String>,
|
||||||
pub ref_selector: Option<String>,
|
pub ref_selector: Option<String>,
|
||||||
pub risk_flags: Vec<String>,
|
pub risk_flags: Vec<String>,
|
||||||
@@ -95,7 +94,10 @@ pub struct TicketDetail {
|
|||||||
pub relations: TicketRelationView,
|
pub relations: TicketRelationView,
|
||||||
pub linked_objectives: Vec<ObjectiveLinkSummary>,
|
pub linked_objectives: Vec<ObjectiveLinkSummary>,
|
||||||
pub implementation_reports: Vec<TicketEvidenceEvent>,
|
pub implementation_reports: Vec<TicketEvidenceEvent>,
|
||||||
pub current_assignment: Option<TicketAssignmentSummary>,
|
pub assignments: Vec<TicketRoleAssignmentSummary>,
|
||||||
|
pub current_coder: Option<TicketAssignmentSummary>,
|
||||||
|
pub assignment_diagnostics: Vec<String>,
|
||||||
|
pub action_eligibility: TicketActionEligibility,
|
||||||
pub merge_request: Option<TicketMergeRequestSummary>,
|
pub merge_request: Option<TicketMergeRequestSummary>,
|
||||||
pub evidence: TicketEvidenceSummary,
|
pub evidence: TicketEvidenceSummary,
|
||||||
pub resolution: Option<String>,
|
pub resolution: Option<String>,
|
||||||
@@ -260,6 +262,43 @@ pub struct TicketAssignmentSummary {
|
|||||||
pub worker_resource_key: Option<String>,
|
pub worker_resource_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct TicketMergeRequestSummary {
|
pub struct TicketMergeRequestSummary {
|
||||||
@@ -424,6 +463,9 @@ pub fn ticket_api_typescript() -> String {
|
|||||||
ObjectiveLinkSummary::decl(&config),
|
ObjectiveLinkSummary::decl(&config),
|
||||||
TicketEvidenceEvent::decl(&config),
|
TicketEvidenceEvent::decl(&config),
|
||||||
TicketAssignmentSummary::decl(&config),
|
TicketAssignmentSummary::decl(&config),
|
||||||
|
TicketRoleAssignmentSummary::decl(&config),
|
||||||
|
TicketAssignmentPrincipalSummary::decl(&config),
|
||||||
|
TicketActionEligibility::decl(&config),
|
||||||
TicketMergeRequestSummary::decl(&config),
|
TicketMergeRequestSummary::decl(&config),
|
||||||
MergeRequestListItem::decl(&config),
|
MergeRequestListItem::decl(&config),
|
||||||
MergeRequestListResponse::decl(&config),
|
MergeRequestListResponse::decl(&config),
|
||||||
|
|||||||
@@ -1047,7 +1047,7 @@ fn parse_state(v: &str) -> rusqlite::Result<WorkerRemovalPlanState> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::store::{ControlPlaneStore, TicketWorkerAssignmentRecord, WorkerRegistryRecord};
|
use crate::store::{ControlPlaneStore, TicketCoderAssignmentRecord, WorkerRegistryRecord};
|
||||||
use worker_runtime::identity::WorkerId;
|
use worker_runtime::identity::WorkerId;
|
||||||
fn worker_id() -> WorkerId {
|
fn worker_id() -> WorkerId {
|
||||||
WorkerId::from_legacy_u64(1)
|
WorkerId::from_legacy_u64(1)
|
||||||
@@ -1505,7 +1505,7 @@ mod tests {
|
|||||||
).map_err(StoreError::from)).unwrap();
|
).map_err(StoreError::from)).unwrap();
|
||||||
assert_eq!(revision, "rev1");
|
assert_eq!(revision, "rev1");
|
||||||
|
|
||||||
let assignment = TicketWorkerAssignmentRecord {
|
let assignment = TicketCoderAssignmentRecord {
|
||||||
workspace_id: "w".into(),
|
workspace_id: "w".into(),
|
||||||
ticket_id: "new-ticket".into(),
|
ticket_id: "new-ticket".into(),
|
||||||
assignment_id: "new-assignment".into(),
|
assignment_id: "new-assignment".into(),
|
||||||
@@ -1518,7 +1518,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert!(
|
assert!(
|
||||||
store
|
store
|
||||||
.set_current_ticket_worker_assignment(
|
.set_current_ticket_coder_assignment(
|
||||||
&assignment,
|
&assignment,
|
||||||
None,
|
None,
|
||||||
"event",
|
"event",
|
||||||
|
|||||||
@@ -115,7 +115,8 @@ use crate::skills;
|
|||||||
use crate::store::{
|
use crate::store::{
|
||||||
AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore,
|
AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore,
|
||||||
DeviceLoginFlowRecord, FlowSourceRecord, PasskeyCredentialRecord, RepositoryRecord,
|
DeviceLoginFlowRecord, FlowSourceRecord, PasskeyCredentialRecord, RepositoryRecord,
|
||||||
TicketWorkerAssignmentRecord, UserRecord, WorkdirRegistryRecord, WorkerControlGrantRecord,
|
TicketAssignmentPrincipal, TicketAssignmentRole, TicketCoderAssignmentRecord,
|
||||||
|
TicketRoleAssignmentRecord, UserRecord, WorkdirRegistryRecord, WorkerControlGrantRecord,
|
||||||
WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord, WorkspaceResourceKind,
|
WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord, WorkspaceResourceKind,
|
||||||
};
|
};
|
||||||
use crate::workspace_catalog::{WorkspaceCatalogService, WorkspaceCreateRequest};
|
use crate::workspace_catalog::{WorkspaceCatalogService, WorkspaceCreateRequest};
|
||||||
@@ -1819,14 +1820,12 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
|||||||
post(scoped_show_ticket),
|
post(scoped_show_ticket),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/tickets/{id}/assignment",
|
"/api/w/{workspace_id}/tickets/{id}/assignments",
|
||||||
get(scoped_get_ticket_worker_assignment)
|
get(scoped_list_ticket_assignments),
|
||||||
.put(scoped_set_ticket_worker_assignment)
|
|
||||||
.delete(scoped_clear_ticket_worker_assignment),
|
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/tickets/{id}/assignment/reassign",
|
"/api/w/{workspace_id}/tickets/{id}/assignments/{role}",
|
||||||
post(scoped_reassign_ticket_worker_assignment),
|
put(scoped_set_ticket_assignment).delete(scoped_clear_ticket_assignment),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/tickets/{id}/state",
|
"/api/w/{workspace_id}/tickets/{id}/state",
|
||||||
@@ -3156,131 +3155,174 @@ async fn scoped_show_ticket(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
struct TicketWorkerAssignmentResponse {
|
struct TicketRoleAssignmentsResponse {
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
ticket_id: String,
|
ticket_id: String,
|
||||||
assignment: Option<TicketWorkerAssignmentRecord>,
|
assignments: Vec<TicketRoleAssignmentRecord>,
|
||||||
worker: Option<WorkerSummary>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
struct TicketWorkerAssignmentMutationResponse {
|
struct TicketRoleAssignmentMutationResponse {
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
ticket_id: String,
|
ticket_id: String,
|
||||||
assignment: Option<TicketWorkerAssignmentRecord>,
|
assignment: Option<TicketRoleAssignmentRecord>,
|
||||||
previous_assignment_id: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
struct SetTicketWorkerAssignmentRequest {
|
struct SetTicketRoleAssignmentRequest {
|
||||||
operation_id: String,
|
operation_id: String,
|
||||||
#[serde(flatten)]
|
principal: TicketAssignmentPrincipal,
|
||||||
worker: RuntimeWorkerRef,
|
|
||||||
expected_assignment_id: Option<String>,
|
expected_assignment_id: Option<String>,
|
||||||
assigned_by: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
struct ClearTicketWorkerAssignmentQuery {
|
struct ClearTicketRoleAssignmentQuery {
|
||||||
operation_id: Option<String>,
|
operation_id: Option<String>,
|
||||||
expected_assignment_id: Option<String>,
|
assignment_id: Option<String>,
|
||||||
actor: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_get_ticket_worker_assignment(
|
fn parse_ticket_assignment_role(role: &str) -> ApiResult<TicketAssignmentRole> {
|
||||||
|
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<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedRecordPath>,
|
AxumPath(path): AxumPath<ScopedRecordPath>,
|
||||||
) -> ApiResult<Json<TicketWorkerAssignmentResponse>> {
|
) -> ApiResult<Json<TicketRoleAssignmentsResponse>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
let ticket = api.authority.ticket(&path.id)?;
|
let ticket = api.authority.ticket(&path.id)?;
|
||||||
let assignment = api
|
let assignments = api
|
||||||
.store
|
.store
|
||||||
.get_current_ticket_worker_assignment(&path.workspace_id, &ticket.id)?;
|
.list_current_ticket_role_assignments(&path.workspace_id, &ticket.id)?;
|
||||||
let worker = assignment
|
Ok(Json(TicketRoleAssignmentsResponse {
|
||||||
.as_ref()
|
|
||||||
.and_then(|assignment| api.runtime.worker(&assignment.worker).ok());
|
|
||||||
Ok(Json(TicketWorkerAssignmentResponse {
|
|
||||||
workspace_id: path.workspace_id,
|
workspace_id: path.workspace_id,
|
||||||
ticket_id: ticket.id,
|
ticket_id: ticket.id,
|
||||||
assignment,
|
assignments,
|
||||||
worker,
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_set_ticket_worker_assignment(
|
async fn scoped_set_ticket_assignment(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedRecordPath>,
|
AxumPath((workspace_id, id, role)): AxumPath<(String, String, String)>,
|
||||||
Json(request): Json<SetTicketWorkerAssignmentRequest>,
|
Json(request): Json<SetTicketRoleAssignmentRequest>,
|
||||||
) -> ApiResult<Json<TicketWorkerAssignmentMutationResponse>> {
|
) -> ApiResult<Json<TicketRoleAssignmentMutationResponse>> {
|
||||||
set_ticket_worker_assignment(api, path, request, false).await
|
validate_workspace_scope(&api, &workspace_id)?;
|
||||||
}
|
let ticket = api.authority.ticket(&id)?;
|
||||||
|
let role = parse_ticket_assignment_role(&role)?;
|
||||||
async fn scoped_reassign_ticket_worker_assignment(
|
|
||||||
State(api): State<WorkspaceApi>,
|
|
||||||
AxumPath(path): AxumPath<ScopedRecordPath>,
|
|
||||||
Json(request): Json<SetTicketWorkerAssignmentRequest>,
|
|
||||||
) -> ApiResult<Json<TicketWorkerAssignmentMutationResponse>> {
|
|
||||||
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<Json<TicketWorkerAssignmentMutationResponse>> {
|
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
|
||||||
let ticket = api.authority.ticket(&path.id)?;
|
|
||||||
let operation_id = require_ticket_assignment_value("operation_id", request.operation_id)?;
|
let 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
|
let expected_assignment_id = request
|
||||||
.expected_assignment_id
|
.expected_assignment_id
|
||||||
.map(|value| require_ticket_assignment_value("expected_assignment_id", value))
|
.map(|value| require_ticket_assignment_value("expected_assignment_id", value))
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
let assigned_by = request
|
if matches!(request.principal, TicketAssignmentPrincipal::User { .. }) {
|
||||||
.assigned_by
|
return Err(Error::TicketAssignmentConflict(
|
||||||
.map(|value| require_ticket_assignment_value("assigned_by", value))
|
"user-principal Ticket assignment requires an authenticated authoring boundary; weak Workspace Web access is not authority"
|
||||||
.transpose()?
|
.to_string(),
|
||||||
.unwrap_or_else(|| "workspace-api".to_string());
|
)
|
||||||
let requested_worker = RuntimeWorkerRef::new(runtime_id, worker_id);
|
.into());
|
||||||
let worker = api
|
}
|
||||||
.runtime
|
|
||||||
.worker(&requested_worker)
|
|
||||||
.map_err(|err| err.into_error())?;
|
|
||||||
let assigned_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
|
let assigned_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
|
||||||
let record = TicketWorkerAssignmentRecord {
|
let record = TicketRoleAssignmentRecord {
|
||||||
workspace_id: path.workspace_id.clone(),
|
workspace_id: workspace_id.clone(),
|
||||||
ticket_id: ticket.id.clone(),
|
ticket_id: ticket.id.clone(),
|
||||||
assignment_id: new_id("tasg"),
|
assignment_id: new_id("tasg"),
|
||||||
worker: worker.worker.clone(),
|
role,
|
||||||
assigned_by,
|
principal: request.principal,
|
||||||
|
assigned_by: "workspace-web".to_string(),
|
||||||
assigned_at,
|
assigned_at,
|
||||||
};
|
};
|
||||||
let update = api.store.set_current_ticket_worker_assignment(
|
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,
|
&record,
|
||||||
expected_assignment_id.as_deref(),
|
expected_assignment_id.as_deref(),
|
||||||
&new_id("tasev"),
|
&new_id("tasev"),
|
||||||
&operation_id,
|
&operation_id,
|
||||||
allow_reassign,
|
expected_assignment_id.is_some(),
|
||||||
)?;
|
)?
|
||||||
Ok(Json(TicketWorkerAssignmentMutationResponse {
|
}
|
||||||
workspace_id: path.workspace_id,
|
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,
|
ticket_id: ticket.id,
|
||||||
assignment: Some(update.current),
|
assignment: Some(assignment),
|
||||||
previous_assignment_id: update.previous.map(|assignment| assignment.assignment_id),
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn scoped_clear_ticket_worker_assignment(
|
async fn scoped_clear_ticket_assignment(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedRecordPath>,
|
AxumPath((workspace_id, id, role)): AxumPath<(String, String, String)>,
|
||||||
Query(query): Query<ClearTicketWorkerAssignmentQuery>,
|
Query(query): Query<ClearTicketRoleAssignmentQuery>,
|
||||||
) -> ApiResult<Json<TicketWorkerAssignmentMutationResponse>> {
|
) -> ApiResult<Json<TicketRoleAssignmentMutationResponse>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &workspace_id)?;
|
||||||
let ticket = api.authority.ticket(&path.id)?;
|
let ticket = api.authority.ticket(&id)?;
|
||||||
|
let role = parse_ticket_assignment_role(&role)?;
|
||||||
let operation_id = query
|
let operation_id = query
|
||||||
.operation_id
|
.operation_id
|
||||||
.map(|value| require_ticket_assignment_value("operation_id", value))
|
.map(|value| require_ticket_assignment_value("operation_id", value))
|
||||||
@@ -3288,32 +3330,49 @@ async fn scoped_clear_ticket_worker_assignment(
|
|||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
Error::TicketAssignmentConflict("unassign requires operation_id".to_string())
|
Error::TicketAssignmentConflict("unassign requires operation_id".to_string())
|
||||||
})?;
|
})?;
|
||||||
let expected_assignment_id = query
|
let assignment_id = query
|
||||||
.expected_assignment_id
|
.assignment_id
|
||||||
.map(|value| require_ticket_assignment_value("expected_assignment_id", value))
|
.map(|value| require_ticket_assignment_value("assignment_id", value))
|
||||||
.transpose()?;
|
|
||||||
let actor = query
|
|
||||||
.actor
|
|
||||||
.map(|value| require_ticket_assignment_value("actor", value))
|
|
||||||
.transpose()?
|
.transpose()?
|
||||||
.unwrap_or_else(|| "workspace-api".to_string());
|
.ok_or_else(|| {
|
||||||
let previous = api.store.clear_current_ticket_worker_assignment(
|
Error::TicketAssignmentConflict("unassign requires assignment_id".to_string())
|
||||||
&path.workspace_id,
|
})?;
|
||||||
|
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,
|
&ticket.id,
|
||||||
expected_assignment_id.as_deref(),
|
role,
|
||||||
&operation_id,
|
&assignment_id,
|
||||||
&new_id("tasev"),
|
&new_id("tasev"),
|
||||||
&actor,
|
&operation_id,
|
||||||
|
"workspace-web",
|
||||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
&Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||||
|
Some("role assignment removed from Ticket detail"),
|
||||||
)?;
|
)?;
|
||||||
Ok(Json(TicketWorkerAssignmentMutationResponse {
|
if !cleared {
|
||||||
workspace_id: path.workspace_id,
|
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,
|
ticket_id: ticket.id,
|
||||||
assignment: None,
|
assignment: None,
|
||||||
previous_assignment_id: previous.map(|assignment| assignment.assignment_id),
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_ticket_assignment_state(
|
fn validate_ticket_assignment_state(
|
||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
assignment: &WorkerTicketAssignmentRequest,
|
assignment: &WorkerTicketAssignmentRequest,
|
||||||
@@ -3329,6 +3388,28 @@ fn validate_ticket_assignment_state(
|
|||||||
ticket.id, ticket.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3374,7 +3455,7 @@ fn validate_ticket_assignment_spawn(
|
|||||||
|
|
||||||
if let Some(current) = api
|
if let Some(current) = api
|
||||||
.store
|
.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
|
let replay_matches = api
|
||||||
.store
|
.store
|
||||||
@@ -3447,7 +3528,7 @@ fn assign_ticket_worker_from_lifecycle(
|
|||||||
assignment: &crate::hosts::WorkerTicketAssignmentRequest,
|
assignment: &crate::hosts::WorkerTicketAssignmentRequest,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
worker_id: &str,
|
worker_id: &str,
|
||||||
) -> Result<TicketWorkerAssignmentRecord> {
|
) -> Result<TicketCoderAssignmentRecord> {
|
||||||
let ticket = api.authority.ticket(&assignment.ticket_id)?;
|
let ticket = api.authority.ticket(&assignment.ticket_id)?;
|
||||||
let worker = RuntimeWorkerRef::new(runtime_id, worker_id);
|
let worker = RuntimeWorkerRef::new(runtime_id, worker_id);
|
||||||
if let Some(operation) = api
|
if let Some(operation) = api
|
||||||
@@ -3458,7 +3539,7 @@ fn assign_ticket_worker_from_lifecycle(
|
|||||||
if operation.action == "assign"
|
if operation.action == "assign"
|
||||||
&& operation.ticket_id == assignment.ticket_id
|
&& operation.ticket_id == assignment.ticket_id
|
||||||
&& operation.worker.as_ref() == Some(&worker)
|
&& 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,
|
&api.config.workspace_id,
|
||||||
&assignment.ticket_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 assigned_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
|
||||||
let record = TicketWorkerAssignmentRecord {
|
let record = TicketCoderAssignmentRecord {
|
||||||
workspace_id: api.config.workspace_id.clone(),
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
ticket_id: ticket.id,
|
ticket_id: ticket.id,
|
||||||
assignment_id: new_id("tasg"),
|
assignment_id: new_id("tasg"),
|
||||||
@@ -3483,7 +3564,7 @@ fn assign_ticket_worker_from_lifecycle(
|
|||||||
};
|
};
|
||||||
Ok(api
|
Ok(api
|
||||||
.store
|
.store
|
||||||
.set_current_ticket_worker_assignment(
|
.set_current_ticket_coder_assignment(
|
||||||
&record,
|
&record,
|
||||||
None,
|
None,
|
||||||
&new_id("tasev"),
|
&new_id("tasev"),
|
||||||
@@ -3873,11 +3954,52 @@ async fn execute_ticket_rest_operation(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|ticket| ticket.meta.workflow_state.as_str().to_string())
|
.map(|ticket| ticket.meta.workflow_state.as_str().to_string())
|
||||||
.unwrap_or_else(|| ticket_operation_initial_state(&operation));
|
.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::<String>();
|
||||||
|
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() {
|
if let Some(source) = source.as_ref() {
|
||||||
bind_worker_ticket_operation_source(source, &mut operation);
|
bind_worker_ticket_operation_source(source, &mut operation);
|
||||||
let source_context =
|
let source_context =
|
||||||
worker_ticket_source_context(api, workspace_id, source, before.as_ref());
|
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)?;
|
let result = execute_ticket_backend_operation(&backend, operation).map_err(Error::from)?;
|
||||||
@@ -4223,7 +4345,7 @@ async fn scoped_queue_ticket_record(
|
|||||||
headers,
|
headers,
|
||||||
TicketBackendOperation::QueueReady {
|
TicketBackendOperation::QueueReady {
|
||||||
id: TicketIdOrSlug::Query(id),
|
id: TicketIdOrSlug::Query(id),
|
||||||
queued_by: String::new(),
|
queued_by: "workspace-web".to_string(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -4316,7 +4438,7 @@ impl merge_request::AssignmentSource for MergeRequestAssignmentSource {
|
|||||||
ticket_id: &str,
|
ticket_id: &str,
|
||||||
) -> std::result::Result<Option<merge_request::CurrentAssignment>, String> {
|
) -> std::result::Result<Option<merge_request::CurrentAssignment>, String> {
|
||||||
self.store
|
self.store
|
||||||
.get_current_ticket_worker_assignment(workspace_id, ticket_id)
|
.get_current_ticket_coder_assignment(workspace_id, ticket_id)
|
||||||
.map(|value| {
|
.map(|value| {
|
||||||
value.map(|assignment| merge_request::CurrentAssignment {
|
value.map(|assignment| merge_request::CurrentAssignment {
|
||||||
assignment_id: assignment.assignment_id,
|
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 source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
|
||||||
let assignment = api
|
let assignment = api
|
||||||
.store
|
.store
|
||||||
.get_current_ticket_worker_assignment(&workspace_id, &ticket_id)?
|
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into())
|
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 source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
|
||||||
let assignment = api
|
let assignment = api
|
||||||
.store
|
.store
|
||||||
.get_current_ticket_worker_assignment(&workspace_id, &ticket_id)?
|
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into())
|
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 source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
|
||||||
let assignment = api
|
let assignment = api
|
||||||
.store
|
.store
|
||||||
.get_current_ticket_worker_assignment(&workspace_id, &ticket_id)?
|
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into())
|
Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into())
|
||||||
})?;
|
})?;
|
||||||
@@ -4925,7 +5047,7 @@ async fn scoped_complete_merge_request(
|
|||||||
}
|
}
|
||||||
let assignment = api
|
let assignment = api
|
||||||
.store
|
.store
|
||||||
.get_current_ticket_worker_assignment(&workspace_id, &ticket_id)?
|
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into())
|
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<Option<TicketRoleAssignmentRecord>> {
|
||||||
|
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<Option<TicketRoleAssignmentRecord>> {
|
||||||
|
if !matches!(state, "queued" | "inprogress") {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
active_orchestrator_assignment(api, workspace_id, ticket_id)
|
||||||
|
}
|
||||||
|
|
||||||
fn worker_ticket_source_context(
|
fn worker_ticket_source_context(
|
||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -5307,7 +5469,7 @@ fn worker_ticket_source_context(
|
|||||||
) -> WorkerTicketSourceContext {
|
) -> WorkerTicketSourceContext {
|
||||||
let assignment = ticket.and_then(|ticket| {
|
let assignment = ticket.and_then(|ticket| {
|
||||||
api.store
|
api.store
|
||||||
.get_current_ticket_worker_assignment(workspace_id, &ticket.meta.id)
|
.get_current_ticket_coder_assignment(workspace_id, &ticket.meta.id)
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
});
|
});
|
||||||
@@ -5315,7 +5477,17 @@ fn worker_ticket_source_context(
|
|||||||
let is_current_assignment = assignment
|
let is_current_assignment = assignment
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|assignment| &assignment.worker == source);
|
.is_some_and(|assignment| &assignment.worker == source);
|
||||||
let is_orchestrator = orchestrator
|
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()
|
.as_ref()
|
||||||
.is_some_and(|worker| worker.worker == *source);
|
.is_some_and(|worker| worker.worker == *source);
|
||||||
let actor_role = worker_source_actor_role(is_current_assignment, is_orchestrator);
|
let actor_role = worker_source_actor_role(is_current_assignment, is_orchestrator);
|
||||||
@@ -5338,21 +5510,23 @@ fn notify_ticket_recipients(
|
|||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
ticket_id: &str,
|
ticket_id: &str,
|
||||||
previous_state: &str,
|
_previous_state: &str,
|
||||||
current_state: &str,
|
current_state: &str,
|
||||||
source: Option<RuntimeWorkerRef>,
|
source: Option<RuntimeWorkerRef>,
|
||||||
) {
|
) {
|
||||||
let mut recipients = Vec::new();
|
let mut recipients = Vec::new();
|
||||||
if let Some(assignment) = api
|
if let Some(assignment) = api
|
||||||
.store
|
.store
|
||||||
.get_current_ticket_worker_assignment(workspace_id, ticket_id)
|
.get_current_ticket_coder_assignment(workspace_id, ticket_id)
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
{
|
{
|
||||||
recipients.push(assignment.worker.clone());
|
recipients.push(assignment.worker.clone());
|
||||||
}
|
}
|
||||||
if (matches!(previous_state, "queued" | "inprogress")
|
if orchestrator_interested(api, workspace_id, ticket_id, current_state)
|
||||||
|| matches!(current_state, "queued" | "inprogress"))
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some()
|
||||||
&& let Some(orchestrator) = find_workspace_orchestrator(api)
|
&& let Some(orchestrator) = find_workspace_orchestrator(api)
|
||||||
{
|
{
|
||||||
recipients.push(orchestrator.worker.clone());
|
recipients.push(orchestrator.worker.clone());
|
||||||
@@ -5855,6 +6029,24 @@ fn dispatch_orchestrator_queue_attention(api: &WorkspaceApi) {
|
|||||||
])) else {
|
])) else {
|
||||||
return;
|
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));
|
queued.sort_by(|left, right| left.id.cmp(&right.id));
|
||||||
if queued.is_empty() {
|
if queued.is_empty() {
|
||||||
*api.orchestrator_attention_fingerprint
|
*api.orchestrator_attention_fingerprint
|
||||||
@@ -5862,15 +6054,6 @@ fn dispatch_orchestrator_queue_attention(api: &WorkspaceApi) {
|
|||||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
|
.unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Ok(inprogress) = backend.list(ticket::TicketListQuery::states([
|
|
||||||
ticket::TicketListState::InProgress,
|
|
||||||
])) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if !inprogress.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let fingerprint = queued
|
let fingerprint = queued
|
||||||
.iter()
|
.iter()
|
||||||
.map(|ticket| ticket.id.as_str())
|
.map(|ticket| ticket.id.as_str())
|
||||||
@@ -13668,6 +13851,7 @@ mod tests {
|
|||||||
let mut input = ticket::NewTicket::new("Assigned Ticket");
|
let mut input = ticket::NewTicket::new("Assigned Ticket");
|
||||||
input.workflow_state = Some(TicketWorkflowState::Queued);
|
input.workflow_state = Some(TicketWorkflowState::Queued);
|
||||||
let ticket = backend.create(input).unwrap();
|
let ticket = backend.create(input).unwrap();
|
||||||
|
assign_test_orchestrator(&api, &ticket.id);
|
||||||
let response = create_workspace_worker(
|
let response = create_workspace_worker(
|
||||||
State(api.clone()),
|
State(api.clone()),
|
||||||
HeaderMap::new(),
|
HeaderMap::new(),
|
||||||
@@ -13697,7 +13881,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let current = api
|
let current = api
|
||||||
.store
|
.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()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(current.worker, response.worker_ref);
|
assert_eq!(current.worker, response.worker_ref);
|
||||||
@@ -13722,6 +13906,7 @@ mod tests {
|
|||||||
let mut input = ticket::NewTicket::new("Queued Ticket");
|
let mut input = ticket::NewTicket::new("Queued Ticket");
|
||||||
input.workflow_state = Some(TicketWorkflowState::Queued);
|
input.workflow_state = Some(TicketWorkflowState::Queued);
|
||||||
let ticket = backend.create(input).unwrap();
|
let ticket = backend.create(input).unwrap();
|
||||||
|
assign_test_orchestrator(&api, &ticket.id);
|
||||||
|
|
||||||
let result = create_workspace_worker(
|
let result = create_workspace_worker(
|
||||||
State(api.clone()),
|
State(api.clone()),
|
||||||
@@ -13751,7 +13936,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
api.store
|
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()
|
||||||
.is_none()
|
.is_none()
|
||||||
);
|
);
|
||||||
@@ -15354,6 +15539,7 @@ mod tests {
|
|||||||
input.repository_id = Some(TEST_REPOSITORY_ID.to_owned());
|
input.repository_id = Some(TEST_REPOSITORY_ID.to_owned());
|
||||||
input.ref_selector = Some("develop".to_owned());
|
input.ref_selector = Some("develop".to_owned());
|
||||||
let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap();
|
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 ticket_id = TicketIdOrSlug::Id(ticket.id.clone());
|
||||||
let operations = [
|
let operations = [
|
||||||
TicketBackendOperation::MarkReady {
|
TicketBackendOperation::MarkReady {
|
||||||
@@ -15442,7 +15628,7 @@ mod tests {
|
|||||||
updated_at: TEST_CREATED_AT.to_string(),
|
updated_at: TEST_CREATED_AT.to_string(),
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let assignment = TicketWorkerAssignmentRecord {
|
let assignment = TicketCoderAssignmentRecord {
|
||||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
ticket_id: ticket_id.clone(),
|
ticket_id: ticket_id.clone(),
|
||||||
assignment_id: "assignment-api-1".to_string(),
|
assignment_id: "assignment-api-1".to_string(),
|
||||||
@@ -15451,7 +15637,7 @@ mod tests {
|
|||||||
assigned_at: TEST_CREATED_AT.to_string(),
|
assigned_at: TEST_CREATED_AT.to_string(),
|
||||||
};
|
};
|
||||||
api.store
|
api.store
|
||||||
.set_current_ticket_worker_assignment(
|
.set_current_ticket_coder_assignment(
|
||||||
&assignment,
|
&assignment,
|
||||||
None,
|
None,
|
||||||
"event-api-1",
|
"event-api-1",
|
||||||
@@ -15464,18 +15650,23 @@ mod tests {
|
|||||||
id: ticket_id.clone(),
|
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
|
.await
|
||||||
.unwrap();
|
.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()),
|
State(api.clone()),
|
||||||
AxumPath(path()),
|
AxumPath((
|
||||||
Query(ClearTicketWorkerAssignmentQuery {
|
TEST_WORKSPACE_ID.to_string(),
|
||||||
|
ticket_id.clone(),
|
||||||
|
"coder".to_string(),
|
||||||
|
)),
|
||||||
|
Query(ClearTicketRoleAssignmentQuery {
|
||||||
operation_id: Some("clear-stale".to_string()),
|
operation_id: Some("clear-stale".to_string()),
|
||||||
expected_assignment_id: Some("stale-assignment".to_string()),
|
assignment_id: Some("stale-assignment".to_string()),
|
||||||
actor: Some("test-user".to_string()),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -15483,21 +15674,20 @@ mod tests {
|
|||||||
.into_response();
|
.into_response();
|
||||||
assert_eq!(stale.status(), StatusCode::CONFLICT);
|
assert_eq!(stale.status(), StatusCode::CONFLICT);
|
||||||
|
|
||||||
let Json(cleared) = scoped_clear_ticket_worker_assignment(
|
let Json(cleared) = scoped_clear_ticket_assignment(
|
||||||
State(api.clone()),
|
State(api.clone()),
|
||||||
AxumPath(path()),
|
AxumPath((
|
||||||
Query(ClearTicketWorkerAssignmentQuery {
|
TEST_WORKSPACE_ID.to_string(),
|
||||||
|
ticket_id.clone(),
|
||||||
|
"coder".to_string(),
|
||||||
|
)),
|
||||||
|
Query(ClearTicketRoleAssignmentQuery {
|
||||||
operation_id: Some("clear-current".to_string()),
|
operation_id: Some("clear-current".to_string()),
|
||||||
expected_assignment_id: Some("assignment-api-1".to_string()),
|
assignment_id: Some("assignment-api-1".to_string()),
|
||||||
actor: Some("test-user".to_string()),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
|
||||||
cleared.previous_assignment_id.as_deref(),
|
|
||||||
Some("assignment-api-1")
|
|
||||||
);
|
|
||||||
assert_eq!(cleared.assignment, None);
|
assert_eq!(cleared.assignment, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15588,8 +15778,8 @@ mod tests {
|
|||||||
.create(ticket::NewTicket::new("Notify assigned Worker"))
|
.create(ticket::NewTicket::new("Notify assigned Worker"))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
api.store
|
api.store
|
||||||
.set_current_ticket_worker_assignment(
|
.set_current_ticket_coder_assignment(
|
||||||
&TicketWorkerAssignmentRecord {
|
&TicketCoderAssignmentRecord {
|
||||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
ticket_id: ticket_ref.id.clone(),
|
ticket_id: ticket_ref.id.clone(),
|
||||||
assignment_id: "notify-assignment".to_string(),
|
assignment_id: "notify-assignment".to_string(),
|
||||||
@@ -15691,8 +15881,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
api.store
|
api.store
|
||||||
.set_current_ticket_worker_assignment(
|
.set_current_ticket_coder_assignment(
|
||||||
&TicketWorkerAssignmentRecord {
|
&TicketCoderAssignmentRecord {
|
||||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
ticket_id: ticket_ref.id.clone(),
|
ticket_id: ticket_ref.id.clone(),
|
||||||
assignment_id: "source-assignment".to_string(),
|
assignment_id: "source-assignment".to_string(),
|
||||||
@@ -15759,6 +15949,56 @@ mod tests {
|
|||||||
assert_eq!(invalid_source.status(), StatusCode::BAD_REQUEST);
|
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]
|
#[tokio::test]
|
||||||
async fn queued_ticket_mutation_succeeds_without_orchestrator() {
|
async fn queued_ticket_mutation_succeeds_without_orchestrator() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -15834,6 +16074,7 @@ mod tests {
|
|||||||
input.repository_id = Some(TEST_REPOSITORY_ID.to_owned());
|
input.repository_id = Some(TEST_REPOSITORY_ID.to_owned());
|
||||||
input.ref_selector = Some("HEAD".to_owned());
|
input.ref_selector = Some("HEAD".to_owned());
|
||||||
let ticket_ref = backend.create(input).unwrap();
|
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());
|
*api.orchestrator_attention_fingerprint.lock().unwrap() = Some(ticket_ref.id.clone());
|
||||||
|
|
||||||
let Json(started) = scoped_start_workspace_orchestrator(
|
let Json(started) = scoped_start_workspace_orchestrator(
|
||||||
@@ -15890,6 +16131,7 @@ mod tests {
|
|||||||
let mut first_ticket_input = ticket::NewTicket::new("Spawn assignment");
|
let mut first_ticket_input = ticket::NewTicket::new("Spawn assignment");
|
||||||
first_ticket_input.workflow_state = Some(TicketWorkflowState::InProgress);
|
first_ticket_input.workflow_state = Some(TicketWorkflowState::InProgress);
|
||||||
let first_ticket = backend.create(first_ticket_input).unwrap();
|
let first_ticket = backend.create(first_ticket_input).unwrap();
|
||||||
|
assign_test_orchestrator(&api, &first_ticket.id);
|
||||||
let request = WorkerSpawnRequest {
|
let request = WorkerSpawnRequest {
|
||||||
requested_worker_name: Some("assigned-spawn".to_string()),
|
requested_worker_name: Some("assigned-spawn".to_string()),
|
||||||
intent: WorkerSpawnIntent::TicketRole {
|
intent: WorkerSpawnIntent::TicketRole {
|
||||||
@@ -15928,7 +16170,7 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let first_worker = first.worker.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()),
|
State(api.clone()),
|
||||||
AxumPath(ScopedRecordPath {
|
AxumPath(ScopedRecordPath {
|
||||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
@@ -15938,11 +16180,11 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
projected
|
projected.assignments[0]
|
||||||
.worker
|
.principal
|
||||||
.as_ref()
|
.worker()
|
||||||
.map(|worker| worker.worker.worker_id.as_str()),
|
.map(|worker| worker.worker_id),
|
||||||
Some(first_worker.worker.worker_id.as_str())
|
Some(first_worker.worker.worker_id.clone())
|
||||||
);
|
);
|
||||||
let Json(retried) = scoped_create_runtime_worker(
|
let Json(retried) = scoped_create_runtime_worker(
|
||||||
State(api.clone()),
|
State(api.clone()),
|
||||||
@@ -15960,7 +16202,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
api.store
|
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()
|
.unwrap()
|
||||||
.len(),
|
.len(),
|
||||||
1
|
1
|
||||||
@@ -15968,7 +16210,7 @@ mod tests {
|
|||||||
|
|
||||||
let current = api
|
let current = api
|
||||||
.store
|
.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()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
api.store
|
api.store
|
||||||
@@ -15994,6 +16236,7 @@ mod tests {
|
|||||||
let mut second_ticket_input = ticket::NewTicket::new("Restore assignment");
|
let mut second_ticket_input = ticket::NewTicket::new("Restore assignment");
|
||||||
second_ticket_input.workflow_state = Some(TicketWorkflowState::InProgress);
|
second_ticket_input.workflow_state = Some(TicketWorkflowState::InProgress);
|
||||||
let second_ticket = backend.create(second_ticket_input).unwrap();
|
let second_ticket = backend.create(second_ticket_input).unwrap();
|
||||||
|
assign_test_orchestrator(&api, &second_ticket.id);
|
||||||
let _ = scoped_restore_runtime_worker(
|
let _ = scoped_restore_runtime_worker(
|
||||||
State(api.clone()),
|
State(api.clone()),
|
||||||
AxumPath(ScopedRuntimeWorkerPath {
|
AxumPath(ScopedRuntimeWorkerPath {
|
||||||
@@ -16033,7 +16276,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let restored_assignment = api
|
let restored_assignment = api
|
||||||
.store
|
.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()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -16154,6 +16397,7 @@ mod tests {
|
|||||||
.create(ticket_input)
|
.create(ticket_input)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.id;
|
.id;
|
||||||
|
assign_test_orchestrator(&api, &ticket_id);
|
||||||
let assignment = crate::hosts::WorkerTicketAssignmentRequest {
|
let assignment = crate::hosts::WorkerTicketAssignmentRequest {
|
||||||
ticket_id: ticket_id.clone(),
|
ticket_id: ticket_id.clone(),
|
||||||
operation_id: "compensation-test-operation".to_string(),
|
operation_id: "compensation-test-operation".to_string(),
|
||||||
@@ -16247,7 +16491,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
api.store
|
api.store
|
||||||
.get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket_id)
|
.get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &ticket_id)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.is_none()
|
.is_none()
|
||||||
);
|
);
|
||||||
@@ -16346,7 +16590,8 @@ mod tests {
|
|||||||
assert_eq!(edited.body, "Updated from the Browser API.");
|
assert_eq!(edited.body, "Updated from the Browser API.");
|
||||||
assert_eq!(edited.repository_id.as_deref(), Some("main"));
|
assert_eq!(edited.repository_id.as_deref(), Some("main"));
|
||||||
assert_eq!(edited.ref_selector.as_deref(), Some("develop"));
|
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.len(), 1);
|
||||||
assert_eq!(edited.relations.outgoing[0].target, related_ticket_id);
|
assert_eq!(edited.relations.outgoing[0].target, related_ticket_id);
|
||||||
assert_eq!(edited.relations.outgoing[0].kind, "related");
|
assert_eq!(edited.relations.outgoing[0].kind, "related");
|
||||||
@@ -16552,6 +16797,49 @@ mod tests {
|
|||||||
test_api_with_recording_backend(workspace_root).await.0
|
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]
|
#[tokio::test]
|
||||||
async fn memory_settings_handlers_reject_foreign_workspace_path_scope() {
|
async fn memory_settings_handlers_reject_foreign_workspace_path_scope() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,241 +1,52 @@
|
|||||||
// Generated from yoi-workspace-server. Do not edit by hand.
|
// 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
|
// 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 = {
|
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, };
|
||||||
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 = {
|
export type TicketListResponse = { workspace_id: string, limit: number, items: Array<TicketSummary>, page: QueryPage, invalid_records: Array<InvalidProjectRecord>, record_authority: string, };
|
||||||
workspace_id: string;
|
|
||||||
limit: number;
|
|
||||||
items: Array<TicketSummary>;
|
|
||||||
page: QueryPage;
|
|
||||||
invalid_records: Array<InvalidProjectRecord>;
|
|
||||||
record_authority: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type QueryPage = {
|
export type QueryPage = { limit: number, returned: number, has_more: boolean, next_cursor: string | null, sort: string, source_limit: number | null, source_truncated: boolean, };
|
||||||
limit: number;
|
|
||||||
returned: number;
|
|
||||||
has_more: boolean;
|
|
||||||
next_cursor: string | null;
|
|
||||||
sort: string;
|
|
||||||
source_limit: number | null;
|
|
||||||
source_truncated: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TicketEventDetail = {
|
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<string>, };
|
||||||
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<string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ObjectiveLinkSummary = {
|
export type ObjectiveLinkSummary = { id: string, resource_key: string, title: string, state: string, };
|
||||||
id: string;
|
|
||||||
resource_key: string;
|
|
||||||
title: string;
|
|
||||||
state: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TicketEvidenceEvent = {
|
export type TicketEvidenceEvent = { event_ref: string, sequence: number, kind: string, at: string | null, author: string | null, excerpt: string, };
|
||||||
event_ref: string;
|
|
||||||
sequence: number;
|
|
||||||
kind: string;
|
|
||||||
at: string | null;
|
|
||||||
author: string | null;
|
|
||||||
excerpt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TicketAssignmentSummary = {
|
export type TicketAssignmentSummary = { assignment_id: string, runtime_id: string, worker_id: string, worker_resource_key?: string | null, };
|
||||||
assignment_id: string;
|
|
||||||
runtime_id: string;
|
|
||||||
worker_id: string;
|
|
||||||
worker_resource_key?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TicketMergeRequestSummary = {
|
export type TicketRoleAssignmentSummary = { assignment_id: string, role: string, principal: TicketAssignmentPrincipalSummary, assigned_by: string, assigned_at: string, };
|
||||||
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 MergeRequestListItem = {
|
export type TicketAssignmentPrincipalSummary = { "kind": "user", account_id: string, } | { "kind": "worker", runtime_id: string, worker_id: string, } | { "kind": "workspace_agent", agent_key: string, };
|
||||||
summary: TicketMergeRequestSummary;
|
|
||||||
ticket_ids: Array<string>;
|
|
||||||
thread_event_count: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MergeRequestListResponse = {
|
export type TicketActionEligibility = { can_assign_orchestrator: boolean, can_unassign_orchestrator: boolean, can_queue: boolean, can_start_manual_coder: boolean, blockers: Array<string>, };
|
||||||
items: Array<MergeRequestListItem>;
|
|
||||||
next_cursor: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TicketEvidenceSummary = {
|
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, };
|
||||||
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<string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TicketQueryRequest = {
|
export type MergeRequestListItem = { summary: TicketMergeRequestSummary, ticket_ids: Array<string>, thread_event_count: number, };
|
||||||
query: string | null;
|
|
||||||
states: Array<string>;
|
|
||||||
event_kinds: Array<string>;
|
|
||||||
evidence: Array<string>;
|
|
||||||
review_status: string | null;
|
|
||||||
attention: Array<string>;
|
|
||||||
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 TicketQueryItem = {
|
export type MergeRequestListResponse = { items: Array<MergeRequestListItem>, next_cursor: string | null, };
|
||||||
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<string>;
|
|
||||||
snippet: string | null;
|
|
||||||
matching_event: TicketEvidenceEvent | null;
|
|
||||||
linked_objective_ids: Array<string>;
|
|
||||||
relation_count: number;
|
|
||||||
blocker_count: number;
|
|
||||||
unresolved_blocker_count: number;
|
|
||||||
unresolved_review_count: number;
|
|
||||||
evidence: TicketEvidenceSummary;
|
|
||||||
merge_request: TicketMergeRequestSummary | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TicketQueryResponse = {
|
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<string>, };
|
||||||
items: Array<TicketQueryItem>;
|
|
||||||
page: QueryPage;
|
|
||||||
record_authority: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TicketShowRequest = {
|
export type TicketQueryRequest = { query: string | null, states: Array<string>, event_kinds: Array<string>, evidence: Array<string>, review_status: string | null, attention: Array<string>, 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, };
|
||||||
event_limit: number | null;
|
|
||||||
event_cursor: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TicketRelation = {
|
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<string>, snippet: string | null, matching_event: TicketEvidenceEvent | null, linked_objective_ids: Array<string>, relation_count: number, blocker_count: number, unresolved_blocker_count: number, unresolved_review_count: number, evidence: TicketEvidenceSummary, merge_request: TicketMergeRequestSummary | null, };
|
||||||
ticket_id: string;
|
|
||||||
kind: string;
|
|
||||||
target: string;
|
|
||||||
target_resource_key?: string | null;
|
|
||||||
note: string | null;
|
|
||||||
author: string;
|
|
||||||
at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DerivedTicketRelation = {
|
export type TicketQueryResponse = { items: Array<TicketQueryItem>, page: QueryPage, record_authority: string, };
|
||||||
source_ticket: string;
|
|
||||||
source_resource_key?: string | null;
|
|
||||||
inverse_kind: string;
|
|
||||||
forward_kind: string;
|
|
||||||
note: string | null;
|
|
||||||
author: string;
|
|
||||||
at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TicketRelationBlocker = {
|
export type TicketShowRequest = { event_limit: number | null, event_cursor: string | null, };
|
||||||
blocking_ticket: string;
|
|
||||||
blocking_resource_key?: string | null;
|
|
||||||
reason_kind: string;
|
|
||||||
relation_kind: string;
|
|
||||||
note: string | null;
|
|
||||||
blocking_state: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TicketRelationNotice = {
|
export type TicketRelation = { ticket_id: string, kind: string, target: string, target_resource_key?: string | null, note: string | null, author: string, at: string, };
|
||||||
related_ticket: string;
|
|
||||||
kind: string;
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TicketRelationView = {
|
export type DerivedTicketRelation = { source_ticket: string, source_resource_key?: string | null, inverse_kind: string, forward_kind: string, note: string | null, author: string, at: string, };
|
||||||
outgoing: Array<TicketRelation>;
|
|
||||||
incoming: Array<DerivedTicketRelation>;
|
|
||||||
blockers: Array<TicketRelationBlocker>;
|
|
||||||
notices: Array<TicketRelationNotice>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TicketDetail = {
|
export type TicketRelationBlocker = { blocking_ticket: string, blocking_resource_key?: string | null, reason_kind: string, relation_kind: string, note: string | null, blocking_state: string, };
|
||||||
id: string;
|
|
||||||
resource_key: string;
|
export type TicketRelationNotice = { related_ticket: string, kind: string, message: string, };
|
||||||
title: string;
|
|
||||||
state: string;
|
export type TicketRelationView = { outgoing: Array<TicketRelation>, incoming: Array<DerivedTicketRelation>, blockers: Array<TicketRelationBlocker>, notices: Array<TicketRelationNotice>, };
|
||||||
readiness: string | null;
|
|
||||||
priority: 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, repository_id: string | null, ref_selector: string | null, risk_flags: Array<string>, body: string, body_truncated: boolean, event_count: number, events: Array<TicketEventDetail>, event_page: QueryPage, artifact_count: number, artifacts: Array<string>, relations: TicketRelationView, linked_objectives: Array<ObjectiveLinkSummary>, implementation_reports: Array<TicketEvidenceEvent>, assignments: Array<TicketRoleAssignmentSummary>, current_coder: TicketAssignmentSummary | null, assignment_diagnostics: Array<string>, action_eligibility: TicketActionEligibility, merge_request: TicketMergeRequestSummary | null, evidence: TicketEvidenceSummary, resolution: string | null, record_source: 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<string>;
|
|
||||||
body: string;
|
|
||||||
body_truncated: boolean;
|
|
||||||
event_count: number;
|
|
||||||
events: Array<TicketEventDetail>;
|
|
||||||
event_page: QueryPage;
|
|
||||||
artifact_count: number;
|
|
||||||
artifacts: Array<string>;
|
|
||||||
relations: TicketRelationView;
|
|
||||||
linked_objectives: Array<ObjectiveLinkSummary>;
|
|
||||||
implementation_reports: Array<TicketEvidenceEvent>;
|
|
||||||
current_assignment: TicketAssignmentSummary | null;
|
|
||||||
merge_request: TicketMergeRequestSummary | null;
|
|
||||||
evidence: TicketEvidenceSummary;
|
|
||||||
resolution: string | null;
|
|
||||||
record_source: string;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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 () => {
|
Deno.test("ticket detail keeps the operation rail outside main content", async () => {
|
||||||
const css = await Deno.readTextFile(
|
const css = await Deno.readTextFile(
|
||||||
new URL("../styles/tickets.css", import.meta.url),
|
new URL("../styles/tickets.css", import.meta.url),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import RichMarkdown from "$lib/workspace/console/RichMarkdown.svelte";
|
import RichMarkdown from "$lib/workspace/console/RichMarkdown.svelte";
|
||||||
import {
|
import {
|
||||||
|
workspaceApiJson,
|
||||||
workspaceApiJsonWithBody,
|
workspaceApiJsonWithBody,
|
||||||
workspaceApiPath,
|
workspaceApiPath,
|
||||||
} from "$lib/workspace/api/http";
|
} from "$lib/workspace/api/http";
|
||||||
@@ -9,7 +10,6 @@
|
|||||||
import {
|
import {
|
||||||
relationLabel,
|
relationLabel,
|
||||||
TICKET_STATES,
|
TICKET_STATES,
|
||||||
ticketWorkerLaunchHref,
|
|
||||||
type WorkspaceOrchestratorStatus,
|
type WorkspaceOrchestratorStatus,
|
||||||
} from "$lib/workspace/tickets/ticket-panel";
|
} from "$lib/workspace/tickets/ticket-panel";
|
||||||
import type { ApiResult } from "$lib/workspace/api/http";
|
import type { ApiResult } from "$lib/workspace/api/http";
|
||||||
@@ -37,7 +37,6 @@
|
|||||||
const loadedTicket = initialData.ticket.data;
|
const loadedTicket = initialData.ticket.data;
|
||||||
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
|
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
|
||||||
const loadedRepositories = initialData.repositories.data;
|
const loadedRepositories = initialData.repositories.data;
|
||||||
const orchestratorOnline = initialData.orchestrator.data?.online ?? false;
|
|
||||||
|
|
||||||
let ticket = $state<TicketDetail>(loadedTicket);
|
let ticket = $state<TicketDetail>(loadedTicket);
|
||||||
const mergeRequest = $derived(ticket.merge_request);
|
const mergeRequest = $derived(ticket.merge_request);
|
||||||
@@ -54,6 +53,8 @@
|
|||||||
let busy = $state<string | null>(null);
|
let busy = $state<string | null>(null);
|
||||||
let errorMessage = $state<string | null>(null);
|
let errorMessage = $state<string | null>(null);
|
||||||
let readyOperationKey = $state<string | null>(null);
|
let readyOperationKey = $state<string | null>(null);
|
||||||
|
let manualRuntimeId = $state("");
|
||||||
|
let manualWorkerId = $state("");
|
||||||
const selectedRepository = $derived(
|
const selectedRepository = $derived(
|
||||||
(loadedRepositories?.items ?? []).find((repository: RepositorySummary) => repository.id === repositoryId) ?? null,
|
(loadedRepositories?.items ?? []).find((repository: RepositorySummary) => repository.id === repositoryId) ?? null,
|
||||||
);
|
);
|
||||||
@@ -72,7 +73,7 @@
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
const implementationStartEligible = $derived(
|
const implementationStartEligible = $derived(
|
||||||
persistedTargetValid && ticket.state !== "planning" && ticket.state !== "closed",
|
ticket.action_eligibility.can_start_manual_coder,
|
||||||
);
|
);
|
||||||
|
|
||||||
const ticketPath = $derived(
|
const ticketPath = $derived(
|
||||||
@@ -116,6 +117,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function mutateAssignment(
|
||||||
|
action: string,
|
||||||
|
role: "orchestrator" | "coder",
|
||||||
|
principal: Record<string, string>,
|
||||||
|
): Promise<void> {
|
||||||
|
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<TicketDetail>(ticketPath));
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
} finally {
|
||||||
|
busy = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assignOrchestrator(): Promise<void> {
|
||||||
|
await mutateAssignment("assign-orchestrator", "orchestrator", {
|
||||||
|
kind: "workspace_agent",
|
||||||
|
agent_key: "workspace-orchestrator",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startManualCoder(event: SubmitEvent): Promise<void> {
|
||||||
|
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) {
|
async function saveEdit(event: SubmitEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (
|
if (
|
||||||
@@ -326,24 +372,54 @@
|
|||||||
|
|
||||||
<aside class="ticket-control-rail">
|
<aside class="ticket-control-rail">
|
||||||
<section class="ticket-control-card ticket-worker-card">
|
<section class="ticket-control-card ticket-worker-card">
|
||||||
<header><h2>Start a Worker</h2><span>Ticket role</span></header>
|
<header><h2>Role assignments</h2><span>Server-authoritative</span></header>
|
||||||
<p class="ticket-assignment-line">
|
{#if ticket.assignments.length > 0}
|
||||||
Assigned to <strong>{ticket.assignee ?? "Unassigned"}</strong>
|
<ul class="ticket-assignment-list">
|
||||||
</p>
|
{#each ticket.assignments as assignment}
|
||||||
{#if orchestratorOnline && implementationStartEligible}
|
<li>
|
||||||
<p>The Orchestrator is online. Start a role-specific Worker with the validated Ticket target below.</p>
|
<strong>{assignment.role}</strong>
|
||||||
<div class="ticket-role-actions">
|
<span>
|
||||||
<a class="workspace-primary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "coder")}>Coder</a>
|
{#if assignment.principal.kind === "worker"}
|
||||||
</div>
|
{assignment.principal.runtime_id}/{assignment.principal.worker_id}
|
||||||
|
{:else if assignment.principal.kind === "user"}
|
||||||
|
{assignment.principal.account_id}
|
||||||
{:else}
|
{:else}
|
||||||
<p class="workspace-callout">
|
{assignment.principal.agent_key}
|
||||||
{orchestratorOnline
|
{/if}
|
||||||
? "Validate and persist the repository target before starting a Ticket Worker."
|
</span>
|
||||||
: "Start the Workspace Orchestrator from the Ticket panel before launching Ticket Workers."}
|
</li>
|
||||||
</p>
|
{/each}
|
||||||
<div class="ticket-role-actions">
|
</ul>
|
||||||
<button class="workspace-primary-button" type="button" disabled>Coder</button>
|
{:else}
|
||||||
</div>
|
<p class="workspace-empty-copy">No active role assignment.</p>
|
||||||
|
{/if}
|
||||||
|
{#if ticket.action_eligibility.can_assign_orchestrator}
|
||||||
|
<button
|
||||||
|
class="workspace-primary-button"
|
||||||
|
type="button"
|
||||||
|
disabled={busy !== null}
|
||||||
|
onclick={assignOrchestrator}
|
||||||
|
>
|
||||||
|
{busy === "assign-orchestrator" ? "Assigning…" : "Assign Orchestrator"}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if implementationStartEligible}
|
||||||
|
<form class="ticket-control-form" onsubmit={startManualCoder}>
|
||||||
|
<label>Runtime ID<input bind:value={manualRuntimeId} required /></label>
|
||||||
|
<label>Worker ID<input bind:value={manualWorkerId} required /></label>
|
||||||
|
<button
|
||||||
|
class="workspace-secondary-button"
|
||||||
|
type="submit"
|
||||||
|
disabled={busy !== null || !manualRuntimeId.trim() || !manualWorkerId.trim()}
|
||||||
|
>
|
||||||
|
{busy === "start-manual" ? "Starting…" : "Assign Coder and start"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
{#if ticket.assignment_diagnostics.length > 0}
|
||||||
|
{#each ticket.assignment_diagnostics as diagnostic}
|
||||||
|
<p class="workspace-callout">{diagnostic}</p>
|
||||||
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -386,9 +462,12 @@
|
|||||||
<p class="workspace-empty-copy">Choose a healthy repository and an effective ref selector before marking ready.</p>
|
<p class="workspace-empty-copy">Choose a healthy repository and an effective ref selector before marking ready.</p>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if ticket.state === "ready"}
|
{:else if ticket.state === "ready"}
|
||||||
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !orchestratorOnline || !persistedTargetValid} onclick={() => mutate("queue", "/queue", {})}>
|
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !ticket.action_eligibility.can_queue} onclick={() => mutate("queue", "/queue", {})}>
|
||||||
{busy === "queue" ? "Queueing…" : orchestratorOnline ? "Queue ticket" : "Orchestrator offline"}
|
{busy === "queue" ? "Queueing…" : "Queue ticket"}
|
||||||
</button>
|
</button>
|
||||||
|
{#if !ticket.action_eligibility.can_queue}
|
||||||
|
<p class="workspace-empty-copy">Assign the Orchestrator role and resolve the listed blockers before Queue.</p>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user