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
2 changes: 2 additions & 0 deletions ailoop-cli/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ pub mod handlers;
pub mod message_converter;
pub mod provider;
pub mod provider_handlers;
pub mod queue;
pub mod queue_handlers;
pub mod task;
pub mod task_handlers;
pub mod terminal_input;
18 changes: 18 additions & 0 deletions ailoop-cli/src/cli/queue.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//! CLI arguments for the `ailoop queue` subcommand.

use clap::Args;

#[derive(Args)]
pub struct QueueArgs {
/// Server base URL (default: AILOOP_SERVER env or http://127.0.0.1:8080)
#[arg(long, default_value = "")]
pub server: String,

/// Filter by channel name
#[arg(long)]
pub channel: Option<String>,

/// Output in JSON format
#[arg(long)]
pub json: bool,
}
69 changes: 69 additions & 0 deletions ailoop-cli/src/cli/queue_handlers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//! Handler for the `ailoop queue` subcommand.

use super::queue::QueueArgs;
use super::task_handlers::resolve_server_url;
use ailoop_core::PendingClient;
use anyhow::Result;

pub async fn handle_queue_commands(args: QueueArgs) -> Result<()> {
let server_url = resolve_server_url(args.server)?;
let client = PendingClient::new(&server_url);
let response = client.list_pending(args.channel.as_deref()).await?;

if args.json {
println!("{}", serde_json::to_string_pretty(&response)?);
return Ok(());
}

let filter_label = match &args.channel {
Some(ch) => format!("channel={}", ch),
None => "all channels".to_string(),
};

println!(
"Human queue: {} pending ({})",
response.total_count, filter_label
);
println!();

if response.items.is_empty() {
println!("(no pending items)");
return Ok(());
}

let terminal_width: usize = std::env::var("COLUMNS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(80);

let fixed_cols_width = 2 + 2 + 4 + 2 + 10 + 2 + 10 + 2;
let title_width = terminal_width.saturating_sub(fixed_cols_width).max(10);

println!(
"{:<2} {:<2} {:<10} {:<10} Title",
"#", "Ty", "Channel", "Msg"
);

for item in &response.items {
let ty_char = match item.kind.as_str() {
"decision" => "D",
"authorize" => "A",
"navigate" => "N",
_ => "?",
};

let channel_display: String = item.channel.chars().take(10).collect();

let msg_id_str = item.message_id.to_string().replace('-', "");
let msg_display = format!("{}...", &msg_id_str[..8]);

let title_display: String = item.label.chars().take(title_width).collect();

println!(
"{:<2} {:<2} {:<10} {:<10} {}",
item.position, ty_char, channel_display, msg_display, title_display
);
}

Ok(())
}
7 changes: 6 additions & 1 deletion ailoop-cli/src/cli/task_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ async fn handle_task_blocked(channel: String, server: String, json: bool) -> Res
Ok(())
}

fn resolve_server_url(server: String) -> Result<String> {
pub fn resolve_server_url(server: String) -> Result<String> {
if server.is_empty() {
get_server_url()
} else {
Expand All @@ -402,5 +402,10 @@ fn parse_task_state(value: &str) -> Result<TaskState> {
}

fn get_server_url() -> Result<String> {
if let Ok(url) = std::env::var("AILOOP_SERVER") {
if !url.is_empty() {
return Ok(url);
}
}
Ok("http://127.0.0.1:8080".to_string())
}
6 changes: 6 additions & 0 deletions ailoop-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,9 @@ enum Commands {
#[command(subcommand)]
command: cli::provider::ProviderCommands,
},

/// Inspect the human prompt queue
Queue(cli::queue::QueueArgs),
}

#[tokio::main]
Expand Down Expand Up @@ -316,6 +319,9 @@ async fn main() -> Result<()> {
Commands::Provider { command } => {
crate::cli::provider_handlers::handle_provider_commands(command).await?;
}
Commands::Queue(args) => {
crate::cli::queue_handlers::handle_queue_commands(args).await?;
}
}

Ok(())
Expand Down
173 changes: 173 additions & 0 deletions ailoop-cli/tests/queue_cli_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
//! Integration tests for `ailoop queue` CLI subcommand.

mod common;

use ailoop_server::AiloopServer;
use anyhow::{Context, Result};
use std::process::Command;
use std::time::Duration;
use tokio::sync::oneshot;
use tokio::task::JoinHandle;
use tokio::time::sleep;

const TEST_HOST: &str = "127.0.0.1";

async fn spawn_test_server(
host: &str,
) -> Result<(u16, oneshot::Sender<()>, JoinHandle<Result<()>>)> {
let port = common::find_free_port(host).context("Failed to find free port for test server")?;
let server = AiloopServer::new(host.to_string(), port, "queue-test".to_string());
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let server_handle = tokio::spawn(async move {
server
.start_with_shutdown(async move {
let _ = shutdown_rx.await;
})
.await
});
wait_for_server_ready(host, port, Duration::from_secs(10)).await?;
Ok((port, shutdown_tx, server_handle))
}

async fn wait_for_server_ready(host: &str, port: u16, timeout: Duration) -> Result<()> {
let start = std::time::Instant::now();
loop {
if start.elapsed() > timeout {
return Err(anyhow::anyhow!(
"Server readiness check timed out after {:?}",
timeout
));
}
if tokio::net::TcpStream::connect(format!("{}:{}", host, port))
.await
.is_ok()
{
break;
}
sleep(Duration::from_millis(50)).await;
}
Ok(())
}

async fn run_cmd(args: &[&str]) -> (bool, String, String) {
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
tokio::task::spawn_blocking(move || {
let output = Command::new("cargo")
.args(["run", "--bin", "ailoop", "--"])
.args(&args)
.env_remove("AILOOP_SERVER")
.env_remove("AILOOP_MODE")
.output()
.expect("Failed to run ailoop");
(
output.status.success(),
String::from_utf8_lossy(&output.stdout).trim().to_string(),
String::from_utf8_lossy(&output.stderr).trim().to_string(),
)
})
.await
.expect("spawn_blocking panicked")
}

#[tokio::test]
async fn test_queue_json_output_shape() -> Result<()> {
let _port_lock = common::port_allocation_lock().context("port allocation lock")?;
let (port, shutdown_tx, server_handle) = spawn_test_server(TEST_HOST).await?;
let server_url = format!("http://{}:{}", TEST_HOST, port);

let (ok, stdout, stderr) = run_cmd(&["queue", "--server", &server_url, "--json"]).await;

assert!(ok, "ailoop queue --json should succeed; stderr: {}", stderr);

let json: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("output should be valid JSON: {}; stdout: {}", e, stdout));

assert!(
json.get("items").is_some(),
"JSON must have 'items' key; got: {}",
json
);
assert!(
json.get("total_count").is_some(),
"JSON must have 'total_count' key; got: {}",
json
);
assert_eq!(json["total_count"], 0);
assert!(json["items"].as_array().unwrap().is_empty());

let _ = shutdown_tx.send(());
let _ = server_handle.await;
Ok(())
}

#[tokio::test]
async fn test_queue_human_readable_header() -> Result<()> {
let _port_lock = common::port_allocation_lock().context("port allocation lock")?;
let (port, shutdown_tx, server_handle) = spawn_test_server(TEST_HOST).await?;
let server_url = format!("http://{}:{}", TEST_HOST, port);

let (ok, stdout, stderr) = run_cmd(&["queue", "--server", &server_url]).await;

assert!(ok, "ailoop queue should succeed; stderr: {}", stderr);

assert!(
stdout.contains("Human queue:"),
"output must contain header 'Human queue:'; got: {}",
stdout
);
assert!(
stdout.contains("pending"),
"output must contain 'pending'; got: {}",
stdout
);

let _ = shutdown_tx.send(());
let _ = server_handle.await;
Ok(())
}

#[tokio::test]
async fn test_queue_ailoop_server_env_var() -> Result<()> {
let _port_lock = common::port_allocation_lock().context("port allocation lock")?;
let (port, shutdown_tx, server_handle) = spawn_test_server(TEST_HOST).await?;
let server_url = format!("http://{}:{}", TEST_HOST, port);

// Use AILOOP_SERVER env var instead of --server flag
let args: Vec<String> = vec![
"run".to_string(),
"--bin".to_string(),
"ailoop".to_string(),
"--".to_string(),
"queue".to_string(),
"--json".to_string(),
];
let (ok, stdout, stderr) = tokio::task::spawn_blocking(move || {
let output = Command::new("cargo")
.args(&args)
.env("AILOOP_SERVER", &server_url)
.output()
.expect("Failed to run ailoop");
(
output.status.success(),
String::from_utf8_lossy(&output.stdout).trim().to_string(),
String::from_utf8_lossy(&output.stderr).trim().to_string(),
)
})
.await
.expect("spawn_blocking panicked");

assert!(
ok,
"ailoop queue with AILOOP_SERVER should succeed; stderr: {}",
stderr
);

let json: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("output should be valid JSON: {}; stdout: {}", e, stdout));
assert!(json.get("items").is_some());
assert!(json.get("total_count").is_some());

let _ = shutdown_tx.send(());
let _ = server_handle.await;
Ok(())
}
1 change: 1 addition & 0 deletions ailoop-core/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::models::{
};
use anyhow::Result;

pub mod pending_client;
pub mod task_client;

/// Send a structured decision and wait for the human's selection (returns the Response message).
Expand Down
47 changes: 47 additions & 0 deletions ailoop-core/src/client/pending_client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//! HTTP client for the pending prompt inspection API.

use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingItemResponse {
pub message_id: Uuid,
pub kind: String,
pub channel: String,
pub position: usize,
pub label: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingListResponse {
pub items: Vec<PendingItemResponse>,
pub total_count: usize,
}

pub struct PendingClient {
base_url: String,
client: reqwest::Client,
}

impl PendingClient {
pub fn new(base_url: impl Into<String>) -> Self {
let base = base_url.into();
let base = base.trim_end_matches('/').to_string();
Self {
base_url: base,
client: reqwest::Client::new(),
}
}

pub async fn list_pending(&self, channel: Option<&str>) -> anyhow::Result<PendingListResponse> {
let mut url = format!("{}/api/v1/pending", self.base_url);
if let Some(ch) = channel {
url.push_str(&format!("?channel={}", ch));
}
let resp = self.client.get(&url).send().await?;
if !resp.status().is_success() {
anyhow::bail!("Server returned {}", resp.status());
}
Ok(resp.json::<PendingListResponse>().await?)
}
}
2 changes: 2 additions & 0 deletions ailoop-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,5 @@ pub mod server;
pub mod services;
pub mod terminal;
pub mod transport;

pub use client::pending_client::{PendingClient, PendingItemResponse, PendingListResponse};
2 changes: 1 addition & 1 deletion ailoop-py/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading