diff options
| author | soryu <soryu@soryu.co> | 2026-04-29 01:10:11 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-29 01:10:11 +0100 |
| commit | 4b1d608b839769052634b4facc345b891d468926 (patch) | |
| tree | 1d5ff45b5b34b2e3e378a4cf69fd62ff39cf12de /makima/src | |
| parent | 5bde7c2d7e099fd9c8b2615602ab1d096bd9b6be (diff) | |
| download | soryu-4b1d608b839769052634b4facc345b891d468926.tar.gz soryu-4b1d608b839769052634b4facc345b891d468926.zip | |
feat: document-mode directive UI proof of concept (Lexical) (#101)
* WIP: heartbeat checkpoint
* feat: soryu-co/soryu - makima: Backend: feature flag + goal-edit interrupt messaging
* WIP: heartbeat checkpoint
* WIP: heartbeat checkpoint
* feat: soryu-co/soryu - makima: Frontend: Lexical document editor with step blocks, context menu, countdown
Diffstat (limited to 'makima/src')
| -rw-r--r-- | makima/src/db/repository.rs | 50 | ||||
| -rw-r--r-- | makima/src/orchestration/directive.rs | 346 | ||||
| -rw-r--r-- | makima/src/server/handlers/directives.rs | 85 | ||||
| -rw-r--r-- | makima/src/server/handlers/users.rs | 154 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 5 | ||||
| -rw-r--r-- | makima/src/server/openapi.rs | 4 |
6 files changed, 624 insertions, 20 deletions
diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 57e8a78..ca07d92 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -4962,6 +4962,22 @@ pub async fn get_directive_for_owner( .await } +/// Get a directive without an owner scope check. +/// +/// Used by background orchestration code that has already established the +/// directive identity through other means (e.g. it just received the +/// directive_id from a different already-authorized query). HTTP handlers +/// must continue to use `get_directive_for_owner` to enforce isolation. +pub async fn get_directive( + pool: &PgPool, + id: Uuid, +) -> Result<Option<Directive>, sqlx::Error> { + sqlx::query_as::<_, Directive>(r#"SELECT * FROM directives WHERE id = $1"#) + .bind(id) + .fetch_optional(pool) + .await +} + /// Get a directive with all its steps. pub async fn get_directive_with_steps_for_owner( pool: &PgPool, @@ -5637,6 +5653,40 @@ pub async fn update_directive_goal( .await } +/// Update a directive's goal WITHOUT clearing the orchestrator task id. +/// +/// This is the path used by the goal-edit interrupt cycle: when a small goal +/// edit arrives while a planner is already running, we want to keep the +/// planner attached so a `SendMessage` can summarise the change in-flight +/// instead of cancelling and respawning. We still bump `goal_updated_at` so +/// the timestamp reflects the edit, but we do NOT trigger replanning by +/// clearing the orchestrator task. We also do not flip status from +/// idle/paused → active here, since by definition a planner is already +/// running. +pub async fn update_directive_goal_keep_orchestrator( + pool: &PgPool, + owner_id: Uuid, + directive_id: Uuid, + goal: &str, +) -> Result<Option<Directive>, sqlx::Error> { + sqlx::query_as::<_, Directive>( + r#" + UPDATE directives + SET goal = $3, + goal_updated_at = NOW(), + updated_at = NOW(), + version = version + 1 + WHERE id = $1 AND owner_id = $2 + RETURNING * + "#, + ) + .bind(directive_id) + .bind(owner_id) + .bind(goal) + .fetch_optional(pool) + .await +} + /// Save a goal to the directive goal history. pub async fn save_directive_goal_history( pool: &PgPool, diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs index 8b3ae7e..22279e8 100644 --- a/makima/src/orchestration/directive.rs +++ b/makima/src/orchestration/directive.rs @@ -56,7 +56,7 @@ impl DirectiveOrchestrator { "Directive needs planning — spawning planning task" ); - let plan = build_planning_prompt(&directive, &[], 1, &[]); + let plan = build_planning_prompt(&directive, &[], 1, &[], None); if let Err(e) = self .spawn_orchestrator_task( @@ -477,8 +477,20 @@ impl DirectiveOrchestrator { let goal_history = repository::get_directive_goal_history(&self.pool, directive.id, 3).await?; - let plan = - build_planning_prompt(&directive, &existing_steps, generation, &goal_history); + // If steps are currently running (or recently completed), build a + // WORK IN PROGRESS summary for the planner so it doesn't re-issue + // already-running work. We only include this section when there + // really is work in flight — pure pending plans don't need it. + let progress_summary = + summarize_in_progress_steps(&existing_steps); + + let plan = build_planning_prompt( + &directive, + &existing_steps, + generation, + &goal_history, + progress_summary.as_deref(), + ); if let Err(e) = self .spawn_orchestrator_task( @@ -1390,15 +1402,97 @@ pub async fn trigger_completion_task( Ok(task.id) } +/// Summarize currently-running and recently-completed steps for the planner. +/// +/// Returns `None` when there is no in-flight or recently-completed work to +/// report; otherwise returns a multi-line block listing running steps first +/// (these are the ones the planner most needs to be aware of so it doesn't +/// re-issue them) followed by recently completed steps. +fn summarize_in_progress_steps( + steps: &[crate::db::models::DirectiveStep], +) -> Option<String> { + let mut running: Vec<&crate::db::models::DirectiveStep> = Vec::new(); + let mut completed: Vec<&crate::db::models::DirectiveStep> = Vec::new(); + + for step in steps { + match step.status.as_str() { + "running" => running.push(step), + "completed" => completed.push(step), + _ => {} + } + } + + if running.is_empty() && completed.is_empty() { + return None; + } + + let mut out = String::new(); + if !running.is_empty() { + out.push_str("Currently running:\n"); + for step in &running { + out.push_str(&format!( + " • {} (id: {}){}\n", + step.name, + step.id, + step.description + .as_deref() + .map(|d| format!(" — {}", d)) + .unwrap_or_default() + )); + } + } + if !completed.is_empty() { + if !running.is_empty() { + out.push('\n'); + } + out.push_str("Recently completed (work already done — do not re-issue):\n"); + for step in &completed { + out.push_str(&format!( + " • {} (id: {}){}\n", + step.name, + step.id, + step.description + .as_deref() + .map(|d| format!(" — {}", d)) + .unwrap_or_default() + )); + } + } + + Some(out) +} + /// Build the planning prompt for a directive. +/// +/// `progress_summary` — when supplied, a `WORK IN PROGRESS` section is rendered +/// near the top of the prompt so the (re)planning task knows what step work is +/// currently in flight or recently completed. This is used by replanning when +/// steps are running but the planner has finished, so the new plan can take +/// in-progress work into account instead of re-issuing it. fn build_planning_prompt( directive: &crate::db::models::Directive, existing_steps: &[crate::db::models::DirectiveStep], generation: i32, goal_history: &[crate::db::models::DirectiveGoalHistory], + progress_summary: Option<&str>, ) -> String { let mut prompt = String::new(); + if let Some(progress) = progress_summary { + let trimmed = progress.trim(); + if !trimmed.is_empty() { + prompt.push_str("── WORK IN PROGRESS ──\n"); + prompt.push_str( + "Steps from the previous plan are already executing or recently completed. \ + Take this into account when revising the plan: do not re-issue work that is \ + already underway, and prefer to extend / refine the in-flight chain rather \ + than rebuild it.\n\n", + ); + prompt.push_str(trimmed); + prompt.push_str("\n\n"); + } + } + if !existing_steps.is_empty() { // ── RE-PLANNING header ────────────────────────────────────── prompt.push_str(&format!( @@ -2364,3 +2458,249 @@ Do NOT ask questions for trivial decisions — use your best judgment. prompt } + +// ============================================================================= +// Goal-edit classification (small vs large) and interrupt helpers +// ============================================================================= + +/// Classification of a goal change for the goal-edit interrupt cycle. +/// +/// When a user edits a directive's goal while a planning/replanning task is +/// already running, we want to differentiate between: +/// • Small edits (typo fixes, clarifications, small additions) → interrupt +/// the current planner with a `SendMessage` so it can adjust its in-flight +/// plan rather than throwing away its work. +/// • Large edits (substantial rewrites, completely different objective) → +/// fall back to the existing replan path (cancel + spawn a new planner). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GoalChangeKind { + /// Small change — interrupt the running planner with the diff. + Small, + /// Large change — proceed with full replan. + Large, +} + +/// Heuristic: classify a goal edit as small or large. +/// +/// Rules (POC heuristic, kept deliberately simple): +/// 1. Empty old goal or empty new goal → Large (treat as a fresh start). +/// 2. If one goal is a prefix of the other → Small (pure addition / truncation). +/// 3. If the absolute length difference relative to the longer goal is < 0.3, +/// classify as Small. Otherwise Large. +pub fn classify_goal_change(old: &str, new: &str) -> GoalChangeKind { + let old = old.trim(); + let new = new.trim(); + + if old.is_empty() || new.is_empty() { + return GoalChangeKind::Large; + } + + if old == new { + // No content change — treat as small (no-op for the planner). + return GoalChangeKind::Small; + } + + // Pure prefix changes (added a sentence at the end, or removed a trailing + // clause) are almost always small. + if old.starts_with(new) || new.starts_with(old) { + return GoalChangeKind::Small; + } + + let old_len = old.chars().count(); + let new_len = new.chars().count(); + let longer = old_len.max(new_len) as f64; + let diff = (old_len as i64 - new_len as i64).unsigned_abs() as f64; + if longer == 0.0 { + return GoalChangeKind::Large; + } + let length_ratio = diff / longer; + + if length_ratio < 0.3 { + GoalChangeKind::Small + } else { + GoalChangeKind::Large + } +} + +/// Format the goal-edit interrupt message sent to a running planner task +/// when the user edits the directive goal mid-flight. +pub fn build_goal_edit_interrupt_message(old_goal: &str, new_goal: &str) -> String { + format!( + "GOAL_UPDATED: The user has edited the directive goal. Summary of changes follows. \ + Adjust your current plan in-flight rather than starting over.\n\ + --- OLD GOAL ---\n\ + {old}\n\ + --- NEW GOAL ---\n\ + {new}\n", + old = old_goal, + new = new_goal, + ) +} + +/// Result of attempting to send a goal-edit interrupt to a running planner. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GoalEditInterruptResult { + /// A `SendMessage` daemon command was dispatched to the running planner. + Sent, + /// No suitable planner task was running, or the change was classified as + /// large — caller should fall through to the regular replanning path. + Skipped, +} + +/// Attempt to interrupt a directive's currently-running planner with a goal +/// edit summary instead of replanning from scratch. +/// +/// Returns `Ok(GoalEditInterruptResult::Sent)` when a `SendMessage` was +/// dispatched. Returns `Ok(GoalEditInterruptResult::Skipped)` when the change +/// was large, no orchestrator task exists, the task has already finished, or +/// no daemon is currently assigned. +/// +/// This function is best-effort: errors talking to the daemon are logged and +/// translated into `Skipped` so the caller can fall through to the normal +/// replan path. +pub async fn try_interrupt_planner_with_goal_edit( + pool: &PgPool, + state: &SharedState, + directive_id: Uuid, + old_goal: &str, + new_goal: &str, +) -> Result<GoalEditInterruptResult, anyhow::Error> { + // Only fire if the change classifies as small. + if classify_goal_change(old_goal, new_goal) != GoalChangeKind::Small { + tracing::debug!( + directive_id = %directive_id, + "Goal change classified as large — skipping planner interrupt" + ); + return Ok(GoalEditInterruptResult::Skipped); + } + + // Look up the directive's current orchestrator task (planner). + let directive = match repository::get_directive(pool, directive_id).await? { + Some(d) => d, + None => return Ok(GoalEditInterruptResult::Skipped), + }; + let Some(orchestrator_task_id) = directive.orchestrator_task_id else { + return Ok(GoalEditInterruptResult::Skipped); + }; + + // Fetch the planner task to confirm it's still queued/running. + let task = match repository::get_task(pool, orchestrator_task_id).await? { + Some(t) => t, + None => return Ok(GoalEditInterruptResult::Skipped), + }; + + let interruptible = matches!( + task.status.as_str(), + "queued" | "pending" | "starting" | "running" + ); + if !interruptible { + tracing::debug!( + directive_id = %directive_id, + task_id = %orchestrator_task_id, + task_status = %task.status, + "Planner task is not in an interruptible state — skipping interrupt" + ); + return Ok(GoalEditInterruptResult::Skipped); + } + + let Some(daemon_id) = task.daemon_id else { + tracing::debug!( + directive_id = %directive_id, + task_id = %orchestrator_task_id, + "Planner task has no assigned daemon — skipping interrupt" + ); + return Ok(GoalEditInterruptResult::Skipped); + }; + + let message = build_goal_edit_interrupt_message(old_goal, new_goal); + let command = DaemonCommand::SendMessage { + task_id: orchestrator_task_id, + message, + }; + + match state.send_daemon_command(daemon_id, command).await { + Ok(()) => { + tracing::info!( + directive_id = %directive_id, + task_id = %orchestrator_task_id, + daemon_id = %daemon_id, + "Sent goal-edit interrupt to running planner" + ); + Ok(GoalEditInterruptResult::Sent) + } + Err(e) => { + tracing::warn!( + directive_id = %directive_id, + task_id = %orchestrator_task_id, + daemon_id = %daemon_id, + error = %e, + "Failed to send goal-edit interrupt — falling back to replan" + ); + Ok(GoalEditInterruptResult::Skipped) + } + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifier_identical_goal_is_small() { + assert_eq!( + classify_goal_change("Build a todo app", "Build a todo app"), + GoalChangeKind::Small + ); + } + + #[test] + fn classifier_pure_addition_is_small() { + let old = "Build a todo app"; + let new = "Build a todo app with authentication"; + assert_eq!(classify_goal_change(old, new), GoalChangeKind::Small); + } + + #[test] + fn classifier_pure_truncation_is_small() { + let old = "Build a todo app with authentication and tests"; + let new = "Build a todo app"; + assert_eq!(classify_goal_change(old, new), GoalChangeKind::Small); + } + + #[test] + fn classifier_typo_fix_is_small() { + // Same length, single character diff — well below 0.3 length ratio. + let old = "Build a todo aap with authentication and tests today"; + let new = "Build a todo app with authentication and tests today"; + assert_eq!(classify_goal_change(old, new), GoalChangeKind::Small); + } + + #[test] + fn classifier_completely_different_is_large() { + // Wildly different lengths and content. + let old = "Build a todo app"; + let new = "Migrate the entire backend to Rust, port the frontend to Svelte, \ + and add a new realtime collaboration feature with operational transforms"; + assert_eq!(classify_goal_change(old, new), GoalChangeKind::Large); + } + + #[test] + fn classifier_empty_goals_are_large() { + assert_eq!(classify_goal_change("", "Anything"), GoalChangeKind::Large); + assert_eq!(classify_goal_change("Anything", ""), GoalChangeKind::Large); + } + + #[test] + fn interrupt_message_contains_old_and_new() { + let msg = build_goal_edit_interrupt_message("OLD", "NEW"); + assert!(msg.contains("GOAL_UPDATED")); + assert!(msg.contains("OLD")); + assert!(msg.contains("NEW")); + assert!(msg.contains("--- OLD GOAL ---")); + assert!(msg.contains("--- NEW GOAL ---")); + } +} 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, ¤t.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, ¤t.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(¤t.goal, &req.goal) == GoalChangeKind::Small + { + match try_interrupt_planner_with_goal_edit( + pool, + &state, + id, + ¤t.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, |
