huskies: merge 949

This commit is contained in:
dave
2026-05-13 07:10:00 +00:00
parent d87722f6c8
commit 4a0fbcaa95
15 changed files with 1454 additions and 231 deletions
+246
View File
@@ -31,6 +31,11 @@ use std::sync::{Arc, OnceLock};
use serde_json::Value;
use super::rpc_contract::{
BotConfigPayload, EditorSettingsResult, ForgetProjectParams, OkResult, OpenFileParams,
OpenProjectParams, OpenProjectResult, ProjectSettingsPayload, PutEditorParams,
SetAnthropicApiKeyParams, SetModelPreferenceParams,
};
use super::wire::RpcFrame;
use crate::state::SessionState;
use crate::store::JsonFileStore;
@@ -119,8 +124,50 @@ static HANDLERS: &[(&str, Handler)] = &[
("agents.get_output", |p| {
Box::pin(handle_agents_get_output(p))
}),
// ── typed write methods ──────────────────────────────────────────────
("model.set_preference", |p| {
Box::pin(handle_model_set_preference(p))
}),
("anthropic.set_api_key", |p| {
Box::pin(handle_anthropic_set_api_key(p))
}),
("settings.put_editor", |p| {
Box::pin(handle_settings_put_editor(p))
}),
("settings.open_file", |p| {
Box::pin(handle_settings_open_file(p))
}),
("settings.put_project", |p| {
Box::pin(handle_settings_put_project(p))
}),
("project.open", |p| Box::pin(handle_project_open(p))),
("project.close", |p| Box::pin(handle_project_close(p))),
("project.forget", |p| Box::pin(handle_project_forget(p))),
("bot_config.save", |p| Box::pin(handle_bot_config_save(p))),
("chat.cancel", |p| Box::pin(handle_chat_cancel(p))),
];
// ── typed-write helper macros ───────────────────────────────────────────────
/// Parse the incoming JSON params into a typed struct or return a typed error
/// response shaped as `{"error": "..."}`.
macro_rules! parse_params {
($params:expr, $ty:ty) => {
match serde_json::from_value::<$ty>($params) {
Ok(v) => v,
Err(e) => return serde_json::json!({"error": format!("invalid params: {e}")}),
}
};
}
fn ok_json() -> Value {
serde_json::to_value(OkResult { ok: true }).unwrap_or(Value::Null)
}
fn err_json(msg: impl Into<String>) -> Value {
serde_json::json!({"error": msg.into()})
}
// ── handlers ─────────────────────────────────────────────────────────────────
/// Handler for the `health.check` method.
@@ -532,6 +579,205 @@ async fn handle_agents_get_output(params: Value) -> Value {
}
}
// ── typed write handlers ────────────────────────────────────────────────────
/// Handler for `model.set_preference`. Persists the user's chosen LLM model.
async fn handle_model_set_preference(params: Value) -> Value {
let typed = parse_params!(params, SetModelPreferenceParams);
let Some(ctx) = RPC_CTX.get() else {
return err_json("RPC context not initialised");
};
match crate::io::fs::set_model_preference(typed.model, ctx.store.as_ref()) {
Ok(()) => ok_json(),
Err(e) => err_json(e),
}
}
/// Handler for `anthropic.set_api_key`. Stores an Anthropic API key.
async fn handle_anthropic_set_api_key(params: Value) -> Value {
let typed = parse_params!(params, SetAnthropicApiKeyParams);
let Some(ctx) = RPC_CTX.get() else {
return err_json("RPC context not initialised");
};
match crate::service::anthropic::set_api_key(ctx.store.as_ref(), typed.api_key) {
Ok(()) => ok_json(),
Err(e) => err_json(e.to_string()),
}
}
/// Handler for `settings.put_editor`. Updates the configured editor command.
async fn handle_settings_put_editor(params: Value) -> Value {
use crate::store::StoreOps;
let typed = parse_params!(params, PutEditorParams);
let Some(ctx) = RPC_CTX.get() else {
return err_json("RPC context not initialised");
};
let trimmed = typed
.editor_command
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
match trimmed {
Some(cmd) => {
ctx.store.set(
crate::service::settings::EDITOR_COMMAND_KEY,
serde_json::json!(cmd.clone()),
);
if let Err(e) = ctx.store.save() {
return err_json(e);
}
serde_json::to_value(EditorSettingsResult {
editor_command: Some(cmd),
})
.unwrap_or(Value::Null)
}
None => {
ctx.store
.delete(crate::service::settings::EDITOR_COMMAND_KEY);
if let Err(e) = ctx.store.save() {
return err_json(e);
}
serde_json::to_value(EditorSettingsResult {
editor_command: None,
})
.unwrap_or(Value::Null)
}
}
}
/// Handler for `settings.open_file`. Spawns the configured editor.
async fn handle_settings_open_file(params: Value) -> Value {
let typed = parse_params!(params, OpenFileParams);
let Some(ctx) = RPC_CTX.get() else {
return err_json("RPC context not initialised");
};
match crate::service::settings::open_file_in_editor(ctx.store.as_ref(), &typed.path, typed.line)
{
Ok(()) => ok_json(),
Err(e) => err_json(e.to_string()),
}
}
/// Handler for `settings.put_project`. Persists project.toml scalar settings.
async fn handle_settings_put_project(params: Value) -> Value {
let typed = parse_params!(params, ProjectSettingsPayload);
let Some(ctx) = RPC_CTX.get() else {
return err_json("RPC context not initialised");
};
let Ok(root) = ctx.state.get_project_root() else {
return err_json("No project open");
};
let domain = crate::service::settings::ProjectSettings {
default_qa: typed.default_qa,
default_coder_model: typed.default_coder_model,
max_coders: typed.max_coders,
max_retries: typed.max_retries,
base_branch: typed.base_branch,
rate_limit_notifications: typed.rate_limit_notifications,
timezone: typed.timezone,
rendezvous: typed.rendezvous,
watcher_sweep_interval_secs: typed.watcher_sweep_interval_secs,
watcher_done_retention_secs: typed.watcher_done_retention_secs,
};
if let Err(e) = crate::service::settings::validate_project_settings(&domain) {
return err_json(e.to_string());
}
if let Err(e) = crate::service::settings::write_project_settings(&root, &domain) {
return err_json(e.to_string());
}
match crate::service::settings::load_project_settings(&root) {
Ok(s) => serde_json::to_value(ProjectSettingsPayload {
default_qa: s.default_qa,
default_coder_model: s.default_coder_model,
max_coders: s.max_coders,
max_retries: s.max_retries,
base_branch: s.base_branch,
rate_limit_notifications: s.rate_limit_notifications,
timezone: s.timezone,
rendezvous: s.rendezvous,
watcher_sweep_interval_secs: s.watcher_sweep_interval_secs,
watcher_done_retention_secs: s.watcher_done_retention_secs,
})
.unwrap_or(Value::Null),
Err(e) => err_json(e.to_string()),
}
}
/// Handler for `project.open`. Opens a project on disk and updates session state.
async fn handle_project_open(params: Value) -> Value {
let typed = parse_params!(params, OpenProjectParams);
let Some(ctx) = RPC_CTX.get() else {
return err_json("RPC context not initialised");
};
let port = crate::http::resolve_port();
match crate::service::project::open_project(typed.path, &ctx.state, ctx.store.as_ref(), port)
.await
{
Ok(path) => serde_json::to_value(OpenProjectResult { path }).unwrap_or(Value::Null),
Err(e) => err_json(e.to_string()),
}
}
/// Handler for `project.close`. Closes the currently open project.
async fn handle_project_close(_params: Value) -> Value {
let Some(ctx) = RPC_CTX.get() else {
return err_json("RPC context not initialised");
};
match crate::service::project::close_project(&ctx.state, ctx.store.as_ref()) {
Ok(()) => ok_json(),
Err(e) => err_json(e.to_string()),
}
}
/// Handler for `project.forget`. Removes a project from the known list.
async fn handle_project_forget(params: Value) -> Value {
let typed = parse_params!(params, ForgetProjectParams);
let Some(ctx) = RPC_CTX.get() else {
return err_json("RPC context not initialised");
};
match crate::service::project::forget_known_project(typed.path, ctx.store.as_ref()) {
Ok(()) => ok_json(),
Err(e) => err_json(e.to_string()),
}
}
/// Handler for `bot_config.save`. Writes `.huskies/bot.toml`.
async fn handle_bot_config_save(params: Value) -> Value {
let typed = parse_params!(params, BotConfigPayload);
let Some(ctx) = RPC_CTX.get() else {
return err_json("RPC context not initialised");
};
let Ok(root) = ctx.state.get_project_root() else {
return err_json("No project open");
};
let bot_dir = root.join(".huskies");
if let Err(e) = std::fs::create_dir_all(&bot_dir) {
return err_json(format!("create .huskies dir: {e}"));
}
let bot_path = bot_dir.join("bot.toml");
match toml::to_string_pretty(&typed) {
Ok(s) => {
if let Err(e) = std::fs::write(&bot_path, s) {
return err_json(format!("write bot.toml: {e}"));
}
}
Err(e) => return err_json(format!("serialise bot.toml: {e}")),
}
serde_json::to_value(typed).unwrap_or(Value::Null)
}
/// Handler for `chat.cancel`. Cancels the in-flight LLM chat stream.
async fn handle_chat_cancel(_params: Value) -> Value {
let Some(ctx) = RPC_CTX.get() else {
return err_json("RPC context not initialised");
};
match crate::llm::chat::cancel_chat(&ctx.state) {
Ok(()) => ok_json(),
Err(e) => err_json(e),
}
}
// ── dispatch ──────────────────────────────────────────────────────────────────
/// Dispatch an incoming RPC method call to the registered handler.