summaryrefslogtreecommitdiff
path: root/makima/src/server
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/server')
-rw-r--r--makima/src/server/handlers/directives.rs85
-rw-r--r--makima/src/server/handlers/users.rs154
-rw-r--r--makima/src/server/mod.rs5
-rw-r--r--makima/src/server/openapi.rs4
4 files changed, 231 insertions, 17 deletions
diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs
index d1edf7e..01c4659 100644
--- a/makima/src/server/handlers/directives.rs
+++ b/makima/src/server/handlers/directives.rs
@@ -18,7 +18,10 @@ use crate::db::models::{
OrderListResponse,
};
use crate::db::repository;
-use crate::orchestration::directive::{build_cleanup_prompt, build_order_pickup_prompt};
+use crate::orchestration::directive::{
+ build_cleanup_prompt, build_order_pickup_prompt, classify_goal_change,
+ try_interrupt_planner_with_goal_edit, GoalChangeKind, GoalEditInterruptResult,
+};
use crate::server::auth::Authenticated;
use crate::server::messages::ApiError;
use crate::server::state::SharedState;
@@ -827,31 +830,79 @@ pub async fn update_goal(
.into_response();
};
- // Save old goal to history before overwriting (best-effort)
- match repository::get_directive_for_owner(pool, auth.owner_id, id).await {
- Ok(Some(current)) => {
- if let Err(e) = repository::save_directive_goal_history(pool, id, &current.goal).await
- {
- tracing::warn!(
- directive_id = %id,
- error = %e,
- "Failed to save goal history before update — continuing with goal update"
- );
- }
- }
- Ok(None) => {
- // Directive not found — update_directive_goal will handle this
- }
+ // Fetch the current directive so we can:
+ // 1. Save the old goal to history (best-effort).
+ // 2. Decide whether to fire a goal-edit interrupt at a running planner.
+ let current = match repository::get_directive_for_owner(pool, auth.owner_id, id).await {
+ Ok(Some(d)) => Some(d),
+ Ok(None) => None,
Err(e) => {
tracing::warn!(
directive_id = %id,
error = %e,
"Failed to fetch current directive for goal history — continuing with goal update"
);
+ None
+ }
+ };
+
+ // Save old goal to history before overwriting (best-effort).
+ if let Some(ref current) = current {
+ if let Err(e) = repository::save_directive_goal_history(pool, id, &current.goal).await {
+ tracing::warn!(
+ directive_id = %id,
+ error = %e,
+ "Failed to save goal history before update — continuing with goal update"
+ );
+ }
+ }
+
+ // Goal-edit interrupt cycle: if a planner task is currently running for
+ // this directive AND the goal change classifies as 'small', interrupt the
+ // running planner via SendMessage instead of clearing it (which would
+ // trigger a fresh replan on the next orchestrator tick).
+ let mut interrupted = false;
+ if let Some(ref current) = current {
+ if current.orchestrator_task_id.is_some()
+ && classify_goal_change(&current.goal, &req.goal) == GoalChangeKind::Small
+ {
+ match try_interrupt_planner_with_goal_edit(
+ pool,
+ &state,
+ id,
+ &current.goal,
+ &req.goal,
+ )
+ .await
+ {
+ Ok(GoalEditInterruptResult::Sent) => {
+ interrupted = true;
+ }
+ Ok(GoalEditInterruptResult::Skipped) => {}
+ Err(e) => {
+ tracing::warn!(
+ directive_id = %id,
+ error = %e,
+ "Goal-edit interrupt attempt errored — falling back to replan"
+ );
+ }
+ }
}
}
- match repository::update_directive_goal(pool, auth.owner_id, id, &req.goal).await {
+ // If we successfully interrupted a running planner, persist the new goal
+ // WITHOUT clearing the orchestrator task — the planner will react to the
+ // SendMessage and adjust in-flight. Otherwise, fall through to the normal
+ // path which clears orchestrator_task_id and lets phase_replanning kick
+ // in on the next tick.
+ let update_result = if interrupted {
+ repository::update_directive_goal_keep_orchestrator(pool, auth.owner_id, id, &req.goal)
+ .await
+ } else {
+ repository::update_directive_goal(pool, auth.owner_id, id, &req.goal).await
+ };
+
+ match update_result {
Ok(Some(directive)) => Json(directive).into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
diff --git a/makima/src/server/handlers/users.rs b/makima/src/server/handlers/users.rs
index 0b2ccdd..0b86592 100644
--- a/makima/src/server/handlers/users.rs
+++ b/makima/src/server/handlers/users.rs
@@ -928,6 +928,160 @@ pub async fn delete_account_handler(
}
// =============================================================================
+// User Settings (per-user feature flags)
+// =============================================================================
+
+/// User settings response.
+#[derive(Debug, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct UserSettingsResponse {
+ /// Whether the new "document mode" UI is enabled for this user.
+ pub document_mode_enabled: bool,
+}
+
+/// Update user settings request.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateUserSettingsRequest {
+ /// Whether to enable the new "document mode" UI for this user.
+ pub document_mode_enabled: bool,
+}
+
+/// Get the authenticated user's settings (feature flags).
+///
+/// Returns the user's per-user settings, currently consisting of feature flags
+/// used by the frontend to decide which UI to show.
+#[utoipa::path(
+ get,
+ path = "/api/v1/users/me/settings",
+ responses(
+ (status = 200, description = "User settings", body = UserSettingsResponse),
+ (status = 401, description = "Not authenticated", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = [])
+ ),
+ tag = "Users"
+)]
+pub async fn get_user_settings_handler(
+ State(state): State<SharedState>,
+ UserOnly(user): UserOnly,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match sqlx::query_scalar::<_, bool>(
+ "SELECT document_mode_enabled FROM users WHERE id = $1",
+ )
+ .bind(user.user_id)
+ .fetch_optional(pool)
+ .await
+ {
+ Ok(Some(document_mode_enabled)) => Json(UserSettingsResponse {
+ document_mode_enabled,
+ })
+ .into_response(),
+ Ok(None) => {
+ // User row missing — fall back to defaults rather than 404 since the
+ // user is authenticated.
+ tracing::warn!(user_id = %user.user_id, "User row not found when fetching settings — returning defaults");
+ Json(UserSettingsResponse {
+ document_mode_enabled: false,
+ })
+ .into_response()
+ }
+ Err(e) => {
+ tracing::error!("Failed to fetch user settings: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Update the authenticated user's settings (feature flags).
+///
+/// Replaces the user's settings record with the provided values.
+#[utoipa::path(
+ put,
+ path = "/api/v1/users/me/settings",
+ request_body = UpdateUserSettingsRequest,
+ responses(
+ (status = 200, description = "Updated user settings", body = UserSettingsResponse),
+ (status = 401, description = "Not authenticated", body = ApiError),
+ (status = 404, description = "User not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = [])
+ ),
+ tag = "Users"
+)]
+pub async fn update_user_settings_handler(
+ State(state): State<SharedState>,
+ UserOnly(user): UserOnly,
+ Json(req): Json<UpdateUserSettingsRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ let result = sqlx::query_scalar::<_, bool>(
+ r#"
+ UPDATE users
+ SET document_mode_enabled = $1, updated_at = NOW()
+ WHERE id = $2
+ RETURNING document_mode_enabled
+ "#,
+ )
+ .bind(req.document_mode_enabled)
+ .bind(user.user_id)
+ .fetch_optional(pool)
+ .await;
+
+ match result {
+ Ok(Some(document_mode_enabled)) => {
+ tracing::info!(
+ user_id = %user.user_id,
+ document_mode_enabled = document_mode_enabled,
+ "Updated user settings"
+ );
+ Json(UserSettingsResponse {
+ document_mode_enabled,
+ })
+ .into_response()
+ }
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("USER_NOT_FOUND", "User not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to update user settings: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+// =============================================================================
// Tests
// =============================================================================
diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs
index b382f04..beee0e9 100644
--- a/makima/src/server/mod.rs
+++ b/makima/src/server/mod.rs
@@ -162,6 +162,11 @@ pub fn make_router(state: SharedState) -> Router {
)
.route("/users/me/password", axum::routing::put(users::change_password_handler))
.route("/users/me/email", axum::routing::put(users::change_email_handler))
+ .route(
+ "/users/me/settings",
+ get(users::get_user_settings_handler)
+ .put(users::update_user_settings_handler),
+ )
// Contract endpoints
.route("/contracts/discuss", post(contract_discuss::discuss_contract_handler))
.route(
diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs
index 6065eeb..37cd113 100644
--- a/makima/src/server/openapi.rs
+++ b/makima/src/server/openapi.rs
@@ -90,6 +90,8 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
users::change_password_handler,
users::change_email_handler,
users::delete_account_handler,
+ users::get_user_settings_handler,
+ users::update_user_settings_handler,
// Contract endpoints
contracts::list_contracts,
contracts::get_contract,
@@ -210,6 +212,8 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
users::ChangeEmailResponse,
users::DeleteAccountRequest,
users::DeleteAccountResponse,
+ users::UserSettingsResponse,
+ users::UpdateUserSettingsRequest,
// Contract schemas
Contract,
ContractSummary,