use axum::{
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use mlua_swarm::application::{
BlueprintRef, TaskApplicationError, TaskApplicationInput, TaskApplicationOutput,
};
use mlua_swarm::blueprint::{BindRequest, BindingAttestation, BoundAgent};
use mlua_swarm::core::config::CheckPolicy;
use mlua_swarm::service::merge_init_ctx_3layer;
use mlua_swarm::service::TaskLaunchError;
use mlua_swarm::store::replay::ReplayCursor;
use mlua_swarm::store::run::{
RunContext, RunListFilter, RunRecord, RunStatus, RunStoreError, SnapshotOrigin, StepEntry,
};
use mlua_swarm::store::task::{TaskRecord, TaskRecordStatus, TaskStoreError};
use mlua_swarm::store::trace::{kind as trace_kind, TraceEvent, TraceHandle, TraceQuery};
use mlua_swarm::{
validate_bound_agent_snapshots, OperatorKind, Role, RunId, TaskId, TaskInputSpec,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::{ApiError, AppState};
pub(crate) fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct RunLaunchSnapshot {
blueprint: BlueprintRef,
operator_id: String,
role: Role,
ttl: Duration,
init_ctx: Value,
operator_kind: Option<OperatorKind>,
bridge_id: Option<String>,
hook_id: Option<String>,
operator_backend_id: Option<String>,
#[serde(default)]
operator_kind_overrides: HashMap<String, OperatorKind>,
task_input: Option<TaskInputSpec>,
check_policy: Option<CheckPolicy>,
}
impl RunLaunchSnapshot {
fn from_input(input: &TaskApplicationInput) -> Self {
Self {
blueprint: input.blueprint.clone(),
operator_id: input.operator_id.clone(),
role: input.role,
ttl: input.ttl,
init_ctx: input.init_ctx.clone(),
operator_kind: input.operator_kind,
bridge_id: input.bridge_id.clone(),
hook_id: input.hook_id.clone(),
operator_backend_id: input.operator_backend_id.clone(),
operator_kind_overrides: input.operator_kind_overrides.clone(),
task_input: input.task_input.clone(),
check_policy: input.check_policy,
}
}
fn into_input(self) -> TaskApplicationInput {
TaskApplicationInput {
blueprint: self.blueprint,
operator_id: self.operator_id,
role: self.role,
ttl: self.ttl,
init_ctx: self.init_ctx,
operator_kind: self.operator_kind,
bridge_id: self.bridge_id,
hook_id: self.hook_id,
operator_backend_id: self.operator_backend_id,
operator_kind_overrides: self.operator_kind_overrides,
task_input: self.task_input,
check_policy: self.check_policy,
}
}
}
pub(crate) fn snapshot_launch_input(input: &TaskApplicationInput) -> Result<String, ApiError> {
serde_json::to_string(&RunLaunchSnapshot::from_input(input))
.map_err(|e| ApiError::bad_request(format!("launch input snapshot: {e}")))
}
pub(crate) async fn finalize_run(
state: &AppState,
task_id: &TaskId,
run_id: &RunId,
outcome: Result<TaskApplicationOutput, TaskApplicationError>,
) -> Result<TaskApplicationOutput, TaskApplicationError> {
match &outcome {
Ok(out) => {
if let Err(e) = state
.run_store
.set_result(run_id, out.final_ctx.clone())
.await
{
tracing::warn!(%run_id, error = %e, "finalize_run: set_result failed");
}
if let Err(e) = state.run_store.update_status(run_id, RunStatus::Done).await {
tracing::warn!(%run_id, error = %e, "finalize_run: run update_status(Done) failed");
}
if let Err(e) = state
.task_store
.update_status(task_id, TaskRecordStatus::Done)
.await
{
tracing::warn!(%task_id, error = %e, "finalize_run: task update_status(Done) failed");
}
}
Err(e) => {
let envelope = match e {
TaskApplicationError::Launch(TaskLaunchError::FlowEval {
message,
failed_step,
verdict_value,
partial_ctx,
}) => json!({
"error": {
"message": message,
"failed_step": failed_step,
"verdict_value": verdict_value,
},
"partial_ctx": partial_ctx,
}),
other => json!({
"error": {
"message": other.to_string(),
"failed_step": Value::Null,
"verdict_value": Value::Null,
},
"partial_ctx": Value::Null,
}),
};
if let Err(store_err) = state.run_store.set_result(run_id, envelope).await {
tracing::warn!(%run_id, error = %store_err, "finalize_run: set_result (failure envelope) failed");
}
if let Err(store_err) = state
.run_store
.update_status(run_id, RunStatus::Failed)
.await
{
tracing::warn!(%run_id, error = %store_err, "finalize_run: run update_status(Failed) failed");
}
if let Err(store_err) = state
.task_store
.update_status(task_id, TaskRecordStatus::Failed)
.await
{
tracing::warn!(%task_id, error = %store_err, "finalize_run: task update_status(Failed) failed");
}
tracing::warn!(%task_id, %run_id, error = %e, "finalize_run: dispatch failed");
}
}
let status = if outcome.is_ok() { "done" } else { "failed" };
TraceHandle::new(run_id.clone(), state.run_trace_store.clone())
.append(
trace_kind::RUN_FINISHED,
None,
None,
json!({ "status": status }),
)
.await;
outcome
}
#[derive(Debug, Deserialize, Default)]
pub struct TasksListQuery {
#[serde(default)]
pub limit: Option<usize>,
}
pub async fn tasks_list(
State(state): State<AppState>,
Query(q): Query<TasksListQuery>,
) -> Result<Json<Vec<TaskRecord>>, ApiError> {
let mut records = state.task_store.list().await.map_err(ApiError::engine)?;
if let Some(limit) = q.limit {
records.truncate(limit);
}
Ok(Json(records))
}
#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
pub struct TaskDetailResponse {
pub task: TaskRecord,
pub runs: Vec<RunRecord>,
}
pub async fn task_get(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<TaskDetailResponse>, ApiError> {
let task_id =
TaskId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
let task = state
.task_store
.get(&task_id)
.await
.map_err(map_task_store_err)?;
let runs = state
.run_store
.list_by_task(&task_id)
.await
.map_err(ApiError::engine)?;
Ok(Json(TaskDetailResponse { task, runs }))
}
#[derive(Debug, Deserialize, Default, schemars::JsonSchema)]
pub struct RunKickRequest {
#[serde(default)]
#[schemars(with = "Option<Value>")]
pub init_ctx_override: Option<Value>,
#[serde(default)]
pub task_input_override: Option<TaskInputSpec>,
#[serde(default)]
pub timeout_secs: Option<u64>,
#[serde(default)]
pub detach: bool,
#[serde(default)]
pub operator_sid: Option<String>,
}
#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
pub struct RunKickResponse {
#[schemars(with = "String")]
pub task_id: TaskId,
#[schemars(with = "String")]
pub run_id: RunId,
pub status: RunStatus,
}
pub async fn task_rekick(
State(state): State<AppState>,
Path(id): Path<String>,
body: Option<Json<RunKickRequest>>,
) -> Result<(StatusCode, Json<RunKickResponse>), ApiError> {
let task_id =
TaskId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid task id: {e}")))?;
let task = state
.task_store
.get(&task_id)
.await
.map_err(map_task_store_err)?;
let blueprint_ref: mlua_swarm::application::BlueprintRef =
serde_json::from_value(task.blueprint_ref.clone()).map_err(|e| {
ApiError::bad_request(format!(
"task {task_id}: stored blueprint_ref failed to decode: {e}"
))
})?;
let (resolved_bp, _bound_version) = state
.task_app
.resolve(&blueprint_ref)
.await
.map_err(|e| ApiError::from_task_resolve(&e, &format!("task {task_id}: bp resolve")))?;
let req = body.map(|Json(r)| r).unwrap_or_default();
let operator_backend_id = match &req.operator_sid {
Some(sid) => {
let known_ids = state.engine.list_operator_ids().await;
if !known_ids.iter().any(|id| id == sid) {
return Err(ApiError::bad_request(format!(
"operator_sid: no such registered operator session '{sid}'"
)));
}
Some(sid.clone())
}
None => None,
};
let detach = req.detach;
let sync_timeout_secs = match (detach, req.timeout_secs) {
(true, Some(_)) => {
return Err(ApiError::bad_request(
"timeout_secs is the synchronous rekick ceiling and does not apply to a \
detached rekick (detach: true), whose lifetime bound is the run TTL — omit \
timeout_secs"
.into(),
));
}
(false, Some(0)) => {
return Err(ApiError::bad_request(
"timeout_secs: 0 is invalid; omit the field to use the server default".into(),
));
}
(false, Some(v)) => v,
(_, None) => state.sync_timeout_secs,
};
if resolved_bp
.spawner_hints
.layers
.iter()
.any(|l| l == "operator_delegate")
{
let attached = state.engine.list_operator_ids().await;
if attached.is_empty() {
return Err(ApiError::unavailable(format!(
"no operator attached to serve this rekick (task {task_id}'s \
Blueprint declares the operator_delegate layer): attach an \
operator via POST /v1/operators + WS, or use the poll-style \
flow (GET /v1/worker/prompt + POST /v1/worker/submit)"
)));
}
}
let merged_init_ctx = merge_init_ctx_3layer(
resolved_bp.default_init_ctx.as_ref(),
&task.input_ctx,
req.init_ctx_override.as_ref(),
);
let task_input_spec: Option<TaskInputSpec> = match req.task_input_override {
Some(over) => Some(over),
None => task
.task_input_spec
.as_ref()
.map(|v| serde_json::from_value(v.clone()))
.transpose()
.map_err(|e| {
ApiError::bad_request(format!(
"task {task_id}: stored task_input_spec failed to decode: {e}"
))
})?,
};
let run_id = RunId::new();
let now = now_secs();
let input = TaskApplicationInput {
blueprint: blueprint_ref,
operator_id: "http-run".to_string(),
role: Role::Operator,
ttl: Duration::from_secs(crate::default_run_ttl()),
init_ctx: merged_init_ctx,
operator_kind: None,
bridge_id: None,
hook_id: None,
operator_backend_id,
operator_kind_overrides: HashMap::new(),
task_input: task_input_spec,
check_policy: None,
};
let input_json = Some(snapshot_launch_input(&input)?);
state
.task_store
.update_status(&task_id, TaskRecordStatus::Running)
.await
.map_err(ApiError::engine)?;
state
.run_store
.create(RunRecord {
id: run_id.clone(),
task_id: task_id.clone(),
status: RunStatus::Running,
step_entries: Vec::new(),
degradations: Vec::new(),
operator_sid: req.operator_sid.clone(),
result_ref: None,
input_json,
created_at: now,
updated_at: now,
})
.await
.map_err(ApiError::engine)?;
let trace = TraceHandle::new(run_id.clone(), state.run_trace_store.clone());
trace
.append(
trace_kind::RUN_STARTED,
None,
None,
json!({"mode": "rekick"}),
)
.await;
let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
.with_replay_store(state.replay_store.clone())
.with_trace(trace);
if detach {
let ttl_secs = crate::default_run_ttl();
let bg_state = state.clone();
let bg_task_id = task_id.clone();
let bg_run_id = run_id.clone();
tokio::spawn(async move {
let outcome = match tokio::time::timeout(
Duration::from_secs(ttl_secs),
bg_state.task_app.handle_with_run(input, Some(run_ctx)),
)
.await
{
Ok(outcome) => outcome,
Err(_elapsed) => {
let reason = serde_json::json!({
"error": format!("detached rekick exceeded {ttl_secs}s ttl ceiling"),
});
if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
tracing::warn!(%bg_run_id, error = %e, "task_rekick: detached ttl set_result failed");
}
if let Err(e) = bg_state
.run_store
.update_status(&bg_run_id, RunStatus::Failed)
.await
{
tracing::warn!(%bg_run_id, error = %e, "task_rekick: detached ttl run update_status failed");
}
if let Err(e) = bg_state
.task_store
.update_status(&bg_task_id, TaskRecordStatus::Failed)
.await
{
tracing::warn!(%bg_task_id, error = %e, "task_rekick: detached ttl task update_status failed");
}
TraceHandle::new(bg_run_id.clone(), bg_state.run_trace_store.clone())
.append(
trace_kind::RUN_FINISHED,
None,
None,
json!({ "status": "failed", "reason": format!("ttl {ttl_secs}s exceeded") }),
)
.await;
return;
}
};
let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
});
return Ok((
StatusCode::ACCEPTED,
Json(RunKickResponse {
task_id,
run_id,
status: RunStatus::Running,
}),
));
}
let outcome = match tokio::time::timeout(
Duration::from_secs(sync_timeout_secs),
state.task_app.handle_with_run(input, Some(run_ctx)),
)
.await
{
Ok(outcome) => outcome,
Err(_elapsed) => {
let reason = serde_json::json!({
"error": format!("sync rekick exceeded {sync_timeout_secs}s timeout ceiling")
});
if let Err(e) = state.run_store.set_result(&run_id, reason).await {
tracing::warn!(%run_id, error = %e, "task_rekick: timeout set_result failed");
}
if let Err(e) = state
.run_store
.update_status(&run_id, RunStatus::Failed)
.await
{
tracing::warn!(%run_id, error = %e, "task_rekick: timeout run update_status failed");
}
if let Err(e) = state
.task_store
.update_status(&task_id, TaskRecordStatus::Failed)
.await
{
tracing::warn!(%task_id, error = %e, "task_rekick: timeout task update_status failed");
}
return Err(ApiError::timeout(format!(
"sync rekick exceeded {sync_timeout_secs}s timeout ceiling: task {task_id}, run {run_id}"
)));
}
};
finalize_run(&state, &task_id, &run_id, outcome)
.await
.map_err(|e| ApiError::bad_request(format!("run: {e}")))?;
Ok((
StatusCode::CREATED,
Json(RunKickResponse {
task_id,
run_id,
status: RunStatus::Done,
}),
))
}
#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
pub struct RunResumeResponse {
#[schemars(with = "String")]
pub run_id: RunId,
#[schemars(with = "String")]
pub task_id: TaskId,
pub replayed_steps: usize,
}
pub async fn run_resume(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<(StatusCode, Json<RunResumeResponse>), ApiError> {
let run_id =
RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
let run = state
.run_store
.get(&run_id)
.await
.map_err(map_run_store_err)?;
if run.status != RunStatus::Interrupted {
return Err(ApiError::conflict(format!(
"run {run_id} is {:?}, not Interrupted; only an interrupted run can be resumed",
run.status
)));
}
let Some(input_json) = run.input_json.clone() else {
return Err(ApiError::unprocessable(format!(
"run {run_id} cannot be resumed: no launch input was recorded for it (it \
predates resume support, or was created by a path that does not persist one)"
)));
};
let snapshot_value: Value = serde_json::from_str(&input_json).map_err(|e| {
ApiError::unprocessable(format!(
"run {run_id}: stored launch input failed to decode: {e}"
))
})?;
validated_bound_agents_from_snapshot(&run_id, &snapshot_value)?;
let snapshot: RunLaunchSnapshot = serde_json::from_value(snapshot_value).map_err(|e| {
ApiError::unprocessable(format!(
"run {run_id}: stored launch input failed to decode: {e}"
))
})?;
let won = state
.run_store
.try_transition(&run_id, RunStatus::Interrupted, RunStatus::Running)
.await
.map_err(ApiError::engine)?;
if !won {
return Err(ApiError::conflict(format!(
"run {run_id} was concurrently resumed (or left the Interrupted state); it is \
no longer resumable"
)));
}
let entries = state
.replay_store
.list_by_run(&run_id)
.await
.map_err(|e| ApiError::engine(format!("replay list_by_run: {e}")))?;
let replayed_steps = entries.len();
let cursor = ReplayCursor::from_entries(entries);
let trace = TraceHandle::new(run_id.clone(), state.run_trace_store.clone());
trace
.append(
trace_kind::RUN_STARTED,
None,
None,
json!({"mode": "resume"}),
)
.await;
let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
.with_replay_store(state.replay_store.clone())
.with_replay_cursor(Arc::new(Mutex::new(cursor)))
.with_resume()
.with_trace(trace);
let input = snapshot.into_input();
let task_id = run.task_id.clone();
state
.task_store
.update_status(&task_id, TaskRecordStatus::Running)
.await
.map_err(ApiError::engine)?;
let ttl_secs = crate::default_run_ttl();
let bg_state = state.clone();
let bg_task_id = task_id.clone();
let bg_run_id = run_id.clone();
tokio::spawn(async move {
let outcome = match tokio::time::timeout(
Duration::from_secs(ttl_secs),
bg_state.task_app.handle_with_run(input, Some(run_ctx)),
)
.await
{
Ok(outcome) => outcome,
Err(_elapsed) => {
let reason = serde_json::json!({
"error": format!("resumed run exceeded {ttl_secs}s ttl ceiling"),
});
if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
tracing::warn!(%bg_run_id, error = %e, "run_resume: ttl set_result failed");
}
if let Err(e) = bg_state
.run_store
.update_status(&bg_run_id, RunStatus::Failed)
.await
{
tracing::warn!(%bg_run_id, error = %e, "run_resume: ttl run update_status failed");
}
if let Err(e) = bg_state
.task_store
.update_status(&bg_task_id, TaskRecordStatus::Failed)
.await
{
tracing::warn!(%bg_task_id, error = %e, "run_resume: ttl task update_status failed");
}
return;
}
};
let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
});
Ok((
StatusCode::ACCEPTED,
Json(RunResumeResponse {
run_id,
task_id,
replayed_steps,
}),
))
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct RunRerunFromRequest {
pub from_step: String,
}
#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
pub struct RunRerunFromResponse {
#[schemars(with = "String")]
pub run_id: RunId,
#[schemars(with = "String")]
pub task_id: TaskId,
pub replayed_steps: usize,
pub dropped_steps: usize,
}
pub async fn run_rerun_from(
State(state): State<AppState>,
Path(id): Path<String>,
Json(req): Json<RunRerunFromRequest>,
) -> Result<(StatusCode, Json<RunRerunFromResponse>), ApiError> {
let run_id =
RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
if req.from_step.trim().is_empty() {
return Err(ApiError::bad_request(
"from_step must be a non-empty step ref".to_string(),
));
}
let run = state
.run_store
.get(&run_id)
.await
.map_err(map_run_store_err)?;
let current = run.status;
match current {
RunStatus::Done | RunStatus::Failed | RunStatus::Interrupted | RunStatus::Cancelled => {
}
RunStatus::Running | RunStatus::Pending => {
return Err(ApiError::conflict(format!(
"run {run_id} is {current:?}; rerun-from requires a terminal run \
(Done / Failed / Interrupted / Cancelled)"
)));
}
}
let Some(input_json) = run.input_json.clone() else {
return Err(ApiError::unprocessable(format!(
"run {run_id} cannot be rerun: no launch input was recorded for it (it \
predates resume/rerun support, or was created by a path that does not \
persist one)"
)));
};
let snapshot_value: Value = serde_json::from_str(&input_json).map_err(|e| {
ApiError::unprocessable(format!(
"run {run_id}: stored launch input failed to decode: {e}"
))
})?;
validated_bound_agents_from_snapshot(&run_id, &snapshot_value)?;
let snapshot: RunLaunchSnapshot = serde_json::from_value(snapshot_value).map_err(|e| {
ApiError::unprocessable(format!(
"run {run_id}: stored launch input failed to decode: {e}"
))
})?;
let entries = state
.replay_store
.list_by_run(&run_id)
.await
.map_err(|e| ApiError::engine(format!("replay list_by_run: {e}")))?;
let cut = entries
.iter()
.position(|e| e.step_ref == req.from_step)
.ok_or_else(|| {
if entries.is_empty() && !run.step_entries.is_empty() {
ApiError::unprocessable(format!(
"run {run_id}: replay log is empty but {} step entries are traced \
on the RunRecord — the log was consumed by a prior rerun-from \
that reached the truncate stage. This run can no longer be \
rerun-from; start a fresh run via POST /v1/tasks.",
run.step_entries.len()
))
} else {
ApiError::unprocessable(format!(
"run {run_id}: from_step {:?} not present in this run's replay log \
(nothing to rerun-from)",
req.from_step
))
}
})?;
if let Err(e) = state.task_app.precompile(&snapshot.blueprint).await {
return Err(ApiError::unprocessable(format!(
"run {run_id} cannot be rerun: current-head Blueprint fails to compile — {e}"
)));
}
let won = state
.run_store
.try_transition(&run_id, current, RunStatus::Running)
.await
.map_err(ApiError::engine)?;
if !won {
return Err(ApiError::conflict(format!(
"run {run_id} was concurrently transitioned (or left the {current:?} state); \
it is no longer rerunnable"
)));
}
let dropped_steps = state
.replay_store
.delete_from(&run_id, cut)
.await
.map_err(|e| ApiError::engine(format!("replay delete_from: {e}")))?;
let kept = entries.into_iter().take(cut).collect::<Vec<_>>();
let replayed_steps = kept.len();
let cursor = ReplayCursor::from_entries(kept);
let trace = TraceHandle::new(run_id.clone(), state.run_trace_store.clone());
trace
.append(
trace_kind::RUN_STARTED,
None,
None,
json!({"mode": "rerun_from"}),
)
.await;
let run_ctx = RunContext::new(run_id.clone(), state.run_store.clone())
.with_replay_store(state.replay_store.clone())
.with_replay_cursor(Arc::new(Mutex::new(cursor)))
.with_resume()
.with_trace(trace);
let input = snapshot.into_input();
let task_id = run.task_id.clone();
state
.task_store
.update_status(&task_id, TaskRecordStatus::Running)
.await
.map_err(ApiError::engine)?;
let ttl_secs = crate::default_run_ttl();
let bg_state = state.clone();
let bg_task_id = task_id.clone();
let bg_run_id = run_id.clone();
tokio::spawn(async move {
let outcome = match tokio::time::timeout(
Duration::from_secs(ttl_secs),
bg_state.task_app.handle_with_run(input, Some(run_ctx)),
)
.await
{
Ok(outcome) => outcome,
Err(_elapsed) => {
let reason = serde_json::json!({
"error": format!("rerun-from run exceeded {ttl_secs}s ttl ceiling"),
});
if let Err(e) = bg_state.run_store.set_result(&bg_run_id, reason).await {
tracing::warn!(%bg_run_id, error = %e, "run_rerun_from: ttl set_result failed");
}
if let Err(e) = bg_state
.run_store
.update_status(&bg_run_id, RunStatus::Failed)
.await
{
tracing::warn!(%bg_run_id, error = %e, "run_rerun_from: ttl run update_status failed");
}
if let Err(e) = bg_state
.task_store
.update_status(&bg_task_id, TaskRecordStatus::Failed)
.await
{
tracing::warn!(%bg_task_id, error = %e, "run_rerun_from: ttl task update_status failed");
}
return;
}
};
let _ = finalize_run(&bg_state, &bg_task_id, &bg_run_id, outcome).await;
});
Ok((
StatusCode::ACCEPTED,
Json(RunRerunFromResponse {
run_id,
task_id,
replayed_steps,
dropped_steps,
}),
))
}
pub async fn run_get(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<RunRecord>, ApiError> {
let run_id =
RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
let run = state
.run_store
.get(&run_id)
.await
.map_err(map_run_store_err)?;
Ok(Json(run))
}
#[derive(Debug, Deserialize, Default)]
pub struct RunsListQuery {
#[serde(default)]
pub task_id: Option<String>,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub limit: Option<usize>,
#[serde(default)]
pub offset: Option<usize>,
}
#[derive(Debug, Serialize)]
pub struct RunsListResponse {
pub runs: Vec<RunRecord>,
}
pub async fn runs_list(
State(state): State<AppState>,
Query(q): Query<RunsListQuery>,
) -> Result<Json<RunsListResponse>, ApiError> {
let task_id = q
.task_id
.map(TaskId::parse)
.transpose()
.map_err(|e| ApiError::bad_request(format!("invalid task_id: {e}")))?;
let status = q
.status
.as_deref()
.map(|s| {
serde_json::from_value::<RunStatus>(Value::String(s.to_string())).map_err(|_| {
ApiError::bad_request(format!(
"invalid status {s:?} (expected pending/running/done/failed/interrupted)"
))
})
})
.transpose()?;
let runs = state
.run_store
.list(&RunListFilter {
task_id,
status,
limit: q.limit,
offset: q.offset,
})
.await
.map_err(map_run_store_err)?;
Ok(Json(RunsListResponse { runs }))
}
#[derive(Debug, Serialize)]
pub struct RunStepsResponse {
pub run_id: String,
pub steps: Vec<StepEntry>,
}
pub async fn run_steps(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<RunStepsResponse>, ApiError> {
let run_id =
RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
let run = state
.run_store
.get(&run_id)
.await
.map_err(map_run_store_err)?;
Ok(Json(RunStepsResponse {
run_id: run.id.to_string(),
steps: run.step_entries,
}))
}
#[derive(Debug, Deserialize, Default)]
pub struct RunTraceQuery {
#[serde(default)]
pub after: Option<u64>,
#[serde(default)]
pub limit: Option<usize>,
#[serde(default)]
pub latest: Option<usize>,
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub step: Option<String>,
#[serde(default)]
pub attempt: Option<u32>,
}
#[derive(Debug, Serialize)]
pub struct RunTraceResponse {
pub run_id: String,
pub events: Vec<TraceEvent>,
}
pub async fn run_trace(
State(state): State<AppState>,
Path(id): Path<String>,
Query(q): Query<RunTraceQuery>,
) -> Result<Json<RunTraceResponse>, ApiError> {
let run_id =
RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
let query = TraceQuery {
after: q.after,
limit: q.limit,
latest: q.latest,
kinds: q
.kind
.as_deref()
.map(|s| {
s.split(',')
.map(str::trim)
.filter(|k| !k.is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default(),
step_ref: q.step,
attempt: q.attempt,
};
let events = state
.run_trace_store
.list(&run_id, &query)
.await
.map_err(|e| ApiError::engine(format!("trace list: {e}")))?;
Ok(Json(RunTraceResponse {
run_id: run_id.to_string(),
events,
}))
}
pub async fn run_cancel(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<axum::http::StatusCode, ApiError> {
let run_id =
RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
let record = state
.run_store
.get(&run_id)
.await
.map_err(map_run_store_err)?;
TraceHandle::new(run_id.clone(), state.run_trace_store.clone())
.append(trace_kind::CANCEL_REQUESTED, None, None, json!({}))
.await;
if matches!(record.status, RunStatus::Pending | RunStatus::Running) {
if let Err(e) = state
.run_store
.update_status(&run_id, RunStatus::Cancelled)
.await
{
tracing::warn!(%run_id, error = %e, "run_cancel: update_status(Cancelled) failed");
}
}
Ok(axum::http::StatusCode::NO_CONTENT)
}
pub async fn run_delete(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<axum::http::StatusCode, ApiError> {
let run_id =
RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
state
.run_store
.delete(&run_id)
.await
.map_err(map_run_store_err)?;
if let Err(e) = state.run_trace_store.delete_run(&run_id).await {
tracing::warn!(%run_id, error = %e, "run_delete: trace delete_run failed (run row already deleted)");
}
Ok(axum::http::StatusCode::NO_CONTENT)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum RunBindingStatus {
DeclarationOnly,
Attested,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
pub struct RunBindingDifference {
pub model_changed: bool,
pub missing_requested_tools: Vec<String>,
pub additional_effective_tools: Vec<String>,
pub launch_variant_changed: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
pub struct RunBindingExplainEntry {
pub agent: String,
pub runner_source: mlua_swarm::blueprint::RunnerResolutionSource,
pub status: RunBindingStatus,
pub requested: Option<BindRequest>,
pub effective: Option<BindingAttestation>,
pub difference: Option<RunBindingDifference>,
pub binding_digest: mlua_swarm::blueprint::BindingDigest,
}
#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
pub struct RunBindingsExplainResponse {
#[schemars(with = "String")]
pub run_id: RunId,
#[schemars(with = "String")]
pub task_id: TaskId,
pub snapshot_origin: SnapshotOrigin,
pub bindings: Vec<RunBindingExplainEntry>,
}
fn requested_binding(bound: &BoundAgent) -> Option<BindRequest> {
mlua_swarm::binding_request_for_snapshot(bound)
}
fn binding_difference(
requested: &BindRequest,
effective: &BindingAttestation,
) -> RunBindingDifference {
let missing_requested_tools = requested
.requested_tools
.iter()
.filter(|tool| !effective.effective_tools.contains(tool))
.cloned()
.collect();
let additional_effective_tools = effective
.effective_tools
.iter()
.filter(|tool| !requested.requested_tools.contains(tool))
.cloned()
.collect();
RunBindingDifference {
model_changed: requested.requested_model != effective.resolved_model,
missing_requested_tools,
additional_effective_tools,
launch_variant_changed: requested.launch_variant != effective.launch_variant,
}
}
fn validated_bound_agents_from_snapshot(
run_id: &RunId,
snapshot: &Value,
) -> Result<Option<Vec<BoundAgent>>, ApiError> {
let Some(bound_value) = snapshot.get("bound_agents") else {
return Ok(None);
};
let bound_agents: Vec<BoundAgent> =
serde_json::from_value(bound_value.clone()).map_err(|e| {
ApiError::unprocessable(format!(
"run {run_id} contains an invalid binding snapshot: {e}"
))
})?;
validate_bound_agent_snapshots(&bound_agents).map_err(|error| {
ApiError::unprocessable(format!(
"run {run_id} contains an inconsistent binding snapshot: {error}"
))
})?;
Ok(Some(bound_agents))
}
pub async fn run_bindings_explain(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<RunBindingsExplainResponse>, ApiError> {
let run_id =
RunId::parse(id).map_err(|e| ApiError::bad_request(format!("invalid run id: {e}")))?;
let run = state
.run_store
.get(&run_id)
.await
.map_err(map_run_store_err)?;
let input_json = run.input_json.as_deref().ok_or_else(|| {
ApiError::unprocessable(format!(
"run {run_id} has no launch snapshot; binding explain is unavailable"
))
})?;
let snapshot: Value = serde_json::from_str(input_json).map_err(|e| {
ApiError::unprocessable(format!(
"run {run_id} launch snapshot is invalid JSON; binding explain is unavailable: {e}"
))
})?;
let bound_agents = validated_bound_agents_from_snapshot(&run_id, &snapshot)?.ok_or_else(|| {
ApiError::unprocessable(format!(
"run {run_id} predates immutable binding snapshots; current Blueprint state was not consulted"
))
})?;
let bindings = bound_agents
.into_iter()
.map(|bound| {
let requested = requested_binding(&bound);
let effective = bound.attestation.clone();
let difference = requested
.as_ref()
.zip(effective.as_ref())
.map(|(request, attestation)| binding_difference(request, attestation));
RunBindingExplainEntry {
agent: bound.agent.name,
runner_source: bound.runner_source,
status: if effective.is_some() {
RunBindingStatus::Attested
} else {
RunBindingStatus::DeclarationOnly
},
requested,
effective,
difference,
binding_digest: bound.binding_digest,
}
})
.collect();
Ok(Json(RunBindingsExplainResponse {
run_id: run.id,
task_id: run.task_id,
snapshot_origin: SnapshotOrigin::from_snapshot(&snapshot),
bindings,
}))
}
pub(crate) fn map_task_store_err(e: TaskStoreError) -> ApiError {
match e {
TaskStoreError::NotFound(id) => ApiError::not_found(format!("task not found: {id}")),
other => ApiError::engine(other),
}
}
fn map_run_store_err(e: RunStoreError) -> ApiError {
match e {
RunStoreError::NotFound(id) => ApiError::not_found(format!("run not found: {id}")),
other => ApiError::engine(other),
}
}
#[cfg(test)]
mod tests {
use super::*;
use mlua_swarm::application::BlueprintRef;
use mlua_swarm::blueprint::{
current_schema_version, AgentDef, AgentKind, Blueprint, BlueprintMetadata, CompilerHints,
CompilerStrategy, Runner,
};
use mlua_swarm::core::config::EngineCfg;
use mlua_swarm::core::engine::Engine;
use mlua_swarm::store::output::InMemoryOutputStore;
use mlua_swarm::store::run::InMemoryRunStore;
use mlua_swarm::store::task::InMemoryTaskStore;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
fn identity_blueprint() -> Blueprint {
Blueprint {
schema_version: current_schema_version(),
id: "tasks-test-bp".into(),
flow: serde_json::from_value(serde_json::json!({
"kind": "step",
"ref": mlua_swarm::worker::baseline::AG_IDENTITY,
"in": {"op": "lit", "value": "hello"},
"out": {"op": "path", "at": "$.out"},
}))
.expect("flow parse"),
agents: vec![AgentDef {
name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
kind: AgentKind::RustFn,
spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
profile: None,
meta: None,
runner: None,
runner_ref: None,
verdict: None,
}],
operators: vec![],
metas: vec![],
hints: CompilerHints::default(),
strategy: CompilerStrategy::default(),
metadata: BlueprintMetadata::default(),
spawner_hints: Default::default(),
default_agent_kind: AgentKind::Operator,
default_operator_kind: None,
default_init_ctx: None,
default_agent_ctx: None,
default_context_policy: None,
projection_placement: None,
audits: vec![],
degradation_policy: None,
runners: vec![],
default_runner: None,
subprocesses: vec![],
check_policy: None,
blueprint_ref_includes: Vec::new(),
}
}
fn test_state() -> AppState {
let engine = Engine::new_with_layers(EngineCfg::default(), crate::default_layer_registry());
let compiler = mlua_swarm::Compiler::new(crate::default_registry());
let launch = Arc::new(mlua_swarm::TaskLaunchService::new(engine.clone(), compiler));
AppState {
engine,
sessions: Arc::new(Mutex::new(crate::SessionStore::default())),
task_app: Arc::new(mlua_swarm::TaskApplication::new_inline_only(launch)),
ws_operator_factory: None,
data_store: Arc::new(InMemoryOutputStore::new()),
operator_sessions: Arc::new(Mutex::new(HashMap::new())),
roles_to_sid: Arc::new(Mutex::new(HashMap::new())),
task_store: Arc::new(InMemoryTaskStore::new()),
run_store: Arc::new(InMemoryRunStore::new()),
replay_store: Arc::new(mlua_swarm::store::replay::InMemoryReplayStore::new()),
run_trace_store: Arc::new(mlua_swarm::store::trace::InMemoryRunTraceStore::new()),
base_url: None,
sync_timeout_secs: 300,
}
}
fn post_tasks_req(goal: &str) -> crate::TaskLaunchRequest {
crate::TaskLaunchRequest {
blueprint: BlueprintRef::Inline {
value: Box::new(identity_blueprint()),
},
init_ctx: serde_json::json!({"in": "hello"}),
project_root: None,
work_dir: None,
task_metadata: None,
ttl_secs: None,
operator: None,
operator_sid: None,
timeout_secs: None,
goal: Some(goal.to_string()),
detach: false,
check_policy: None,
}
}
#[test]
fn task_id_serializes_as_bare_string() {
let v = serde_json::to_value(TaskId::parse("T-abc").unwrap()).expect("serialize");
assert_eq!(v, serde_json::json!("T-abc"));
}
#[tokio::test]
async fn post_then_get_drill_down() {
let state = test_state();
let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("smoke goal")))
.await
.expect("tasks_start")
.0;
let task_id = posted.task_id.clone();
let run_id = posted.run_id.clone();
let list = tasks_list(State(state.clone()), Query(TasksListQuery { limit: None }))
.await
.expect("tasks_list")
.0;
assert!(
list.iter().any(|t| t.id == task_id),
"task {task_id} missing from list of {} tasks",
list.len()
);
let detail = task_get(State(state.clone()), Path(task_id.to_string()))
.await
.expect("task_get")
.0;
assert_eq!(detail.task.id, task_id);
assert_eq!(detail.task.goal, "smoke goal");
assert_eq!(detail.task.status, TaskRecordStatus::Done);
assert_eq!(detail.runs.len(), 1);
assert_eq!(detail.runs[0].id, run_id);
assert_eq!(detail.runs[0].status, RunStatus::Done);
let run = run_get(State(state.clone()), Path(run_id.to_string()))
.await
.expect("run_get")
.0;
assert_eq!(run.id, run_id);
assert_eq!(run.task_id, task_id);
assert_eq!(run.result_ref, Some(posted.final_ctx));
assert_eq!(
run.step_entries.len(),
1,
"expected one step_entry for the 1-step identity Blueprint, got {:?}",
run.step_entries
);
assert_eq!(
run.step_entries[0].step_ref,
Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
);
assert_eq!(run.step_entries[0].status, Some("passed".to_string()));
}
fn identity_blueprint_with_operator_delegate() -> Blueprint {
Blueprint {
spawner_hints: mlua_swarm::SpawnerHints {
layers: vec!["operator_delegate".to_string()],
},
..identity_blueprint()
}
}
struct StallingOperator;
#[async_trait::async_trait]
impl mlua_swarm::Operator for StallingOperator {
async fn execute(
&self,
_ctx: &mlua_swarm::Ctx,
_system: Option<String>,
_prompt: Value,
_worker: Option<mlua_swarm::WorkerBinding>,
_worker_token: mlua_swarm::CapToken,
) -> Result<mlua_swarm::WorkerResult, mlua_swarm::WorkerError> {
std::future::pending::<()>().await;
unreachable!("StallingOperator.execute must never resolve")
}
}
fn operator_launch_req(
backend_id: &str,
timeout_secs: Option<u64>,
) -> crate::TaskLaunchRequest {
crate::TaskLaunchRequest {
blueprint: BlueprintRef::Inline {
value: Box::new(identity_blueprint_with_operator_delegate()),
},
init_ctx: serde_json::json!({"in": "hello"}),
project_root: None,
work_dir: None,
task_metadata: None,
ttl_secs: None,
operator: Some(crate::OperatorReq {
operator_backend_id: Some(backend_id.to_string()),
..Default::default()
}),
operator_sid: None,
timeout_secs,
goal: Some("operator delegate test goal".to_string()),
detach: false,
check_policy: None,
}
}
#[tokio::test]
async fn sync_launch_zero_operators_fails_fast() {
let state = test_state();
let req = operator_launch_req("nonexistent-op", None);
let started = std::time::Instant::now();
let result = crate::tasks_start(State(state), Json(req)).await;
let elapsed = started.elapsed();
let err = match result {
Err(e) => e,
Ok(_) => panic!("zero attached operators must fail the operator-delegate launch"),
};
assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
assert!(
err.message.contains("no operator attached"),
"error message must mention the missing operator: {}",
err.message
);
assert!(
elapsed < Duration::from_secs(1),
"guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
);
}
#[tokio::test]
async fn sync_launch_stalled_times_out() {
let state = test_state();
state
.engine
.register_operator("stall-op", Arc::new(StallingOperator))
.await;
let req = operator_launch_req("stall-op", Some(1));
let started = std::time::Instant::now();
let result = tokio::time::timeout(
Duration::from_secs(5),
crate::tasks_start(State(state), Json(req)),
)
.await
.expect("tasks_start must resolve well within 5s when guard 2's ceiling is 1s");
let elapsed = started.elapsed();
let err = match result {
Err(e) => e,
Ok(_) => panic!("a stalled operator session must time out, not succeed"),
};
assert_eq!(err.status, StatusCode::GATEWAY_TIMEOUT);
assert!(
err.message.contains('1'),
"error message must mention the configured 1s ceiling: {}",
err.message
);
assert!(
elapsed < Duration::from_secs(3),
"guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
);
}
#[tokio::test]
async fn sync_launch_without_operator_path_unaffected() {
let state = test_state();
let result = crate::tasks_start(
State(state),
Json(post_tasks_req("non-operator launch goal")),
)
.await;
if let Err(e) = &result {
panic!(
"non-operator launch must succeed unaffected by guard 1: {}",
e.message
);
}
}
#[tokio::test]
async fn sync_launch_zero_timeout_secs_rejected() {
let state = test_state();
let mut req = post_tasks_req("zero timeout goal");
req.timeout_secs = Some(0);
let result = crate::tasks_start(State(state), Json(req)).await;
let err = match result {
Err(e) => e,
Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
};
assert_eq!(err.status, StatusCode::BAD_REQUEST);
assert!(
err.message.contains("timeout_secs"),
"error message must reference timeout_secs: {}",
err.message
);
}
async fn wait_for_terminal_run(state: &AppState, run_id: &RunId) -> RunRecord {
for _ in 0..50 {
let rec = state.run_store.get(run_id).await.expect("run get");
if !matches!(rec.status, RunStatus::Pending | RunStatus::Running) {
return rec;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
panic!("run {run_id} did not reach a terminal status within ~5s");
}
#[tokio::test]
async fn detached_launch_returns_202_and_completes_in_background() {
let state = test_state();
let mut req = post_tasks_req("detached goal");
req.detach = true;
let reply = crate::tasks_start(State(state.clone()), Json(req))
.await
.expect("tasks_start (detached)");
assert_eq!(reply.1, StatusCode::ACCEPTED);
let posted = reply.0;
assert_eq!(posted.status, RunStatus::Running);
assert_eq!(
posted.final_ctx,
serde_json::Value::Null,
"a detached launch has no final_ctx at response time"
);
let rec = wait_for_terminal_run(&state, &posted.run_id).await;
assert_eq!(rec.status, RunStatus::Done);
assert!(
rec.result_ref.is_some(),
"finalize_run must persist the background eval's final_ctx"
);
assert_eq!(
rec.step_entries.len(),
1,
"the background eval must trace its step_entries like the sync path: {:?}",
rec.step_entries
);
let task = state
.task_store
.get(&posted.task_id)
.await
.expect("task get");
assert_eq!(task.status, TaskRecordStatus::Done);
}
#[tokio::test]
async fn detached_launch_with_timeout_secs_rejected() {
let state = test_state();
let mut req = post_tasks_req("detached + ceiling goal");
req.detach = true;
req.timeout_secs = Some(60);
let err = match crate::tasks_start(State(state.clone()), Json(req)).await {
Err(e) => e,
Ok(_) => panic!("detach + timeout_secs must be rejected"),
};
assert_eq!(err.status, StatusCode::BAD_REQUEST);
assert!(
err.message.contains("detach"),
"error message must explain the detach/timeout_secs conflict: {}",
err.message
);
let tasks = state.task_store.list().await.expect("task list");
assert!(
tasks.is_empty(),
"the 400 must fire before any TaskRecord is minted"
);
}
#[tokio::test]
async fn rekick_detached_returns_202_and_completes_in_background() {
let state = test_state();
let posted = crate::tasks_start(
State(state.clone()),
Json(post_tasks_req("detached rekick goal")),
)
.await
.expect("tasks_start")
.0;
let (status, rekicked) = task_rekick(
State(state.clone()),
Path(posted.task_id.to_string()),
Some(Json(RunKickRequest {
init_ctx_override: None,
task_input_override: None,
timeout_secs: None,
detach: true,
operator_sid: None,
})),
)
.await
.expect("task_rekick (detached)");
assert_eq!(status, StatusCode::ACCEPTED);
assert_eq!(rekicked.0.status, RunStatus::Running);
assert_ne!(rekicked.0.run_id, posted.run_id);
let rec = wait_for_terminal_run(&state, &rekicked.0.run_id).await;
assert_eq!(rec.status, RunStatus::Done);
assert!(
rec.result_ref.is_some(),
"finalize_run must persist the background rekick's final_ctx"
);
}
#[tokio::test]
async fn rekick_detached_with_timeout_secs_rejected() {
let state = test_state();
let posted = crate::tasks_start(
State(state.clone()),
Json(post_tasks_req("detached rekick ceiling goal")),
)
.await
.expect("tasks_start")
.0;
let err = match task_rekick(
State(state.clone()),
Path(posted.task_id.to_string()),
Some(Json(RunKickRequest {
init_ctx_override: None,
task_input_override: None,
timeout_secs: Some(60),
detach: true,
operator_sid: None,
})),
)
.await
{
Err(e) => e,
Ok(_) => panic!("detach + timeout_secs must be rejected on rekick"),
};
assert_eq!(err.status, StatusCode::BAD_REQUEST);
assert!(
err.message.contains("detach"),
"error message must explain the detach/timeout_secs conflict: {}",
err.message
);
let runs = state
.run_store
.list_by_task(&posted.task_id)
.await
.expect("runs list");
assert_eq!(
runs.len(),
1,
"the 400 must fire before a second Run is minted"
);
}
#[tokio::test]
async fn rekick_adds_a_second_run_to_the_same_task() {
let state = test_state();
let posted = crate::tasks_start(State(state.clone()), Json(post_tasks_req("rekick goal")))
.await
.expect("tasks_start")
.0;
let task_id = posted.task_id.clone();
let first_run_id = posted.run_id.clone();
let (status, rekicked) = task_rekick(State(state.clone()), Path(task_id.to_string()), None)
.await
.expect("task_rekick");
assert_eq!(status, StatusCode::CREATED);
let second_run_id = rekicked.0.run_id.clone();
assert_ne!(first_run_id, second_run_id);
let detail = task_get(State(state.clone()), Path(task_id.to_string()))
.await
.expect("task_get")
.0;
assert_eq!(
detail.runs.len(),
2,
"expected 2 runs, got {:?}",
detail.runs
);
let ids: Vec<&RunId> = detail.runs.iter().map(|r| &r.id).collect();
assert!(ids.contains(&&first_run_id));
assert!(ids.contains(&&second_run_id));
let first_run = detail
.runs
.iter()
.find(|r| r.id == first_run_id)
.expect("first run present in detail.runs");
let second_run = detail
.runs
.iter()
.find(|r| r.id == second_run_id)
.expect("second run present in detail.runs");
assert_eq!(
first_run.step_entries.len(),
1,
"first run step_entries: {:?}",
first_run.step_entries
);
assert_eq!(
second_run.step_entries.len(),
1,
"second run step_entries: {:?}",
second_run.step_entries
);
assert_eq!(
first_run.step_entries[0].step_ref,
Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
);
assert_eq!(
second_run.step_entries[0].step_ref,
Some(mlua_swarm::worker::baseline::AG_IDENTITY.to_string())
);
assert_eq!(first_run.step_entries[0].status, Some("passed".to_string()));
assert_eq!(
second_run.step_entries[0].status,
Some("passed".to_string())
);
assert_ne!(
first_run.step_entries[0].step_id, second_run.step_entries[0].step_id,
"each kick dispatches its own StepId — runs must not share step_entries"
);
}
#[tokio::test]
async fn rekick_unknown_task_returns_404() {
let state = test_state();
match task_rekick(State(state), Path("T-does-not-exist".to_string()), None).await {
Ok(_) => panic!("expected 404 for an unknown task"),
Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
}
}
fn greeting_blueprint() -> Blueprint {
Blueprint {
schema_version: current_schema_version(),
id: "tasks-test-greeting-bp".into(),
flow: serde_json::from_value(serde_json::json!({
"kind": "step",
"ref": mlua_swarm::worker::baseline::AG_IDENTITY,
"in": {"op": "path", "at": "$.greeting"},
"out": {"op": "path", "at": "$.out"},
}))
.expect("flow parse"),
agents: vec![AgentDef {
name: mlua_swarm::worker::baseline::AG_IDENTITY.into(),
kind: AgentKind::RustFn,
spec: serde_json::json!({"fn_id": mlua_swarm::worker::baseline::AG_IDENTITY}),
profile: None,
meta: None,
runner: None,
runner_ref: None,
verdict: None,
}],
operators: vec![],
metas: vec![],
hints: CompilerHints::default(),
strategy: CompilerStrategy::default(),
metadata: BlueprintMetadata::default(),
spawner_hints: Default::default(),
default_agent_kind: AgentKind::Operator,
default_operator_kind: None,
default_init_ctx: None,
default_agent_ctx: None,
default_context_policy: None,
projection_placement: None,
audits: vec![],
degradation_policy: None,
runners: vec![],
default_runner: None,
subprocesses: vec![],
check_policy: None,
blueprint_ref_includes: Vec::new(),
}
}
fn post_greeting_task_req(
greeting: &str,
project_root: Option<&str>,
) -> crate::TaskLaunchRequest {
crate::TaskLaunchRequest {
blueprint: BlueprintRef::Inline {
value: Box::new(greeting_blueprint()),
},
init_ctx: serde_json::json!({ "greeting": greeting }),
project_root: project_root.map(str::to_string),
work_dir: None,
task_metadata: None,
ttl_secs: None,
operator: None,
operator_sid: None,
timeout_secs: None,
goal: Some("st4 rekick goal".to_string()),
detach: false,
check_policy: None,
}
}
#[tokio::test]
async fn rekick_no_body_preserves_stored_task_input_ctx_byte_for_byte() {
let state = test_state();
let posted = crate::tasks_start(
State(state.clone()),
Json(post_greeting_task_req("from-task", None)),
)
.await
.expect("tasks_start")
.0;
assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
let (status, rekicked) =
task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
.await
.expect("task_rekick");
assert_eq!(status, StatusCode::CREATED);
let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
.await
.expect("run_get")
.0;
assert_eq!(
run.result_ref.expect("result_ref present")["out"]["echoed"],
"from-task"
);
}
#[tokio::test]
async fn rekick_with_init_ctx_override_wins_over_stored_task_input_ctx() {
let state = test_state();
let posted = crate::tasks_start(
State(state.clone()),
Json(post_greeting_task_req("from-task", None)),
)
.await
.expect("tasks_start")
.0;
assert_eq!(posted.final_ctx["out"]["echoed"], "from-task");
let (status, rekicked) = task_rekick(
State(state.clone()),
Path(posted.task_id.to_string()),
Some(Json(RunKickRequest {
init_ctx_override: Some(serde_json::json!({ "greeting": "from-run" })),
task_input_override: None,
timeout_secs: None,
detach: false,
operator_sid: None,
})),
)
.await
.expect("task_rekick");
assert_eq!(status, StatusCode::CREATED);
let run = run_get(State(state.clone()), Path(rekicked.0.run_id.to_string()))
.await
.expect("run_get")
.0;
assert_eq!(
run.result_ref.expect("result_ref present")["out"]["echoed"],
"from-run",
"Run's init_ctx_override must win over the stored Task input_ctx"
);
}
#[tokio::test]
async fn rekick_with_stored_task_input_spec_dispatches_and_leaves_it_unmutated() {
let state = test_state();
let posted = crate::tasks_start(
State(state.clone()),
Json(post_greeting_task_req("from-task", Some("/repo"))),
)
.await
.expect("tasks_start")
.0;
let before = state
.task_store
.get(&posted.task_id)
.await
.expect("task fetch");
let before_spec: Option<TaskInputSpec> = before
.task_input_spec
.as_ref()
.map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
assert_eq!(
before_spec,
Some(TaskInputSpec {
project_root: Some("/repo".to_string()),
work_dir: None,
task_metadata: None,
})
);
let (status, _rekicked) =
task_rekick(State(state.clone()), Path(posted.task_id.to_string()), None)
.await
.expect("task_rekick");
assert_eq!(status, StatusCode::CREATED);
let after = state
.task_store
.get(&posted.task_id)
.await
.expect("task fetch");
assert_eq!(
after.task_input_spec, before.task_input_spec,
"rekick must not mutate the stored Task-level task_input_spec snapshot"
);
}
#[tokio::test]
async fn rekick_with_task_input_override_does_not_mutate_stored_task_record() {
let state = test_state();
let posted = crate::tasks_start(
State(state.clone()),
Json(post_greeting_task_req("from-task", Some("/repo"))),
)
.await
.expect("tasks_start")
.0;
let (status, _rekicked) = task_rekick(
State(state.clone()),
Path(posted.task_id.to_string()),
Some(Json(RunKickRequest {
init_ctx_override: None,
task_input_override: Some(TaskInputSpec {
project_root: Some("/override".to_string()),
work_dir: None,
task_metadata: None,
}),
timeout_secs: None,
detach: false,
operator_sid: None,
})),
)
.await
.expect("task_rekick");
assert_eq!(status, StatusCode::CREATED);
let after = state
.task_store
.get(&posted.task_id)
.await
.expect("task fetch");
let after_spec: Option<TaskInputSpec> = after
.task_input_spec
.as_ref()
.map(|v| serde_json::from_value(v.clone()).expect("decode task_input_spec"));
assert_eq!(
after_spec,
Some(TaskInputSpec {
project_root: Some("/repo".to_string()),
work_dir: None,
task_metadata: None,
}),
"a per-Run task_input_override must not leak into the stored TaskRecord"
);
}
fn delegate_launch_req(goal: &str) -> crate::TaskLaunchRequest {
crate::TaskLaunchRequest {
blueprint: BlueprintRef::Inline {
value: Box::new(identity_blueprint_with_operator_delegate()),
},
init_ctx: serde_json::json!({"in": "hello"}),
project_root: None,
work_dir: None,
task_metadata: None,
ttl_secs: None,
operator: None,
operator_sid: None,
timeout_secs: None,
goal: Some(goal.to_string()),
detach: false,
check_policy: None,
}
}
#[tokio::test]
async fn rekick_zero_operators_with_operator_delegate_blueprint_fails_fast() {
let state = test_state();
let posted = crate::tasks_start(
State(state.clone()),
Json(delegate_launch_req("operator delegate rekick goal")),
)
.await
.expect("tasks_start (no operator referenced, dispatches through baseline)")
.0;
let started = std::time::Instant::now();
let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
let elapsed = started.elapsed();
let err = match result {
Err(e) => e,
Ok(_) => panic!(
"rekicking a Task whose Blueprint declares operator_delegate with zero \
attached operators must fail, not dispatch"
),
};
assert_eq!(err.status, StatusCode::SERVICE_UNAVAILABLE);
assert!(
err.message.contains("no operator attached"),
"error message must mention the missing operator: {}",
err.message
);
assert!(
elapsed < Duration::from_secs(1),
"guard 1 must fail fast (no dispatch, no timeout wait): took {elapsed:?}"
);
}
#[tokio::test]
async fn rekick_stalled_operator_times_out() {
let state = test_state();
state
.engine
.register_operator("stall-op", Arc::new(StallingOperator))
.await;
let posted = crate::tasks_start(
State(state.clone()),
Json(delegate_launch_req("stalled rekick goal")),
)
.await
.expect("tasks_start")
.0;
let started = std::time::Instant::now();
let result = tokio::time::timeout(
Duration::from_secs(5),
task_rekick(
State(state),
Path(posted.task_id.to_string()),
Some(Json(RunKickRequest {
init_ctx_override: None,
task_input_override: None,
timeout_secs: Some(1),
detach: false,
operator_sid: None,
})),
),
)
.await
.expect("task_rekick must resolve well within 5s when guard 2's ceiling is 1s");
let elapsed = started.elapsed();
match &result {
Err(e) => {
assert_eq!(e.status, StatusCode::GATEWAY_TIMEOUT);
assert!(
e.message.contains('1'),
"error message must mention the configured 1s ceiling: {}",
e.message
);
assert!(
elapsed < Duration::from_secs(3),
"guard 2 must fire close to the requested 1s ceiling: took {elapsed:?}"
);
}
Ok(_) => {
assert!(
elapsed < Duration::from_secs(1),
"a rekick that never engages an Operator (task_rekick has no \
per-request operator override) must resolve fast, not stall: took {elapsed:?}"
);
}
}
}
#[tokio::test]
async fn rekick_timeout_secs_zero_rejected() {
let state = test_state();
let posted = crate::tasks_start(
State(state.clone()),
Json(post_tasks_req("zero timeout rekick goal")),
)
.await
.expect("tasks_start")
.0;
let before = task_get(State(state.clone()), Path(posted.task_id.to_string()))
.await
.expect("task_get")
.0;
let runs_before = before.runs.len();
let result = task_rekick(
State(state.clone()),
Path(posted.task_id.to_string()),
Some(Json(RunKickRequest {
init_ctx_override: None,
task_input_override: None,
timeout_secs: Some(0),
detach: false,
operator_sid: None,
})),
)
.await;
let err = match result {
Err(e) => e,
Ok(_) => panic!("timeout_secs: Some(0) must be rejected, not treated as a no-op"),
};
assert_eq!(err.status, StatusCode::BAD_REQUEST);
assert!(
err.message.contains("timeout_secs"),
"error message must reference timeout_secs: {}",
err.message
);
let after = task_get(State(state), Path(posted.task_id.to_string()))
.await
.expect("task_get")
.0;
assert_eq!(
after.runs.len(),
runs_before,
"a rejected timeout_secs: Some(0) rekick must not create a new Run"
);
}
#[tokio::test]
async fn rekick_non_operator_path_unaffected_by_guard_1() {
let state = test_state();
let posted = crate::tasks_start(
State(state.clone()),
Json(post_tasks_req("non-operator rekick goal")),
)
.await
.expect("tasks_start")
.0;
let result = task_rekick(State(state), Path(posted.task_id.to_string()), None).await;
if let Err(e) = &result {
panic!(
"a plain (non-operator_delegate) Task rekick must succeed unaffected by \
guard 1: {}",
e.message
);
}
}
#[tokio::test]
async fn rekick_unknown_operator_sid_rejected_before_side_effects() {
let state = test_state();
let posted = crate::tasks_start(
State(state.clone()),
Json(post_tasks_req("unknown operator_sid rekick goal")),
)
.await
.expect("tasks_start")
.0;
let before = task_get(State(state.clone()), Path(posted.task_id.to_string()))
.await
.expect("task_get")
.0;
let runs_before = before.runs.len();
let result = task_rekick(
State(state.clone()),
Path(posted.task_id.to_string()),
Some(Json(RunKickRequest {
init_ctx_override: None,
task_input_override: None,
timeout_secs: None,
detach: false,
operator_sid: Some("S-not-registered".to_string()),
})),
)
.await;
let err = match result {
Err(e) => e,
Ok(_) => panic!("an unknown operator_sid must be rejected, not dispatched"),
};
assert_eq!(err.status, StatusCode::BAD_REQUEST);
assert!(
err.message.contains("operator_sid"),
"error message must reference operator_sid: {}",
err.message
);
let after = task_get(State(state), Path(posted.task_id.to_string()))
.await
.expect("task_get")
.0;
assert_eq!(
after.runs.len(),
runs_before,
"a rejected unknown-operator_sid rekick must not create a new Run"
);
}
#[tokio::test]
async fn rekick_with_registered_operator_sid_persists_it_on_the_run() {
let state = test_state();
state
.engine
.register_operator("S-live-op", Arc::new(StallingOperator))
.await;
let posted = crate::tasks_start(
State(state.clone()),
Json(post_tasks_req("registered operator_sid rekick goal")),
)
.await
.expect("tasks_start")
.0;
let (status, rekicked) = task_rekick(
State(state.clone()),
Path(posted.task_id.to_string()),
Some(Json(RunKickRequest {
init_ctx_override: None,
task_input_override: None,
timeout_secs: None,
detach: false,
operator_sid: Some("S-live-op".to_string()),
})),
)
.await
.expect("task_rekick with a registered operator_sid");
assert_eq!(status, StatusCode::CREATED);
let run = state
.run_store
.get(&rekicked.0.run_id)
.await
.expect("run get");
assert_eq!(
run.operator_sid,
Some("S-live-op".to_string()),
"the pinned operator_sid must be persisted verbatim on the RunRecord"
);
}
#[tokio::test]
async fn run_get_unknown_id_returns_404() {
let state = test_state();
match run_get(State(state), Path("R-does-not-exist".to_string())).await {
Ok(_) => panic!("expected 404 for an unknown run"),
Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
}
}
#[tokio::test]
async fn run_bindings_explain_reports_pinned_requested_effective_diff() {
let state = test_state();
let posted = crate::tasks_start(
State(state.clone()),
Json(post_tasks_req("binding explain")),
)
.await
.expect("tasks_start")
.0;
let run = state
.run_store
.get(&posted.run_id)
.await
.expect("stored run");
let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
let mut bound_agents: Vec<BoundAgent> =
serde_json::from_value(snapshot["bound_agents"].clone()).unwrap();
let bound = &mut bound_agents[0];
bound.runner = Some(Runner::WsClaudeCode {
variant: "coder".to_string(),
tools: vec!["Read".to_string()],
});
bound.recompute_binding_digest().unwrap();
let request_digest = bound.binding_digest.clone();
bound
.set_attestation(BindingAttestation {
request_digest: request_digest.clone(),
provider_id: "operator-manifest".to_string(),
provider_revision: Some("claude-code-1.2".to_string()),
resolved_model: Some("claude-sonnet-4".to_string()),
effective_tools: vec!["Bash".to_string(), "Read".to_string()],
launch_variant: Some("coder".to_string()),
capability_snapshot_digest: Some(mlua_swarm::blueprint::BindingDigest::sha256(
b"manifest-v1",
)),
})
.unwrap();
snapshot["bound_agents"] = serde_json::to_value(&bound_agents).unwrap();
state
.run_store
.set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
.await
.unwrap();
let explained = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
.await
.expect("binding explain")
.0;
let entry = &explained.bindings[0];
assert_eq!(entry.status, RunBindingStatus::Attested);
assert_eq!(
entry.requested.as_ref().unwrap().request_digest,
request_digest
);
assert_eq!(
entry
.effective
.as_ref()
.unwrap()
.provider_revision
.as_deref(),
Some("claude-code-1.2")
);
assert_eq!(
entry
.difference
.as_ref()
.unwrap()
.additional_effective_tools,
vec!["Bash"]
);
assert!(entry
.difference
.as_ref()
.unwrap()
.missing_requested_tools
.is_empty());
assert_ne!(entry.binding_digest, request_digest);
}
#[tokio::test]
async fn run_bindings_explain_reports_snapshot_origin() {
let state = test_state();
let posted =
crate::tasks_start(State(state.clone()), Json(post_tasks_req("origin explain")))
.await
.expect("tasks_start")
.0;
let explained = run_bindings_explain(State(state.clone()), Path(posted.run_id.to_string()))
.await
.expect("binding explain")
.0;
assert_eq!(explained.snapshot_origin, SnapshotOrigin::Launch);
let run = state.run_store.get(&posted.run_id).await.unwrap();
let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
snapshot["bound_agents_origin"] = serde_json::json!("resume_backfill");
state
.run_store
.set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
.await
.unwrap();
let explained = run_bindings_explain(State(state.clone()), Path(posted.run_id.to_string()))
.await
.expect("binding explain")
.0;
assert_eq!(explained.snapshot_origin, SnapshotOrigin::ResumeBackfill);
snapshot
.as_object_mut()
.unwrap()
.remove("bound_agents_origin");
state
.run_store
.set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
.await
.unwrap();
let explained = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
.await
.expect("explain still 200 without an origin marker")
.0;
assert_eq!(explained.snapshot_origin, SnapshotOrigin::ResumeBackfill);
}
#[tokio::test]
async fn run_bindings_explain_never_guesses_for_legacy_snapshot() {
let state = test_state();
let posted = crate::tasks_start(
State(state.clone()),
Json(post_tasks_req("legacy binding explain")),
)
.await
.expect("tasks_start")
.0;
state
.run_store
.set_input_json(&posted.run_id, "{}".to_string())
.await
.unwrap();
let error = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
.await
.expect_err("legacy run must not be re-resolved");
assert_eq!(error.status, StatusCode::UNPROCESSABLE_ENTITY);
assert!(error
.message
.contains("current Blueprint state was not consulted"));
}
#[tokio::test]
async fn run_bindings_explain_rejects_a_tampered_snapshot() {
let state = test_state();
let posted = crate::tasks_start(
State(state.clone()),
Json(post_tasks_req("tampered binding explain")),
)
.await
.expect("tasks_start")
.0;
let run = state.run_store.get(&posted.run_id).await.unwrap();
let mut snapshot: Value = serde_json::from_str(run.input_json.as_deref().unwrap()).unwrap();
snapshot["bound_agents"][0]["agent"]["name"] = Value::String("tampered".into());
state
.run_store
.set_input_json(&posted.run_id, serde_json::to_string(&snapshot).unwrap())
.await
.unwrap();
let error = run_bindings_explain(State(state), Path(posted.run_id.to_string()))
.await
.expect_err("digest drift must fail closed");
assert_eq!(error.status, StatusCode::UNPROCESSABLE_ENTITY);
assert!(error.message.contains("inconsistent binding snapshot"));
}
#[tokio::test]
async fn task_get_unknown_id_returns_404() {
let state = test_state();
match task_get(State(state), Path("T-does-not-exist".to_string())).await {
Ok(_) => panic!("expected 404 for an unknown task"),
Err(e) => assert_eq!(e.status, StatusCode::NOT_FOUND),
}
}
async fn seed_task_and_run(state: &AppState) -> (TaskId, RunId) {
let task_id = TaskId::new();
let run_id = RunId::new();
state
.task_store
.create(TaskRecord {
id: task_id.clone(),
goal: "finalize-run-err-envelope".to_string(),
blueprint_ref: json!("inline"),
input_ctx: Value::Null,
task_input_spec: None,
status: TaskRecordStatus::Running,
created_at: 0,
updated_at: 0,
})
.await
.expect("seed TaskRecord");
state
.run_store
.create(RunRecord {
id: run_id.clone(),
task_id: task_id.clone(),
status: RunStatus::Running,
step_entries: Vec::new(),
degradations: Vec::new(),
operator_sid: None,
result_ref: None,
input_json: Some("{}".to_string()),
created_at: 0,
updated_at: 0,
})
.await
.expect("seed RunRecord");
(task_id, run_id)
}
#[tokio::test]
async fn finalize_run_err_arm_populates_result_ref_with_structured_envelope() {
let state = test_state();
let (task_id, run_id) = seed_task_and_run(&state).await;
let err: Result<TaskApplicationOutput, TaskApplicationError> =
Err(TaskApplicationError::Launch(TaskLaunchError::FlowEval {
message: "blocked: {\"verdict\":\"BLOCKED\"}".to_string(),
failed_step: Some("gate".to_string()),
verdict_value: Some(json!({"verdict": "BLOCKED", "reason": "not-applicable"})),
partial_ctx: Some(
json!({"steps": {"ST-abc": {"step_ref": "gate", "status": "blocked"}}}),
),
}));
let _ = finalize_run(&state, &task_id, &run_id, err).await;
let run = state.run_store.get(&run_id).await.expect("run present");
assert_eq!(run.status, RunStatus::Failed);
let envelope = run
.result_ref
.as_ref()
.expect("result_ref must be Some on Err arm");
assert_eq!(
envelope["error"]["message"],
"blocked: {\"verdict\":\"BLOCKED\"}"
);
assert_eq!(envelope["error"]["failed_step"], "gate");
assert_eq!(envelope["error"]["verdict_value"]["verdict"], "BLOCKED");
assert_eq!(
envelope["partial_ctx"]["steps"]["ST-abc"]["status"],
"blocked"
);
let task = state.task_store.get(&task_id).await.expect("task present");
assert_eq!(task.status, TaskRecordStatus::Failed);
}
#[tokio::test]
async fn finalize_run_err_arm_non_flow_eval_populates_envelope_with_null_structural_fields() {
let state = test_state();
let (task_id, run_id) = seed_task_and_run(&state).await;
let err: Result<TaskApplicationOutput, TaskApplicationError> =
Err(TaskApplicationError::NoStore);
let _ = finalize_run(&state, &task_id, &run_id, err).await;
let run = state.run_store.get(&run_id).await.expect("run present");
let envelope = run
.result_ref
.as_ref()
.expect("result_ref must be Some on Err arm");
assert!(envelope["error"]["message"]
.as_str()
.expect("message string")
.contains("store"));
assert_eq!(envelope["error"]["failed_step"], Value::Null);
assert_eq!(envelope["error"]["verdict_value"], Value::Null);
assert_eq!(envelope["partial_ctx"], Value::Null);
}
#[tokio::test]
async fn finalize_run_ok_arm_still_stores_raw_final_ctx_verbatim() {
let state = test_state();
let (task_id, run_id) = seed_task_and_run(&state).await;
let ok: Result<TaskApplicationOutput, TaskApplicationError> = Ok(TaskApplicationOutput {
token: mlua_swarm::CapToken {
agent_id: "ut".to_string(),
role: mlua_swarm::Role::Operator,
scopes: vec!["*".to_string()],
issued_at: 0,
expire_at: u64::MAX,
max_uses: None,
nonce: "ut-nonce".to_string(),
sig_hex: String::new(),
},
final_ctx: json!({"out": {"echoed": "hi"}}),
bound_version: None,
});
let _ = finalize_run(&state, &task_id, &run_id, ok).await;
let run = state.run_store.get(&run_id).await.expect("run present");
assert_eq!(run.status, RunStatus::Done);
let stored = run.result_ref.as_ref().expect("result_ref Some");
assert_eq!(stored, &json!({"out": {"echoed": "hi"}}));
assert!(
stored.get("error").is_none(),
"Ok arm must never write an `error` key at the top of result_ref (envelope disambiguation)"
);
}
#[tokio::test]
async fn run_get_surfaces_structured_failure_envelope_from_result_ref() {
let state = test_state();
let (_task_id, run_id) = seed_task_and_run(&state).await;
let err: Result<TaskApplicationOutput, TaskApplicationError> =
Err(TaskApplicationError::Launch(TaskLaunchError::FlowEval {
message: "blocked: bad verdict".to_string(),
failed_step: Some("scout".to_string()),
verdict_value: Some(json!("BLOCKED")),
partial_ctx: Some(json!({"steps": {}})),
}));
let _ = finalize_run(&state, &_task_id, &run_id, err).await;
let Json(run) = run_get(State(state), Path(run_id.to_string()))
.await
.expect("run_get");
assert_eq!(run.status, RunStatus::Failed);
let envelope = run.result_ref.expect("result_ref Some");
assert_eq!(envelope["error"]["failed_step"], "scout");
assert_eq!(envelope["error"]["verdict_value"], "BLOCKED");
}
}