feat: add colibri task commands
This commit is contained in:
parent
12596d1a71
commit
eaa2f27680
3 changed files with 317 additions and 0 deletions
|
|
@ -30,6 +30,18 @@ enum Command {
|
|||
CompactSession {
|
||||
session_id: String,
|
||||
},
|
||||
ListTasks {
|
||||
status: Option<String>,
|
||||
},
|
||||
CreateTask {
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
},
|
||||
IntakeTask {
|
||||
title: String,
|
||||
description: Option<String>,
|
||||
capabilities: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_socket_path() -> PathBuf {
|
||||
|
|
@ -46,6 +58,9 @@ fn usage() -> &'static str {
|
|||
colibri [--socket PATH] kill AGENT_ID
|
||||
colibri [--socket PATH] get-session SESSION_ID
|
||||
colibri [--socket PATH] compact-session SESSION_ID
|
||||
colibri [--socket PATH] list-tasks [--status STATUS]
|
||||
colibri [--socket PATH] create-task --title TEXT [--description TEXT]
|
||||
colibri [--socket PATH] intake-task --title TEXT [--description TEXT] [--capability CAP]...
|
||||
|
||||
Socket defaults to COLIBRI_DAEMON_SOCKET, then the daemon's configured default.
|
||||
|
||||
|
|
@ -53,6 +68,9 @@ Examples:
|
|||
colibri status
|
||||
colibri --socket /tmp/colibri-smoke/colibri.sock snapshot
|
||||
colibri spawn-local target/release/colibri-smoke-agent
|
||||
colibri create-task --title "verify OSA smoke" --description "manual follow-up"
|
||||
colibri intake-task --title "triage watchdog" --capability freebsd
|
||||
colibri list-tasks --status queued
|
||||
"#
|
||||
}
|
||||
|
||||
|
|
@ -124,6 +142,19 @@ where
|
|||
"compact-session" => expect_arity(&args, 2).map(|()| Command::CompactSession {
|
||||
session_id: args[1].clone(),
|
||||
}),
|
||||
"list-tasks" => parse_list_tasks_options(&args[1..]),
|
||||
"create-task" => {
|
||||
let (title, description) = parse_task_text_options("create-task", &args[1..])?;
|
||||
Ok(Command::CreateTask { title, description })
|
||||
}
|
||||
"intake-task" => {
|
||||
let (title, description, capabilities) = parse_intake_task_options(&args[1..])?;
|
||||
Ok(Command::IntakeTask {
|
||||
title,
|
||||
description,
|
||||
capabilities,
|
||||
})
|
||||
}
|
||||
other => Err(format!("unknown command: {other}\n\n{}", usage())),
|
||||
}?;
|
||||
|
||||
|
|
@ -145,6 +176,109 @@ fn expect_arity(args: &[String], expected: usize) -> Result<(), String> {
|
|||
}
|
||||
}
|
||||
|
||||
fn parse_list_tasks_options(args: &[String]) -> Result<Command, String> {
|
||||
let mut status = None;
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--status" => {
|
||||
let Some(value) = args.get(i + 1) else {
|
||||
return Err("--status requires STATUS\n\n".to_string() + usage());
|
||||
};
|
||||
status = Some(value.clone());
|
||||
i += 2;
|
||||
}
|
||||
other => return Err(format!("unknown list-tasks option: {other}\n\n{}", usage())),
|
||||
}
|
||||
}
|
||||
Ok(Command::ListTasks { status })
|
||||
}
|
||||
|
||||
fn parse_task_text_options(
|
||||
command: &str,
|
||||
args: &[String],
|
||||
) -> Result<(String, Option<String>), String> {
|
||||
let mut title = None;
|
||||
let mut description = None;
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--title" => {
|
||||
let Some(value) = args.get(i + 1) else {
|
||||
return Err("--title requires TEXT\n\n".to_string() + usage());
|
||||
};
|
||||
title = Some(value.clone());
|
||||
i += 2;
|
||||
}
|
||||
"--description" => {
|
||||
let Some(value) = args.get(i + 1) else {
|
||||
return Err("--description requires TEXT\n\n".to_string() + usage());
|
||||
};
|
||||
description = Some(value.clone());
|
||||
i += 2;
|
||||
}
|
||||
other => return Err(format!("unknown {command} option: {other}\n\n{}", usage())),
|
||||
}
|
||||
}
|
||||
let title = title.ok_or_else(|| format!("{command} requires --title TEXT\n\n{}", usage()))?;
|
||||
Ok((title, description))
|
||||
}
|
||||
|
||||
fn parse_intake_task_options(
|
||||
args: &[String],
|
||||
) -> Result<(String, Option<String>, Vec<String>), String> {
|
||||
let mut title = None;
|
||||
let mut description = None;
|
||||
let mut capabilities = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--title" => {
|
||||
let Some(value) = args.get(i + 1) else {
|
||||
return Err("--title requires TEXT\n\n".to_string() + usage());
|
||||
};
|
||||
title = Some(value.clone());
|
||||
i += 2;
|
||||
}
|
||||
"--description" => {
|
||||
let Some(value) = args.get(i + 1) else {
|
||||
return Err("--description requires TEXT\n\n".to_string() + usage());
|
||||
};
|
||||
description = Some(value.clone());
|
||||
i += 2;
|
||||
}
|
||||
"--capability" => {
|
||||
let Some(value) = args.get(i + 1) else {
|
||||
return Err("--capability requires CAP\n\n".to_string() + usage());
|
||||
};
|
||||
capabilities.push(value.clone());
|
||||
i += 2;
|
||||
}
|
||||
"--capabilities" => {
|
||||
let Some(value) = args.get(i + 1) else {
|
||||
return Err("--capabilities requires CSV\n\n".to_string() + usage());
|
||||
};
|
||||
capabilities.extend(
|
||||
value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|cap| !cap.is_empty())
|
||||
.map(ToString::to_string),
|
||||
);
|
||||
i += 2;
|
||||
}
|
||||
other => {
|
||||
return Err(format!(
|
||||
"unknown intake-task option: {other}\n\n{}",
|
||||
usage()
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
let title = title.ok_or_else(|| format!("intake-task requires --title TEXT\n\n{}", usage()))?;
|
||||
Ok((title, description, capabilities))
|
||||
}
|
||||
|
||||
fn parse_spawn_options(args: &[String]) -> Result<(Option<String>, Option<String>), String> {
|
||||
let mut session_id = None;
|
||||
let mut system_prompt = None;
|
||||
|
|
@ -192,6 +326,15 @@ async fn run(options: Options) -> Result<(), ClientError> {
|
|||
Command::CompactSession { session_id } => {
|
||||
print_json(&client.compact_session(session_id).await?)
|
||||
}
|
||||
Command::ListTasks { status } => print_json(&client.list_tasks(status).await?),
|
||||
Command::CreateTask { title, description } => {
|
||||
print_json(&client.create_task(title, description).await?)
|
||||
}
|
||||
Command::IntakeTask {
|
||||
title,
|
||||
description,
|
||||
capabilities,
|
||||
} => print_json(&client.intake_task(title, description, capabilities).await?),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -265,6 +408,64 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_task_commands() {
|
||||
assert_eq!(
|
||||
parsed(&["list-tasks", "--status", "queued"]),
|
||||
Options {
|
||||
socket_path: default_socket_path(),
|
||||
command: Command::ListTasks {
|
||||
status: Some("queued".to_string()),
|
||||
},
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parsed(&[
|
||||
"create-task",
|
||||
"--title",
|
||||
"check smoke",
|
||||
"--description",
|
||||
"from cli",
|
||||
]),
|
||||
Options {
|
||||
socket_path: default_socket_path(),
|
||||
command: Command::CreateTask {
|
||||
title: "check smoke".to_string(),
|
||||
description: Some("from cli".to_string()),
|
||||
},
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parsed(&[
|
||||
"intake-task",
|
||||
"--title",
|
||||
"triage",
|
||||
"--capability",
|
||||
"freebsd",
|
||||
"--capabilities",
|
||||
"sqlite,scheduler",
|
||||
]),
|
||||
Options {
|
||||
socket_path: default_socket_path(),
|
||||
command: Command::IntakeTask {
|
||||
title: "triage".to_string(),
|
||||
description: None,
|
||||
capabilities: vec![
|
||||
"freebsd".to_string(),
|
||||
"sqlite".to_string(),
|
||||
"scheduler".to_string(),
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_create_task_without_title() {
|
||||
let err = parse_args(["create-task", "--description", "missing title"]).unwrap_err();
|
||||
assert!(err.contains("create-task requires --title"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_command() {
|
||||
let err = parse_args(["bogus"]).unwrap_err();
|
||||
|
|
|
|||
|
|
@ -141,6 +141,39 @@ impl DaemonClient {
|
|||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_tasks(
|
||||
&self,
|
||||
status: Option<String>,
|
||||
) -> Result<serde_json::Value, ClientError> {
|
||||
self.request(&HerdrCommand::ListTasks { status }).await
|
||||
}
|
||||
|
||||
pub async fn create_task(
|
||||
&self,
|
||||
title: impl Into<String>,
|
||||
description: Option<String>,
|
||||
) -> Result<serde_json::Value, ClientError> {
|
||||
self.request(&HerdrCommand::CreateTask {
|
||||
title: title.into(),
|
||||
description,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn intake_task(
|
||||
&self,
|
||||
title: impl Into<String>,
|
||||
description: Option<String>,
|
||||
capabilities: Vec<String>,
|
||||
) -> Result<serde_json::Value, ClientError> {
|
||||
self.request(&HerdrCommand::IntakeTask {
|
||||
title: title.into(),
|
||||
description,
|
||||
capabilities: Some(capabilities),
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use std::{
|
||||
process::Command,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
|
@ -42,6 +43,30 @@ async fn wait_for_socket(client: &DaemonClient) {
|
|||
}
|
||||
}
|
||||
|
||||
async fn run_colibri_cli(socket_path: &std::path::Path, args: &[&str]) -> serde_json::Value {
|
||||
let bin = env!("CARGO_BIN_EXE_colibri");
|
||||
let socket_path = socket_path.to_path_buf();
|
||||
let args: Vec<String> = args.iter().map(|arg| arg.to_string()).collect();
|
||||
let output = tokio::task::spawn_blocking(move || {
|
||||
Command::new(bin)
|
||||
.arg("--socket")
|
||||
.arg(socket_path)
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("run colibri CLI")
|
||||
})
|
||||
.await
|
||||
.expect("join colibri CLI task");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"colibri CLI failed\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
serde_json::from_slice(&output.stdout).expect("parse colibri CLI JSON output")
|
||||
}
|
||||
|
||||
async fn wait_for_state(client: &DaemonClient, expected: AgentState) -> String {
|
||||
let deadline = Instant::now() + Duration::from_secs(8);
|
||||
loop {
|
||||
|
|
@ -111,6 +136,64 @@ async fn daemon_client_live_socket_smoke_with_local_fake_agent() {
|
|||
let _ = tokio::fs::remove_dir_all(config.data_dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn colibri_cli_task_commands_use_socket_api() {
|
||||
let config = smoke_config();
|
||||
tokio::fs::create_dir_all(&config.data_dir).await.unwrap();
|
||||
|
||||
let state: SharedState = Arc::new(DaemonState::new(config.clone()));
|
||||
let shutdown = state.shutdown_rx.resubscribe();
|
||||
let server_state = state.clone();
|
||||
let server = tokio::spawn(async move {
|
||||
socket::serve(server_state, shutdown).await;
|
||||
});
|
||||
|
||||
let client = DaemonClient::new(config.socket_path.clone());
|
||||
wait_for_socket(&client).await;
|
||||
|
||||
let created = run_colibri_cli(
|
||||
&config.socket_path,
|
||||
&[
|
||||
"create-task",
|
||||
"--title",
|
||||
"cli-created-task",
|
||||
"--description",
|
||||
"created through the colibri binary",
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(created["title"], "cli-created-task");
|
||||
assert_eq!(created["status"], "queued");
|
||||
|
||||
let tasks = run_colibri_cli(&config.socket_path, &["list-tasks", "--status", "queued"]).await;
|
||||
assert!(tasks
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|task| task["title"] == "cli-created-task"));
|
||||
|
||||
let intake = run_colibri_cli(
|
||||
&config.socket_path,
|
||||
&[
|
||||
"intake-task",
|
||||
"--title",
|
||||
"cli-intake-task",
|
||||
"--description",
|
||||
"queued through the scheduler intake",
|
||||
"--capability",
|
||||
"freebsd",
|
||||
"--capabilities",
|
||||
"sqlite,scheduler",
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(intake["status"], "queued");
|
||||
|
||||
let _ = state.shutdown_tx.send(());
|
||||
server.await.unwrap();
|
||||
let _ = tokio::fs::remove_dir_all(config.data_dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn harness_double_spawn_session_isolation() {
|
||||
let config = smoke_config();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue