feat: integrate hare/develop
This commit is contained in:
@@ -311,7 +311,10 @@ impl DelegatingWorkdirSession {
|
||||
if !self.capabilities.supports(WorkdirSessionCapability::Read)
|
||||
|| (writable
|
||||
&& (!self.capabilities.supports(WorkdirSessionCapability::Write)
|
||||
|| !self.capabilities.supports(WorkdirSessionCapability::Edit)))
|
||||
|| !self.capabilities.supports(WorkdirSessionCapability::Edit)
|
||||
|| !self
|
||||
.capabilities
|
||||
.supports(WorkdirSessionCapability::Command)))
|
||||
{
|
||||
return Err(WorkdirError::Denied(
|
||||
"parent workdir session cannot delegate the requested capabilities".into(),
|
||||
@@ -342,6 +345,7 @@ impl DelegatingWorkdirSession {
|
||||
if writable {
|
||||
delegated.push(WorkdirSessionCapability::Write);
|
||||
delegated.push(WorkdirSessionCapability::Edit);
|
||||
delegated.push(WorkdirSessionCapability::Command);
|
||||
}
|
||||
Ok(WorkdirSessionCapabilities::from_capabilities(delegated))
|
||||
}
|
||||
@@ -929,6 +933,43 @@ mod tests {
|
||||
.delegate(request("leased", WorkdirDelegationPermission::Write))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
child
|
||||
.capabilities
|
||||
.supports(WorkdirSessionCapability::Command)
|
||||
);
|
||||
let command = child
|
||||
.scoped_session
|
||||
.start_command(CommandRequest {
|
||||
command: "printf child-command".into(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
tool_call_id: Some("delegated-child-command".into()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let command_output = child
|
||||
.scoped_session
|
||||
.command_output(CommandOutputRequest {
|
||||
handle: command,
|
||||
cursor: 0,
|
||||
limit: 1024,
|
||||
wait: true,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(command_output.content, "child-command");
|
||||
assert!(
|
||||
parent
|
||||
.start_command(CommandRequest {
|
||||
command: "printf parent-command".into(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
tool_call_id: Some("blocked-parent-command".into()),
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
parent.write(write("leased/file", "parent")).await,
|
||||
|
||||
@@ -329,13 +329,13 @@ fn validate_reviewer_handoff(input: &SubWorkerSpawnInput) -> Result<(), ToolErro
|
||||
"reviewer handoff requires the explicit effective profile builtin:reviewer".to_string(),
|
||||
));
|
||||
}
|
||||
if input
|
||||
if !input
|
||||
.scope
|
||||
.iter()
|
||||
.any(|rule| matches!(rule.permission, PermissionInput::Write))
|
||||
{
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"Merge Request Reviewer SubWorkers must have read-only delegated scope".to_string(),
|
||||
"Merge Request Reviewer SubWorkers must include writable delegated scope".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
@@ -1008,28 +1008,28 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reviewer_handoff_requires_explicit_builtin_profile_and_read_only_scope() {
|
||||
fn reviewer_handoff_requires_explicit_builtin_profile_and_writable_scope() {
|
||||
let valid: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
||||
"name":"reviewer","task":"review","profile":"builtin:reviewer",
|
||||
"scope":[{"target":"work","permission":"read"}],
|
||||
"scope":[{"target":"work","permission":"write"}],
|
||||
"review":{"ticket_id":"T1"}
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(validate_reviewer_handoff(&valid).is_ok());
|
||||
let wrong_profile: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
||||
"name":"reviewer","task":"review","profile":"builtin:coder",
|
||||
"scope":[{"target":"work","permission":"read"}],
|
||||
"review":{"ticket_id":"T1"}
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(validate_reviewer_handoff(&wrong_profile).is_err());
|
||||
let writable: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
||||
"name":"reviewer","task":"review","profile":"builtin:reviewer",
|
||||
"scope":[{"target":"work","permission":"write"}],
|
||||
"review":{"ticket_id":"T1"}
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(validate_reviewer_handoff(&writable).is_err());
|
||||
assert!(validate_reviewer_handoff(&wrong_profile).is_err());
|
||||
let read_only: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
||||
"name":"reviewer","task":"review","profile":"builtin:reviewer",
|
||||
"scope":[{"target":"work","permission":"read"}],
|
||||
"review":{"ticket_id":"T1"}
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(validate_reviewer_handoff(&read_only).is_err());
|
||||
}
|
||||
|
||||
fn abs_rule(path: &Path, permission: Permission) -> ScopeRule {
|
||||
@@ -1079,7 +1079,7 @@ extract_threshold = 4000
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reviewer_profile_spawns_and_notifies_parent_controller() {
|
||||
async fn reviewer_profile_write_scope_exposes_command_tools_and_notifies_parent_controller() {
|
||||
let runtime = TempDir::new().unwrap();
|
||||
let workspace_root = runtime.path().join("project");
|
||||
let available_profiles = write_project_profile_registry(
|
||||
@@ -1140,7 +1140,7 @@ extract_threshold = 4000
|
||||
"task": "review immutable commit",
|
||||
"scope": [{
|
||||
"target": ".",
|
||||
"permission": "read",
|
||||
"permission": "write",
|
||||
"recursive": true
|
||||
}]
|
||||
});
|
||||
@@ -1171,11 +1171,10 @@ extract_threshold = 4000
|
||||
let record = registry
|
||||
.get_internal("reviewer-child")
|
||||
.expect("Internal reviewer registry record");
|
||||
assert!(record.installed_tools.iter().any(|name| name == "Read"));
|
||||
for denied in ["Write", "Edit", "Bash"] {
|
||||
for required in ["Read", "Write", "Edit", "Glob", "Grep", "Bash"] {
|
||||
assert!(
|
||||
!record.installed_tools.iter().any(|name| name == denied),
|
||||
"read-only child unexpectedly received {denied}: {:?}",
|
||||
record.installed_tools.iter().any(|name| name == required),
|
||||
"write-scoped child is missing {required}: {:?}",
|
||||
record.installed_tools
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2069,6 +2069,10 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
|
||||
"/api/w/{workspace_id}/tickets/{id}/assignments/{role}",
|
||||
put(scoped_set_ticket_assignment).delete(scoped_clear_ticket_assignment),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/tickets/{id}/implementation-cancellations",
|
||||
post(scoped_cancel_ticket_implementation),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/tickets/{id}/state",
|
||||
post(scoped_transition_ticket_state),
|
||||
@@ -3427,6 +3431,14 @@ struct SetTicketRoleAssignmentRequest {
|
||||
expected_assignment_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CancelTicketImplementationRequest {
|
||||
operation_id: String,
|
||||
assignment_id: String,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ClearTicketRoleAssignmentQuery {
|
||||
@@ -3624,6 +3636,113 @@ async fn scoped_clear_ticket_assignment(
|
||||
assignment: None,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn scoped_cancel_ticket_implementation(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedRecordPath>,
|
||||
Json(request): Json<CancelTicketImplementationRequest>,
|
||||
) -> ApiResult<Json<TicketDetail>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let ticket = api.authority.ticket(&path.id)?;
|
||||
let operation_id = require_ticket_assignment_value("operation_id", request.operation_id)?;
|
||||
let assignment_id = require_ticket_assignment_value("assignment_id", request.assignment_id)?;
|
||||
let reason = require_ticket_assignment_value("reason", request.reason)?;
|
||||
if reason.len() > 512 {
|
||||
return Err(Error::InvalidInput(
|
||||
"implementation cancellation reason must be at most 512 bytes".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
if !matches!(
|
||||
ticket.state.as_str(),
|
||||
state if state == TicketWorkflowState::InProgress.as_str()
|
||||
|| state == TicketWorkflowState::Ready.as_str()
|
||||
) {
|
||||
return Err(Error::TicketAssignmentConflict(format!(
|
||||
"implementation cancellation requires an inprogress Ticket; current state is {}",
|
||||
ticket.state
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
let current = api.store.get_current_ticket_role_assignment(
|
||||
&path.workspace_id,
|
||||
&ticket.id,
|
||||
TicketAssignmentRole::Coder,
|
||||
)?;
|
||||
if let Some(assignment) = current.filter(|value| value.assignment_id == assignment_id)
|
||||
&& let TicketAssignmentPrincipal::Worker {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
} = assignment.principal
|
||||
{
|
||||
if api
|
||||
.store
|
||||
.get_ticket_assignment_operation(&path.workspace_id, &operation_id)?
|
||||
.is_some()
|
||||
{
|
||||
return Err(Error::TicketAssignmentConflict(format!(
|
||||
"operation `{operation_id}` was already used for another Ticket assignment mutation"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
let worker = RuntimeWorkerRef::new(runtime_id, worker_id);
|
||||
cancel_ticket_coder_worker(&api, &worker, &reason).await?;
|
||||
}
|
||||
|
||||
let cancelled = api.store.cancel_current_ticket_coder_assignment(
|
||||
&path.workspace_id,
|
||||
&ticket.id,
|
||||
&assignment_id,
|
||||
&new_id("tasev"),
|
||||
&new_id("tev"),
|
||||
&operation_id,
|
||||
"workspace-web",
|
||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||
&reason,
|
||||
)?;
|
||||
if !cancelled {
|
||||
return Err(Error::TicketAssignmentConflict(format!(
|
||||
"assignment `{assignment_id}` is not the current Coder implementation"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
browser_ticket_detail(&api, &ticket.id)
|
||||
}
|
||||
|
||||
async fn cancel_ticket_coder_worker(
|
||||
api: &WorkspaceApi,
|
||||
worker: &RuntimeWorkerRef,
|
||||
reason: &str,
|
||||
) -> ApiResult<()> {
|
||||
let session_lock = current_worker_session_lock(api, worker);
|
||||
let _session_guard = session_lock.lock().await;
|
||||
match api.runtime.cancel_worker(
|
||||
worker,
|
||||
WorkerLifecycleRequest {
|
||||
reason: Some(format!("Ticket implementation cancelled: {reason}")),
|
||||
ticket_assignment: None,
|
||||
},
|
||||
) {
|
||||
Ok(result) if result.state == WorkerOperationState::Accepted => {}
|
||||
Ok(result) => {
|
||||
return Err(ApiError::with_diagnostics(
|
||||
Error::RuntimeOperationFailed {
|
||||
runtime_id: worker.runtime_id.clone(),
|
||||
code: "workspace_ticket_implementation_cancel_rejected".to_string(),
|
||||
message: "Runtime did not cancel the assigned Coder Worker".to_string(),
|
||||
},
|
||||
result.diagnostics,
|
||||
));
|
||||
}
|
||||
Err(RuntimeRegistryError::UnknownWorker { .. }) => {}
|
||||
Err(error) => return Err(error.into_error().into()),
|
||||
}
|
||||
close_current_worker_session_locked(api, worker).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_ticket_assignment_state(
|
||||
api: &WorkspaceApi,
|
||||
assignment: &WorkerTicketAssignmentRequest,
|
||||
@@ -8473,9 +8592,19 @@ fn build_runtime_cleanup_plan(
|
||||
let links = api
|
||||
.store
|
||||
.list_worker_workdir_links(&api.config.workspace_id, &record.worker)?;
|
||||
let current_assignment = api.store.get_current_ticket_role_assignment_for_worker(
|
||||
&api.config.workspace_id,
|
||||
&record.worker,
|
||||
)?;
|
||||
let is_running = live_running_worker_ids.contains(&record.worker);
|
||||
let pinned = record.retention_state == "pinned";
|
||||
let blocking_reason = if pinned {
|
||||
let blocking_reason = if let Some(assignment) = current_assignment {
|
||||
Some(format!(
|
||||
"worker has current Ticket assignment `{}` (`{}`)",
|
||||
assignment.ticket_id,
|
||||
assignment.role.as_str()
|
||||
))
|
||||
} else if pinned {
|
||||
Some("worker is pinned".to_string())
|
||||
} else if is_running {
|
||||
Some("worker is running".to_string())
|
||||
@@ -8653,6 +8782,24 @@ async fn execute_runtime_cleanup(
|
||||
.iter()
|
||||
.filter(|candidate| worker_targets.contains(candidate.target_id.as_str()))
|
||||
{
|
||||
let worker = RuntimeWorkerRef::new(
|
||||
candidate.runtime_id.clone(),
|
||||
candidate.runtime_worker_id.clone(),
|
||||
);
|
||||
if let Some(assignment) = api
|
||||
.store
|
||||
.get_current_ticket_role_assignment_for_worker(&api.config.workspace_id, &worker)?
|
||||
{
|
||||
return Err(cleanup_api_error(
|
||||
runtime_id,
|
||||
"workspace_cleanup_worker_assigned",
|
||||
&format!(
|
||||
"Worker is assigned to Ticket `{}` as `{}` and cannot be deleted",
|
||||
assignment.ticket_id,
|
||||
assignment.role.as_str()
|
||||
),
|
||||
));
|
||||
}
|
||||
if let Some(reason) = &candidate.blocking_reason {
|
||||
return Err(cleanup_api_error(
|
||||
runtime_id,
|
||||
@@ -8668,10 +8815,6 @@ async fn execute_runtime_cleanup(
|
||||
));
|
||||
}
|
||||
parse_runtime_worker_id_for_registry(&candidate.runtime_worker_id)?;
|
||||
let worker = RuntimeWorkerRef::new(
|
||||
candidate.runtime_id.clone(),
|
||||
candidate.runtime_worker_id.clone(),
|
||||
);
|
||||
let session_lock = current_worker_session_lock(api, &worker);
|
||||
let _session_guard = session_lock.lock().await;
|
||||
close_current_worker_session_locked(api, &worker).await?;
|
||||
@@ -17207,6 +17350,114 @@ mod tests {
|
||||
assert_eq!(replayed_clear.assignment, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn implementation_cancellation_cancels_coder_and_returns_ticket_to_ready() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let worker = api
|
||||
.runtime
|
||||
.spawn_worker(
|
||||
EMBEDDED_WORKER_RUNTIME_ID,
|
||||
test_create_binding(),
|
||||
WorkerSpawnRequest {
|
||||
requested_worker_name: Some("cancelled-coder".to_string()),
|
||||
intent: WorkerSpawnIntent::TicketRole {
|
||||
ticket_id: "implementation-cancellation".to_string(),
|
||||
role: TicketWorkerRole::Coder,
|
||||
},
|
||||
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
|
||||
expected_segments: 0,
|
||||
},
|
||||
profile: ProfileSelector::Builtin("builtin:coder".to_string()),
|
||||
ticket_assignment: None,
|
||||
initial_submit: Vec::new(),
|
||||
working_directory_request: None,
|
||||
resolved_working_directory_request: None,
|
||||
resolved_working_directory: None,
|
||||
resolved_config_bundle: None,
|
||||
resolved_worker_observation_enabled: false,
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_workspace_api: Some(test_worker_workspace_api(
|
||||
EMBEDDED_WORKER_RUNTIME_ID,
|
||||
)),
|
||||
resolved_memory_settings: Some(test_worker_memory_settings()),
|
||||
resolved_control_operation: None,
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.worker
|
||||
.unwrap()
|
||||
.worker;
|
||||
let worker = RuntimeWorkerRef::new(EMBEDDED_WORKER_RUNTIME_ID, worker.worker_id);
|
||||
api.store
|
||||
.upsert_worker_registry(&WorkerRegistryRecord {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
worker: worker.clone(),
|
||||
display_name: "Cancelled Coder".to_string(),
|
||||
profile: Some("builtin:coder".to_string()),
|
||||
retention_state: "normal".to_string(),
|
||||
transcript_ref: None,
|
||||
session_ref: None,
|
||||
summary_ref: None,
|
||||
diagnostics_ref: None,
|
||||
created_at: TEST_CREATED_AT.to_string(),
|
||||
updated_at: TEST_CREATED_AT.to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
let backend = browser_ticket_backend(&api).unwrap();
|
||||
let mut input = ticket::NewTicket::new("Implementation cancellation");
|
||||
input.workflow_state = Some(TicketWorkflowState::InProgress);
|
||||
let ticket = backend.create(input).unwrap();
|
||||
api.store
|
||||
.set_current_ticket_coder_assignment(
|
||||
&TicketCoderAssignmentRecord {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
ticket_id: ticket.id.clone(),
|
||||
assignment_id: "cancelled-assignment".to_string(),
|
||||
worker: worker.clone(),
|
||||
assigned_by: "test-user".to_string(),
|
||||
assigned_at: TEST_CREATED_AT.to_string(),
|
||||
},
|
||||
None,
|
||||
"cancelled-assignment-event",
|
||||
"cancelled-assignment-operation",
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let path = || {
|
||||
AxumPath(ScopedRecordPath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
id: ticket.id.clone(),
|
||||
})
|
||||
};
|
||||
let request = || {
|
||||
Json(CancelTicketImplementationRequest {
|
||||
operation_id: "cancel-implementation-operation".to_string(),
|
||||
assignment_id: "cancelled-assignment".to_string(),
|
||||
reason: "redo with the corrected design".to_string(),
|
||||
})
|
||||
};
|
||||
|
||||
let Json(cancelled) =
|
||||
scoped_cancel_ticket_implementation(State(api.clone()), path(), request())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(cancelled.state, TicketWorkflowState::Ready.as_str());
|
||||
assert!(cancelled.current_coder.is_none());
|
||||
assert!(
|
||||
!cancelled
|
||||
.assignments
|
||||
.iter()
|
||||
.any(|assignment| assignment.role == "coder")
|
||||
);
|
||||
assert_eq!(api.runtime.worker(&worker).unwrap().state, "cancelled");
|
||||
|
||||
let Json(replayed) = scoped_cancel_ticket_implementation(State(api), path(), request())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(replayed.state, TicketWorkflowState::Ready.as_str());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authenticated_worker_ticket_mutation_notifies_current_assignment() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -19382,6 +19633,43 @@ mod tests {
|
||||
runtime_worker_id.to_string()
|
||||
}
|
||||
|
||||
fn seed_cleanup_worker_assignment(
|
||||
api: &WorkspaceApi,
|
||||
runtime_worker_id: &str,
|
||||
ticket_id: &str,
|
||||
) {
|
||||
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
crate::store::configure_sqlite(&conn).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO typed_tickets (
|
||||
workspace_id, ticket_id, slug, title, status, kind, priority, body,
|
||||
workflow_state, workflow_state_explicit
|
||||
) VALUES (?1, ?2, ?2, ?2, 'open', 'task', 'normal', '', 'inprogress', 1)",
|
||||
rusqlite::params![api.config.workspace_id, ticket_id],
|
||||
)
|
||||
.unwrap();
|
||||
api.store
|
||||
.set_current_ticket_role_assignment(
|
||||
&TicketRoleAssignmentRecord {
|
||||
workspace_id: api.config.workspace_id.clone(),
|
||||
ticket_id: ticket_id.to_string(),
|
||||
assignment_id: format!("assignment-{ticket_id}"),
|
||||
role: TicketAssignmentRole::Coder,
|
||||
principal: TicketAssignmentPrincipal::Worker {
|
||||
runtime_id: "runtime-test".to_string(),
|
||||
worker_id: runtime_worker_id.to_string(),
|
||||
},
|
||||
assigned_by: "test".to_string(),
|
||||
assigned_at: "2026-08-25T00:00:00Z".to_string(),
|
||||
},
|
||||
None,
|
||||
&format!("event-{ticket_id}"),
|
||||
&format!("operation-{ticket_id}"),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn seed_test_repository(api: &WorkspaceApi, repository_id: &str) {
|
||||
if api
|
||||
.store
|
||||
@@ -19684,6 +19972,56 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_blocks_assigned_worker_before_runtime_deletion() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(workspace.path());
|
||||
let api = test_api(workspace.path()).await;
|
||||
let worker_id = seed_cleanup_worker(&api, 3, "normal");
|
||||
seed_cleanup_worker_assignment(&api, &worker_id, "ticket-assigned");
|
||||
|
||||
let plan = build_runtime_cleanup_plan(&api, "runtime-test")
|
||||
.unwrap_or_else(|err| panic!("cleanup plan: {}", err.error));
|
||||
let candidate = plan
|
||||
.workers
|
||||
.iter()
|
||||
.find(|candidate| candidate.worker_id == worker_id)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
candidate.blocking_reason.as_deref(),
|
||||
Some("worker has current Ticket assignment `ticket-assigned` (`coder`)")
|
||||
);
|
||||
let request = ExecuteRuntimeCleanupRequest {
|
||||
expected_plan_revision: plan.revision.clone(),
|
||||
expected_plan_digest: plan.digest.clone(),
|
||||
worker_target_ids: vec![candidate.target_id.clone()],
|
||||
workdir_target_ids: Vec::new(),
|
||||
confirm_dirty_discard_target_ids: Vec::new(),
|
||||
};
|
||||
|
||||
let error = execute_runtime_cleanup(&api, "runtime-test", request)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
error.error,
|
||||
Error::RuntimeOperationFailed { ref code, .. }
|
||||
if code == "workspace_cleanup_worker_assigned"
|
||||
),
|
||||
"unexpected cleanup error: {:?}",
|
||||
error.error
|
||||
);
|
||||
assert!(
|
||||
api.store
|
||||
.get_worker_registry(
|
||||
&api.config.workspace_id,
|
||||
&RuntimeWorkerRef::new("runtime-test", worker_id),
|
||||
)
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_execution_requires_dirty_confirmation_and_deletes_removed_record() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -1081,6 +1081,11 @@ pub trait ControlPlaneStore: Send + Sync {
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
) -> Result<Vec<TicketRoleAssignmentRecord>>;
|
||||
fn get_current_ticket_role_assignment_for_worker(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
worker: &RuntimeWorkerRef,
|
||||
) -> Result<Option<TicketRoleAssignmentRecord>>;
|
||||
fn get_current_ticket_role_assignment(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -1113,6 +1118,18 @@ pub trait ControlPlaneStore: Send + Sync {
|
||||
occurred_at: &str,
|
||||
reason: Option<&str>,
|
||||
) -> Result<bool>;
|
||||
fn cancel_current_ticket_coder_assignment(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
assignment_id: &str,
|
||||
assignment_event_id: &str,
|
||||
state_event_id: &str,
|
||||
operation_id: &str,
|
||||
actor: &str,
|
||||
occurred_at: &str,
|
||||
reason: &str,
|
||||
) -> Result<bool>;
|
||||
fn get_current_ticket_coder_assignment(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -3659,6 +3676,28 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
})
|
||||
}
|
||||
|
||||
fn get_current_ticket_role_assignment_for_worker(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
worker: &RuntimeWorkerRef,
|
||||
) -> Result<Option<TicketRoleAssignmentRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
let sql = ticket_role_assignment_select_sql(
|
||||
"WHERE current.workspace_id = ?1 \
|
||||
AND current.principal_kind = 'worker' \
|
||||
AND current.runtime_id = ?2 AND current.worker_id = ?3 \
|
||||
ORDER BY a.assigned_at, a.assignment_id LIMIT 1",
|
||||
);
|
||||
Ok(conn
|
||||
.query_row(
|
||||
&sql,
|
||||
params![workspace_id, worker.runtime_id, worker.worker_id],
|
||||
read_ticket_role_assignment_record,
|
||||
)
|
||||
.optional()?)
|
||||
})
|
||||
}
|
||||
|
||||
fn set_current_ticket_role_assignment(
|
||||
&self,
|
||||
record: &TicketRoleAssignmentRecord,
|
||||
@@ -4106,106 +4145,110 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
) -> Result<bool> {
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let current = read_ticket_role_assignment_by_id(&tx, workspace_id, assignment_id)?;
|
||||
let Some(current) = current.filter(|value| {
|
||||
value.ticket_id == ticket_id && value.role == role
|
||||
}) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let principal_json = serde_json::to_string(¤t.principal)
|
||||
.map_err(|error| Error::Store(format!("serialize Ticket assignment principal: {error}")))?;
|
||||
let mut hasher = Sha256::new();
|
||||
for value in [
|
||||
"ticket-role-assignment:clear:v1",
|
||||
workspace_id,
|
||||
ticket_id,
|
||||
role.as_str(),
|
||||
assignment_id,
|
||||
principal_json.as_str(),
|
||||
actor,
|
||||
reason.unwrap_or(""),
|
||||
] {
|
||||
hasher.update(value.as_bytes());
|
||||
hasher.update([0]);
|
||||
}
|
||||
let fingerprint = hasher
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>();
|
||||
let (principal_id, runtime_id, worker_id) = match ¤t.principal {
|
||||
TicketAssignmentPrincipal::User { account_id } => {
|
||||
(Some(account_id.as_str()), None, None)
|
||||
}
|
||||
TicketAssignmentPrincipal::Worker {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
} => (None, Some(runtime_id.as_str()), Some(worker_id.as_str())),
|
||||
TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => {
|
||||
(Some(agent_key.as_str()), None, None)
|
||||
}
|
||||
};
|
||||
let inserted = tx.execute(
|
||||
"INSERT OR IGNORE INTO ticket_assignment_operations (
|
||||
workspace_id, operation_id, action, ticket_id, role, principal_kind,
|
||||
principal_id, runtime_id, worker_id, assignment_id,
|
||||
expected_assignment_id, created_at, request_fingerprint
|
||||
) VALUES (?1, ?2, 'unassign', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9, ?10, ?11)",
|
||||
params![
|
||||
let cleared = clear_current_ticket_role_assignment_in_tx(
|
||||
&tx,
|
||||
workspace_id,
|
||||
operation_id,
|
||||
ticket_id,
|
||||
role.as_str(),
|
||||
current.principal.kind(),
|
||||
principal_id,
|
||||
runtime_id,
|
||||
worker_id,
|
||||
role,
|
||||
assignment_id,
|
||||
event_id,
|
||||
operation_id,
|
||||
actor,
|
||||
occurred_at,
|
||||
fingerprint,
|
||||
],
|
||||
)?;
|
||||
if inserted == 0 {
|
||||
let persisted: String = tx.query_row(
|
||||
"SELECT request_fingerprint FROM ticket_assignment_operations
|
||||
WHERE workspace_id = ?1 AND operation_id = ?2",
|
||||
params![workspace_id, operation_id],
|
||||
reason,
|
||||
"ticket-role-assignment:clear:v1",
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(cleared)
|
||||
})
|
||||
}
|
||||
|
||||
fn cancel_current_ticket_coder_assignment(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
assignment_id: &str,
|
||||
assignment_event_id: &str,
|
||||
state_event_id: &str,
|
||||
operation_id: &str,
|
||||
actor: &str,
|
||||
occurred_at: &str,
|
||||
reason: &str,
|
||||
) -> Result<bool> {
|
||||
self.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
let state: String = tx.query_row(
|
||||
"SELECT workflow_state FROM typed_tickets
|
||||
WHERE workspace_id = ?1 AND ticket_id = ?2",
|
||||
params![workspace_id, ticket_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if persisted != fingerprint {
|
||||
if !matches!(state.as_str(), "inprogress" | "ready") {
|
||||
return Err(Error::TicketAssignmentConflict(format!(
|
||||
"operation `{operation_id}` was already used for different Ticket assignment input"
|
||||
"implementation cancellation requires an inprogress Ticket; current state is `{state}`"
|
||||
)));
|
||||
}
|
||||
tx.commit()?;
|
||||
return Ok(true);
|
||||
}
|
||||
let deleted = tx.execute(
|
||||
"DELETE FROM ticket_current_worker_assignments
|
||||
WHERE workspace_id = ?1 AND ticket_id = ?2 AND role = ?3 AND assignment_id = ?4",
|
||||
params![workspace_id, ticket_id, role.as_str(), assignment_id],
|
||||
)?;
|
||||
if deleted != 0 {
|
||||
tx.execute(
|
||||
"INSERT INTO ticket_worker_assignment_events (
|
||||
workspace_id, ticket_id, role, event_id, action, assignment_id,
|
||||
previous_assignment_id, actor, created_at, operation_id, reason
|
||||
) VALUES (?1, ?2, ?3, ?4, 'unassigned', NULL, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![
|
||||
workspace_id,
|
||||
ticket_id,
|
||||
role.as_str(),
|
||||
event_id,
|
||||
assignment_id,
|
||||
actor,
|
||||
occurred_at,
|
||||
operation_id,
|
||||
reason,
|
||||
],
|
||||
let cleared = clear_current_ticket_role_assignment_in_tx(
|
||||
&tx,
|
||||
workspace_id,
|
||||
ticket_id,
|
||||
TicketAssignmentRole::Coder,
|
||||
assignment_id,
|
||||
assignment_event_id,
|
||||
operation_id,
|
||||
actor,
|
||||
occurred_at,
|
||||
Some(reason),
|
||||
"ticket-role-assignment:cancel-implementation:v1",
|
||||
)?;
|
||||
}
|
||||
if !cleared {
|
||||
return Ok(false);
|
||||
}
|
||||
if state == "inprogress" {
|
||||
let event_index: i64 = tx.query_row(
|
||||
"SELECT COALESCE(MAX(event_index), -1) + 1 FROM typed_ticket_events
|
||||
WHERE workspace_id = ?1 AND ticket_id = ?2",
|
||||
params![workspace_id, ticket_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO typed_ticket_events (
|
||||
workspace_id, ticket_id, event_index, kind, author, at,
|
||||
from_state, to_state, reason, state_field, heading, body
|
||||
) VALUES (?1, ?2, ?3, 'state_changed', ?4, ?5,
|
||||
'inprogress', 'ready', ?6, 'state',
|
||||
'Implementation cancelled', '')",
|
||||
params![workspace_id, ticket_id, event_index, actor, occurred_at, reason],
|
||||
)?;
|
||||
for (key, value) in [
|
||||
("event_id", state_event_id),
|
||||
("assignment_id", assignment_id),
|
||||
("assignment_role", "coder"),
|
||||
("operation_id", operation_id),
|
||||
] {
|
||||
tx.execute(
|
||||
"INSERT INTO typed_ticket_event_attributes (
|
||||
workspace_id, ticket_id, event_index, key, value
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![workspace_id, ticket_id, event_index, key, value],
|
||||
)?;
|
||||
}
|
||||
let updated = tx.execute(
|
||||
"UPDATE typed_tickets SET workflow_state = 'ready',
|
||||
workflow_state_explicit = 1,
|
||||
queued_by = NULL, queued_at = NULL, updated_at = ?3
|
||||
WHERE workspace_id = ?1 AND ticket_id = ?2
|
||||
AND workflow_state = 'inprogress'",
|
||||
params![workspace_id, ticket_id, occurred_at],
|
||||
)?;
|
||||
if updated != 1 {
|
||||
return Err(Error::TicketAssignmentConflict(
|
||||
"Ticket state changed during implementation cancellation".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(deleted != 0)
|
||||
Ok(true)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5582,6 +5625,117 @@ fn validate_ticket_assignment_role_principal(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn clear_current_ticket_role_assignment_in_tx(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
role: TicketAssignmentRole,
|
||||
assignment_id: &str,
|
||||
event_id: &str,
|
||||
operation_id: &str,
|
||||
actor: &str,
|
||||
occurred_at: &str,
|
||||
reason: Option<&str>,
|
||||
fingerprint_domain: &str,
|
||||
) -> Result<bool> {
|
||||
let current = read_ticket_role_assignment_by_id(tx, workspace_id, assignment_id)?;
|
||||
let Some(current) = current.filter(|value| value.ticket_id == ticket_id && value.role == role)
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
let principal_json = serde_json::to_string(¤t.principal)
|
||||
.map_err(|error| Error::Store(format!("serialize Ticket assignment principal: {error}")))?;
|
||||
let mut hasher = Sha256::new();
|
||||
for value in [
|
||||
fingerprint_domain,
|
||||
workspace_id,
|
||||
ticket_id,
|
||||
role.as_str(),
|
||||
assignment_id,
|
||||
principal_json.as_str(),
|
||||
actor,
|
||||
reason.unwrap_or(""),
|
||||
] {
|
||||
hasher.update(value.as_bytes());
|
||||
hasher.update([0]);
|
||||
}
|
||||
let fingerprint = hasher
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>();
|
||||
let (principal_id, runtime_id, worker_id) = match ¤t.principal {
|
||||
TicketAssignmentPrincipal::User { account_id } => (Some(account_id.as_str()), None, None),
|
||||
TicketAssignmentPrincipal::Worker {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
} => (None, Some(runtime_id.as_str()), Some(worker_id.as_str())),
|
||||
TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => {
|
||||
(Some(agent_key.as_str()), None, None)
|
||||
}
|
||||
};
|
||||
let inserted = tx.execute(
|
||||
"INSERT OR IGNORE INTO ticket_assignment_operations (
|
||||
workspace_id, operation_id, action, ticket_id, role, principal_kind,
|
||||
principal_id, runtime_id, worker_id, assignment_id,
|
||||
expected_assignment_id, created_at, request_fingerprint
|
||||
) VALUES (?1, ?2, 'unassign', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9, ?10, ?11)",
|
||||
params![
|
||||
workspace_id,
|
||||
operation_id,
|
||||
ticket_id,
|
||||
role.as_str(),
|
||||
current.principal.kind(),
|
||||
principal_id,
|
||||
runtime_id,
|
||||
worker_id,
|
||||
assignment_id,
|
||||
occurred_at,
|
||||
fingerprint,
|
||||
],
|
||||
)?;
|
||||
if inserted == 0 {
|
||||
let persisted: String = tx.query_row(
|
||||
"SELECT request_fingerprint FROM ticket_assignment_operations
|
||||
WHERE workspace_id = ?1 AND operation_id = ?2",
|
||||
params![workspace_id, operation_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if persisted != fingerprint {
|
||||
return Err(Error::TicketAssignmentConflict(format!(
|
||||
"operation `{operation_id}` was already used for different Ticket assignment input"
|
||||
)));
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
let deleted = tx.execute(
|
||||
"DELETE FROM ticket_current_worker_assignments
|
||||
WHERE workspace_id = ?1 AND ticket_id = ?2 AND role = ?3 AND assignment_id = ?4",
|
||||
params![workspace_id, ticket_id, role.as_str(), assignment_id],
|
||||
)?;
|
||||
if deleted != 0 {
|
||||
tx.execute(
|
||||
"INSERT INTO ticket_worker_assignment_events (
|
||||
workspace_id, ticket_id, role, event_id, action, assignment_id,
|
||||
previous_assignment_id, actor, created_at, operation_id, reason
|
||||
) VALUES (?1, ?2, ?3, ?4, 'unassigned', NULL, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![
|
||||
workspace_id,
|
||||
ticket_id,
|
||||
role.as_str(),
|
||||
event_id,
|
||||
assignment_id,
|
||||
actor,
|
||||
occurred_at,
|
||||
operation_id,
|
||||
reason,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
Ok(deleted != 0)
|
||||
}
|
||||
|
||||
fn current_ticket_worker_assignment_select_sql() -> String {
|
||||
"SELECT a.workspace_id, a.ticket_id, a.assignment_id, a.runtime_id, a.worker_id, \
|
||||
a.assigned_by, a.assigned_at \
|
||||
@@ -6609,6 +6763,25 @@ fn validate_workspace_resource_references(conn: &Connection) -> Result<()> {
|
||||
|
||||
fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result<Vec<String>> {
|
||||
let mut diagnostics = Vec::new();
|
||||
let current_assignment_reference_sql =
|
||||
if column_exists(conn, "ticket_current_worker_assignments", "role")?
|
||||
&& column_exists(conn, "ticket_worker_assignments", "role")?
|
||||
{
|
||||
"SELECT current.workspace_id || '/' || current.assignment_id \
|
||||
FROM ticket_current_worker_assignments AS current \
|
||||
WHERE NOT EXISTS (SELECT 1 FROM ticket_worker_assignments AS assignment \
|
||||
WHERE assignment.workspace_id = current.workspace_id \
|
||||
AND assignment.ticket_id = current.ticket_id \
|
||||
AND assignment.role = current.role \
|
||||
AND assignment.assignment_id = current.assignment_id) LIMIT 100"
|
||||
} else {
|
||||
"SELECT current.workspace_id || '/' || current.assignment_id \
|
||||
FROM ticket_current_worker_assignments AS current \
|
||||
WHERE NOT EXISTS (SELECT 1 FROM ticket_worker_assignments AS assignment \
|
||||
WHERE assignment.workspace_id = current.workspace_id \
|
||||
AND assignment.ticket_id = current.ticket_id \
|
||||
AND assignment.assignment_id = current.assignment_id) LIMIT 100"
|
||||
};
|
||||
for (table, repository_nullable) in [
|
||||
("workdir_registry", false),
|
||||
("artifacts", true),
|
||||
@@ -6681,14 +6854,7 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result<Vec<Str
|
||||
),
|
||||
(
|
||||
"ticket_current_worker_assignments.assignment_id",
|
||||
"SELECT current.workspace_id || '/' || current.assignment_id \
|
||||
FROM ticket_current_worker_assignments AS current \
|
||||
WHERE NOT EXISTS (SELECT 1 FROM ticket_worker_assignments AS assignment \
|
||||
WHERE assignment.workspace_id = current.workspace_id \
|
||||
AND assignment.ticket_id = current.ticket_id \
|
||||
AND assignment.assignment_id = current.assignment_id \
|
||||
AND assignment.runtime_id = current.runtime_id \
|
||||
AND assignment.worker_id = current.worker_id) LIMIT 100",
|
||||
current_assignment_reference_sql,
|
||||
),
|
||||
(
|
||||
"ticket_worker_assignment_events.assignment_id",
|
||||
@@ -6828,11 +6994,17 @@ fn collect_assignment_worker_reference_diagnostics(
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let worker_principal_filter =
|
||||
if column_exists(conn, "ticket_worker_assignments", "principal_kind")? {
|
||||
"assignment.principal_kind = 'worker' AND "
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT assignment.workspace_id, assignment.assignment_id, \
|
||||
assignment.runtime_id, assignment.worker_id \
|
||||
FROM ticket_worker_assignments AS assignment \
|
||||
WHERE NOT EXISTS (SELECT 1 FROM worker_registry AS worker \
|
||||
WHERE {worker_principal_filter}NOT EXISTS (SELECT 1 FROM worker_registry AS worker \
|
||||
WHERE worker.workspace_id = assignment.workspace_id \
|
||||
AND worker.runtime_id = assignment.runtime_id \
|
||||
AND worker.worker_id = assignment.worker_id) \
|
||||
@@ -9514,6 +9686,60 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_dry_run_accepts_non_worker_ticket_assignments() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("server.db");
|
||||
{
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
apply_migrations(&conn).unwrap();
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
INSERT INTO workspaces (
|
||||
workspace_id, display_name, state, created_at, updated_at
|
||||
) VALUES ('workspace-a', 'Workspace A', 'active', '1', '1');
|
||||
INSERT INTO typed_tickets (
|
||||
workspace_id, ticket_id, slug, title, status, kind, priority, body,
|
||||
workflow_state, workflow_state_explicit
|
||||
) VALUES (
|
||||
'workspace-a', 'ticket-a', 'ticket-a', 'Ticket A', 'open', 'task',
|
||||
'normal', '', 'planning', 1
|
||||
);
|
||||
INSERT INTO ticket_worker_assignments (
|
||||
workspace_id, ticket_id, assignment_id, role, principal_kind,
|
||||
principal_id, runtime_id, worker_id, assigned_by, assigned_at
|
||||
) VALUES (
|
||||
'workspace-a', 'ticket-a', 'assignment-a', 'orchestrator',
|
||||
'workspace_agent', 'workspace-orchestrator', NULL, NULL, 'tester', '2'
|
||||
);
|
||||
INSERT INTO ticket_current_worker_assignments (
|
||||
workspace_id, ticket_id, role, assignment_id, principal_kind,
|
||||
principal_id, runtime_id, worker_id, updated_at
|
||||
) VALUES (
|
||||
'workspace-a', 'ticket-a', 'orchestrator', 'assignment-a',
|
||||
'workspace_agent', 'workspace-orchestrator', NULL, NULL, '2'
|
||||
);
|
||||
INSERT INTO ticket_assignment_operations (
|
||||
workspace_id, operation_id, action, ticket_id, role, principal_kind,
|
||||
principal_id, runtime_id, worker_id, assignment_id, created_at
|
||||
) VALUES (
|
||||
'workspace-a', 'operation-a', 'assign', 'ticket-a', 'orchestrator',
|
||||
'workspace_agent', 'workspace-orchestrator', NULL, NULL, 'assignment-a', '2'
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap();
|
||||
assert_eq!(plan.current_schema_version, 43);
|
||||
assert!(!plan.migration_required);
|
||||
assert!(plan.repairs.is_empty());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v38_backfills_workspace_scoped_objective_and_worker_resource_keys() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
@@ -11179,18 +11405,58 @@ INSERT INTO worker_registry (
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.clear_current_ticket_role_assignment(
|
||||
.cancel_current_ticket_coder_assignment(
|
||||
"workspace-role",
|
||||
&ticket.meta.id,
|
||||
"coder-manual-1",
|
||||
"event-cancel-coder",
|
||||
"event-cancel-state",
|
||||
"op-cancel-coder",
|
||||
"user",
|
||||
"2026-09-01T00:03:00Z",
|
||||
"implementation needs to be redone",
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.cancel_current_ticket_coder_assignment(
|
||||
"workspace-role",
|
||||
&ticket.meta.id,
|
||||
"coder-manual-1",
|
||||
"event-cancel-coder-replay",
|
||||
"event-cancel-state-replay",
|
||||
"op-cancel-coder",
|
||||
"user",
|
||||
"2026-09-01T00:03:30Z",
|
||||
"implementation needs to be redone",
|
||||
)
|
||||
.unwrap(),
|
||||
"same operation must be idempotent after the assignment is cleared"
|
||||
);
|
||||
let cancelled_ticket =
|
||||
ticket::TicketBackend::show(&backend, ticket.meta.id.clone().into()).unwrap();
|
||||
assert_eq!(
|
||||
cancelled_ticket.meta.workflow_state,
|
||||
ticket::TicketWorkflowState::Ready
|
||||
);
|
||||
assert_eq!(
|
||||
cancelled_ticket
|
||||
.events
|
||||
.last()
|
||||
.and_then(|event| event.attributes.get("assignment_id"))
|
||||
.map(String::as_str),
|
||||
Some("coder-manual-1")
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get_current_ticket_role_assignment(
|
||||
"workspace-role",
|
||||
&ticket.meta.id,
|
||||
TicketAssignmentRole::Coder,
|
||||
"coder-manual-1",
|
||||
"event-clear-coder",
|
||||
"op-clear-coder",
|
||||
"user",
|
||||
"2026-09-01T00:03:00Z",
|
||||
Some("test removal guard"),
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
|
||||
Reference in New Issue
Block a user