use std::sync::Arc;
use std::time::SystemTime;
use axum::Json;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode};
use crate::AppState;
use crate::cm_api_contract::error_codes;
use crate::cm_internal::tool_jobs::registry::{CancelOutcome, GetOutcome};
use crate::cm_internal::tool_jobs::types::{JobRecord, JobStatus};
use crate::web::http_types::chat::ApiError;
use crate::web::http_types::tool_jobs::{ToolJobCancelResponseBody, ToolJobStatusResponseBody};
const X_WORKSPACE_ROOT: &str = "x-workspace-root";
type ApiErr = (StatusCode, Json<ApiError>);
fn err(status: StatusCode, code: &'static str, message: impl Into<String>) -> ApiErr {
(status, Json(ApiError::new(code, message)))
}
fn normalize_workspace_root(p: &str) -> String {
let mut s = p.trim().to_string();
while s.len() > 1 && s.ends_with('/') {
s.pop();
}
s
}
fn check_workspace_ownership(rec: &JobRecord, headers: &HeaderMap) -> Result<(), ApiErr> {
let Some(hdr) = headers.get(X_WORKSPACE_ROOT) else {
return Ok(());
};
let Ok(root) = hdr.to_str() else {
return Err(err(
StatusCode::FORBIDDEN,
error_codes::JOB_OWNERSHIP_MISMATCH,
"无法解析 X-Workspace-Root 请求头",
));
};
let root = root.trim();
if root.is_empty() {
return Ok(());
}
let rec_root = normalize_workspace_root(&rec.workspace.to_string_lossy());
if normalize_workspace_root(root) != rec_root {
return Err(err(
StatusCode::FORBIDDEN,
error_codes::JOB_OWNERSHIP_MISMATCH,
"请求的 X-Workspace-Root 与该后台任务的归属工作区不符",
));
}
Ok(())
}
pub(crate) async fn tool_job_status_handler(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
headers: HeaderMap,
) -> Result<Json<ToolJobStatusResponseBody>, ApiErr> {
let registry = &state.aux.tool_job_registry;
let rec = match registry.get_checked(&id, SystemTime::now()) {
GetOutcome::Found(rec) => rec,
GetOutcome::Expired => {
return Err(err(
StatusCode::GONE,
error_codes::JOB_EXPIRED,
"后台任务已过保留时长(TTL+宽限)并被清理",
));
}
GetOutcome::NotFound => {
return Err(err(
StatusCode::NOT_FOUND,
error_codes::JOB_NOT_FOUND,
"后台任务不存在或从未创建",
));
}
};
check_workspace_ownership(&rec, &headers)?;
let terminal = rec.status.is_terminal();
let outcome = rec.outcome.as_ref();
let stdout = outcome.map(|o| String::from_utf8_lossy(&o.stdout).into_owned());
let stderr = outcome.map(|o| String::from_utf8_lossy(&o.stderr).into_owned());
let summary = if terminal {
crate::cm_tools::tools::summarize_tool_call("run_command", &rec.args_json)
} else {
None
};
Ok(Json(ToolJobStatusResponseBody {
tool_job_id: rec.id,
status: rec.status.as_str().to_string(),
exit_code: outcome.and_then(|o| o.exit_code),
stdout,
stderr,
summary,
error_code: outcome.and_then(|o| o.error_code.clone()),
failure_category: outcome.and_then(|o| o.failure_category.clone()),
workspace_changed: rec.workspace_changed,
result_version: 1,
}))
}
pub(crate) async fn tool_job_cancel_handler(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
headers: HeaderMap,
) -> Result<Json<ToolJobCancelResponseBody>, (StatusCode, Json<serde_json::Value>)> {
let registry = &state.aux.tool_job_registry;
let rec = match registry.get_checked(&id, SystemTime::now()) {
GetOutcome::Found(rec) => rec,
GetOutcome::Expired => {
return Err((
StatusCode::GONE,
Json(serde_json::json!({
"code": error_codes::JOB_EXPIRED,
"message": "后台任务已过保留时长(TTL+宽限)并被清理",
})),
));
}
GetOutcome::NotFound => {
return Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"code": error_codes::JOB_NOT_FOUND,
"message": "后台任务不存在或从未创建",
})),
));
}
};
if let Err((status, Json(api))) = check_workspace_ownership(&rec, &headers) {
return Err((
status,
Json(serde_json::json!({ "code": api.code, "message": api.message })),
));
}
cancel_response(id.clone(), registry.cancel(&id))
}
fn cancel_response(
id: String,
outcome: CancelOutcome,
) -> Result<Json<ToolJobCancelResponseBody>, (StatusCode, Json<serde_json::Value>)> {
match outcome {
CancelOutcome::Cancelled => Ok(Json(ToolJobCancelResponseBody {
tool_job_id: id,
status: "cancelled".to_string(),
})),
CancelOutcome::AlreadyFinished(JobStatus::Cancelled) => Ok(Json(ToolJobCancelResponseBody {
tool_job_id: id,
status: "cancelled".to_string(),
})),
CancelOutcome::AlreadyFinished(status) => Err((
StatusCode::CONFLICT,
Json(serde_json::json!({ "status": status.as_str() })),
)),
CancelOutcome::Expired => Err((
StatusCode::GONE,
Json(serde_json::json!({
"code": error_codes::JOB_EXPIRED,
"message": "后台任务已过保留时长(TTL+宽限)并被清理",
})),
)),
CancelOutcome::NotFound => Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"code": error_codes::JOB_NOT_FOUND,
"message": "后台任务不存在或从未创建",
})),
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn record(workspace: &str) -> JobRecord {
JobRecord {
id: "tooljob_test".to_string(),
workspace: PathBuf::from(workspace),
source_turn_job_id: None,
status: crate::cm_internal::tool_jobs::types::JobStatus::Queued,
created_at: SystemTime::now(),
finished_at: None,
cancel_requested: false,
cancel_flag: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
spawn: crate::cm_internal::tool_jobs::JobSpawn {
program: "true".to_string(),
args: Vec::new(),
cwd: PathBuf::from("/"),
extra_env: Vec::new(),
wall: std::time::Duration::from_secs(10),
max_output_len: 1024,
},
args_json: r#"{"command":"true"}"#.to_string(),
workspace_changed: false,
outcome: None,
}
}
fn headers_with(root: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert(X_WORKSPACE_ROOT, root.parse().expect("header"));
h
}
#[test]
fn ownership_passes_without_header_or_matching_root() {
let rec = record("/home/user/project");
assert!(check_workspace_ownership(&rec, &HeaderMap::new()).is_ok());
assert!(check_workspace_ownership(&rec, &headers_with("/home/user/project")).is_ok());
assert!(check_workspace_ownership(&rec, &headers_with(" /home/user/project/ ")).is_ok());
assert!(check_workspace_ownership(&rec, &headers_with(" ")).is_ok());
}
#[test]
fn ownership_rejects_mismatched_root() {
let rec = record("/home/user/project");
assert!(check_workspace_ownership(&rec, &headers_with("/home/user/other")).is_err());
assert!(check_workspace_ownership(&rec, &headers_with("/home/user/project/sub")).is_err());
}
fn cancel_outcome_body(
id: &str,
outcome: CancelOutcome,
) -> (Option<ToolJobCancelResponseBody>, StatusCode, serde_json::Value) {
match cancel_response(id.to_string(), outcome) {
Ok(Json(body)) => (Some(body), StatusCode::OK, serde_json::Value::Null),
Err((status, Json(body))) => (None, status, body),
}
}
#[test]
fn cancel_response_cancelled_is_idempotent_200() {
let (body, status, _) = cancel_outcome_body("tooljob_x", CancelOutcome::Cancelled);
assert_eq!(status, StatusCode::OK);
assert_eq!(body.expect("body").status, "cancelled");
let (body, status, _) = cancel_outcome_body(
"tooljob_x",
CancelOutcome::AlreadyFinished(JobStatus::Cancelled),
);
assert_eq!(status, StatusCode::OK);
assert_eq!(body.expect("body").status, "cancelled");
}
#[test]
fn cancel_response_other_terminal_is_409_not_overwrite() {
for status in [
JobStatus::Succeeded,
JobStatus::Failed,
JobStatus::TimedOut,
] {
let (body, status_code, body_json) =
cancel_outcome_body("tooljob_x", CancelOutcome::AlreadyFinished(status));
assert!(body.is_none());
assert_eq!(status_code, StatusCode::CONFLICT);
assert_eq!(body_json["status"], status.as_str());
}
}
#[test]
fn cancel_response_expired_410_and_not_found_404() {
let (_, status, body) = cancel_outcome_body("tooljob_x", CancelOutcome::Expired);
assert_eq!(status, StatusCode::GONE);
assert_eq!(body["code"], error_codes::JOB_EXPIRED);
let (_, status, body) = cancel_outcome_body("tooljob_x", CancelOutcome::NotFound);
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["code"], error_codes::JOB_NOT_FOUND);
}
}