Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/slashit-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ enum Commands {
/// Priority: urgent, high, medium, low
#[arg(long)]
priority: Option<String>,
/// Review mode: manual (no AI Review, run goes to Human Review) or
/// automatic (AI Review runs after each run; the default)
#[arg(long, value_parser = ["manual", "automatic"])]
review_mode: Option<String>,
},
/// Move a task to a different status
Move {
Expand Down Expand Up @@ -140,11 +144,13 @@ async fn main() -> Result<()> {
title,
description,
priority,
review_mode,
} => IpcRequest::CreateTask {
project_id: project.clone(),
title: title.clone(),
description: description.clone(),
priority: priority.clone(),
review_mode: review_mode.clone(),
},
Commands::Move { task_id, status } => IpcRequest::MoveTask {
task_id: task_id.clone(),
Expand Down
5 changes: 5 additions & 0 deletions crates/slashit-ipc/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ pub enum IpcRequest {
title: String,
description: Option<String>,
priority: Option<String>,
/// `manual` or `automatic`; absent keeps the default, automatic.
#[serde(default, skip_serializing_if = "Option::is_none")]
review_mode: Option<String>,
},
/// Move a task to a different status
MoveTask { task_id: String, status: String },
Expand Down Expand Up @@ -326,6 +329,7 @@ mod tests {
title: String::new(),
description: None,
priority: None,
review_mode: None,
}
.spawns_agent());
assert!(IpcRequest::MoveTask {
Expand Down Expand Up @@ -368,6 +372,7 @@ mod tests {
title: String::new(),
description: None,
priority: None,
review_mode: None,
},
IpcRequest::MoveTask {
task_id: String::new(),
Expand Down
143 changes: 142 additions & 1 deletion src-tauri/src/commands/task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ pub struct CreateTaskParams {
pub github_issue_url: Option<String>,
pub gitlab_issue_url: Option<String>,
pub linear_ticket_id: Option<String>,
/// How a finished run is reviewed. Absent keeps the default, Automatic.
pub review_mode: Option<crate::domain::task::ReviewMode>,
/// The coding Worker. Absent keeps the default, Claude Code.
pub provider: Option<CodingProvider>,
}

#[tauri::command]
Expand All @@ -106,6 +110,8 @@ pub async fn create_task(
&dependencies,
)?;

let review_mode = params.review_mode.unwrap_or_default();
let provider = params.provider.unwrap_or_default();
let new = NewTask {
id,
project_id,
Expand All @@ -132,7 +138,10 @@ pub async fn create_task(
project_id,
move |existing| {
crate::domain::task_dependencies::check_targets(existing, id, project_id, &dependencies)?;
Ok(Task::new_backlog(existing, new))
let mut task = Task::new_backlog(existing, new);
task.review_mode = review_mode;
task.provider = provider;
Ok(task)
},
)
.await
Expand Down Expand Up @@ -3076,11 +3085,143 @@ mod task_dependency_contract {
github_issue_url: None,
gitlab_issue_url: None,
linear_ticket_id: None,
review_mode: None,
provider: None,
},
)
.await
}

async fn create_in_mode(
app: &App,
project_id: Uuid,
review_mode: Option<crate::domain::task::ReviewMode>,
) -> Result<Task, String> {
create_task(
GuiMutation::untracked(),
app.state(),
CreateTaskParams {
reviewer_model: None,
project_id: project_id.to_string(),
title: "Moded".to_string(),
description: None,
model: "default".to_string(),
planning_mode: false,
dependencies: vec![],
category: None,
priority: None,
complexity: None,
impact: None,
security_severity: None,
github_issue_url: None,
gitlab_issue_url: None,
linear_ticket_id: None,
review_mode,
provider: None,
},
)
.await
}

/// Each mode is written in the creating write and survives a reload. That
/// the mode reaches the Worker handoff is proved by the next test.
#[tokio::test]
async fn a_new_task_keeps_the_review_mode_it_was_created_with() {
use crate::domain::task::ReviewMode::{Automatic, Manual};
let project_id = Uuid::new_v4();
let (_tmp, _paths, app) = app_with(vec![], &[project_id]).await;

for (asked, expected) in [(Some(Manual), Manual), (Some(Automatic), Automatic), (None, Automatic)] {
let created = create_in_mode(&app, project_id, asked).await.expect("created");
assert_eq!(created.review_mode, expected);
let (memory, disk) = both_copies(&app, project_id, created.id).await;
let mode = |v: &serde_json::Value| v.get("review_mode").and_then(|m| m.as_str()).map(str::to_owned);
let stored = (mode(&memory), mode(&disk));
match expected {
Manual => assert_eq!(stored, (Some("manual".into()), Some("manual".into()))),
Automatic => assert_eq!(stored, (None, None), "the default is omitted"),
}
let reloaded = app.state::<crate::AppState>().storage.load_project_tasks(project_id).expect("reload");
let reloaded = reloaded.into_iter().find(|t| t.id == created.id).expect("present");
assert_eq!(reloaded.review_mode, expected);
}
}

fn git(dir: &std::path::Path, args: &[&str]) {
let out = std::process::Command::new("git")
.current_dir(dir)
.args(["-c", "user.email=t@example.com", "-c", "user.name=T"])
.args(args)
.output()
.expect("git runs");
assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
}

/// A Task created through `create_task` and then passed through the
/// shared run-start write (via the `cfg(test)` `record_run_start`
/// wrapper, which skips new-attempt authorization and the
/// additional-directory refusal) records the selected mode in the stored
/// `worker_handoff`, in memory and on disk. No test helper sets the mode;
/// an omitted mode keeps the Automatic default. Only the Manual iteration
/// discriminates the propagation: the Some(Automatic) and None iterations
/// would pass even if the mode were dropped, because Automatic is the
/// default.
#[tokio::test]
async fn the_mode_chosen_at_creation_reaches_the_stored_worker_handoff() {
use crate::domain::task::ReviewMode::{Automatic, Manual};
let project_id = Uuid::new_v4();
let (tmp, _paths, app) = app_with(vec![], &[project_id]).await;
let state = app.state::<crate::AppState>();

let repo = tmp.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
git(&repo, &["commit", "-q", "--allow-empty", "-m", "base"]);
let repository_id = Uuid::new_v4();
state.repository.repositories.write().await.insert(
repository_id,
crate::domain::Repository {
id: repository_id,
local_path: repo.to_string_lossy().to_string(),
remote_url: None,
remote_type: None,
created_at: chrono::Utc::now(),
},
);
{
let mut projects = state.project.projects.write().await;
let project = projects.get_mut(&project_id).unwrap();
project.repository_id = Some(repository_id);
project.base = Some(crate::domain::project::ProjectBase::LocalBranch {
branch: "main".to_string(),
});
}
let executor = crate::test_helpers::attach_test_executor(&state);

for (asked, expected) in [(Some(Manual), Manual), (Some(Automatic), Automatic), (None, Automatic)] {
let created = create_in_mode(&app, project_id, asked).await.expect("created");
executor
.record_run_start_for_test(created.id, repo.to_str().unwrap())
.await
.expect("the run start is recorded");

let memory = state.task.tasks.read().await[&created.id].clone();
let disk = state
.storage
.load_project_tasks(project_id)
.expect("reload")
.into_iter()
.find(|t| t.id == created.id)
.expect("present");
for task in [memory, disk] {
let handoff = task.worker_handoff.expect("the start wrote a handoff");
assert!(matches!(handoff.stage, crate::domain::WorkerHandoffStage::Started {}));
assert_eq!(handoff.review_mode, expected, "asked {asked:?}");
assert!(!handoff.review_mode_unproven);
}
}
}

async fn set(app: &App, task_id: Uuid, dependencies: Vec<Uuid>) -> Result<Option<Task>, String> {
set_task_dependencies(
GuiMutation::untracked(),
Expand Down
20 changes: 18 additions & 2 deletions src-tauri/src/ipc/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ pub async fn dispatch(req: IpcRequest, ctx: &IpcContext, peer: &PeerContext) ->
title,
description,
priority,
} => Dispatch::reply(handle_create_task(ctx, project_id, title, description, priority).await),
review_mode,
} => Dispatch::reply(
handle_create_task(ctx, project_id, title, description, priority, review_mode).await,
),
IpcRequest::MoveTask { task_id, status } => {
Dispatch::reply(handle_move_task(ctx, task_id, status).await)
}
Expand Down Expand Up @@ -150,11 +153,21 @@ async fn handle_create_task(
title: String,
description: Option<String>,
priority: Option<String>,
review_mode: Option<String>,
) -> IpcResponse {
let project_uuid = match Uuid::parse_str(&project_id) {
Ok(id) => id,
Err(_) => return IpcResponse::error(format!("Invalid project_id: {project_id}")),
};
let review_mode = match review_mode.as_deref() {
None | Some("automatic") => crate::domain::task::ReviewMode::Automatic,
Some("manual") => crate::domain::task::ReviewMode::Manual,
Some(other) => {
return IpcResponse::error(format!(
"Invalid review mode: {other}. Use manual or automatic."
))
}
};

// Verify project exists and get its name
let project_name = {
Expand Down Expand Up @@ -192,7 +205,9 @@ async fn handle_create_task(
&ctx.storage,
project_uuid,
move |existing| {
crate::domain::Task::new_backlog(existing, new)
let mut task = crate::domain::Task::new_backlog(existing, new);
task.review_mode = review_mode;
task
},
)
.await;
Expand Down Expand Up @@ -1069,6 +1084,7 @@ pub(super) mod tests {
title: format!("task {i}"),
description: None,
priority: None,
review_mode: None,
},
&ctx,
&peer(),
Expand Down
21 changes: 21 additions & 0 deletions src-tauri/src/queue/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2534,6 +2534,27 @@ impl TaskExecutor {
}
}

/// Runs the production checkout acquisition, then the `cfg(test)`
/// `record_run_start` wrapper, without launching a Worker. That wrapper
/// passes no new-attempt authorization and no additional directories, so
/// this exercises the shared write that stores `review_mode` from
/// `t.review_mode` into `worker_handoff`. It does not exercise new-attempt
/// authorization or the additional-directory refusal that the production
/// `record_run_start_for` applies with its computed decision.
#[cfg(test)]
pub(crate) async fn record_run_start_for_test(
&self,
task_id: Uuid,
repo_path: &str,
) -> Result<(), String> {
let (_, acquired) = self.acquire_task_worktree(task_id, repo_path).await;
let acquired = acquired.map_err(|e| format!("{e:?}"))?;
self.record_run_start(task_id, repo_path, acquired)
.await
.map(|_| ())
.map_err(|e| format!("{e:?}"))
}

/// Makes the current run's record the rebuilt kind that recovery writes
/// when the original record is lost: Manual, and unproven.
#[cfg(all(test, unix))]
Expand Down
1 change: 1 addition & 0 deletions src-tauri/tests/ipc_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,7 @@ fn agent_spawning_requests() -> Vec<IpcRequest> {
title: "from a remote peer".to_string(),
description: Some("rm -rf".to_string()),
priority: None,
review_mode: None,
},
IpcRequest::MoveTask {
task_id: uuid::Uuid::new_v4().to_string(),
Expand Down
2 changes: 2 additions & 0 deletions src/components/task_edit_modal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,8 @@ pub fn TaskEditModal(
complexity: None,
impact: None,
security_severity: None,
review_mode: review_mode_val,
provider: provider_val,
}).await
}
TaskEditMode::Edit(task) => {
Expand Down
4 changes: 4 additions & 0 deletions src/services/task_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ pub struct CreateTaskParams {
pub complexity: Option<TaskComplexity>,
pub impact: Option<TaskImpact>,
pub security_severity: Option<SecuritySeverity>,
pub review_mode: ReviewMode,
pub provider: CodingProvider,
}

pub async fn create_task(params: CreateTaskParams) -> Result<Task, String> {
Expand All @@ -50,6 +52,8 @@ pub async fn create_task(params: CreateTaskParams) -> Result<Task, String> {
"complexity": params.complexity,
"impact": params.impact,
"securitySeverity": params.security_severity,
"reviewMode": params.review_mode,
"provider": params.provider,
}
})).unwrap();

Expand Down
Loading