huskies: merge 595_story_web_ui_settings_page_with_form_based_project_toml_editor
This commit is contained in:
+411
-1
@@ -1,13 +1,181 @@
|
||||
//! HTTP settings endpoints — REST API for user preferences and editor configuration.
|
||||
use crate::config::ProjectConfig;
|
||||
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
||||
use crate::store::StoreOps;
|
||||
use poem_openapi::{Object, OpenApi, Tags, param::Query, payload::Json};
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
const EDITOR_COMMAND_KEY: &str = "editor_command";
|
||||
|
||||
/// Project-level settings exposed via `GET /api/settings` and `PUT /api/settings`.
|
||||
///
|
||||
/// Only contains the scalar fields of `ProjectConfig` — array sections
|
||||
/// (`[[component]]`, `[[agent]]`, `[watcher]`) are preserved in the TOML file
|
||||
/// and are not editable through this API.
|
||||
#[derive(Debug, Object, Serialize, Deserialize)]
|
||||
struct ProjectSettings {
|
||||
/// Project-wide default QA mode: "server", "agent", or "human". Default: "server".
|
||||
default_qa: String,
|
||||
/// Default model for coder-stage agents (e.g. "sonnet"). When set, only agents whose
|
||||
/// model matches this value are used for auto-assignment.
|
||||
default_coder_model: Option<String>,
|
||||
/// Maximum number of concurrent coder-stage agents. When set, stories wait in
|
||||
/// 2_current/ until a slot is free.
|
||||
max_coders: Option<u32>,
|
||||
/// Maximum retries per story per pipeline stage before marking as blocked. Default: 2.
|
||||
max_retries: u32,
|
||||
/// Optional base branch name (e.g. "main", "master"). Overrides auto-detection.
|
||||
base_branch: Option<String>,
|
||||
/// Whether to send RateLimitWarning chat notifications. Default: true.
|
||||
rate_limit_notifications: bool,
|
||||
/// IANA timezone name (e.g. "Europe/London"). Timer inputs are interpreted in this tz.
|
||||
timezone: Option<String>,
|
||||
/// WebSocket URL of a remote huskies node to sync CRDT state with.
|
||||
rendezvous: Option<String>,
|
||||
/// How often (seconds) to check 5_done/ for items to archive. Default: 60.
|
||||
watcher_sweep_interval_secs: u64,
|
||||
/// How long (seconds) an item must remain in 5_done/ before archiving. Default: 14400.
|
||||
watcher_done_retention_secs: u64,
|
||||
}
|
||||
|
||||
/// Load `ProjectSettings` from `ProjectConfig`.
|
||||
fn settings_from_config(cfg: &ProjectConfig) -> ProjectSettings {
|
||||
ProjectSettings {
|
||||
default_qa: cfg.default_qa.clone(),
|
||||
default_coder_model: cfg.default_coder_model.clone(),
|
||||
max_coders: cfg.max_coders.map(|v| v as u32),
|
||||
max_retries: cfg.max_retries,
|
||||
base_branch: cfg.base_branch.clone(),
|
||||
rate_limit_notifications: cfg.rate_limit_notifications,
|
||||
timezone: cfg.timezone.clone(),
|
||||
rendezvous: cfg.rendezvous.clone(),
|
||||
watcher_sweep_interval_secs: cfg.watcher.sweep_interval_secs,
|
||||
watcher_done_retention_secs: cfg.watcher.done_retention_secs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the incoming `ProjectSettings` before writing.
|
||||
fn validate_project_settings(s: &ProjectSettings) -> Result<(), String> {
|
||||
match s.default_qa.as_str() {
|
||||
"server" | "agent" | "human" => {}
|
||||
other => {
|
||||
return Err(format!(
|
||||
"Invalid default_qa value '{other}'. Must be one of: server, agent, human"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write only the scalar settings from `s` into the project.toml at the given root.
|
||||
/// Array sections (`[[component]]`, `[[agent]]`) are preserved unchanged.
|
||||
fn write_project_settings(project_root: &Path, s: &ProjectSettings) -> Result<(), String> {
|
||||
let config_path = project_root.join(".huskies/project.toml");
|
||||
|
||||
let content = if config_path.exists() {
|
||||
std::fs::read_to_string(&config_path).map_err(|e| format!("Read config: {e}"))?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let mut val: toml::Value = if content.trim().is_empty() {
|
||||
toml::Value::Table(toml::map::Map::new())
|
||||
} else {
|
||||
toml::from_str(&content).map_err(|e| format!("Parse config: {e}"))?
|
||||
};
|
||||
|
||||
let table = val
|
||||
.as_table_mut()
|
||||
.ok_or_else(|| "Config is not a TOML table".to_string())?;
|
||||
|
||||
// Scalar root fields
|
||||
table.insert(
|
||||
"default_qa".to_string(),
|
||||
toml::Value::String(s.default_qa.clone()),
|
||||
);
|
||||
table.insert(
|
||||
"max_retries".to_string(),
|
||||
toml::Value::Integer(s.max_retries as i64),
|
||||
);
|
||||
table.insert(
|
||||
"rate_limit_notifications".to_string(),
|
||||
toml::Value::Boolean(s.rate_limit_notifications),
|
||||
);
|
||||
|
||||
// Optional scalar fields
|
||||
match &s.default_coder_model {
|
||||
Some(v) => {
|
||||
table.insert(
|
||||
"default_coder_model".to_string(),
|
||||
toml::Value::String(v.clone()),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
table.remove("default_coder_model");
|
||||
}
|
||||
}
|
||||
match s.max_coders {
|
||||
Some(v) => {
|
||||
table.insert("max_coders".to_string(), toml::Value::Integer(v as i64));
|
||||
}
|
||||
None => {
|
||||
table.remove("max_coders");
|
||||
}
|
||||
}
|
||||
match &s.base_branch {
|
||||
Some(v) => {
|
||||
table.insert("base_branch".to_string(), toml::Value::String(v.clone()));
|
||||
}
|
||||
None => {
|
||||
table.remove("base_branch");
|
||||
}
|
||||
}
|
||||
match &s.timezone {
|
||||
Some(v) => {
|
||||
table.insert("timezone".to_string(), toml::Value::String(v.clone()));
|
||||
}
|
||||
None => {
|
||||
table.remove("timezone");
|
||||
}
|
||||
}
|
||||
match &s.rendezvous {
|
||||
Some(v) => {
|
||||
table.insert("rendezvous".to_string(), toml::Value::String(v.clone()));
|
||||
}
|
||||
None => {
|
||||
table.remove("rendezvous");
|
||||
}
|
||||
}
|
||||
|
||||
// [watcher] sub-table
|
||||
let watcher_entry = table
|
||||
.entry("watcher".to_string())
|
||||
.or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
|
||||
if let toml::Value::Table(wt) = watcher_entry {
|
||||
wt.insert(
|
||||
"sweep_interval_secs".to_string(),
|
||||
toml::Value::Integer(s.watcher_sweep_interval_secs as i64),
|
||||
);
|
||||
wt.insert(
|
||||
"done_retention_secs".to_string(),
|
||||
toml::Value::Integer(s.watcher_done_retention_secs as i64),
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure .huskies/ directory exists
|
||||
if let Some(parent) = config_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("Create .huskies dir: {e}"))?;
|
||||
}
|
||||
|
||||
let new_content = toml::to_string_pretty(&val).map_err(|e| format!("Serialize config: {e}"))?;
|
||||
std::fs::write(&config_path, new_content).map_err(|e| format!("Write config: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Tags)]
|
||||
enum SettingsTags {
|
||||
Settings,
|
||||
@@ -71,6 +239,30 @@ impl SettingsApi {
|
||||
Ok(Json(OpenFileResponse { success: true }))
|
||||
}
|
||||
|
||||
/// Get current project.toml scalar settings as JSON.
|
||||
#[oai(path = "/settings", method = "get")]
|
||||
async fn get_settings(&self) -> OpenApiResult<Json<ProjectSettings>> {
|
||||
let project_root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||
let config = ProjectConfig::load(&project_root).map_err(bad_request)?;
|
||||
Ok(Json(settings_from_config(&config)))
|
||||
}
|
||||
|
||||
/// Update project.toml scalar settings. Array sections (component, agent) are preserved.
|
||||
///
|
||||
/// Returns 400 if the input fails validation (e.g. unknown qa mode, negative max_retries).
|
||||
#[oai(path = "/settings", method = "put")]
|
||||
async fn put_settings(
|
||||
&self,
|
||||
payload: Json<ProjectSettings>,
|
||||
) -> OpenApiResult<Json<ProjectSettings>> {
|
||||
validate_project_settings(&payload.0).map_err(bad_request)?;
|
||||
let project_root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||
write_project_settings(&project_root, &payload.0).map_err(bad_request)?;
|
||||
// Re-read to confirm what was written
|
||||
let config = ProjectConfig::load(&project_root).map_err(bad_request)?;
|
||||
Ok(Json(settings_from_config(&config)))
|
||||
}
|
||||
|
||||
/// Set the preferred editor command (e.g. "zed", "code", "cursor").
|
||||
/// Pass null or empty string to clear the preference.
|
||||
#[oai(path = "/settings/editor", method = "put")]
|
||||
@@ -360,4 +552,222 @@ mod tests {
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ── /api/settings GET/PUT ──────────────────────────────────────────────
|
||||
|
||||
fn default_project_settings() -> ProjectSettings {
|
||||
let cfg = ProjectConfig::default();
|
||||
settings_from_config(&cfg)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_settings_returns_defaults_when_no_project_toml() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Create .huskies dir so project root detection works but no project.toml
|
||||
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
|
||||
let ctx = AppContext::new_test(dir.path().to_path_buf());
|
||||
let api = SettingsApi { ctx: Arc::new(ctx) };
|
||||
let result = api.get_settings().await.unwrap().0;
|
||||
assert_eq!(result.default_qa, "server");
|
||||
assert_eq!(result.max_retries, 2);
|
||||
assert!(result.rate_limit_notifications);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_settings_writes_and_returns_settings() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
|
||||
let ctx = AppContext::new_test(dir.path().to_path_buf());
|
||||
let api = SettingsApi { ctx: Arc::new(ctx) };
|
||||
|
||||
let mut s = default_project_settings();
|
||||
s.default_qa = "agent".to_string();
|
||||
s.max_retries = 5;
|
||||
s.rate_limit_notifications = false;
|
||||
|
||||
let result = api.put_settings(Json(s)).await.unwrap().0;
|
||||
assert_eq!(result.default_qa, "agent");
|
||||
assert_eq!(result.max_retries, 5);
|
||||
assert!(!result.rate_limit_notifications);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_settings_preserves_agent_sections() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let huskies_dir = dir.path().join(".huskies");
|
||||
std::fs::create_dir_all(&huskies_dir).unwrap();
|
||||
|
||||
// Write a project.toml with agent sections
|
||||
std::fs::write(
|
||||
huskies_dir.join("project.toml"),
|
||||
r#"
|
||||
[[agent]]
|
||||
name = "coder-1"
|
||||
model = "sonnet"
|
||||
stage = "coder"
|
||||
|
||||
[[component]]
|
||||
name = "server"
|
||||
path = "."
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let ctx = AppContext::new_test(dir.path().to_path_buf());
|
||||
let api = SettingsApi { ctx: Arc::new(ctx) };
|
||||
|
||||
let mut s = default_project_settings();
|
||||
s.default_qa = "human".to_string();
|
||||
api.put_settings(Json(s)).await.unwrap();
|
||||
|
||||
// Re-read the file and verify agent/component sections are still there
|
||||
let written = std::fs::read_to_string(huskies_dir.join("project.toml")).unwrap();
|
||||
assert!(
|
||||
written.contains("coder-1"),
|
||||
"agent section should be preserved"
|
||||
);
|
||||
assert!(
|
||||
written.contains("server"),
|
||||
"component section should be preserved"
|
||||
);
|
||||
assert!(written.contains("human"), "new setting should be written");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_settings_rejects_invalid_qa_mode() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
|
||||
let ctx = AppContext::new_test(dir.path().to_path_buf());
|
||||
let api = SettingsApi { ctx: Arc::new(ctx) };
|
||||
|
||||
let mut s = default_project_settings();
|
||||
s.default_qa = "invalid_mode".to_string();
|
||||
|
||||
let result = api.put_settings(Json(s)).await;
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert_eq!(err.status(), poem::http::StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_project_settings_accepts_valid_qa_modes() {
|
||||
for mode in &["server", "agent", "human"] {
|
||||
let s = ProjectSettings {
|
||||
default_qa: mode.to_string(),
|
||||
default_coder_model: None,
|
||||
max_coders: None,
|
||||
max_retries: 2,
|
||||
base_branch: None,
|
||||
rate_limit_notifications: true,
|
||||
timezone: None,
|
||||
rendezvous: None,
|
||||
watcher_sweep_interval_secs: 60,
|
||||
watcher_done_retention_secs: 14400,
|
||||
};
|
||||
assert!(
|
||||
validate_project_settings(&s).is_ok(),
|
||||
"qa mode '{mode}' should be valid"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_project_settings_rejects_unknown_qa_mode() {
|
||||
let s = ProjectSettings {
|
||||
default_qa: "robot".to_string(),
|
||||
default_coder_model: None,
|
||||
max_coders: None,
|
||||
max_retries: 2,
|
||||
base_branch: None,
|
||||
rate_limit_notifications: true,
|
||||
timezone: None,
|
||||
rendezvous: None,
|
||||
watcher_sweep_interval_secs: 60,
|
||||
watcher_done_retention_secs: 14400,
|
||||
};
|
||||
let err = validate_project_settings(&s).unwrap_err();
|
||||
assert!(err.contains("robot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_and_read_project_settings_roundtrip() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
|
||||
|
||||
let s = ProjectSettings {
|
||||
default_qa: "agent".to_string(),
|
||||
default_coder_model: Some("opus".to_string()),
|
||||
max_coders: Some(2),
|
||||
max_retries: 3,
|
||||
base_branch: Some("main".to_string()),
|
||||
rate_limit_notifications: false,
|
||||
timezone: Some("America/New_York".to_string()),
|
||||
rendezvous: Some("ws://host:3001/crdt-sync".to_string()),
|
||||
watcher_sweep_interval_secs: 30,
|
||||
watcher_done_retention_secs: 7200,
|
||||
};
|
||||
|
||||
write_project_settings(dir.path(), &s).unwrap();
|
||||
|
||||
let config = ProjectConfig::load(dir.path()).unwrap();
|
||||
let loaded = settings_from_config(&config);
|
||||
|
||||
assert_eq!(loaded.default_qa, "agent");
|
||||
assert_eq!(loaded.default_coder_model, Some("opus".to_string()));
|
||||
assert_eq!(loaded.max_coders, Some(2));
|
||||
assert_eq!(loaded.max_retries, 3);
|
||||
assert_eq!(loaded.base_branch, Some("main".to_string()));
|
||||
assert!(!loaded.rate_limit_notifications);
|
||||
assert_eq!(loaded.timezone, Some("America/New_York".to_string()));
|
||||
assert_eq!(
|
||||
loaded.rendezvous,
|
||||
Some("ws://host:3001/crdt-sync".to_string())
|
||||
);
|
||||
assert_eq!(loaded.watcher_sweep_interval_secs, 30);
|
||||
assert_eq!(loaded.watcher_done_retention_secs, 7200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_project_settings_clears_optional_fields_when_none() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let huskies_dir = dir.path().join(".huskies");
|
||||
std::fs::create_dir_all(&huskies_dir).unwrap();
|
||||
|
||||
// First write with optional fields set
|
||||
let s_with = ProjectSettings {
|
||||
default_qa: "server".to_string(),
|
||||
default_coder_model: Some("sonnet".to_string()),
|
||||
max_coders: Some(3),
|
||||
max_retries: 2,
|
||||
base_branch: Some("master".to_string()),
|
||||
rate_limit_notifications: true,
|
||||
timezone: Some("UTC".to_string()),
|
||||
rendezvous: None,
|
||||
watcher_sweep_interval_secs: 60,
|
||||
watcher_done_retention_secs: 14400,
|
||||
};
|
||||
write_project_settings(dir.path(), &s_with).unwrap();
|
||||
|
||||
// Then write with optional fields cleared
|
||||
let s_clear = ProjectSettings {
|
||||
default_qa: "server".to_string(),
|
||||
default_coder_model: None,
|
||||
max_coders: None,
|
||||
max_retries: 2,
|
||||
base_branch: None,
|
||||
rate_limit_notifications: true,
|
||||
timezone: None,
|
||||
rendezvous: None,
|
||||
watcher_sweep_interval_secs: 60,
|
||||
watcher_done_retention_secs: 14400,
|
||||
};
|
||||
write_project_settings(dir.path(), &s_clear).unwrap();
|
||||
|
||||
let config = ProjectConfig::load(dir.path()).unwrap();
|
||||
let loaded = settings_from_config(&config);
|
||||
assert!(loaded.default_coder_model.is_none());
|
||||
assert!(loaded.max_coders.is_none());
|
||||
assert!(loaded.base_branch.is_none());
|
||||
assert!(loaded.timezone.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user