fabro/lib/apps/fabro-server/src/server/handler/lifecycle.rs
2026-08-26 11:16:37 -04:00

1310 lines
44 KiB
Rust

use std::collections::HashSet;
use std::sync::Arc;
use chrono::Utc;
use tokio::time::{Instant, sleep_until};
use super::super::{
ApiError, AppState, AskFabroReadiness, BatchDeleteRunsRequest, BatchDeleteRunsResponse,
BatchDeleteRunsResult, BatchDeleteRunsResultOutcome, BatchDeleteRunsSummary,
BatchRunLifecycleRequest, BatchRunLifecycleResponse, BatchRunLifecycleResult,
BatchRunLifecycleResultOutcome, BatchRunLifecycleSummary, DeleteRunOutcome, DeleteRunSandbox,
DenyRunRequest, FailureReason, ForkRequest, ForkResponse, HeaderMap, IntoResponse, Json, Path,
PendingReason, Principal, RequireRunManagementTarget, RequiredUser, Response, RewindRequest,
RewindResponse, Router, RunAnswerTransport, RunControlAction, RunExecutionMode, RunId,
RunRunnableSource, RunStatus, StartRunRequest, State, StatusCode, Storage,
TimelineEntryResponse, WORKER_CANCEL_GRACE, WorkflowError, append_control_request,
clear_live_run_state, delete_run_internal, durable_run_status, get, load_pending_control,
managed_run, operations, parse_run_id_path, persist_cancelled_run_status, post,
reject_if_archived, update_live_run_from_event, workflow_event,
};
use super::runs::run_provenance;
use crate::worker_runtime::WorkerRef;
pub(super) fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/runs/{id}/cancel", post(cancel_run))
.route("/runs/{id}/start", post(start_run))
.route("/runs/{id}/approve", post(approve_run))
.route("/runs/{id}/deny", post(deny_run))
.route("/runs/{id}/pause", post(pause_run))
.route("/runs/{id}/unpause", post(unpause_run))
.route("/runs/archive", post(batch_archive_runs))
.route("/runs/delete", post(batch_delete_runs))
.route("/runs/unarchive", post(batch_unarchive_runs))
.route("/runs/{id}/archive", post(archive_run))
.route("/runs/{id}/rewind", post(rewind_run))
.route("/runs/{id}/retry", post(retry_run))
.route("/runs/{id}/fork", post(fork_run))
.route("/runs/{id}/timeline", get(run_timeline))
.route("/runs/{id}/unarchive", post(unarchive_run))
}
async fn run_response(state: &AppState, id: RunId, status: StatusCode) -> Response {
match state.stores.runs.get_cached_summary(&id, Utc::now()).await {
Ok(Some(summary)) => {
(status, Json(state.decorate_run_summary(summary).await)).into_response()
}
Ok(None) => ApiError::not_found("Run not found.").into_response(),
Err(err) => {
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
}
}
}
async fn start_run(
RequireRunManagementTarget(id, actor): RequireRunManagementTarget,
State(state): State<Arc<AppState>>,
body: Option<Json<StartRunRequest>>,
) -> Response {
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
return response;
}
let resume = body.is_some_and(|Json(req)| req.resume);
match queue_run_start(state.as_ref(), id, resume, actor).await {
Ok(()) => run_response(state.as_ref(), id, StatusCode::OK).await,
Err(err) => err.into_response(),
}
}
pub(in crate::server) async fn queue_run_start(
state: &AppState,
id: RunId,
resume: bool,
actor: Principal,
) -> Result<(), ApiError> {
{
let runs = state.runs.lock().expect("runs lock poisoned");
if let Some(managed_run) = runs.get(&id) {
if matches!(
managed_run.status,
RunStatus::Pending { .. }
| RunStatus::Runnable
| RunStatus::Starting
| RunStatus::Running
| RunStatus::Blocked { .. }
| RunStatus::Paused { .. }
) {
return Err(ApiError::new(
StatusCode::CONFLICT,
if resume {
"an engine process is still running for this run — cannot resume"
} else if matches!(
managed_run.status,
RunStatus::Pending { .. } | RunStatus::Runnable
) {
"start has already been requested for this run"
} else {
"an engine process is still running for this run — cannot start"
},
));
}
}
}
let Ok(run_store) = state.stores.runs.open_run(&id).await else {
return Err(ApiError::not_found("Run not found."));
};
let run_state = match run_store.state().await {
Ok(state) => state,
Err(err) => {
return Err(ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to load run state: {err}"),
));
}
};
if resume {
if run_state.current_checkpoint().is_none() {
return Err(ApiError::new(
StatusCode::CONFLICT,
"no checkpoint to resume from",
));
}
} else {
let status = run_state.status;
if !matches!(status, RunStatus::Submitted) {
return Err(ApiError::new(
StatusCode::CONFLICT,
format!("cannot start run: status is {status}, expected submitted"),
));
}
}
let run_dir = Storage::new(state.server_storage_dir())
.run_scratch(&id)
.root()
.to_path_buf();
let dot_source = run_state.spec.graph_source.clone().unwrap_or_default();
let approval_required = !resume
&& matches!(
&actor,
Principal::Worker { run_id } if run_state.parent_id == Some(*run_id)
);
if let Err(err) =
workflow_event::append_event(&run_store, &id, &workflow_event::Event::RunStartRequested {
resume,
actor: Some(actor.clone()),
})
.await
{
return Err(ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
err.to_string(),
));
}
let (next_status, next_event) = if approval_required {
(
RunStatus::Pending {
reason: PendingReason::ApprovalRequired,
},
workflow_event::Event::RunPending {
reason: PendingReason::ApprovalRequired,
actor: Some(actor),
},
)
} else {
(RunStatus::Runnable, workflow_event::Event::RunRunnable {
source: RunRunnableSource::StartRequested,
actor: Some(actor),
})
};
if let Err(err) = workflow_event::append_event(&run_store, &id, &next_event).await {
return Err(ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
err.to_string(),
));
}
{
let mut runs = state.runs.lock().expect("runs lock poisoned");
runs.insert(
id,
managed_run(
dot_source,
next_status,
id.created_at(),
run_dir,
if resume {
RunExecutionMode::Resume
} else {
RunExecutionMode::Start
},
),
);
}
if !approval_required {
state.scheduler_notify.notify_one();
}
Ok(())
}
async fn approve_run(
RequiredUser(user): RequiredUser,
Path(id): Path<String>,
State(state): State<Arc<AppState>>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
return response;
}
let Ok(run_store) = state.stores.runs.open_run(&id).await else {
return ApiError::not_found("Run not found.").into_response();
};
let run_state = match run_store.state().await {
Ok(state) => state,
Err(err) => {
return ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to load run state: {err}"),
)
.into_response();
}
};
if !matches!(run_state.status, RunStatus::Pending {
reason: PendingReason::ApprovalRequired,
}) {
return ApiError::new(StatusCode::CONFLICT, "Run is not pending approval.").into_response();
}
let actor = Some(Principal::User(user));
for event in [
workflow_event::Event::RunApproved {
actor: actor.clone(),
},
workflow_event::Event::RunRunnable {
source: RunRunnableSource::Approved,
actor,
},
] {
if let Err(err) = workflow_event::append_event(&run_store, &id, &event).await {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
}
{
let mut runs = state.runs.lock().expect("runs lock poisoned");
if let Some(managed_run) = runs.get_mut(&id) {
managed_run.status = RunStatus::Runnable;
} else {
let run_dir = Storage::new(state.server_storage_dir())
.run_scratch(&id)
.root()
.to_path_buf();
let dot_source = run_state.spec.graph_source.clone().unwrap_or_default();
runs.insert(
id,
managed_run(
dot_source,
RunStatus::Runnable,
id.created_at(),
run_dir,
RunExecutionMode::Start,
),
);
}
}
state.scheduler_notify.notify_one();
run_response(state.as_ref(), id, StatusCode::OK).await
}
async fn deny_run(
RequiredUser(user): RequiredUser,
Path(id): Path<String>,
State(state): State<Arc<AppState>>,
body: Option<Json<DenyRunRequest>>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
return response;
}
let reason = body
.and_then(|Json(req)| req.reason)
.map(|reason| reason.trim().to_string())
.filter(|reason| !reason.is_empty());
let message = reason
.clone()
.unwrap_or_else(|| "Not approved for execution".to_string());
let Ok(run_store) = state.stores.runs.open_run(&id).await else {
return ApiError::not_found("Run not found.").into_response();
};
let run_state = match run_store.state().await {
Ok(state) => state,
Err(err) => {
return ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to load run state: {err}"),
)
.into_response();
}
};
if !matches!(run_state.status, RunStatus::Pending {
reason: PendingReason::ApprovalRequired,
}) {
return ApiError::new(StatusCode::CONFLICT, "Run is not pending approval.").into_response();
}
let actor = Some(Principal::User(user));
let denied_event = workflow_event::Event::RunDenied {
reason: reason.clone(),
actor,
};
if let Err(err) = workflow_event::append_event(&run_store, &id, &denied_event).await {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
}
let failure_event = workflow_event::Event::workflow_run_failed_from_error(
&WorkflowError::engine(message.clone()),
fabro_types::RunTiming::default(),
FailureReason::ApprovalDenied,
None,
None,
None,
None,
);
if let Err(err) = workflow_event::append_event(&run_store, &id, &failure_event).await {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
}
{
let mut runs = state.runs.lock().expect("runs lock poisoned");
if let Some(managed_run) = runs.get_mut(&id) {
managed_run.status = RunStatus::Failed {
reason: FailureReason::ApprovalDenied,
};
managed_run.error = Some(message);
clear_live_run_state(managed_run);
}
}
run_response(state.as_ref(), id, StatusCode::OK).await
}
fn schedule_worker_cancel_escalation(state: Arc<AppState>, run_id: RunId, worker_ref: WorkerRef) {
let requested_at = Instant::now();
let armed = {
let mut runs = state.runs.lock().expect("runs lock poisoned");
let Some(run) = runs.get_mut(&run_id) else {
return;
};
if run.cancel_escalation_worker.as_ref() == Some(&worker_ref) {
false
} else {
run.cancel_escalation_worker = Some(worker_ref.clone());
true
}
};
if !armed {
tracing::debug!(
run_id = %run_id,
worker_kind = worker_ref.kind(),
worker_ref = ?worker_ref,
"Worker cancellation escalation is already armed"
);
return;
}
tokio::spawn(async move {
sleep_until(requested_at + WORKER_CANCEL_GRACE).await;
let should_escalate = {
let mut runs = state.runs.lock().expect("runs lock poisoned");
let Some(run) = runs.get_mut(&run_id) else {
return;
};
run.escalation_still_current(&worker_ref)
};
if !should_escalate {
tracing::debug!(
run_id = %run_id,
worker_kind = worker_ref.kind(),
worker_ref = ?worker_ref,
"Skipping stale worker cancellation escalation"
);
return;
}
if !state.worker_runtime.is_alive(&worker_ref).await {
let mut runs = state.runs.lock().expect("runs lock poisoned");
if let Some(run) = runs.get_mut(&run_id) {
run.clear_escalation_for(&worker_ref);
}
tracing::debug!(
run_id = %run_id,
worker_kind = worker_ref.kind(),
worker_ref = ?worker_ref,
"Skipping worker cancellation escalation because worker exited"
);
return;
}
let still_current = {
let mut runs = state.runs.lock().expect("runs lock poisoned");
let Some(run) = runs.get_mut(&run_id) else {
return;
};
run.escalation_still_current(&worker_ref)
};
if !still_current {
tracing::debug!(
run_id = %run_id,
worker_kind = worker_ref.kind(),
worker_ref = ?worker_ref,
"Skipping worker cancellation escalation after liveness check"
);
return;
}
let elapsed_ms = u64::try_from(requested_at.elapsed().as_millis()).unwrap_or(u64::MAX);
tracing::warn!(
run_id = %run_id,
worker_kind = worker_ref.kind(),
worker_ref = ?worker_ref,
elapsed_ms,
"Force-stopping worker after cancellation grace period"
);
state.worker_runtime.force_stop(&worker_ref).await;
let mut runs = state.runs.lock().expect("runs lock poisoned");
if let Some(run) = runs.get_mut(&run_id) {
run.clear_escalation_for(&worker_ref);
}
});
}
async fn cancel_run(
RequireRunManagementTarget(id, actor): RequireRunManagementTarget,
State(state): State<Arc<AppState>>,
) -> Response {
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
return response;
}
let durable_summary = match state.stores.runs.runs().find(&id).await {
Ok(summary) => summary,
Err(err) => {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
};
let pending_control = durable_summary
.as_ref()
.and_then(|summary| summary.lifecycle.pending_control);
let durable_status = durable_summary
.as_ref()
.map(|summary| summary.lifecycle.status);
let cancel_target = {
let mut runs = state.runs.lock().expect("runs lock poisoned");
match runs.get_mut(&id) {
Some(managed_run) => {
let managed_status = managed_run.status;
match managed_status {
RunStatus::Submitted
| RunStatus::Pending { .. }
| RunStatus::Runnable
| RunStatus::Starting
| RunStatus::Running
| RunStatus::Blocked { .. }
| RunStatus::Paused { .. } => {
let answer_transport = managed_run.answer_transport.clone();
let should_cancel_pending_interview =
matches!(
&answer_transport,
Some(RunAnswerTransport::InProcess { .. })
) && (matches!(managed_status, RunStatus::Blocked { .. })
|| matches!(durable_status, Some(RunStatus::Blocked { .. })));
let persist_cancelled_status = matches!(
managed_status,
RunStatus::Submitted | RunStatus::Pending { .. } | RunStatus::Runnable
) && !should_cancel_pending_interview;
if persist_cancelled_status {
managed_run.status = RunStatus::Failed {
reason: FailureReason::Cancelled,
};
}
let cancel_tx = if should_cancel_pending_interview {
None
} else {
managed_run.cancel_tx.take()
};
Some((
persist_cancelled_status,
answer_transport,
managed_run.cancel_token.clone(),
cancel_tx,
managed_run.worker_ref.clone(),
))
}
_ => {
return ApiError::new(StatusCode::CONFLICT, "Run is not cancellable.")
.into_response();
}
}
}
None => None,
}
};
let Some((persist_cancelled_status, answer_transport, cancel_token, cancel_tx, worker_ref)) =
cancel_target
else {
return unmanaged_cancel_response(state.as_ref(), id, actor, pending_control).await;
};
if pending_control != Some(RunControlAction::Cancel) {
if let Err(err) =
append_control_request(state.as_ref(), id, RunControlAction::Cancel, Some(actor)).await
{
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
}
if let Some(token) = &cancel_token {
token.cancel();
}
let sent_in_process_cancel = if let Some(cancel_tx) = cancel_tx {
let _ = cancel_tx.send(());
true
} else {
false
};
let delivered_control = if let Some(answer_transport) = answer_transport {
if sent_in_process_cancel
&& matches!(answer_transport, RunAnswerTransport::InProcess { .. })
{
true
} else {
answer_transport.cancel_run().await.is_ok()
}
} else {
false
};
tracing::debug!(
run_id = %id,
delivered_control,
"Processed cooperative run cancellation signal"
);
if let Some(worker_ref) = worker_ref {
if !delivered_control {
state.worker_runtime.request_stop(&worker_ref).await;
}
schedule_worker_cancel_escalation(Arc::clone(&state), id, worker_ref);
}
let response_status = if persist_cancelled_status {
if let Err(err) = persist_cancelled_run_status(state.as_ref(), id).await {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
StatusCode::OK
} else {
StatusCode::ACCEPTED
};
run_response(state.as_ref(), id, response_status).await
}
async fn unmanaged_cancel_response(
state: &AppState,
id: RunId,
actor: Principal,
pending_control: Option<RunControlAction>,
) -> Response {
match durable_run_status(state, id).await {
Ok(Some(status)) if status.is_terminal() => ApiError::new(
StatusCode::CONFLICT,
"Run is already terminal and cannot be cancelled.",
)
.into_response(),
Ok(Some(RunStatus::Submitted | RunStatus::Pending { .. } | RunStatus::Runnable)) => {
if pending_control != Some(RunControlAction::Cancel) {
if let Err(err) =
append_control_request(state, id, RunControlAction::Cancel, Some(actor)).await
{
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
}
match persist_cancelled_run_status(state, id).await {
Ok(()) => run_response(state, id, StatusCode::OK).await,
Err(err) => ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response(),
}
}
Ok(Some(_)) => {
ApiError::new(StatusCode::CONFLICT, "Run is not cancellable.").into_response()
}
Ok(None) => ApiError::not_found("Run not found.").into_response(),
Err(err) => {
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
}
}
}
/// How `pause_run` should enact the transition, chosen from the current run
/// status.
enum PauseMode {
/// Worker is running; ask it to pause via the worker control bus. Status
/// flips to `Paused` once the worker acknowledges.
Transport { transport: RunAnswerTransport },
/// Worker is blocked on a human gate; flip to `Paused` directly by
/// appending `RunPaused` ourselves.
AppendEvent,
}
/// How `unpause_run` should enact the transition.
enum UnpauseMode {
/// No outstanding block; ask the worker to resume via the worker control
/// bus.
Transport { transport: RunAnswerTransport },
/// Was paused while blocked; append `RunUnpaused` and let the reducer
/// restore the underlying blocked state from `Paused { prior_block }`.
AppendEvent,
}
async fn pause_run(
subject: RequiredUser,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
return response;
}
let pending_control = match load_pending_control(state.as_ref(), id).await {
Ok(pending_control) => pending_control,
Err(err) => {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
};
let mode = {
let runs = state.runs.lock().expect("runs lock poisoned");
match runs.get(&id) {
Some(managed_run) if managed_run.status == RunStatus::Running => {
let Some(transport) = managed_run.answer_transport.clone() else {
return ApiError::new(StatusCode::CONFLICT, "Run worker is not available.")
.into_response();
};
PauseMode::Transport { transport }
}
Some(managed_run) if matches!(managed_run.status, RunStatus::Blocked { .. }) => {
PauseMode::AppendEvent
}
Some(_) => {
return ApiError::new(StatusCode::CONFLICT, "Run is not pausable.").into_response();
}
None => return ApiError::not_found("Run not found.").into_response(),
}
};
if pending_control.is_some() {
return ApiError::new(
StatusCode::CONFLICT,
"Run control request is already pending.",
)
.into_response();
}
if let Err(err) = append_control_request(
state.as_ref(),
id,
RunControlAction::Pause,
Some(Principal::User(subject.0.clone())),
)
.await
{
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
}
match mode {
PauseMode::Transport { transport } => {
if transport.pause_run().await.is_err() {
return ApiError::new(
StatusCode::SERVICE_UNAVAILABLE,
"Failed to deliver pause request to the active run.",
)
.into_response();
}
}
PauseMode::AppendEvent => {
if let Some(response) = synchronous_transition(state.as_ref(), id, |events| {
events.push(workflow_event::Event::RunPaused);
})
.await
{
return response;
}
}
}
run_response(state.as_ref(), id, StatusCode::OK).await
}
async fn unpause_run(
subject: RequiredUser,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
return response;
}
let pending_control = match load_pending_control(state.as_ref(), id).await {
Ok(pending_control) => pending_control,
Err(err) => {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
};
let mode = {
let runs = state.runs.lock().expect("runs lock poisoned");
match runs.get(&id) {
Some(managed_run) => match managed_run.status {
RunStatus::Paused {
prior_block: Some(_),
} => UnpauseMode::AppendEvent,
RunStatus::Paused { prior_block: None } => {
let Some(transport) = managed_run.answer_transport.clone() else {
return ApiError::new(StatusCode::CONFLICT, "Run worker is not available.")
.into_response();
};
UnpauseMode::Transport { transport }
}
_ => {
return ApiError::new(StatusCode::CONFLICT, "Run is not paused.")
.into_response();
}
},
None => return ApiError::not_found("Run not found.").into_response(),
}
};
if pending_control.is_some() {
return ApiError::new(
StatusCode::CONFLICT,
"Run control request is already pending.",
)
.into_response();
}
if let Err(err) = append_control_request(
state.as_ref(),
id,
RunControlAction::Unpause,
Some(Principal::User(subject.0.clone())),
)
.await
{
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
}
match mode {
UnpauseMode::Transport { transport } => {
if transport.unpause_run().await.is_err() {
return ApiError::new(
StatusCode::SERVICE_UNAVAILABLE,
"Failed to deliver unpause request to the active run.",
)
.into_response();
}
}
UnpauseMode::AppendEvent => {
if let Some(response) = synchronous_transition(state.as_ref(), id, |events| {
events.push(workflow_event::Event::RunUnpaused);
})
.await
{
return response;
}
}
}
run_response(state.as_ref(), id, StatusCode::OK).await
}
async fn archive_run(
RequireRunManagementTarget(id, actor): RequireRunManagementTarget,
State(state): State<Arc<AppState>>,
) -> Response {
run_archive_action(state, actor, id, ArchiveAction::Archive).await
}
async fn unarchive_run(
RequireRunManagementTarget(id, actor): RequireRunManagementTarget,
State(state): State<Arc<AppState>>,
) -> Response {
run_archive_action(state, actor, id, ArchiveAction::Unarchive).await
}
async fn batch_archive_runs(
RequiredUser(user): RequiredUser,
State(state): State<Arc<AppState>>,
Json(request): Json<BatchRunLifecycleRequest>,
) -> Response {
Box::pin(batch_run_archive_action(
state,
Principal::User(user),
request,
ArchiveAction::Archive,
))
.await
}
async fn batch_unarchive_runs(
RequiredUser(user): RequiredUser,
State(state): State<Arc<AppState>>,
Json(request): Json<BatchRunLifecycleRequest>,
) -> Response {
Box::pin(batch_run_archive_action(
state,
Principal::User(user),
request,
ArchiveAction::Unarchive,
))
.await
}
async fn batch_delete_runs(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
Json(request): Json<BatchDeleteRunsRequest>,
) -> Response {
let force = request.force;
let ids = match validate_batch_run_ids(request.run_ids) {
Ok(ids) => ids,
Err(err) => return err.into_response(),
};
let mut results = Vec::with_capacity(ids.len());
for id in ids {
results.push(batch_delete_run_item(state.as_ref(), id, force).await);
}
let requested = results.len() as u64;
let succeeded = results.iter().filter(|result| result.ok).count() as u64;
(
StatusCode::OK,
Json(BatchDeleteRunsResponse {
results,
summary: BatchDeleteRunsSummary {
requested,
succeeded,
failed: requested - succeeded,
},
}),
)
.into_response()
}
async fn rewind_run(
subject: RequiredUser,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
body: Option<Json<RewindRequest>>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
return response;
}
let request = body.map(|Json(body)| body).unwrap_or_default();
let target = match parse_fork_target(request.target) {
Ok(target) => target,
Err(err) => return err.into_response(),
};
let input = operations::RewindInput { run_id: id, target };
match Box::pin(operations::rewind(
&state.stores.runs,
&input,
Some(Principal::User(subject.0.clone())),
))
.await
{
Ok(operations::RewindOutcome::Full {
source_run_id,
new_run_id,
target,
}) => (
StatusCode::OK,
Json(RewindResponse {
source_run_id: source_run_id.to_string(),
new_run_id: new_run_id.to_string(),
target: target.response_target(),
archived: true,
archive_error: None,
}),
)
.into_response(),
Ok(operations::RewindOutcome::Partial {
source_run_id,
new_run_id,
target,
archive_error,
}) => (
StatusCode::MULTI_STATUS,
Json(RewindResponse {
source_run_id: source_run_id.to_string(),
new_run_id: new_run_id.to_string(),
target: target.response_target(),
archived: false,
archive_error: Some(archive_error),
}),
)
.into_response(),
Err(err) => workflow_operation_error_response(err),
}
}
async fn fork_run(
_subject: RequiredUser,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
body: Option<Json<ForkRequest>>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
return response;
}
let request = body.map(|Json(body)| body).unwrap_or_default();
let target = match parse_fork_target(request.target) {
Ok(target) => target,
Err(err) => return err.into_response(),
};
let input = operations::ForkRunInput {
source_run_id: id,
target,
};
match Box::pin(operations::fork_run(&state.stores.runs, &input)).await {
Ok(outcome) => (
StatusCode::OK,
Json(ForkResponse {
source_run_id: outcome.source_run_id.to_string(),
new_run_id: outcome.new_run_id.to_string(),
target: outcome.target.response_target(),
}),
)
.into_response(),
Err(err) => workflow_operation_error_response(err),
}
}
async fn retry_run(
RequiredUser(user): RequiredUser,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
let actor = Principal::User(user);
let new_run_id = RunId::new();
let input = operations::RetryRunInput {
source_run_id: id,
new_run_id,
provenance: run_provenance(&headers, &actor),
web_url: state.run_web_url(&new_run_id),
};
match Box::pin(operations::retry_run(&state.stores.runs, &input)).await {
Ok(outcome) => {
let new_run_id = outcome.new_run_id;
if let Err(err) = queue_run_start(state.as_ref(), new_run_id, false, actor).await {
return err.into_response();
}
run_response(state.as_ref(), new_run_id, StatusCode::CREATED).await
}
Err(err) => workflow_operation_error_response(err),
}
}
async fn run_timeline(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
match operations::timeline(&state.stores.runs, &id).await {
Ok(entries) => Json(
entries
.into_iter()
.map(|entry| TimelineEntryResponse {
ordinal: std::num::NonZeroU64::new(entry.ordinal as u64)
.expect("timeline ordinals start at 1"),
node_name: entry.node_name,
visit: std::num::NonZeroU64::new(entry.visit as u64)
.expect("timeline visits start at 1"),
checkpoint_seq: std::num::NonZeroU64::new(u64::from(entry.checkpoint_seq))
.expect("checkpoint event sequence starts at 1"),
run_commit_sha: entry.run_commit_sha,
})
.collect::<Vec<_>>(),
)
.into_response(),
Err(err) => workflow_operation_error_response(err),
}
}
fn parse_fork_target(target: Option<String>) -> Result<Option<operations::ForkTarget>, ApiError> {
target
.map(|target| {
target
.parse::<operations::ForkTarget>()
.map_err(|err| ApiError::bad_request(err.to_string()))
})
.transpose()
}
fn workflow_operation_error_response(err: WorkflowError) -> Response {
match err {
WorkflowError::Parse(message) | WorkflowError::Validation(message) => {
ApiError::bad_request(message).into_response()
}
WorkflowError::ValidationFailed { .. } => {
ApiError::bad_request("Validation failed").into_response()
}
WorkflowError::Precondition(message) => {
ApiError::new(StatusCode::CONFLICT, message).into_response()
}
WorkflowError::RunNotFound(_) => ApiError::not_found("Run not found.").into_response(),
WorkflowError::Unsupported(message) => {
ApiError::new(StatusCode::NOT_IMPLEMENTED, message).into_response()
}
err => ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
}
}
#[derive(Clone, Copy)]
enum ArchiveAction {
Archive,
Unarchive,
}
const MAX_BATCH_RUN_IDS: usize = 250;
async fn batch_run_archive_action(
state: Arc<AppState>,
actor: Principal,
request: BatchRunLifecycleRequest,
action: ArchiveAction,
) -> Response {
let ids = match validate_batch_run_ids(request.run_ids) {
Ok(ids) => ids,
Err(err) => return err.into_response(),
};
// Resolve Ask Fabro readiness once per batch instead of inside each
// per-item summary lookup; readiness is identical for every run in the
// request and resolving it performs LLM credential work.
let readiness = state.ask_fabro_readiness().await;
let mut results = Vec::with_capacity(ids.len());
for id in ids {
results.push(
batch_run_archive_item(state.as_ref(), &readiness, actor.clone(), id, action).await,
);
}
let requested = results.len() as u64;
let succeeded = results.iter().filter(|result| result.ok).count() as u64;
(
StatusCode::OK,
Json(BatchRunLifecycleResponse {
results,
summary: BatchRunLifecycleSummary {
requested,
succeeded,
failed: requested - succeeded,
},
}),
)
.into_response()
}
fn validate_batch_run_ids(run_ids: Vec<String>) -> Result<Vec<RunId>, ApiError> {
if run_ids.is_empty() {
return Err(ApiError::bad_request(
"run_ids must contain at least one run ID.",
));
}
if run_ids.len() > MAX_BATCH_RUN_IDS {
return Err(ApiError::bad_request(format!(
"run_ids must contain no more than {MAX_BATCH_RUN_IDS} run IDs.",
)));
}
let mut seen = HashSet::with_capacity(run_ids.len());
let mut ids = Vec::with_capacity(run_ids.len());
for raw in run_ids {
let id = raw.parse::<RunId>().map_err(|_| {
ApiError::bad_request(format!("run_ids contains invalid run ID: {raw}"))
})?;
if !seen.insert(id) {
return Err(ApiError::bad_request(
"run_ids must not contain duplicate IDs.",
));
}
ids.push(id);
}
Ok(ids)
}
async fn batch_delete_run_item(state: &AppState, id: RunId, force: bool) -> BatchDeleteRunsResult {
match delete_run_internal(state, id, force).await {
Ok(DeleteRunOutcome::Deleted) => {
batch_delete_success(id, BatchDeleteRunsResultOutcome::Deleted, None)
}
Ok(DeleteRunOutcome::AlreadyAbsent) => {
batch_delete_success(id, BatchDeleteRunsResultOutcome::AlreadyAbsent, None)
}
Ok(DeleteRunOutcome::Preserved(response)) => batch_delete_success(
id,
BatchDeleteRunsResultOutcome::SandboxPreserved,
Some(response.sandbox),
),
Err(error) => {
let outcome = match error.status() {
StatusCode::CONFLICT => BatchDeleteRunsResultOutcome::Conflict,
_ => BatchDeleteRunsResultOutcome::Error,
};
BatchDeleteRunsResult {
run_id: id.to_string(),
ok: false,
outcome,
sandbox: None,
error: Some(error.into_response_entry()),
}
}
}
}
fn batch_delete_success(
id: RunId,
outcome: BatchDeleteRunsResultOutcome,
sandbox: Option<DeleteRunSandbox>,
) -> BatchDeleteRunsResult {
BatchDeleteRunsResult {
run_id: id.to_string(),
ok: true,
outcome,
sandbox,
error: None,
}
}
async fn batch_run_archive_item(
state: &AppState,
readiness: &AskFabroReadiness,
actor: Principal,
id: RunId,
action: ArchiveAction,
) -> BatchRunLifecycleResult {
let outcome = match run_archive_operation(state, &id, Some(actor), action).await {
Ok(outcome) => outcome,
Err(err) => {
let api_error = archive_workflow_error_to_api_error(err);
let result_outcome = match api_error.status() {
StatusCode::NOT_FOUND => BatchRunLifecycleResultOutcome::NotFound,
StatusCode::CONFLICT => BatchRunLifecycleResultOutcome::Conflict,
_ => BatchRunLifecycleResultOutcome::Error,
};
return batch_result_failure(id, result_outcome, api_error);
}
};
match state.stores.runs.get_cached_summary(&id, Utc::now()).await {
Ok(Some(summary)) => BatchRunLifecycleResult {
run_id: id.to_string(),
ok: true,
outcome,
run: Some(readiness.decorate(summary)),
error: None,
},
Ok(None) => batch_result_failure(
id,
BatchRunLifecycleResultOutcome::Error,
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to load run summary after lifecycle action.",
),
),
Err(err) => batch_result_failure(
id,
BatchRunLifecycleResultOutcome::Error,
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
),
}
}
fn batch_result_failure(
id: RunId,
outcome: BatchRunLifecycleResultOutcome,
error: ApiError,
) -> BatchRunLifecycleResult {
BatchRunLifecycleResult {
run_id: id.to_string(),
ok: false,
outcome,
run: None,
error: Some(error.into_response_entry()),
}
}
async fn run_archive_operation(
state: &AppState,
id: &RunId,
actor: Option<Principal>,
action: ArchiveAction,
) -> Result<BatchRunLifecycleResultOutcome, WorkflowError> {
match action {
ArchiveAction::Archive => operations::archive(&state.stores.runs, id, actor)
.await
.map(|outcome| match outcome {
operations::ArchiveOutcome::Archived { .. } => {
BatchRunLifecycleResultOutcome::Archived
}
operations::ArchiveOutcome::AlreadyArchived => {
BatchRunLifecycleResultOutcome::AlreadyArchived
}
}),
ArchiveAction::Unarchive => operations::unarchive(&state.stores.runs, id, actor)
.await
.map(|outcome| match outcome {
operations::UnarchiveOutcome::Unarchived { .. } => {
BatchRunLifecycleResultOutcome::Unarchived
}
operations::UnarchiveOutcome::NotArchived { .. } => {
BatchRunLifecycleResultOutcome::NotArchived
}
}),
}
}
fn archive_workflow_error_to_api_error(err: WorkflowError) -> ApiError {
match err {
WorkflowError::Precondition(message) => ApiError::new(StatusCode::CONFLICT, message),
WorkflowError::RunNotFound(_) => ApiError::not_found("Run not found."),
err => ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
}
}
async fn run_archive_action(
state: Arc<AppState>,
actor: Principal,
id: RunId,
action: ArchiveAction,
) -> Response {
match run_archive_operation(state.as_ref(), &id, Some(actor), action).await {
Ok(_) => archive_status_response(state.as_ref(), id).await,
Err(err) => archive_workflow_error_to_api_error(err).into_response(),
}
}
async fn archive_status_response(state: &AppState, id: RunId) -> Response {
run_response(state, id, StatusCode::OK).await
}
/// Persist a synchronous pause/unpause transition: append the caller-supplied
/// events to the run store and mirror the new status in the in-memory run map.
/// Returns `Some(Response)` on error, `None` on success.
async fn synchronous_transition(
state: &AppState,
id: RunId,
append_events: impl FnOnce(&mut Vec<workflow_event::Event>),
) -> Option<Response> {
let run_store = match state.stores.runs.open_run(&id).await {
Ok(run_store) => run_store,
Err(err) => {
return Some(
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
);
}
};
let mut events = Vec::new();
append_events(&mut events);
for event in events {
if let Err(err) = workflow_event::append_event(&run_store, &id, &event).await {
return Some(
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
);
}
let stored = workflow_event::to_run_event(&id, &event);
update_live_run_from_event(state, id, &stored);
}
None
}