mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 00:59:33 +00:00
feat(web): DB-backed Jobs tab + scheduler-dispatched local jobs (#436)
* feat(web): DB-backed Jobs tab, scheduler-dispatched local jobs, remove active-jobs-bar - Remove active-jobs-bar UI element (HTML, CSS, JS polling) - Move job handlers from server.rs to handlers/jobs.rs - Remove user_id scoping (single-user gateway) - Add list_agent_jobs() and agent_job_summary() to Database trait (both postgres and libsql backends) for non-sandbox job visibility - Wire SchedulerSlot into CreateJobTool so execute_local dispatches via scheduler (persists to DB + spawns worker) instead of creating phantom ContextManager-only jobs - Update /status and /list slash commands to read from DB for consistency with Jobs tab - Fix worker mark_completed: skip if already terminal or stuck - Add agent job cancel via DB update in both web handler and slash cmd - Add Stuck → Completed guard with tracing in worker completion path Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * fix: address PR review comments - Log warning when get_context fails in worker completion path - Extract duplicated status-counting logic into AgentJobSummary::add_count() helper, used by both postgres and libsql backends Co-Authored-By: Claude Sonnet 4.6 <[email protected]> --------- Co-authored-by: Claude Sonnet 4.6 <[email protected]> Co-authored-by: Nick Pismenkov <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
Nick Pismenkov
parent
afb49597ac
commit
6481448d50
@@ -1,5 +1,6 @@
|
||||
//! Job and sandbox API handlers.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
@@ -21,32 +22,55 @@ pub async fn jobs_list_handler(
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
// Fetch sandbox jobs scoped to the authenticated user.
|
||||
let sandbox_jobs = store
|
||||
.list_sandbox_jobs_for_user(&state.user_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let mut jobs: Vec<JobInfo> = Vec::new();
|
||||
let mut seen_ids: HashSet<Uuid> = HashSet::new();
|
||||
|
||||
// Scope jobs to the authenticated user.
|
||||
let mut jobs: Vec<JobInfo> = sandbox_jobs
|
||||
.iter()
|
||||
.filter(|j| j.user_id == state.user_id)
|
||||
.map(|j| {
|
||||
let ui_state = match j.status.as_str() {
|
||||
"creating" => "pending",
|
||||
"running" => "in_progress",
|
||||
s => s,
|
||||
};
|
||||
JobInfo {
|
||||
id: j.id,
|
||||
title: j.task.clone(),
|
||||
state: ui_state.to_string(),
|
||||
user_id: j.user_id.clone(),
|
||||
created_at: j.created_at.to_rfc3339(),
|
||||
started_at: j.started_at.map(|dt| dt.to_rfc3339()),
|
||||
// Fetch sandbox jobs from database.
|
||||
match store.list_sandbox_jobs().await {
|
||||
Ok(sandbox_jobs) => {
|
||||
for j in &sandbox_jobs {
|
||||
let ui_state = match j.status.as_str() {
|
||||
"creating" => "pending",
|
||||
"running" => "in_progress",
|
||||
s => s,
|
||||
};
|
||||
seen_ids.insert(j.id);
|
||||
jobs.push(JobInfo {
|
||||
id: j.id,
|
||||
title: j.task.clone(),
|
||||
state: ui_state.to_string(),
|
||||
user_id: j.user_id.clone(),
|
||||
created_at: j.created_at.to_rfc3339(),
|
||||
started_at: j.started_at.map(|dt| dt.to_rfc3339()),
|
||||
});
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to list sandbox jobs: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch agent (non-sandbox) jobs from database, deduplicating by ID.
|
||||
match store.list_agent_jobs().await {
|
||||
Ok(agent_jobs) => {
|
||||
for j in &agent_jobs {
|
||||
if seen_ids.contains(&j.id) {
|
||||
continue;
|
||||
}
|
||||
jobs.push(JobInfo {
|
||||
id: j.id,
|
||||
title: j.title.clone(),
|
||||
state: j.status.clone(),
|
||||
user_id: j.user_id.clone(),
|
||||
created_at: j.created_at.to_rfc3339(),
|
||||
started_at: j.started_at.map(|dt| dt.to_rfc3339()),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to list agent jobs: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Most recent first.
|
||||
jobs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
@@ -62,18 +86,49 @@ pub async fn jobs_summary_handler(
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let s = store
|
||||
.sandbox_job_summary_for_user(&state.user_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let mut total = 0;
|
||||
let mut pending = 0;
|
||||
let mut in_progress = 0;
|
||||
let mut completed = 0;
|
||||
let mut failed = 0;
|
||||
let mut stuck = 0;
|
||||
|
||||
// Sandbox job counts.
|
||||
match store.sandbox_job_summary().await {
|
||||
Ok(s) => {
|
||||
total += s.total;
|
||||
pending += s.creating;
|
||||
in_progress += s.running;
|
||||
completed += s.completed;
|
||||
failed += s.failed + s.interrupted;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to fetch sandbox job summary: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Agent job counts.
|
||||
match store.agent_job_summary().await {
|
||||
Ok(s) => {
|
||||
total += s.total;
|
||||
pending += s.pending;
|
||||
in_progress += s.in_progress;
|
||||
completed += s.completed;
|
||||
failed += s.failed;
|
||||
stuck += s.stuck;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to fetch agent job summary: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(JobSummaryResponse {
|
||||
total: s.total,
|
||||
pending: s.creating,
|
||||
in_progress: s.running,
|
||||
completed: s.completed,
|
||||
failed: s.failed + s.interrupted,
|
||||
stuck: 0,
|
||||
total,
|
||||
pending,
|
||||
in_progress,
|
||||
completed,
|
||||
failed,
|
||||
stuck,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -81,16 +136,16 @@ pub async fn jobs_detail_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<JobDetailResponse>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let job_id = Uuid::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
// Try sandbox job from DB first, scoped to the authenticated user.
|
||||
if let Some(ref store) = state.store
|
||||
&& let Ok(Some(job)) = store.get_sandbox_job(job_id).await
|
||||
{
|
||||
if job.user_id != state.user_id {
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
// Try sandbox job from DB first.
|
||||
if let Ok(Some(job)) = store.get_sandbox_job(job_id).await {
|
||||
let browse_id = std::path::Path::new(&job.project_dir)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
@@ -146,6 +201,30 @@ pub async fn jobs_detail_handler(
|
||||
}));
|
||||
}
|
||||
|
||||
// Fall back to agent job from DB.
|
||||
if let Ok(Some(ctx)) = store.get_job(job_id).await {
|
||||
let elapsed_secs = ctx.started_at.map(|start| {
|
||||
let end = ctx.completed_at.unwrap_or_else(chrono::Utc::now);
|
||||
(end - start).num_seconds().max(0) as u64
|
||||
});
|
||||
|
||||
return Ok(Json(JobDetailResponse {
|
||||
id: ctx.job_id,
|
||||
title: ctx.title.clone(),
|
||||
description: ctx.description.clone(),
|
||||
state: ctx.state.to_string(),
|
||||
user_id: ctx.user_id.clone(),
|
||||
created_at: ctx.created_at.to_rfc3339(),
|
||||
started_at: ctx.started_at.map(|dt| dt.to_rfc3339()),
|
||||
completed_at: ctx.completed_at.map(|dt| dt.to_rfc3339()),
|
||||
elapsed_secs,
|
||||
project_dir: None,
|
||||
browse_url: None,
|
||||
job_mode: None,
|
||||
transitions: Vec::new(),
|
||||
}));
|
||||
}
|
||||
|
||||
Err((StatusCode::NOT_FOUND, "Job not found".to_string()))
|
||||
}
|
||||
|
||||
@@ -156,13 +235,10 @@ pub async fn jobs_cancel_handler(
|
||||
let job_id = Uuid::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
// Try sandbox job cancellation, scoped to the authenticated user.
|
||||
// Try sandbox job cancellation.
|
||||
if let Some(ref store) = state.store
|
||||
&& let Ok(Some(job)) = store.get_sandbox_job(job_id).await
|
||||
{
|
||||
if job.user_id != state.user_id {
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
if job.status == "running" || job.status == "creating" {
|
||||
// Stop the container if we have a job manager.
|
||||
if let Some(ref jm) = state.job_manager
|
||||
@@ -188,6 +264,26 @@ pub async fn jobs_cancel_handler(
|
||||
})));
|
||||
}
|
||||
|
||||
// Fall back to agent job cancellation via DB status update.
|
||||
if let Some(ref store) = state.store
|
||||
&& let Ok(Some(job)) = store.get_job(job_id).await
|
||||
{
|
||||
if job.state.is_active() {
|
||||
store
|
||||
.update_job_status(
|
||||
job_id,
|
||||
crate::context::JobState::Cancelled,
|
||||
Some("Cancelled by user"),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
}
|
||||
return Ok(Json(serde_json::json!({
|
||||
"status": "cancelled",
|
||||
"job_id": job_id,
|
||||
})));
|
||||
}
|
||||
|
||||
Err((StatusCode::NOT_FOUND, "Job not found".to_string()))
|
||||
}
|
||||
|
||||
@@ -213,11 +309,6 @@ pub async fn jobs_restart_handler(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?;
|
||||
|
||||
// Scope to the authenticated user.
|
||||
if old_job.user_id != state.user_id {
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
|
||||
if old_job.status != "interrupted" && old_job.status != "failed" {
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
@@ -310,16 +401,6 @@ pub async fn jobs_prompt_handler(
|
||||
.parse()
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
// Verify user owns this job.
|
||||
if let Some(ref store) = state.store
|
||||
&& !store
|
||||
.sandbox_job_belongs_to_user(job_id, &state.user_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
|
||||
let content = body
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -358,15 +439,6 @@ pub async fn jobs_events_handler(
|
||||
.parse()
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
// Verify user owns this job.
|
||||
if !store
|
||||
.sandbox_job_belongs_to_user(job_id, &state.user_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
|
||||
let events = store
|
||||
.list_job_events(job_id, None)
|
||||
.await
|
||||
@@ -416,11 +488,6 @@ pub async fn job_files_list_handler(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?;
|
||||
|
||||
// Verify user owns this job.
|
||||
if job.user_id != state.user_id {
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
|
||||
let base = std::path::PathBuf::from(&job.project_dir);
|
||||
let rel_path = query.path.as_deref().unwrap_or("");
|
||||
let target = base.join(rel_path);
|
||||
@@ -484,11 +551,6 @@ pub async fn job_files_read_handler(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?;
|
||||
|
||||
// Verify user owns this job.
|
||||
if job.user_id != state.user_id {
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
|
||||
let path = query.path.as_deref().ok_or((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"path parameter required".to_string(),
|
||||
|
||||
+6
-508
@@ -29,6 +29,11 @@ use crate::agent::SessionManager;
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::channels::IncomingMessage;
|
||||
use crate::channels::web::auth::{AuthState, auth_middleware};
|
||||
use crate::channels::web::handlers::jobs::{
|
||||
job_files_list_handler, job_files_read_handler, jobs_cancel_handler, jobs_detail_handler,
|
||||
jobs_events_handler, jobs_list_handler, jobs_prompt_handler, jobs_restart_handler,
|
||||
jobs_summary_handler,
|
||||
};
|
||||
use crate::channels::web::handlers::skills::{
|
||||
skills_install_handler, skills_list_handler, skills_remove_handler, skills_search_handler,
|
||||
};
|
||||
@@ -1116,514 +1121,7 @@ async fn memory_search_handler(
|
||||
Ok(Json(MemorySearchResponse { results: hits }))
|
||||
}
|
||||
|
||||
// --- Jobs handlers ---
|
||||
|
||||
async fn jobs_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Result<Json<JobListResponse>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
// Fetch sandbox jobs scoped to the authenticated user.
|
||||
let sandbox_jobs = store
|
||||
.list_sandbox_jobs_for_user(&state.user_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Scope jobs to the authenticated user.
|
||||
let mut jobs: Vec<JobInfo> = sandbox_jobs
|
||||
.iter()
|
||||
.filter(|j| j.user_id == state.user_id)
|
||||
.map(|j| {
|
||||
let ui_state = match j.status.as_str() {
|
||||
"creating" => "pending",
|
||||
"running" => "in_progress",
|
||||
s => s,
|
||||
};
|
||||
JobInfo {
|
||||
id: j.id,
|
||||
title: j.task.clone(),
|
||||
state: ui_state.to_string(),
|
||||
user_id: j.user_id.clone(),
|
||||
created_at: j.created_at.to_rfc3339(),
|
||||
started_at: j.started_at.map(|dt| dt.to_rfc3339()),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Most recent first.
|
||||
jobs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
|
||||
Ok(Json(JobListResponse { jobs }))
|
||||
}
|
||||
|
||||
async fn jobs_summary_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Result<Json<JobSummaryResponse>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let s = store
|
||||
.sandbox_job_summary_for_user(&state.user_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(JobSummaryResponse {
|
||||
total: s.total,
|
||||
pending: s.creating,
|
||||
in_progress: s.running,
|
||||
completed: s.completed,
|
||||
failed: s.failed + s.interrupted,
|
||||
stuck: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn jobs_detail_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<JobDetailResponse>, (StatusCode, String)> {
|
||||
let job_id = Uuid::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
// Try sandbox job from DB first, scoped to the authenticated user.
|
||||
if let Some(ref store) = state.store
|
||||
&& let Ok(Some(job)) = store.get_sandbox_job(job_id).await
|
||||
{
|
||||
if job.user_id != state.user_id {
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
let browse_id = std::path::Path::new(&job.project_dir)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| job.id.to_string());
|
||||
|
||||
let ui_state = match job.status.as_str() {
|
||||
"creating" => "pending",
|
||||
"running" => "in_progress",
|
||||
s => s,
|
||||
};
|
||||
|
||||
let elapsed_secs = job.started_at.map(|start| {
|
||||
let end = job.completed_at.unwrap_or_else(chrono::Utc::now);
|
||||
(end - start).num_seconds().max(0) as u64
|
||||
});
|
||||
|
||||
// Synthesize transitions from timestamps.
|
||||
let mut transitions = Vec::new();
|
||||
if let Some(started) = job.started_at {
|
||||
transitions.push(TransitionInfo {
|
||||
from: "creating".to_string(),
|
||||
to: "running".to_string(),
|
||||
timestamp: started.to_rfc3339(),
|
||||
reason: None,
|
||||
});
|
||||
}
|
||||
if let Some(completed) = job.completed_at {
|
||||
transitions.push(TransitionInfo {
|
||||
from: "running".to_string(),
|
||||
to: job.status.clone(),
|
||||
timestamp: completed.to_rfc3339(),
|
||||
reason: job.failure_reason.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(Json(JobDetailResponse {
|
||||
id: job.id,
|
||||
title: job.task.clone(),
|
||||
description: String::new(),
|
||||
state: ui_state.to_string(),
|
||||
user_id: job.user_id.clone(),
|
||||
created_at: job.created_at.to_rfc3339(),
|
||||
started_at: job.started_at.map(|dt| dt.to_rfc3339()),
|
||||
completed_at: job.completed_at.map(|dt| dt.to_rfc3339()),
|
||||
elapsed_secs,
|
||||
project_dir: Some(job.project_dir.clone()),
|
||||
browse_url: Some(format!("/projects/{}/", browse_id)),
|
||||
job_mode: {
|
||||
let mode = store.get_sandbox_job_mode(job.id).await.ok().flatten();
|
||||
mode.filter(|m| m != "worker")
|
||||
},
|
||||
transitions,
|
||||
}));
|
||||
}
|
||||
|
||||
Err((StatusCode::NOT_FOUND, "Job not found".to_string()))
|
||||
}
|
||||
|
||||
async fn jobs_cancel_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let job_id = Uuid::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
// Try sandbox job cancellation, scoped to the authenticated user.
|
||||
if let Some(ref store) = state.store
|
||||
&& let Ok(Some(job)) = store.get_sandbox_job(job_id).await
|
||||
{
|
||||
if job.user_id != state.user_id {
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
if job.status == "running" || job.status == "creating" {
|
||||
// Stop the container if we have a job manager.
|
||||
if let Some(ref jm) = state.job_manager
|
||||
&& let Err(e) = jm.stop_job(job_id).await
|
||||
{
|
||||
tracing::warn!(job_id = %job_id, error = %e, "Failed to stop container during cancellation");
|
||||
}
|
||||
store
|
||||
.update_sandbox_job_status(
|
||||
job_id,
|
||||
"failed",
|
||||
Some(false),
|
||||
Some("Cancelled by user"),
|
||||
None,
|
||||
Some(chrono::Utc::now()),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
}
|
||||
return Ok(Json(serde_json::json!({
|
||||
"status": "cancelled",
|
||||
"job_id": job_id,
|
||||
})));
|
||||
}
|
||||
|
||||
Err((StatusCode::NOT_FOUND, "Job not found".to_string()))
|
||||
}
|
||||
|
||||
async fn jobs_restart_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
let jm = state.job_manager.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Sandbox not enabled".to_string(),
|
||||
))?;
|
||||
|
||||
let old_job_id = Uuid::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
let old_job = store
|
||||
.get_sandbox_job(old_job_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?;
|
||||
|
||||
// Scope to the authenticated user.
|
||||
if old_job.user_id != state.user_id {
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
|
||||
if old_job.status != "interrupted" && old_job.status != "failed" {
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
format!("Cannot restart job in state '{}'", old_job.status),
|
||||
));
|
||||
}
|
||||
|
||||
// Create a new job with the same task and project_dir.
|
||||
let new_job_id = Uuid::new_v4();
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let record = crate::history::SandboxJobRecord {
|
||||
id: new_job_id,
|
||||
task: old_job.task.clone(),
|
||||
status: "creating".to_string(),
|
||||
user_id: old_job.user_id.clone(),
|
||||
project_dir: old_job.project_dir.clone(),
|
||||
success: None,
|
||||
failure_reason: None,
|
||||
created_at: now,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
credential_grants_json: old_job.credential_grants_json.clone(),
|
||||
};
|
||||
store
|
||||
.save_sandbox_job(&record)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Look up the original job's mode so the restart uses the same mode.
|
||||
let mode = match store.get_sandbox_job_mode(old_job_id).await {
|
||||
Ok(Some(m)) if m == "claude_code" => crate::orchestrator::job_manager::JobMode::ClaudeCode,
|
||||
_ => crate::orchestrator::job_manager::JobMode::Worker,
|
||||
};
|
||||
|
||||
// Restore credential grants from the original job so the restarted container
|
||||
// has access to the same secrets.
|
||||
let credential_grants: Vec<crate::orchestrator::auth::CredentialGrant> =
|
||||
serde_json::from_str(&old_job.credential_grants_json).unwrap_or_else(|e| {
|
||||
tracing::warn!(
|
||||
job_id = %old_job.id,
|
||||
"Failed to deserialize credential grants from stored job: {}. \
|
||||
Restarted job will have no credentials.",
|
||||
e
|
||||
);
|
||||
vec![]
|
||||
});
|
||||
|
||||
let project_dir = std::path::PathBuf::from(&old_job.project_dir);
|
||||
let _token = jm
|
||||
.create_job(
|
||||
new_job_id,
|
||||
&old_job.task,
|
||||
Some(project_dir),
|
||||
mode,
|
||||
credential_grants,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to create container: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
store
|
||||
.update_sandbox_job_status(new_job_id, "running", None, None, Some(now), None)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"status": "restarted",
|
||||
"old_job_id": old_job_id,
|
||||
"new_job_id": new_job_id,
|
||||
})))
|
||||
}
|
||||
|
||||
// --- Claude Code prompt and events handlers ---
|
||||
|
||||
/// Submit a follow-up prompt to a running Claude Code sandbox job.
|
||||
async fn jobs_prompt_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let prompt_queue = state.prompt_queue.as_ref().ok_or((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"Claude Code not configured".to_string(),
|
||||
))?;
|
||||
|
||||
let job_id: uuid::Uuid = id
|
||||
.parse()
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
// Verify user owns this job.
|
||||
if let Some(ref store) = state.store
|
||||
&& !store
|
||||
.sandbox_job_belongs_to_user(job_id, &state.user_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
|
||||
let content = body
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Missing 'content' field".to_string(),
|
||||
))?
|
||||
.to_string();
|
||||
|
||||
let done = body.get("done").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
|
||||
let prompt = crate::orchestrator::api::PendingPrompt { content, done };
|
||||
|
||||
{
|
||||
let mut queue = prompt_queue.lock().await;
|
||||
queue.entry(job_id).or_default().push_back(prompt);
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"status": "queued",
|
||||
"job_id": job_id.to_string(),
|
||||
})))
|
||||
}
|
||||
|
||||
/// Load persisted job events for a job (for history replay on page open).
|
||||
async fn jobs_events_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let job_id: uuid::Uuid = id
|
||||
.parse()
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
// Verify user owns this job.
|
||||
if !store
|
||||
.sandbox_job_belongs_to_user(job_id, &state.user_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
|
||||
let events = store
|
||||
.list_job_events(job_id, None)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let events_json: Vec<serde_json::Value> = events
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
serde_json::json!({
|
||||
"id": e.id,
|
||||
"event_type": e.event_type,
|
||||
"data": e.data,
|
||||
"created_at": e.created_at.to_rfc3339(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"job_id": job_id.to_string(),
|
||||
"events": events_json,
|
||||
})))
|
||||
}
|
||||
|
||||
// --- Project file handlers for sandbox jobs ---
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FilePathQuery {
|
||||
path: Option<String>,
|
||||
}
|
||||
|
||||
async fn job_files_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
Query(query): Query<FilePathQuery>,
|
||||
) -> Result<Json<ProjectFilesResponse>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let job_id = Uuid::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
let job = store
|
||||
.get_sandbox_job(job_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?;
|
||||
|
||||
// Verify user owns this job.
|
||||
if job.user_id != state.user_id {
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
|
||||
let base = std::path::PathBuf::from(&job.project_dir);
|
||||
let rel_path = query.path.as_deref().unwrap_or("");
|
||||
let target = base.join(rel_path);
|
||||
|
||||
// Path traversal guard.
|
||||
let canonical = target
|
||||
.canonicalize()
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Path not found".to_string()))?;
|
||||
let base_canonical = base
|
||||
.canonicalize()
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Project dir not found".to_string()))?;
|
||||
if !canonical.starts_with(&base_canonical) {
|
||||
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
|
||||
}
|
||||
|
||||
let mut entries = Vec::new();
|
||||
let mut read_dir = tokio::fs::read_dir(&canonical)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Cannot read directory".to_string()))?;
|
||||
|
||||
while let Ok(Some(entry)) = read_dir.next_entry().await {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let is_dir = entry
|
||||
.file_type()
|
||||
.await
|
||||
.map(|ft| ft.is_dir())
|
||||
.unwrap_or(false);
|
||||
let rel = if rel_path.is_empty() {
|
||||
name.clone()
|
||||
} else {
|
||||
format!("{}/{}", rel_path, name)
|
||||
};
|
||||
entries.push(ProjectFileEntry {
|
||||
name,
|
||||
path: rel,
|
||||
is_dir,
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name)));
|
||||
|
||||
Ok(Json(ProjectFilesResponse { entries }))
|
||||
}
|
||||
|
||||
async fn job_files_read_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
Query(query): Query<FilePathQuery>,
|
||||
) -> Result<Json<ProjectFileReadResponse>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let job_id = Uuid::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?;
|
||||
|
||||
let job = store
|
||||
.get_sandbox_job(job_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?;
|
||||
|
||||
// Verify user owns this job.
|
||||
if job.user_id != state.user_id {
|
||||
return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
|
||||
}
|
||||
|
||||
let path = query.path.as_deref().ok_or((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"path parameter required".to_string(),
|
||||
))?;
|
||||
|
||||
let base = std::path::PathBuf::from(&job.project_dir);
|
||||
let file_path = base.join(path);
|
||||
|
||||
let canonical = file_path
|
||||
.canonicalize()
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "File not found".to_string()))?;
|
||||
let base_canonical = base
|
||||
.canonicalize()
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Project dir not found".to_string()))?;
|
||||
if !canonical.starts_with(&base_canonical) {
|
||||
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
|
||||
}
|
||||
|
||||
let content = tokio::fs::read_to_string(&canonical)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Cannot read file".to_string()))?;
|
||||
|
||||
Ok(Json(ProjectFileReadResponse {
|
||||
path: path.to_string(),
|
||||
content,
|
||||
}))
|
||||
}
|
||||
|
||||
// Job handlers moved to handlers/jobs.rs
|
||||
// --- Logs handlers ---
|
||||
|
||||
async fn logs_events_handler(
|
||||
|
||||
Reference in New Issue
Block a user