fabro/lib/crates/arc-api/src/server.rs
Bryan Helmkamp 7ad187a78f Add signoff resource to verification system
Signoffs are stamps of approval for (control, repository, commit SHA)
tuples, enabling external tools and humans to mark a control as
pass/fail/pending for a specific commit. Adds OpenAPI spec, demo
handlers with 5 fixtures, route registration, and regenerated TS client.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-10 19:32:33 -04:00

2443 lines
86 KiB
Rust

use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::sse::{Event, Sse};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use tokio::sync::broadcast;
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::StreamExt;
use tower::ServiceExt;
use tracing::{error, info};
use arc_agent::LocalSandbox;
use crate::error::ApiError;
use crate::jwt_auth::{AuthMode, AuthenticatedService, AuthenticatedUser};
use arc_workflows::checkpoint::Checkpoint;
use arc_workflows::context::Context;
use arc_workflows::engine::{RunConfig, WorkflowRunEngine};
use arc_workflows::event::{EventEmitter, WorkflowRunEvent};
use arc_workflows::handler::HandlerRegistry;
use arc_workflows::interviewer::web::WebInterviewer;
use arc_workflows::interviewer::{Answer, Interviewer, QuestionType};
pub use arc_types::{
ApiQuestion, ApiQuestionOption, PaginatedRunList, PaginationMeta,
QuestionType as ApiQuestionType, RunStatus, RunStatusResponse, StartRunRequest,
SubmitAnswerRequest,
};
pub fn default_page_limit() -> u32 {
20
}
#[derive(serde::Deserialize)]
pub struct PaginationParams {
#[serde(rename = "page[limit]", default = "default_page_limit")]
pub limit: u32,
#[serde(rename = "page[offset]", default)]
pub offset: u32,
}
/// Non-paginated list response wrapper with `has_more: false`.
#[derive(serde::Serialize)]
pub struct ListResponse<T: serde::Serialize> {
data: T,
meta: PaginationMeta,
}
impl<T: serde::Serialize> ListResponse<T> {
pub fn new(data: T) -> Self {
Self {
data,
meta: PaginationMeta { has_more: false },
}
}
}
/// Snapshot of a managed run.
struct ManagedRun {
dot_source: String,
graph: arc_workflows::graph::Graph,
status: RunStatus,
error: Option<String>,
created_at: chrono::DateTime<chrono::Utc>,
// Populated when running:
interviewer: Option<Arc<WebInterviewer>>,
event_tx: Option<broadcast::Sender<WorkflowRunEvent>>,
context: Option<Context>,
checkpoint: Option<Checkpoint>,
cancel_tx: Option<tokio::sync::oneshot::Sender<()>>,
cancel_token: Option<Arc<AtomicBool>>,
run_dir: Option<std::path::PathBuf>,
}
/// Per-model usage totals.
#[derive(Default)]
struct ModelUsageTotals {
stages: i64,
input_tokens: i64,
output_tokens: i64,
cost: f64,
}
/// In-memory aggregate usage counters, reset on server restart.
#[derive(Default)]
struct AggregateUsageTotals {
total_runs: i64,
total_runtime_secs: f64,
by_model: HashMap<String, ModelUsageTotals>,
}
/// Shared application state for the server.
pub struct AppState {
runs: Mutex<HashMap<String, ManagedRun>>,
aggregate_usage: Mutex<AggregateUsageTotals>,
registry_factory: Box<dyn Fn(Arc<dyn Interviewer>) -> HandlerRegistry + Send + Sync>,
pub dry_run: bool,
pub db: sqlx::SqlitePool,
max_concurrent_runs: usize,
scheduler_notify: tokio::sync::Notify,
pub hook_config: arc_workflows::hook::HookConfig,
git_author: arc_workflows::git::GitAuthor,
pub sessions: crate::sessions::SessionStore,
llm_client: tokio::sync::OnceCell<arc_llm::client::Client>,
}
/// Build the axum Router with all run endpoints.
///
/// Both a demo router and a real router are constructed. Incoming requests
/// with the `X-Arc-Demo: 1` header are dispatched to the demo router;
/// all other requests go to the real router.
pub fn build_router(state: Arc<AppState>, auth_mode: AuthMode) -> Router {
let common = Router::new()
.route("/", get(root))
.route("/health", get(health))
.route("/openapi.json", get(openapi_spec))
.route("/user", get(get_user));
let demo_router = common
.clone()
.merge(demo_routes())
.layer(axum::Extension(AuthMode::Disabled))
.with_state(state.clone());
let real_router = common
.merge(real_routes())
.layer(axum::Extension(auth_mode))
.with_state(state);
let dispatch = tower::service_fn(move |req: axum::extract::Request| {
let demo = demo_router.clone();
let real = real_router.clone();
async move {
if req.headers().get("x-arc-demo").is_some_and(|v| v == "1") {
demo.oneshot(req).await
} else {
real.oneshot(req).await
}
}
});
Router::new().fallback_service(dispatch)
}
fn demo_routes() -> Router<Arc<AppState>> {
Router::new()
.route(
"/runs",
get(crate::demo::list_runs).post(crate::demo::start_run_stub),
)
.route("/runs/{id}", get(crate::demo::get_run_status))
.route("/runs/{id}/questions", get(crate::demo::get_questions_stub))
.route(
"/runs/{id}/questions/{qid}/answer",
post(crate::demo::answer_stub),
)
.route("/runs/{id}/events", get(crate::demo::run_events_stub))
.route("/runs/{id}/checkpoint", get(crate::demo::checkpoint_stub))
.route("/runs/{id}/context", get(crate::demo::context_stub))
.route("/runs/{id}/cancel", post(crate::demo::cancel_stub))
.route("/runs/{id}/pause", post(crate::demo::pause_stub))
.route("/runs/{id}/unpause", post(crate::demo::unpause_stub))
.route("/runs/{id}/graph", get(crate::demo::get_run_graph))
.route("/runs/{id}/retro", get(crate::demo::get_run_retro))
.route("/runs/{id}/stages", get(crate::demo::get_run_stages))
.route(
"/runs/{id}/stages/{stageId}/turns",
get(crate::demo::get_stage_turns),
)
.route("/runs/{id}/files", get(crate::demo::get_run_files))
.route("/runs/{id}/usage", get(crate::demo::get_run_usage))
.route(
"/runs/{id}/verification",
get(crate::demo::get_run_verification),
)
.route(
"/runs/{id}/configuration",
get(crate::demo::get_run_configuration),
)
.route("/runs/{id}/steer", post(crate::demo::steer_run_stub))
.route(
"/runs/{id}/preview",
post(crate::demo::generate_preview_url_stub),
)
.route("/workflows", get(crate::demo::list_workflows))
.route("/workflows/{name}", get(crate::demo::get_workflow))
.route(
"/workflows/{name}/runs",
get(crate::demo::list_workflow_runs),
)
.route(
"/verification/criteria",
get(crate::demo::list_verification_criteria),
)
.route(
"/verification/criteria/{id}",
get(crate::demo::get_verification_criterion),
)
.route(
"/verification/controls",
get(crate::demo::list_verification_controls),
)
.route(
"/verification/controls/{id}",
get(crate::demo::get_verification_control),
)
.route(
"/verification/signoffs",
get(crate::demo::list_signoffs).post(crate::demo::create_signoff_stub),
)
.route("/verification/signoffs/{id}", get(crate::demo::get_signoff))
.route("/retros", get(crate::demo::list_retros))
.route(
"/sessions",
get(crate::demo::list_sessions).post(crate::demo::create_session_stub),
)
.route("/sessions/{id}", get(crate::demo::get_session))
.route(
"/sessions/{id}/messages",
post(crate::demo::send_message_stub),
)
.route(
"/sessions/{id}/events",
get(crate::demo::session_events_stub),
)
.route(
"/insights/queries",
get(crate::demo::list_saved_queries).post(crate::demo::save_query_stub),
)
.route(
"/insights/queries/{id}",
get(crate::demo::get_saved_query)
.put(crate::demo::update_query_stub)
.delete(crate::demo::delete_query_stub),
)
.route("/insights/execute", post(crate::demo::execute_query_stub))
.route("/insights/history", get(crate::demo::list_query_history))
.route("/models", get(crate::demo::list_models))
.route("/models/{id}/test", post(test_model))
.route("/completions", post(create_completion))
.route("/settings", get(crate::demo::get_server_configuration))
.route("/usage", get(crate::demo::get_aggregate_usage))
}
fn real_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/runs", get(list_runs).post(start_run))
.route("/runs/{id}", get(get_run_status))
.route("/runs/{id}/questions", get(get_questions))
.route("/runs/{id}/questions/{qid}/answer", post(submit_answer))
.route("/runs/{id}/events", get(get_events))
.route("/runs/{id}/checkpoint", get(get_checkpoint))
.route("/runs/{id}/context", get(get_context))
.route("/runs/{id}/cancel", post(cancel_run))
.route("/runs/{id}/pause", post(pause_run))
.route("/runs/{id}/unpause", post(unpause_run))
.route("/runs/{id}/graph", get(get_graph))
.route("/runs/{id}/retro", get(get_retro))
.route("/runs/{id}/stages", get(not_implemented))
.route("/runs/{id}/stages/{stageId}/turns", get(not_implemented))
.route("/runs/{id}/files", get(not_implemented))
.route("/runs/{id}/usage", get(not_implemented))
.route("/runs/{id}/verification", get(not_implemented))
.route("/runs/{id}/configuration", get(not_implemented))
.route("/runs/{id}/steer", post(not_implemented))
.route("/runs/{id}/preview", post(not_implemented))
.route("/workflows", get(not_implemented))
.route("/workflows/{name}", get(not_implemented))
.route("/workflows/{name}/runs", get(not_implemented))
.route("/verification/criteria", get(not_implemented))
.route("/verification/criteria/{id}", get(not_implemented))
.route("/verification/controls", get(not_implemented))
.route("/verification/controls/{id}", get(not_implemented))
.route(
"/verification/signoffs",
get(not_implemented).post(not_implemented),
)
.route("/verification/signoffs/{id}", get(not_implemented))
.route("/retros", get(not_implemented))
.route(
"/sessions",
get(crate::sessions::list_sessions).post(crate::sessions::create_session),
)
.route("/sessions/{id}", get(crate::sessions::retrieve_session))
.route(
"/sessions/{id}/messages",
post(crate::sessions::send_message),
)
.route(
"/sessions/{id}/events",
get(crate::sessions::stream_session_events),
)
.route(
"/insights/queries",
get(not_implemented).post(not_implemented),
)
.route(
"/insights/queries/{id}",
get(not_implemented)
.put(not_implemented)
.delete(not_implemented),
)
.route("/insights/execute", post(not_implemented))
.route("/insights/history", get(not_implemented))
.route("/models", get(crate::demo::list_models))
.route("/models/{id}/test", post(test_model))
.route("/completions", post(create_completion))
.route("/settings", get(not_implemented))
.route("/usage", get(get_aggregate_usage))
}
async fn not_implemented() -> Response {
ApiError::new(StatusCode::NOT_IMPLEMENTED, "Not implemented.").into_response()
}
async fn root() -> Response {
Json(serde_json::json!({
"urls": {
"openapi_url": "/openapi.json",
"current_user_url": "/user",
"health_url": "/health"
}
}))
.into_response()
}
async fn health() -> Response {
Json(serde_json::json!({"status": "ok"})).into_response()
}
async fn openapi_spec() -> Response {
let yaml = include_str!("../../../../docs/api-reference/arc-api.yaml");
let value: serde_json::Value =
serde_yaml::from_str(yaml).expect("embedded OpenAPI YAML is invalid");
Json(value).into_response()
}
async fn get_user(user: AuthenticatedUser) -> Response {
Json(serde_json::json!({"login": user.login})).into_response()
}
async fn get_aggregate_usage(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
) -> Response {
let agg = state
.aggregate_usage
.lock()
.expect("aggregate_usage lock poisoned");
let by_model: Vec<arc_types::UsageByModel> = agg
.by_model
.iter()
.map(|(model, totals)| arc_types::UsageByModel {
model: arc_types::ModelReference { id: model.clone() },
stages: totals.stages,
usage: arc_types::TokenUsage {
input_tokens: totals.input_tokens,
output_tokens: totals.output_tokens,
cost: totals.cost,
},
})
.collect();
let response = arc_types::AggregateUsage {
totals: arc_types::AggregateUsageTotals {
runs: agg.total_runs,
input_tokens: by_model.iter().map(|m| m.usage.input_tokens).sum(),
output_tokens: by_model.iter().map(|m| m.usage.output_tokens).sum(),
cost: by_model.iter().map(|m| m.usage.cost).sum(),
runtime_secs: agg.total_runtime_secs,
},
by_model,
};
(StatusCode::OK, Json(response)).into_response()
}
/// Create an `AppState` with the given registry factory and database pool.
///
/// The factory receives the run's `WebInterviewer` so it can wire it
/// into handlers that need human-in-the-loop interaction (e.g., `HumanHandler`).
pub fn create_app_state(
db: sqlx::SqlitePool,
registry_factory: impl Fn(Arc<dyn Interviewer>) -> HandlerRegistry + Send + Sync + 'static,
) -> Arc<AppState> {
create_app_state_with_options(
db,
registry_factory,
false,
5,
arc_workflows::git::GitAuthor::default(),
)
}
/// Create an `AppState` with the given database pool, registry factory, dry-run flag, and concurrency limit.
pub fn create_app_state_with_options(
db: sqlx::SqlitePool,
registry_factory: impl Fn(Arc<dyn Interviewer>) -> HandlerRegistry + Send + Sync + 'static,
dry_run: bool,
max_concurrent_runs: usize,
git_author: arc_workflows::git::GitAuthor,
) -> Arc<AppState> {
Arc::new(AppState {
runs: Mutex::new(HashMap::new()),
aggregate_usage: Mutex::new(AggregateUsageTotals::default()),
registry_factory: Box::new(registry_factory),
dry_run,
db,
max_concurrent_runs,
scheduler_notify: tokio::sync::Notify::new(),
hook_config: arc_workflows::hook::HookConfig::default(),
git_author,
sessions: crate::sessions::new_session_store(),
llm_client: tokio::sync::OnceCell::new(),
})
}
async fn list_runs(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Query(pagination): Query<PaginationParams>,
) -> Response {
let runs = state.runs.lock().expect("runs lock poisoned");
let queue_positions = compute_queue_positions(&runs);
let limit = pagination.limit.clamp(1, 100) as usize;
let offset = pagination.offset as usize;
let all_items: Vec<RunStatusResponse> = runs
.iter()
.map(|(id, managed_run)| RunStatusResponse {
id: id.clone(),
status: managed_run.status,
error: managed_run.error.as_ref().map(|msg| arc_types::RunError {
message: msg.clone(),
}),
queue_position: queue_positions.get(id).copied(),
created_at: managed_run.created_at,
})
.collect();
let page: Vec<_> = all_items.into_iter().skip(offset).take(limit + 1).collect();
let has_more = page.len() > limit;
let data: Vec<_> = page.into_iter().take(limit).collect();
(
StatusCode::OK,
Json(serde_json::json!({
"data": data,
"meta": { "has_more": has_more }
})),
)
.into_response()
}
fn compute_queue_positions(runs: &HashMap<String, ManagedRun>) -> HashMap<String, i64> {
let mut queued: Vec<(&String, &ManagedRun)> = runs
.iter()
.filter(|(_, r)| r.status == RunStatus::Queued)
.collect();
queued.sort_by_key(|(_, r)| r.created_at);
queued
.into_iter()
.enumerate()
.map(|(i, (id, _))| (id.clone(), (i + 1) as i64))
.collect()
}
async fn start_run(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Json(req): Json<StartRunRequest>,
) -> Response {
// Parse the DOT source
let graph = match arc_workflows::workflow::prepare_from_source(&req.dot_source) {
Ok(g) => g,
Err(e) => {
return ApiError::bad_request(e.to_string()).into_response();
}
};
let run_id = ulid::Ulid::new().to_string();
info!(run_id = %run_id, "Run queued");
let created_at = chrono::Utc::now();
{
let mut runs = state.runs.lock().expect("runs lock poisoned");
runs.insert(
run_id.clone(),
ManagedRun {
dot_source: req.dot_source,
graph,
status: RunStatus::Queued,
error: None,
created_at,
interviewer: None,
event_tx: None,
context: None,
checkpoint: None,
cancel_tx: None,
cancel_token: None,
run_dir: None,
},
);
}
state.scheduler_notify.notify_one();
(
StatusCode::CREATED,
Json(RunStatusResponse {
id: run_id,
status: RunStatus::Queued,
error: None,
queue_position: None,
created_at,
}),
)
.into_response()
}
/// Execute a single run: transitions queued → starting → running → completed/failed/cancelled.
async fn execute_run(state: Arc<AppState>, run_id: String) {
// Transition to Starting and set up cancel infrastructure
let (cancel_rx, graph) = {
let mut runs = state.runs.lock().expect("runs lock poisoned");
let managed_run = match runs.get_mut(&run_id) {
Some(r) if r.status == RunStatus::Queued => r,
_ => return,
};
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>();
let cancel_token = Arc::new(AtomicBool::new(false));
let (event_tx, _) = broadcast::channel(256);
managed_run.status = RunStatus::Starting;
managed_run.cancel_tx = Some(cancel_tx);
managed_run.cancel_token = Some(Arc::clone(&cancel_token));
managed_run.event_tx = Some(event_tx);
(cancel_rx, managed_run.graph.clone())
};
// Create interviewer, sandbox, engine (this is the "provisioning" phase)
let interviewer = Arc::new(WebInterviewer::new());
let context = Context::new();
let event_tx = {
let runs = state.runs.lock().expect("runs lock poisoned");
runs.get(&run_id).and_then(|r| r.event_tx.clone())
};
let mut emitter = EventEmitter::new();
if let Some(tx_clone) = event_tx {
emitter.on_event(move |event| {
let _ = tx_clone.send(event.clone());
});
}
let cancel_token = {
let runs = state.runs.lock().expect("runs lock poisoned");
runs.get(&run_id).and_then(|r| r.cancel_token.clone())
};
let cancel_token = match cancel_token {
Some(ct) => ct,
None => return,
};
let registry = (state.registry_factory)(Arc::clone(&interviewer) as Arc<dyn Interviewer>);
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let sandbox: Arc<dyn arc_agent::Sandbox> = Arc::new(arc_agent::ReadBeforeWriteSandbox::new(
Arc::new(LocalSandbox::new(cwd)),
));
let mut engine = WorkflowRunEngine::with_interviewer(
registry,
Arc::new(emitter),
Arc::clone(&interviewer) as Arc<dyn Interviewer>,
sandbox,
);
// Wire up hook runner from server config
if !state.hook_config.hooks.is_empty() {
let runner = arc_workflows::hook::HookRunner::new(state.hook_config.clone());
engine.set_hook_runner(std::sync::Arc::new(runner));
}
// Transition to Running, populate interviewer + context
{
let mut runs = state.runs.lock().expect("runs lock poisoned");
if let Some(managed_run) = runs.get_mut(&run_id) {
if managed_run.status != RunStatus::Starting {
// Was cancelled during setup
state.scheduler_notify.notify_one();
return;
}
managed_run.status = RunStatus::Running;
managed_run.interviewer = Some(Arc::clone(&interviewer));
managed_run.context = Some(context);
}
}
let run_dir = std::env::temp_dir().join(format!("arc-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&run_dir).expect("failed to create run directory");
let config = RunConfig {
run_dir,
cancel_token: Some(cancel_token),
dry_run: state.dry_run,
run_id: run_id.clone(),
git_checkpoint_enabled: false,
host_repo_path: None,
base_sha: None,
run_branch: None,
meta_branch: None,
labels: std::collections::HashMap::new(),
checkpoint_exclude_globs: Vec::new(),
github_app: None,
git_author: state.git_author.clone(),
base_branch: None,
pull_request_enabled: false,
pull_request_draft: true,
asset_globs: Vec::new(),
};
let result = tokio::select! {
result = engine.run(&graph, &config) => result,
_ = cancel_rx => {
let mut runs = state.runs.lock().expect("runs lock poisoned");
if let Some(managed_run) = runs.get_mut(&run_id) {
managed_run.status = RunStatus::Cancelled;
managed_run.event_tx = None;
}
state.scheduler_notify.notify_one();
return;
}
};
// Save final checkpoint
let checkpoint = Checkpoint::load(&config.run_dir.join("checkpoint.json")).ok();
// Auto-derive retro and accumulate aggregate usage
if let Some(ref cp) = checkpoint {
let (failed, failure_reason) = match &result {
Ok(_) => (false, None),
Err(e) => (true, Some(e.to_string())),
};
let stage_durations = arc_workflows::retro::extract_stage_durations(&config.run_dir);
let retro = arc_workflows::retro::derive_retro(
&run_id,
"workflow",
"",
cp,
failed,
failure_reason.as_deref(),
0,
&stage_durations,
);
let _ = retro.save(&config.run_dir);
// Accumulate aggregate usage
let mut agg = state
.aggregate_usage
.lock()
.expect("aggregate_usage lock poisoned");
agg.total_runs += 1;
let mut run_runtime: f64 = 0.0;
for (node_id, outcome) in &cp.node_outcomes {
if let Some(usage) = &outcome.usage {
let entry = agg.by_model.entry(usage.model.clone()).or_default();
entry.stages += 1;
entry.input_tokens += usage.input_tokens;
entry.output_tokens += usage.output_tokens;
entry.cost += usage.cost.unwrap_or(0.0);
}
let duration_ms = stage_durations.get(node_id).copied().unwrap_or(0);
run_runtime += duration_ms as f64 / 1000.0;
}
agg.total_runtime_secs += run_runtime;
}
let mut runs = state.runs.lock().expect("runs lock poisoned");
if let Some(managed_run) = runs.get_mut(&run_id) {
match result {
Ok(_) => {
info!(run_id = %run_id, "Run completed");
managed_run.status = RunStatus::Completed;
}
Err(arc_workflows::error::ArcError::Cancelled) => {
info!(run_id = %run_id, "Run cancelled");
managed_run.status = RunStatus::Cancelled;
}
Err(e) => {
error!(run_id = %run_id, error = %e, "Run failed");
managed_run.status = RunStatus::Failed;
managed_run.error = Some(e.to_string());
}
}
managed_run.checkpoint = checkpoint;
managed_run.run_dir = Some(config.run_dir.clone());
managed_run.event_tx = None;
}
drop(runs);
state.scheduler_notify.notify_one();
}
/// Background task that promotes queued runs when capacity is available.
pub fn spawn_scheduler(state: Arc<AppState>) {
tokio::spawn(async move {
loop {
tokio::select! {
_ = state.scheduler_notify.notified() => {},
_ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {},
}
// Promote as many queued runs as capacity allows
loop {
let run_to_start = {
let runs = state.runs.lock().expect("runs lock poisoned");
let active = runs
.values()
.filter(|r| {
r.status == RunStatus::Starting || r.status == RunStatus::Running
})
.count();
if active >= state.max_concurrent_runs {
break;
}
runs.iter()
.filter(|(_, r)| r.status == RunStatus::Queued)
.min_by_key(|(_, r)| r.created_at)
.map(|(id, _)| id.clone())
};
match run_to_start {
Some(id) => {
let state_clone = Arc::clone(&state);
tokio::spawn(execute_run(state_clone, id));
}
None => break,
};
}
}
});
}
async fn get_run_status(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let runs = state.runs.lock().expect("runs lock poisoned");
match runs.get(&id) {
Some(managed_run) => {
let queue_position = if managed_run.status == RunStatus::Queued {
let positions = compute_queue_positions(&runs);
positions.get(&id).copied()
} else {
None
};
(
StatusCode::OK,
Json(RunStatusResponse {
id: id.clone(),
status: managed_run.status,
error: managed_run.error.as_ref().map(|msg| arc_types::RunError {
message: msg.clone(),
}),
created_at: managed_run.created_at,
queue_position,
}),
)
.into_response()
}
None => ApiError::not_found("Run not found.").into_response(),
}
}
async fn get_questions(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let runs = state.runs.lock().expect("runs lock poisoned");
match runs.get(&id) {
Some(managed_run) => {
let interviewer = match &managed_run.interviewer {
Some(i) => i,
None => {
return (
StatusCode::OK,
Json(ListResponse::new(Vec::<ApiQuestion>::new())),
)
.into_response()
}
};
let pending = interviewer.pending_questions();
let questions: Vec<ApiQuestion> = pending
.into_iter()
.map(|pq| ApiQuestion {
id: pq.id,
text: pq.question.text.clone(),
question_type: match pq.question.question_type {
QuestionType::YesNo => ApiQuestionType::YesNo,
QuestionType::MultipleChoice => ApiQuestionType::MultipleChoice,
QuestionType::MultiSelect => ApiQuestionType::MultiSelect,
QuestionType::Freeform => ApiQuestionType::Freeform,
QuestionType::Confirmation => ApiQuestionType::Confirmation,
},
options: pq
.question
.options
.iter()
.map(|o| ApiQuestionOption {
key: o.key.clone(),
label: o.label.clone(),
})
.collect(),
allow_freeform: pq.question.allow_freeform,
})
.collect();
(StatusCode::OK, Json(ListResponse::new(questions))).into_response()
}
None => ApiError::not_found("Run not found.").into_response(),
}
}
async fn submit_answer(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Path((id, qid)): Path<(String, String)>,
Json(req): Json<SubmitAnswerRequest>,
) -> Response {
let runs = state.runs.lock().expect("runs lock poisoned");
match runs.get(&id) {
Some(managed_run) => {
let interviewer = match &managed_run.interviewer {
Some(i) => i,
None => {
return ApiError::new(StatusCode::CONFLICT, "Run is not yet running.")
.into_response();
}
};
let answer = if let Some(key) = &req.selected_option_key {
let option = interviewer
.pending_questions()
.iter()
.find(|pq| pq.id == qid)
.and_then(|pq| pq.question.options.iter().find(|o| o.key == *key))
.cloned();
match option {
Some(opt) => Answer::selected(key.clone(), opt),
None => {
return ApiError::bad_request("Invalid option key.").into_response();
}
}
} else if !req.selected_option_keys.is_empty() {
let pending = interviewer.pending_questions();
let pq = pending.iter().find(|pq| pq.id == qid);
let mut options = Vec::new();
for key in &req.selected_option_keys {
let opt = pq
.and_then(|pq| pq.question.options.iter().find(|o| o.key == *key).cloned());
match opt {
Some(o) => options.push(o),
None => {
return ApiError::bad_request("Invalid option key.").into_response();
}
}
}
Answer::multi_selected(req.selected_option_keys, options)
} else if let Some(v) = req.value {
Answer::text(v)
} else {
return ApiError::bad_request(
"One of value, selected_option_key, or selected_option_keys is required.",
)
.into_response();
};
let accepted = interviewer.submit_answer(&qid, answer);
if accepted {
StatusCode::NO_CONTENT.into_response()
} else {
ApiError::new(
StatusCode::CONFLICT,
"Question no longer exists or was already answered.",
)
.into_response()
}
}
None => ApiError::not_found("Run not found.").into_response(),
}
}
async fn get_events(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let rx = {
let runs = state.runs.lock().expect("runs lock poisoned");
match runs.get(&id) {
Some(managed_run) => match &managed_run.event_tx {
Some(tx) => tx.subscribe(),
None => {
return ApiError::new(StatusCode::GONE, "Event stream closed.").into_response()
}
},
None => return ApiError::not_found("Run not found.").into_response(),
}
};
let stream = BroadcastStream::new(rx).filter_map(|result| match result {
Ok(event) => {
let data = serde_json::to_string(&event).unwrap_or_default();
let data = arc_util::redact::redact_jsonl_line(&data);
Some(Ok::<Event, std::convert::Infallible>(
Event::default().data(data),
))
}
Err(_) => None,
});
Sse::new(stream).into_response()
}
async fn get_checkpoint(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let runs = state.runs.lock().expect("runs lock poisoned");
match runs.get(&id) {
Some(managed_run) => match &managed_run.checkpoint {
Some(cp) => (StatusCode::OK, Json(cp.clone())).into_response(),
None => (StatusCode::OK, Json(serde_json::json!(null))).into_response(),
},
None => ApiError::not_found("Run not found.").into_response(),
}
}
async fn get_context(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let runs = state.runs.lock().expect("runs lock poisoned");
match runs.get(&id) {
Some(managed_run) => match &managed_run.context {
Some(ctx) => (StatusCode::OK, Json(ctx.snapshot())).into_response(),
None => (StatusCode::OK, Json(serde_json::json!({}))).into_response(),
},
None => ApiError::not_found("Run not found.").into_response(),
}
}
async fn cancel_run(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let mut runs = state.runs.lock().expect("runs lock poisoned");
match runs.get_mut(&id) {
Some(managed_run) => match managed_run.status {
RunStatus::Queued | RunStatus::Starting | RunStatus::Running => {
if let Some(token) = &managed_run.cancel_token {
token.store(true, Ordering::Relaxed);
}
if let Some(cancel_tx) = managed_run.cancel_tx.take() {
let _ = cancel_tx.send(());
}
managed_run.status = RunStatus::Cancelled;
let created_at = managed_run.created_at;
(
StatusCode::OK,
Json(RunStatusResponse {
id: id.clone(),
status: RunStatus::Cancelled,
error: None,
queue_position: None,
created_at,
}),
)
.into_response()
}
_ => ApiError::new(StatusCode::CONFLICT, "Run is not cancellable.").into_response(),
},
None => ApiError::not_found("Run not found.").into_response(),
}
}
async fn pause_run(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let mut runs = state.runs.lock().expect("runs lock poisoned");
match runs.get_mut(&id) {
Some(managed_run) => match managed_run.status {
RunStatus::Running => {
managed_run.status = RunStatus::Paused;
let created_at = managed_run.created_at;
(
StatusCode::OK,
Json(RunStatusResponse {
id: id.clone(),
status: RunStatus::Paused,
error: None,
queue_position: None,
created_at,
}),
)
.into_response()
}
_ => ApiError::new(StatusCode::CONFLICT, "Run is not pausable.").into_response(),
},
None => ApiError::not_found("Run not found.").into_response(),
}
}
async fn unpause_run(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let mut runs = state.runs.lock().expect("runs lock poisoned");
match runs.get_mut(&id) {
Some(managed_run) => match managed_run.status {
RunStatus::Paused => {
managed_run.status = RunStatus::Running;
let created_at = managed_run.created_at;
(
StatusCode::OK,
Json(RunStatusResponse {
id: id.clone(),
status: RunStatus::Running,
error: None,
queue_position: None,
created_at,
}),
)
.into_response()
}
_ => ApiError::new(StatusCode::CONFLICT, "Run is not paused.").into_response(),
},
None => ApiError::not_found("Run not found.").into_response(),
}
}
async fn test_model(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let Some(info) = arc_llm::catalog::get_model_info(&id) else {
return ApiError::not_found(format!("Model not found: {id}")).into_response();
};
if state.dry_run {
return Json(serde_json::json!({
"model_id": id,
"status": "ok",
}))
.into_response();
}
let params = arc_llm::generate::GenerateParams::new(&info.id)
.provider(&info.provider)
.prompt("Say OK")
.max_tokens(16);
let result =
tokio::time::timeout(Duration::from_secs(30), arc_llm::generate::generate(params)).await;
match result {
Ok(Ok(_)) => Json(serde_json::json!({
"model_id": id,
"status": "ok",
}))
.into_response(),
Ok(Err(e)) => Json(serde_json::json!({
"model_id": id,
"status": "error",
"error_message": e.to_string(),
}))
.into_response(),
Err(_) => Json(serde_json::json!({
"model_id": id,
"status": "error",
"error_message": "timeout (30s)",
}))
.into_response(),
}
}
fn finish_reason_to_api_stop_reason(reason: &arc_llm::types::FinishReason) -> String {
match reason {
arc_llm::types::FinishReason::Stop => "end_turn".to_string(),
arc_llm::types::FinishReason::Length => "max_tokens".to_string(),
arc_llm::types::FinishReason::ToolCalls => "tool_calls".to_string(),
arc_llm::types::FinishReason::ContentFilter => "content_filter".to_string(),
arc_llm::types::FinishReason::Error => "error".to_string(),
arc_llm::types::FinishReason::Other(s) => s.clone(),
}
}
fn convert_api_message(msg: &arc_types::CompletionMessage) -> arc_llm::types::Message {
let role = match msg.role {
arc_types::CompletionMessageRole::System => arc_llm::types::Role::System,
arc_types::CompletionMessageRole::User => arc_llm::types::Role::User,
arc_types::CompletionMessageRole::Assistant => arc_llm::types::Role::Assistant,
arc_types::CompletionMessageRole::Tool => arc_llm::types::Role::Tool,
arc_types::CompletionMessageRole::Developer => arc_llm::types::Role::Developer,
};
let content: Vec<arc_llm::types::ContentPart> = msg
.content
.iter()
.filter_map(|part| {
let json = serde_json::to_value(part).ok()?;
serde_json::from_value(json).ok()
})
.collect();
arc_llm::types::Message {
role,
content,
name: msg.name.clone(),
tool_call_id: msg.tool_call_id.clone(),
}
}
fn convert_llm_message(msg: &arc_llm::types::Message) -> arc_types::CompletionMessage {
let role = match msg.role {
arc_llm::types::Role::System => arc_types::CompletionMessageRole::System,
arc_llm::types::Role::User => arc_types::CompletionMessageRole::User,
arc_llm::types::Role::Assistant => arc_types::CompletionMessageRole::Assistant,
arc_llm::types::Role::Tool => arc_types::CompletionMessageRole::Tool,
arc_llm::types::Role::Developer => arc_types::CompletionMessageRole::Developer,
};
let content: Vec<arc_types::CompletionContentPart> = msg
.content
.iter()
.filter_map(|part| {
let json = serde_json::to_value(part).ok()?;
serde_json::from_value(json).ok()
})
.collect();
arc_types::CompletionMessage {
role,
content,
name: msg.name.clone(),
tool_call_id: msg.tool_call_id.clone(),
}
}
async fn create_completion(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Json(req): Json<arc_types::CreateCompletionRequest>,
) -> Response {
// Resolve model
let model_id = req.model.unwrap_or_else(|| {
arc_llm::catalog::list_models(None)
.first()
.map_or_else(|| "claude-sonnet-4-5".to_string(), |m| m.id.clone())
});
let catalog_info = arc_llm::catalog::get_model_info(&model_id);
// Resolve provider: explicit request > catalog > None
let provider_name = req
.provider
.or_else(|| catalog_info.as_ref().map(|i| i.provider.clone()));
info!(model = %model_id, provider = ?provider_name, "Completion request received");
// Build messages list
let mut messages: Vec<arc_llm::types::Message> = Vec::new();
if let Some(system) = req.system {
messages.push(arc_llm::types::Message::system(system));
}
for msg in &req.messages {
messages.push(convert_api_message(msg));
}
// Convert tools
let tools: Option<Vec<arc_llm::types::ToolDefinition>> = if req.tools.is_empty() {
None
} else {
Some(
req.tools
.into_iter()
.map(|t| arc_llm::types::ToolDefinition {
name: t.name,
description: t.description,
parameters: t.parameters,
})
.collect(),
)
};
// Convert tool_choice
let tool_choice: Option<arc_llm::types::ToolChoice> = req.tool_choice.map(|tc| match tc.mode {
arc_types::CompletionToolChoiceMode::Auto => arc_llm::types::ToolChoice::Auto,
arc_types::CompletionToolChoiceMode::None => arc_llm::types::ToolChoice::None,
arc_types::CompletionToolChoiceMode::Required => arc_llm::types::ToolChoice::Required,
arc_types::CompletionToolChoiceMode::Named => {
arc_llm::types::ToolChoice::named(tc.tool_name.unwrap_or_default())
}
});
// Build the LLM request
let request = arc_llm::types::Request {
model: model_id.clone(),
messages,
provider: provider_name,
tools,
tool_choice,
response_format: None,
temperature: req.temperature,
top_p: req.top_p,
max_tokens: req.max_tokens,
stop_sequences: if req.stop_sequences.is_empty() {
None
} else {
Some(req.stop_sequences)
},
reasoning_effort: req.reasoning_effort,
metadata: None,
provider_options: req.provider_options,
};
// Force non-streaming for structured output
let use_stream = req.stream && req.schema.is_none();
// Dry-run mode returns a stub response
if state.dry_run {
let msg_id = ulid::Ulid::new().to_string();
if use_stream {
let finish_event = arc_llm::types::StreamEvent::finish(
arc_llm::types::FinishReason::Stop,
arc_llm::types::Usage::default(),
arc_llm::types::Response {
id: msg_id.clone(),
model: model_id.clone(),
provider: String::new(),
message: arc_llm::types::Message::assistant(""),
finish_reason: arc_llm::types::FinishReason::Stop,
usage: arc_llm::types::Usage::default(),
raw: None,
warnings: vec![],
rate_limit: None,
},
);
let json = serde_json::to_string(&finish_event).unwrap_or_default();
let sse_stream = futures_util::stream::iter(vec![Ok::<_, std::convert::Infallible>(
Event::default().event("stream_event").data(json),
)]);
return Sse::new(sse_stream).into_response();
}
let empty_msg = arc_types::CompletionMessage {
role: arc_types::CompletionMessageRole::Assistant,
content: vec![],
name: None,
tool_call_id: None,
};
return Json(arc_types::CompletionResponse {
id: msg_id,
model: model_id,
message: empty_msg,
stop_reason: "end_turn".to_string(),
usage: arc_types::CompletionUsage {
input_tokens: 0,
output_tokens: 0,
},
output: None,
})
.into_response();
}
// Get or create LLM client (cached in AppState)
let client = match state
.llm_client
.get_or_try_init(arc_llm::client::Client::from_env)
.await
{
Ok(c) => c,
Err(e) => {
return ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to create LLM client: {e}"),
)
.into_response()
}
};
if use_stream {
// Streaming path: forward all StreamEvents as SSE
let stream_result = match client.stream(&request).await {
Ok(s) => s,
Err(e) => {
return ApiError::new(StatusCode::BAD_GATEWAY, format!("LLM error: {e}"))
.into_response()
}
};
let sse_stream = tokio_stream::StreamExt::filter_map(stream_result, |event| match event {
Ok(ref evt) => match serde_json::to_string(evt) {
Ok(json) => Some(Ok::<_, std::convert::Infallible>(
Event::default().event("stream_event").data(json),
)),
Err(e) => Some(Ok(Event::default().event("stream_event").data(
serde_json::json!({
"type": "error",
"error": {"Stream": {"message": format!("failed to serialize event: {e}")}},
"raw": null
})
.to_string(),
))),
},
Err(e) => Some(Ok(Event::default().event("stream_event").data(
serde_json::json!({
"type": "error",
"error": {"Stream": {"message": e.to_string()}},
"raw": null
})
.to_string(),
))),
});
Sse::new(sse_stream)
.keep_alive(
axum::response::sse::KeepAlive::new()
.interval(Duration::from_secs(15))
.event(
Event::default()
.event("ping")
.data(serde_json::json!({"type": "ping"}).to_string()),
),
)
.into_response()
} else {
// Non-streaming path
let msg_id = ulid::Ulid::new().to_string();
if let Some(schema) = req.schema {
// Structured output uses generate_object for JSON parsing logic
let mut params = arc_llm::generate::GenerateParams::new(&request.model)
.messages(request.messages)
.client(std::sync::Arc::new(client.clone()));
if let Some(ref p) = request.provider {
params = params.provider(p);
}
if let Some(temp) = request.temperature {
params = params.temperature(temp);
}
if let Some(max_tokens) = request.max_tokens {
params = params.max_tokens(max_tokens);
}
if let Some(top_p) = request.top_p {
params = params.top_p(top_p);
}
match arc_llm::generate::generate_object(params, schema).await {
Ok(result) => Json(arc_types::CompletionResponse {
id: msg_id,
model: model_id,
message: convert_llm_message(&result.response.message),
stop_reason: finish_reason_to_api_stop_reason(&result.finish_reason),
usage: arc_types::CompletionUsage {
input_tokens: result.usage.input_tokens,
output_tokens: result.usage.output_tokens,
},
output: result.output,
})
.into_response(),
Err(e) => ApiError::new(StatusCode::BAD_GATEWAY, format!("LLM error: {e}"))
.into_response(),
}
} else {
match client.complete(&request).await {
Ok(response) => Json(arc_types::CompletionResponse {
id: response.id,
model: response.model,
message: convert_llm_message(&response.message),
stop_reason: finish_reason_to_api_stop_reason(&response.finish_reason),
usage: arc_types::CompletionUsage {
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
},
output: None,
})
.into_response(),
Err(e) => ApiError::new(StatusCode::BAD_GATEWAY, format!("LLM error: {e}"))
.into_response(),
}
}
}
}
async fn get_retro(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let run_dir = {
let runs = state.runs.lock().expect("runs lock poisoned");
match runs.get(&id) {
Some(managed_run) => managed_run.run_dir.clone(),
None => return ApiError::not_found("Run not found.").into_response(),
}
};
let Some(run_dir) = run_dir else {
return (StatusCode::OK, Json(serde_json::json!(null))).into_response();
};
match arc_workflows::retro::Retro::load(&run_dir) {
Ok(retro) => (StatusCode::OK, Json(retro)).into_response(),
Err(_) => (StatusCode::OK, Json(serde_json::json!(null))).into_response(),
}
}
async fn get_graph(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let dot_source = {
let runs = state.runs.lock().expect("runs lock poisoned");
match runs.get(&id) {
Some(managed_run) => managed_run.dot_source.clone(),
None => return ApiError::not_found("Run not found.").into_response(),
}
};
let mut child = match tokio::process::Command::new("dot")
.arg("-Tsvg")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
{
Ok(child) => child,
Err(_) => {
return ApiError::new(
StatusCode::BAD_GATEWAY,
"Graphviz dot command not available.",
)
.into_response();
}
};
if let Some(mut stdin) = child.stdin.take() {
use tokio::io::AsyncWriteExt;
let _ = stdin.write_all(dot_source.as_bytes()).await;
// stdin is dropped here, closing the pipe
}
match child.wait_with_output().await {
Ok(output) if output.status.success() => (
StatusCode::OK,
[("content-type", "image/svg+xml")],
output.stdout,
)
.into_response(),
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
ApiError::new(StatusCode::BAD_GATEWAY, format!("dot failed: {stderr}")).into_response()
}
Err(e) => ApiError::new(StatusCode::BAD_GATEWAY, format!("dot process error: {e}"))
.into_response(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt;
use arc_workflows::handler::exit::ExitHandler;
use arc_workflows::handler::start::StartHandler;
const MINIMAL_DOT: &str = r#"digraph Test {
graph [goal="Test"]
start [shape=Mdiamond]
exit [shape=Msquare]
start -> exit
}"#;
fn test_registry(
_interviewer: Arc<dyn arc_workflows::interviewer::Interviewer>,
) -> HandlerRegistry {
let mut registry = HandlerRegistry::new(Box::new(StartHandler));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry
}
async fn test_db() -> sqlx::SqlitePool {
let pool = arc_db::connect_memory().await.unwrap();
arc_db::initialize_db(&pool).await.unwrap();
pool
}
fn test_app_with(db: sqlx::SqlitePool) -> Router {
let state = create_app_state(db, test_registry);
build_router(state, AuthMode::Disabled)
}
fn test_app_with_scheduler(state: Arc<AppState>) -> Router {
spawn_scheduler(Arc::clone(&state));
build_router(state, AuthMode::Disabled)
}
async fn body_json(body: Body) -> serde_json::Value {
let bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
#[tokio::test]
async fn test_model_unknown_returns_404() {
let app = test_app_with(test_db().await);
let req = Request::builder()
.method("POST")
.uri("/models/nonexistent-model-xyz/test")
.header("content-type", "application/json")
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_model_known_returns_200_with_status() {
let app = test_app_with(test_db().await);
let req = Request::builder()
.method("POST")
.uri("/models/claude-opus-4-6/test")
.header("content-type", "application/json")
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
assert_eq!(body["model_id"], "claude-opus-4-6");
assert!(body["status"] == "ok" || body["status"] == "error");
}
#[tokio::test]
async fn test_model_dry_run_returns_ok() {
let state = create_app_state_with_options(
test_db().await,
test_registry,
true,
5,
arc_workflows::git::GitAuthor::default(),
);
let app = build_router(state, AuthMode::Disabled);
let req = Request::builder()
.method("POST")
.uri("/models/claude-opus-4-6/test")
.header("content-type", "application/json")
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
assert_eq!(body["model_id"], "claude-opus-4-6");
assert_eq!(body["status"], "ok");
}
#[tokio::test]
async fn test_model_dry_run_unknown_returns_404() {
let state = create_app_state_with_options(
test_db().await,
test_registry,
true,
5,
arc_workflows::git::GitAuthor::default(),
);
let app = build_router(state, AuthMode::Disabled);
let req = Request::builder()
.method("POST")
.uri("/models/nonexistent-model-xyz/test")
.header("content-type", "application/json")
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn post_runs_starts_run_and_returns_id() {
let app = test_app_with(test_db().await);
let req = Request::builder()
.method("POST")
.uri("/runs")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(),
))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body = body_json(response.into_body()).await;
assert!(body["id"].is_string());
assert!(!body["id"].as_str().unwrap().is_empty());
}
#[tokio::test]
async fn post_runs_invalid_dot_returns_bad_request() {
let app = test_app_with(test_db().await);
let req = Request::builder()
.method("POST")
.uri("/runs")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"dot_source": "not a graph"})).unwrap(),
))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_run_status_returns_status() {
let state = create_app_state(test_db().await, test_registry);
let app = test_app_with_scheduler(state);
// Start a run
let req = Request::builder()
.method("POST")
.uri("/runs")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
// Give run a moment to start
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// Check status
let req = Request::builder()
.method("GET")
.uri(format!("/runs/{run_id}"))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
assert_eq!(body["id"].as_str().unwrap(), run_id);
let status = body["status"].as_str().unwrap();
assert!(
status == "queued"
|| status == "starting"
|| status == "running"
|| status == "completed",
"unexpected status: {status}"
);
}
#[tokio::test]
async fn get_run_status_not_found() {
let app = test_app_with(test_db().await);
let req = Request::builder()
.method("GET")
.uri("/runs/nonexistent")
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn get_questions_returns_empty_list() {
let state = create_app_state(test_db().await, test_registry);
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
// Start a run
let req = Request::builder()
.method("POST")
.uri("/runs")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
// Get questions (should be empty for a run without wait.human nodes)
let req = Request::builder()
.method("GET")
.uri(format!("/runs/{run_id}/questions"))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
assert!(body["data"].is_array());
assert_eq!(body["meta"]["has_more"], false);
}
#[tokio::test]
async fn submit_answer_not_found_run() {
let app = test_app_with(test_db().await);
let req = Request::builder()
.method("POST")
.uri("/runs/nonexistent/questions/q1/answer")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"value": "yes"})).unwrap(),
))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn get_events_not_found() {
let app = test_app_with(test_db().await);
let req = Request::builder()
.method("GET")
.uri("/runs/nonexistent/events")
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn get_checkpoint_returns_null_initially() {
let state = create_app_state(test_db().await, test_registry);
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
// Start a run
let req = Request::builder()
.method("POST")
.uri("/runs")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
// Get checkpoint immediately (before run completes, may be null)
let req = Request::builder()
.method("GET")
.uri(format!("/runs/{run_id}/checkpoint"))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn get_context_returns_map() {
let state = create_app_state(test_db().await, test_registry);
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
// Start a run
let req = Request::builder()
.method("POST")
.uri("/runs")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
// Get context
let req = Request::builder()
.method("GET")
.uri(format!("/runs/{run_id}/context"))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
assert!(body.is_object());
}
#[tokio::test]
async fn cancel_run_succeeds() {
let state = create_app_state(test_db().await, test_registry);
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
// Start a run
let req = Request::builder()
.method("POST")
.uri("/runs")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
// Cancel it
let req = Request::builder()
.method("POST")
.uri(format!("/runs/{run_id}/cancel"))
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
// Could be OK (cancelled) or CONFLICT (already completed)
let status = response.status();
assert!(
status == StatusCode::OK || status == StatusCode::CONFLICT,
"unexpected status: {status}"
);
}
#[tokio::test]
async fn cancel_nonexistent_run_returns_not_found() {
let app = test_app_with(test_db().await);
let req = Request::builder()
.method("POST")
.uri("/runs/nonexistent/cancel")
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn get_events_returns_sse_stream() {
let state = create_app_state(test_db().await, test_registry);
let app = test_app_with_scheduler(state);
// Start a run
let req = Request::builder()
.method("POST")
.uri("/runs")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
// Wait for scheduler to promote run (creates event_tx)
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
// Request the SSE stream
let req = Request::builder()
.method("GET")
.uri(format!("/runs/{run_id}/events"))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
// May be 200 (stream open) or 410 (run completed before we connect)
let status = response.status();
assert!(
status == StatusCode::OK || status == StatusCode::GONE,
"unexpected status: {status}"
);
if status == StatusCode::OK {
let content_type = response
.headers()
.get("content-type")
.expect("content-type header should be present")
.to_str()
.unwrap();
assert!(
content_type.contains("text/event-stream"),
"expected text/event-stream, got: {content_type}"
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn run_completes_and_status_is_completed() {
let state = create_app_state(test_db().await, test_registry);
let app = test_app_with_scheduler(state);
// Start a run
let req = Request::builder()
.method("POST")
.uri("/runs")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
// Poll until run completes
let mut status = String::new();
for _ in 0..100 {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let req = Request::builder()
.method("GET")
.uri(format!("/runs/{run_id}"))
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
status = body["status"].as_str().unwrap().to_string();
if status == "completed" || status == "failed" {
break;
}
}
assert_eq!(status, "completed");
}
#[tokio::test]
async fn get_graph_returns_svg() {
let state = create_app_state(test_db().await, test_registry);
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
// Start a run
let req = Request::builder()
.method("POST")
.uri("/runs")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
// Request graph SVG
let req = Request::builder()
.method("GET")
.uri(format!("/runs/{run_id}/graph"))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
// If graphviz is not installed, we get 502 — skip assertion
if response.status() == StatusCode::BAD_GATEWAY {
return;
}
assert_eq!(response.status(), StatusCode::OK);
let content_type = response
.headers()
.get("content-type")
.expect("content-type header should be present")
.to_str()
.unwrap();
assert_eq!(content_type, "image/svg+xml");
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let svg = String::from_utf8_lossy(&bytes);
assert!(
svg.contains("<?xml") || svg.contains("<svg"),
"expected SVG content, got: {}",
&svg[..svg.len().min(200)]
);
}
#[tokio::test]
async fn get_graph_not_found() {
let app = test_app_with(test_db().await);
let req = Request::builder()
.method("GET")
.uri("/runs/nonexistent/graph")
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn list_runs_returns_started_run() {
let state = create_app_state(test_db().await, test_registry);
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
// List should be empty initially
let req = Request::builder()
.method("GET")
.uri("/runs")
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
assert_eq!(body["data"].as_array().unwrap().len(), 0);
assert!(!body["meta"]["has_more"].as_bool().unwrap());
// Start a run
let req = Request::builder()
.method("POST")
.uri("/runs")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
// List should now contain one run
let req = Request::builder()
.method("GET")
.uri("/runs")
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
let items = body["data"].as_array().unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0]["id"].as_str().unwrap(), run_id);
assert!(items[0]["status"].as_str().is_some());
assert!(!body["meta"]["has_more"].as_bool().unwrap());
}
#[tokio::test]
async fn get_aggregate_usage_returns_zeros_initially() {
let state = create_app_state(test_db().await, test_registry);
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
let req = Request::builder()
.method("GET")
.uri("/usage")
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
assert_eq!(body["totals"]["runs"].as_i64().unwrap(), 0);
assert_eq!(body["totals"]["input_tokens"].as_i64().unwrap(), 0);
assert_eq!(body["totals"]["output_tokens"].as_i64().unwrap(), 0);
assert_eq!(body["totals"]["cost"].as_f64().unwrap(), 0.0);
assert_eq!(body["totals"]["runtime_secs"].as_f64().unwrap(), 0.0);
assert!(body["by_model"].as_array().unwrap().is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn aggregate_usage_increments_after_run_completes() {
let state = create_app_state(test_db().await, test_registry);
let app = test_app_with_scheduler(state);
// Start a run
let req = Request::builder()
.method("POST")
.uri("/runs")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
// Poll until run completes
let mut status = String::new();
for _ in 0..100 {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let req = Request::builder()
.method("GET")
.uri(format!("/runs/{run_id}"))
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
status = body["status"].as_str().unwrap().to_string();
if status == "completed" || status == "failed" {
break;
}
}
assert_eq!(status, "completed");
// Check aggregate usage
let req = Request::builder()
.method("GET")
.uri("/usage")
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
assert_eq!(body["totals"]["runs"].as_i64().unwrap(), 1);
}
#[tokio::test]
async fn post_runs_returns_queued_status() {
let state = create_app_state(test_db().await, test_registry);
let app = build_router(state, AuthMode::Disabled);
let req = Request::builder()
.method("POST")
.uri("/runs")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
// Check status is queued (no scheduler running)
let req = Request::builder()
.method("GET")
.uri(format!("/runs/{run_id}"))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
assert_eq!(body["status"].as_str().unwrap(), "queued");
}
#[tokio::test]
async fn cancel_queued_run_succeeds() {
let state = create_app_state(test_db().await, test_registry);
let app = build_router(state, AuthMode::Disabled);
// Submit a run (no scheduler, stays queued)
let req = Request::builder()
.method("POST")
.uri("/runs")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
// Cancel it
let req = Request::builder()
.method("POST")
.uri(format!("/runs/{run_id}/cancel"))
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
// Verify status is cancelled
let req = Request::builder()
.method("GET")
.uri(format!("/runs/{run_id}"))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
assert_eq!(body["status"].as_str().unwrap(), "cancelled");
}
#[tokio::test]
async fn queue_position_reported_for_queued_runs() {
let state = create_app_state(test_db().await, test_registry);
let app = build_router(state, AuthMode::Disabled);
// Submit two runs (no scheduler, both stay queued)
let mut run_ids = Vec::new();
for _ in 0..2 {
let req = Request::builder()
.method("POST")
.uri("/runs")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
run_ids.push(body["id"].as_str().unwrap().to_string());
}
// Check queue positions via individual status
let req = Request::builder()
.method("GET")
.uri(format!("/runs/{}", run_ids[0]))
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
assert_eq!(body["queue_position"].as_i64().unwrap(), 1);
let req = Request::builder()
.method("GET")
.uri(format!("/runs/{}", run_ids[1]))
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
assert_eq!(body["queue_position"].as_i64().unwrap(), 2);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn concurrency_limit_respected() {
let state = create_app_state_with_options(
test_db().await,
test_registry,
false,
1,
arc_workflows::git::GitAuthor::default(),
);
let app = test_app_with_scheduler(state);
// Submit two runs with max_concurrent_runs=1
let mut run_ids = Vec::new();
for _ in 0..2 {
let req = Request::builder()
.method("POST")
.uri("/runs")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
run_ids.push(body["id"].as_str().unwrap().to_string());
}
// Give scheduler time to pick up the first run
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// Check statuses: at most 1 should be starting/running, the other queued
let req = Request::builder()
.method("GET")
.uri("/runs")
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let items = body["data"].as_array().unwrap();
let active_count = items
.iter()
.filter(|item| {
let s = item["status"].as_str().unwrap();
s == "starting" || s == "running"
})
.count();
// With max_concurrent_runs=1, at most 1 should be active
// (the first one might have completed already, so active could be 0 or 1)
assert!(
active_count <= 1,
"expected at most 1 active run, got {active_count}"
);
}
#[tokio::test]
async fn submit_answer_to_queued_run_returns_conflict() {
let state = create_app_state(test_db().await, test_registry);
let app = build_router(state, AuthMode::Disabled);
let req = Request::builder()
.method("POST")
.uri("/runs")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
// Try to submit an answer to a queued run
let req = Request::builder()
.method("POST")
.uri(format!("/runs/{run_id}/questions/q1/answer"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"value": "yes"})).unwrap(),
))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn create_completion_non_streaming_returns_json() {
let state = create_app_state_with_options(
test_db().await,
test_registry,
true,
5,
arc_workflows::git::GitAuthor::default(),
);
let app = build_router(state, AuthMode::Disabled);
let req = Request::builder()
.method("POST")
.uri("/completions")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"messages": [{"role": "user", "content": [{"kind": "text", "data": "Hello"}]}],
"stream": false
}))
.unwrap(),
))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response.into_body()).await;
assert!(body["id"].is_string());
assert!(body["model"].is_string());
assert_eq!(body["stop_reason"], "end_turn");
assert!(body["message"].is_object());
assert!(body["usage"]["input_tokens"].is_number());
assert!(body["usage"]["output_tokens"].is_number());
}
#[tokio::test]
async fn create_completion_streaming_returns_sse() {
let state = create_app_state_with_options(
test_db().await,
test_registry,
true,
5,
arc_workflows::git::GitAuthor::default(),
);
let app = build_router(state, AuthMode::Disabled);
let req = Request::builder()
.method("POST")
.uri("/completions")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"messages": [{"role": "user", "content": [{"kind": "text", "data": "Hello"}]}],
"stream": true
}))
.unwrap(),
))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"text/event-stream"
);
}
#[tokio::test]
async fn create_completion_missing_messages_returns_422() {
let app = test_app_with(test_db().await);
let req = Request::builder()
.method("POST")
.uri("/completions")
.header("content-type", "application/json")
.body(Body::from("{}"))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
}