huskies: merge 611_story_extract_settings_service

This commit is contained in:
dave
2026-04-24 17:07:44 +00:00
parent da6ae89667
commit 62bfaf20f4
7 changed files with 897 additions and 196 deletions
+2 -2
View File
@@ -2,7 +2,7 @@
use crate::agents::PipelineStage;
use crate::config::ProjectConfig;
use crate::http::context::AppContext;
use crate::http::settings::get_editor_command_from_store;
use crate::service::settings::get_editor_command;
use crate::slog_warn;
use crate::worktree;
use serde_json::{Value, json};
@@ -414,7 +414,7 @@ pub(super) fn tool_get_editor_command(args: &Value, ctx: &AppContext) -> Result<
.and_then(|v| v.as_str())
.ok_or("Missing required argument: worktree_path")?;
let editor = get_editor_command_from_store(ctx)
let editor = get_editor_command(&*ctx.store)
.ok_or_else(|| "No editor configured. Set one via PUT /api/settings/editor.".to_string())?;
Ok(format!("{editor} {worktree_path}"))
+35 -194
View File
@@ -1,179 +1,38 @@
//! 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::service::settings as svc;
use crate::store::StoreOps;
use poem_openapi::{Object, OpenApi, Tags, param::Query, payload::Json};
use serde::{Deserialize, Serialize};
use serde::Serialize;
use serde_json::json;
#[cfg(test)]
use std::path::Path;
use std::sync::Arc;
const EDITOR_COMMAND_KEY: &str = "editor_command";
// Re-export service types so the test module (which does `use super::*`) can
// access them without modification.
pub use svc::EDITOR_COMMAND_KEY;
pub use svc::ProjectSettings;
#[cfg(test)]
pub use svc::settings_from_config;
/// 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.
/// Thin wrapper — delegates to [`svc::validate_project_settings`] and maps
/// the typed error to `String` so existing tests calling `.unwrap_err()` can
/// call `.contains()` directly.
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(())
svc::validate_project_settings(s).map_err(|e| e.to_string())
}
/// Write only the scalar settings from `s` into the project.toml at the given root.
/// Array sections (`[[component]]`, `[[agent]]`) are preserved unchanged.
/// Thin wrapper — delegates to [`svc::write_project_settings`] and maps the
/// typed error to `String` so existing tests can call `.unwrap()` unchanged.
#[cfg(test)]
fn write_project_settings(project_root: &Path, s: &ProjectSettings) -> Result<(), String> {
let config_path = project_root.join(".huskies/project.toml");
svc::write_project_settings(project_root, s).map_err(|e| e.to_string())
}
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(())
/// Return the configured editor command from the store, or `None` if not set.
pub fn get_editor_command_from_store(ctx: &AppContext) -> Option<String> {
svc::get_editor_command(&*ctx.store)
}
#[derive(Tags)]
@@ -205,11 +64,7 @@ impl SettingsApi {
/// Get the configured editor command (e.g. "zed", "code", "cursor"), or null if not set.
#[oai(path = "/settings/editor", method = "get")]
async fn get_editor(&self) -> OpenApiResult<Json<EditorCommandResponse>> {
let editor_command = self
.ctx
.store
.get(EDITOR_COMMAND_KEY)
.and_then(|v| v.as_str().map(|s| s.to_string()));
let editor_command = get_editor_command_from_store(&self.ctx);
Ok(Json(EditorCommandResponse { editor_command }))
}
@@ -223,19 +78,8 @@ impl SettingsApi {
path: Query<String>,
line: Query<Option<u32>>,
) -> OpenApiResult<Json<OpenFileResponse>> {
let editor_command = get_editor_command_from_store(&self.ctx)
.ok_or_else(|| bad_request("No editor configured".to_string()))?;
let file_ref = match line.0 {
Some(l) => format!("{}:{}", path.0, l),
None => path.0.clone(),
};
std::process::Command::new(&editor_command)
.arg(&file_ref)
.spawn()
.map_err(|e| bad_request(format!("Failed to open editor: {e}")))?;
svc::open_file_in_editor(&*self.ctx.store, &path.0, line.0)
.map_err(|e| bad_request(e.to_string()))?;
Ok(Json(OpenFileResponse { success: true }))
}
@@ -243,8 +87,9 @@ impl SettingsApi {
#[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)))
let s =
svc::load_project_settings(&project_root).map_err(|e| bad_request(e.to_string()))?;
Ok(Json(s))
}
/// Update project.toml scalar settings. Array sections (component, agent) are preserved.
@@ -257,10 +102,12 @@ impl SettingsApi {
) -> 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)?;
svc::write_project_settings(&project_root, &payload.0)
.map_err(|e| bad_request(e.to_string()))?;
// Re-read to confirm what was written
let config = ProjectConfig::load(&project_root).map_err(bad_request)?;
Ok(Json(settings_from_config(&config)))
let s =
svc::load_project_settings(&project_root).map_err(|e| bad_request(e.to_string()))?;
Ok(Json(s))
}
/// Set the preferred editor command (e.g. "zed", "code", "cursor").
@@ -294,12 +141,6 @@ impl SettingsApi {
}
}
pub fn get_editor_command_from_store(ctx: &AppContext) -> Option<String> {
ctx.store
.get(EDITOR_COMMAND_KEY)
.and_then(|v| v.as_str().map(|s| s.to_string()))
}
#[cfg(test)]
impl From<std::sync::Arc<AppContext>> for SettingsApi {
fn from(ctx: std::sync::Arc<AppContext>) -> Self {
@@ -556,7 +397,7 @@ mod tests {
// ── /api/settings GET/PUT ──────────────────────────────────────────────
fn default_project_settings() -> ProjectSettings {
let cfg = ProjectConfig::default();
let cfg = crate::config::ProjectConfig::default();
settings_from_config(&cfg)
}
@@ -709,7 +550,7 @@ path = "."
write_project_settings(dir.path(), &s).unwrap();
let config = ProjectConfig::load(dir.path()).unwrap();
let config = crate::config::ProjectConfig::load(dir.path()).unwrap();
let loaded = settings_from_config(&config);
assert_eq!(loaded.default_qa, "agent");
@@ -763,7 +604,7 @@ path = "."
};
write_project_settings(dir.path(), &s_clear).unwrap();
let config = ProjectConfig::load(dir.path()).unwrap();
let config = crate::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());