use std::fmt;
use std::io::Read;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::{Duration, Instant};
use soma_studio_core::{
AppConfig, WorkspaceTaskId, WorkspaceTaskRunRequest, WorkspaceTaskRunResponse,
};
use crate::workspace::{WorkspaceError, WorkspaceErrorKind};
const WORKSPACE_TASK_SHORT_TIMEOUT: Duration = Duration::from_secs(5);
const WORKSPACE_TASK_LONG_TIMEOUT: Duration = Duration::from_secs(120);
const WORKSPACE_TASK_MAX_OUTPUT_BYTES: usize = 64 * 1024;
pub type WorkspaceTaskResult<T> = Result<T, WorkspaceTaskError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkspaceTaskErrorKind {
InvalidRequest,
PathNotFound,
RunNotFound,
Busy,
Upstream,
Internal,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceTaskError {
kind: WorkspaceTaskErrorKind,
message: String,
}
impl WorkspaceTaskError {
pub fn invalid_request(message: impl Into<String>) -> Self {
Self::new(WorkspaceTaskErrorKind::InvalidRequest, message)
}
pub fn path_not_found(message: impl Into<String>) -> Self {
Self::new(WorkspaceTaskErrorKind::PathNotFound, message)
}
pub fn run_not_found(message: impl Into<String>) -> Self {
Self::new(WorkspaceTaskErrorKind::RunNotFound, message)
}
pub fn busy(message: impl Into<String>) -> Self {
Self::new(WorkspaceTaskErrorKind::Busy, message)
}
pub fn upstream(message: impl Into<String>) -> Self {
Self::new(WorkspaceTaskErrorKind::Upstream, message)
}
pub fn internal(message: impl Into<String>) -> Self {
Self::new(WorkspaceTaskErrorKind::Internal, message)
}
pub fn kind(&self) -> WorkspaceTaskErrorKind {
self.kind
}
pub fn message(&self) -> &str {
&self.message
}
pub fn api_code(&self) -> &'static str {
match self.kind() {
WorkspaceTaskErrorKind::InvalidRequest => "invalid_request",
WorkspaceTaskErrorKind::PathNotFound => "workspace_task_path_not_found",
WorkspaceTaskErrorKind::RunNotFound => "workspace_task_run_not_found",
WorkspaceTaskErrorKind::Busy => "workspace_task_busy",
WorkspaceTaskErrorKind::Upstream => "upstream_error",
WorkspaceTaskErrorKind::Internal => "internal_error",
}
}
pub fn status_code(&self) -> u16 {
match self.kind() {
WorkspaceTaskErrorKind::InvalidRequest => 400,
WorkspaceTaskErrorKind::PathNotFound | WorkspaceTaskErrorKind::RunNotFound => 404,
WorkspaceTaskErrorKind::Busy => 409,
WorkspaceTaskErrorKind::Upstream => 502,
WorkspaceTaskErrorKind::Internal => 500,
}
}
fn new(kind: WorkspaceTaskErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
}
impl fmt::Display for WorkspaceTaskError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for WorkspaceTaskError {}
impl From<WorkspaceError> for WorkspaceTaskError {
fn from(error: WorkspaceError) -> Self {
match error.kind() {
WorkspaceErrorKind::InvalidRequest
| WorkspaceErrorKind::Conflict
| WorkspaceErrorKind::FileStale
| WorkspaceErrorKind::PreviewMissing => Self::invalid_request(error.to_string()),
WorkspaceErrorKind::NotFound => Self::path_not_found(error.to_string()),
WorkspaceErrorKind::Upstream => Self::upstream(error.to_string()),
WorkspaceErrorKind::Internal => Self::internal(error.to_string()),
}
}
}
pub fn workspace_task_command_label(task_id: WorkspaceTaskId) -> &'static str {
match task_id {
WorkspaceTaskId::GitStatus => "git status --short --branch",
WorkspaceTaskId::GitDiff => "git diff --no-ext-diff",
WorkspaceTaskId::CargoCheck => "cargo check --workspace",
WorkspaceTaskId::NpmCheck => "npm --prefix web run check",
}
}
pub fn workspace_task_max_output_bytes() -> usize {
WORKSPACE_TASK_MAX_OUTPUT_BYTES
}
pub fn workspace_task_response_path(
config: &AppConfig,
input: &WorkspaceTaskRunRequest,
) -> WorkspaceTaskResult<String> {
let relative_path = workspace_task_relative_path(config, input.task_id, input.path.as_deref())?;
Ok(response_path(&relative_path))
}
pub fn run_workspace_task(
config: &AppConfig,
input: WorkspaceTaskRunRequest,
) -> WorkspaceTaskResult<WorkspaceTaskRunResponse> {
if !workspace_task_supports_inline(input.task_id) {
return Err(WorkspaceTaskError::invalid_request(format!(
"{} must use /api/workspace/task-runs",
workspace_task_id_label(input.task_id)
)));
}
run_workspace_task_with_cancel(config, input, Arc::new(AtomicBool::new(false)))
}
pub fn run_workspace_task_with_cancel(
config: &AppConfig,
input: WorkspaceTaskRunRequest,
cancel_requested: Arc<AtomicBool>,
) -> WorkspaceTaskResult<WorkspaceTaskRunResponse> {
match input.task_id {
WorkspaceTaskId::GitStatus => {
run_git_status(config, input.path.as_deref(), cancel_requested)
}
WorkspaceTaskId::GitDiff => run_git_diff(config, input.path.as_deref(), cancel_requested),
WorkspaceTaskId::CargoCheck => {
run_cargo_check(config, input.path.as_deref(), cancel_requested)
}
WorkspaceTaskId::NpmCheck => run_npm_check(config, input.path.as_deref(), cancel_requested),
}
}
fn workspace_task_supports_inline(task_id: WorkspaceTaskId) -> bool {
matches!(
task_id,
WorkspaceTaskId::GitStatus | WorkspaceTaskId::GitDiff
)
}
fn run_git_status(
config: &AppConfig,
raw_path: Option<&str>,
cancel_requested: Arc<AtomicBool>,
) -> WorkspaceTaskResult<WorkspaceTaskRunResponse> {
let canonical_root = canonical_workspace_root(config)?;
let relative_path = workspace_task_relative_path(config, WorkspaceTaskId::GitStatus, raw_path)?;
let mut command = Command::new("git");
command
.current_dir(&canonical_root)
.arg("status")
.arg("--short")
.arg("--branch");
if !relative_path.is_empty() {
command.arg("--").arg(&relative_path);
}
run_workspace_command(
command,
WorkspaceTaskId::GitStatus,
relative_path,
cancel_requested,
)
}
struct CommandOutput {
exit_code: Option<i32>,
stdout: CapturedOutput,
stderr: CapturedOutput,
timed_out: bool,
cancelled: bool,
duration: Duration,
}
struct CapturedOutput {
text: String,
truncated: bool,
}
fn run_git_diff(
config: &AppConfig,
raw_path: Option<&str>,
cancel_requested: Arc<AtomicBool>,
) -> WorkspaceTaskResult<WorkspaceTaskRunResponse> {
let canonical_root = canonical_workspace_root(config)?;
let relative_path = workspace_task_relative_path(config, WorkspaceTaskId::GitDiff, raw_path)?;
let mut command = Command::new("git");
command
.current_dir(&canonical_root)
.arg("diff")
.arg("--no-ext-diff");
if !relative_path.is_empty() {
command.arg("--").arg(&relative_path);
}
run_workspace_command(
command,
WorkspaceTaskId::GitDiff,
relative_path,
cancel_requested,
)
}
fn workspace_task_relative_path(
config: &AppConfig,
task_id: WorkspaceTaskId,
raw_path: Option<&str>,
) -> WorkspaceTaskResult<String> {
match task_id {
WorkspaceTaskId::GitStatus | WorkspaceTaskId::GitDiff => {
workspace_task_existing_relative_path(config, raw_path)
}
WorkspaceTaskId::CargoCheck | WorkspaceTaskId::NpmCheck => {
workspace_task_root_only_relative_path(config, task_id, raw_path)
}
}
}
fn workspace_task_existing_relative_path(
config: &AppConfig,
raw_path: Option<&str>,
) -> WorkspaceTaskResult<String> {
match raw_path.map(str::trim).filter(|path| !path.is_empty()) {
Some(path) => {
let (_target, relative_path) =
crate::workspace::resolve_workspace_existing_path(config, path)?;
Ok(relative_path)
}
None => Ok(String::new()),
}
}
fn workspace_task_root_only_relative_path(
config: &AppConfig,
task_id: WorkspaceTaskId,
raw_path: Option<&str>,
) -> WorkspaceTaskResult<String> {
canonical_workspace_root(config)?;
match raw_path
.map(str::trim)
.filter(|path| !path.is_empty() && *path != ".")
{
Some(_) => Err(WorkspaceTaskError::invalid_request(format!(
"{} must run at the workspace root, without path-scoped execution",
workspace_task_id_label(task_id)
))),
None => Ok(String::new()),
}
}
fn run_cargo_check(
config: &AppConfig,
raw_path: Option<&str>,
cancel_requested: Arc<AtomicBool>,
) -> WorkspaceTaskResult<WorkspaceTaskRunResponse> {
let canonical_root = canonical_workspace_root(config)?;
let relative_path =
workspace_task_relative_path(config, WorkspaceTaskId::CargoCheck, raw_path)?;
let mut command = Command::new("cargo");
command
.current_dir(&canonical_root)
.arg("check")
.arg("--workspace");
run_workspace_command(
command,
WorkspaceTaskId::CargoCheck,
relative_path,
cancel_requested,
)
}
fn run_npm_check(
config: &AppConfig,
raw_path: Option<&str>,
cancel_requested: Arc<AtomicBool>,
) -> WorkspaceTaskResult<WorkspaceTaskRunResponse> {
let canonical_root = canonical_workspace_root(config)?;
let relative_path = workspace_task_relative_path(config, WorkspaceTaskId::NpmCheck, raw_path)?;
if !canonical_root.join("web").join("package.json").is_file() {
return Err(WorkspaceTaskError::invalid_request(
"npm_check requires web/package.json under workspace root",
));
}
let mut command = npm_command();
command
.current_dir(&canonical_root)
.arg("--prefix")
.arg("web")
.arg("run")
.arg("check");
run_workspace_command(
command,
WorkspaceTaskId::NpmCheck,
relative_path,
cancel_requested,
)
}
fn run_workspace_command(
mut command: Command,
task_id: WorkspaceTaskId,
relative_path: String,
cancel_requested: Arc<AtomicBool>,
) -> WorkspaceTaskResult<WorkspaceTaskRunResponse> {
let output = run_command_with_timeout(
&mut command,
workspace_task_timeout(task_id),
cancel_requested,
)?;
Ok(WorkspaceTaskRunResponse {
task_id,
path: response_path(&relative_path),
command_label: workspace_task_command_label(task_id).to_string(),
exit_code: output.exit_code,
stdout: output.stdout.text,
stderr: output.stderr.text,
stdout_truncated: output.stdout.truncated,
stderr_truncated: output.stderr.truncated,
timed_out: output.timed_out,
cancelled: output.cancelled,
duration_ms: output.duration.as_millis() as u64,
max_output_bytes: WORKSPACE_TASK_MAX_OUTPUT_BYTES,
})
}
fn workspace_task_timeout(task_id: WorkspaceTaskId) -> Duration {
match task_id {
WorkspaceTaskId::GitStatus | WorkspaceTaskId::GitDiff => WORKSPACE_TASK_SHORT_TIMEOUT,
WorkspaceTaskId::CargoCheck | WorkspaceTaskId::NpmCheck => WORKSPACE_TASK_LONG_TIMEOUT,
}
}
fn canonical_workspace_root(config: &AppConfig) -> WorkspaceTaskResult<PathBuf> {
config.project_root.canonicalize().map_err(|error| {
WorkspaceTaskError::internal(format!("failed to canonicalize workspace root: {error}"))
})
}
fn workspace_task_id_label(task_id: WorkspaceTaskId) -> &'static str {
match task_id {
WorkspaceTaskId::GitStatus => "git_status",
WorkspaceTaskId::GitDiff => "git_diff",
WorkspaceTaskId::CargoCheck => "cargo_check",
WorkspaceTaskId::NpmCheck => "npm_check",
}
}
fn npm_command() -> Command {
if cfg!(windows) {
Command::new("npm.cmd")
} else {
Command::new("npm")
}
}
fn response_path(relative_path: &str) -> String {
if relative_path.is_empty() {
".".to_string()
} else {
relative_path.to_string()
}
}
fn run_command_with_timeout(
command: &mut Command,
timeout: Duration,
cancel_requested: Arc<AtomicBool>,
) -> WorkspaceTaskResult<CommandOutput> {
let mut child = command
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|error| {
WorkspaceTaskError::upstream(format!("failed to start workspace task: {error}"))
})?;
let stdout = child
.stdout
.take()
.ok_or_else(|| WorkspaceTaskError::internal("failed to capture workspace task stdout"))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| WorkspaceTaskError::internal("failed to capture workspace task stderr"))?;
let stdout_reader =
thread::spawn(move || read_limited_output(stdout, WORKSPACE_TASK_MAX_OUTPUT_BYTES));
let stderr_reader =
thread::spawn(move || read_limited_output(stderr, WORKSPACE_TASK_MAX_OUTPUT_BYTES));
let started_at = Instant::now();
let mut timed_out = false;
let mut cancelled = false;
let status = loop {
match child.try_wait().map_err(|error| {
WorkspaceTaskError::upstream(format!("failed to poll workspace task: {error}"))
})? {
Some(status) => break status,
None if cancel_requested.load(Ordering::SeqCst) => {
cancelled = true;
let _ = child.kill();
break child.wait().map_err(|error| {
WorkspaceTaskError::upstream(format!(
"failed to cancel workspace task: {error}"
))
})?;
}
None if started_at.elapsed() >= timeout => {
timed_out = true;
let _ = child.kill();
break child.wait().map_err(|error| {
WorkspaceTaskError::upstream(format!(
"failed to stop timed out workspace task: {error}"
))
})?;
}
None => thread::sleep(Duration::from_millis(25)),
}
};
let stdout = stdout_reader
.join()
.map_err(|_| WorkspaceTaskError::internal("workspace task stdout reader failed"))?;
let stderr = stderr_reader
.join()
.map_err(|_| WorkspaceTaskError::internal("workspace task stderr reader failed"))?;
Ok(CommandOutput {
exit_code: status.code(),
stdout,
stderr,
timed_out,
cancelled,
duration: started_at.elapsed(),
})
}
fn read_limited_output<R: Read>(mut reader: R, limit: usize) -> CapturedOutput {
let mut stored = Vec::new();
let mut truncated = false;
let mut buffer = [0_u8; 8192];
loop {
let bytes_read = match reader.read(&mut buffer) {
Ok(0) => break,
Ok(bytes_read) => bytes_read,
Err(_) => {
truncated = true;
break;
}
};
let available = limit.saturating_sub(stored.len());
if available > 0 {
let take = available.min(bytes_read);
stored.extend_from_slice(&buffer[..take]);
if take < bytes_read {
truncated = true;
}
} else {
truncated = true;
}
}
CapturedOutput {
text: String::from_utf8_lossy(&stored).to_string(),
truncated,
}
}
#[cfg(test)]
mod tests {
use soma_studio_core::{AppConfig, WorkspaceTaskId, WorkspaceTaskRunRequest};
use uuid::Uuid;
use super::{
WORKSPACE_TASK_MAX_OUTPUT_BYTES, WorkspaceTaskErrorKind, read_limited_output,
run_workspace_task, workspace_task_response_path,
};
#[test]
fn output_capture_marks_truncated_content() {
let content = vec![b'a'; WORKSPACE_TASK_MAX_OUTPUT_BYTES + 16];
let captured = read_limited_output(content.as_slice(), WORKSPACE_TASK_MAX_OUTPUT_BYTES);
assert_eq!(captured.text.len(), WORKSPACE_TASK_MAX_OUTPUT_BYTES);
assert!(captured.truncated);
}
#[test]
fn inline_long_task_returns_typed_invalid_request() {
let temp_dir = std::env::temp_dir().join(format!("soma-task-inline-{}", Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).expect("workspace dir");
let config = test_config(temp_dir.clone());
let request = WorkspaceTaskRunRequest {
task_id: WorkspaceTaskId::CargoCheck,
path: None,
};
let error = run_workspace_task(&config, request).expect_err("inline long task error");
assert_eq!(error.kind(), WorkspaceTaskErrorKind::InvalidRequest);
assert_eq!(error.api_code(), "invalid_request");
assert_eq!(error.status_code(), 400);
let _ = std::fs::remove_dir_all(temp_dir);
}
#[test]
fn root_only_task_rejects_path_scope_with_typed_invalid_request() {
let temp_dir = std::env::temp_dir().join(format!("soma-task-scope-{}", Uuid::new_v4()));
std::fs::create_dir_all(temp_dir.join("docs")).expect("docs dir");
let config = test_config(temp_dir.clone());
let request = WorkspaceTaskRunRequest {
task_id: WorkspaceTaskId::CargoCheck,
path: Some("docs".to_string()),
};
let error = workspace_task_response_path(&config, &request).expect_err("scoped task error");
assert_eq!(error.kind(), WorkspaceTaskErrorKind::InvalidRequest);
assert_eq!(error.api_code(), "invalid_request");
assert_eq!(error.status_code(), 400);
let _ = std::fs::remove_dir_all(temp_dir);
}
fn test_config(project_root: std::path::PathBuf) -> AppConfig {
AppConfig {
app_name: "Soma Studio".to_string(),
bind_addr: "127.0.0.1:0".to_string(),
data_dir: project_root.join(".soma-studio-data"),
derived_dir: project_root.join(".soma-studio-data").join("derived"),
notebook_dir: project_root.join(".soma-studio-data").join("notebook"),
user_assets_dir: project_root.join(".soma-studio-data").join("assets"),
db_path: project_root.join(".soma-studio-data").join("test.db"),
web_build_dir: project_root.join("web").join("build"),
web_shell_file: project_root.join("web").join("build").join("spa.html"),
project_root,
}
}
}