diff options
| author | soryu <soryu@soryu.co> | 2026-04-28 00:18:40 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-28 00:18:40 +0100 |
| commit | c8b169da8cb7eae0957e0ab5e7370b071093a224 (patch) | |
| tree | c3f9720a8acfe863ac0b65df9439abf9a941323a /makima/src | |
| parent | 3679ceb3325033faa2f889ef3dfee5668ef7aeea (diff) | |
| download | soryu-c8b169da8cb7eae0957e0ab5e7370b071093a224.tar.gz soryu-c8b169da8cb7eae0957e0ab5e7370b071093a224.zip | |
feat: Document UI for directive orchestration with Lexical editor (#93)
* WIP: heartbeat checkpoint
* feat: soryu-co/soryu - makima: Save previous goal on update and include history in re-planning prompt
* feat: soryu-co/soryu - makima: Install Lexical and create base document editor component
* feat: soryu-co/soryu - makima: Create directive file system sidebar and document layout
* feat: soryu-co/soryu - makima: Create custom Lexical step diagram block
* feat: soryu-co/soryu - makima: Add context menu and goal auto-update integration
* WIP: heartbeat checkpoint
Diffstat (limited to 'makima/src')
| -rw-r--r-- | makima/src/db/models.rs | 27 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 82 | ||||
| -rw-r--r-- | makima/src/orchestration/directive.rs | 30 | ||||
| -rw-r--r-- | makima/src/server/handlers/mod.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/handlers/settings.rs | 196 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 11 |
6 files changed, 344 insertions, 3 deletions
diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 97657dc..c03b4ac 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -3050,4 +3050,31 @@ pub struct DirectiveOrderGroupListResponse { pub total: i64, } +/// User setting record from the database (key-value per owner). +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UserSetting { + pub id: Uuid, + pub owner_id: Uuid, + pub key: String, + pub value: serde_json::Value, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +/// Request body for upserting a user setting. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpsertUserSettingRequest { + pub key: String, + pub value: serde_json::Value, +} + +/// Response containing a list of user settings. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UserSettingsResponse { + pub settings: Vec<UserSetting>, +} + diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 57e8a78..401da94 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -21,6 +21,7 @@ use super::models::{ PhaseDefinition, SupervisorHeartbeatRecord, SupervisorState, Task, TaskCheckpoint, TaskEvent, TaskSummary, UpdateContractRequest, UpdateFileRequest, UpdateTaskRequest, UpdateTemplateRequest, + UserSetting, }; /// Repository error types. @@ -6698,3 +6699,84 @@ pub async fn get_available_orders_for_dog_pickup( .await } +// ─── User Settings ─────────────────────────────────────────────────────────── + +/// Get all settings for a given owner. +pub async fn get_user_settings( + pool: &PgPool, + owner_id: Uuid, +) -> Result<Vec<UserSetting>, sqlx::Error> { + sqlx::query_as::<_, UserSetting>( + r#" + SELECT id, owner_id, key, value, created_at, updated_at + FROM user_settings + WHERE owner_id = $1 + ORDER BY key ASC + "#, + ) + .bind(owner_id) + .fetch_all(pool) + .await +} + +/// Get a single setting by owner and key. +pub async fn get_user_setting( + pool: &PgPool, + owner_id: Uuid, + key: &str, +) -> Result<Option<UserSetting>, sqlx::Error> { + sqlx::query_as::<_, UserSetting>( + r#" + SELECT id, owner_id, key, value, created_at, updated_at + FROM user_settings + WHERE owner_id = $1 AND key = $2 + "#, + ) + .bind(owner_id) + .bind(key) + .fetch_optional(pool) + .await +} + +/// Upsert a user setting (insert or update on conflict). +pub async fn upsert_user_setting( + pool: &PgPool, + owner_id: Uuid, + key: &str, + value: &serde_json::Value, +) -> Result<UserSetting, sqlx::Error> { + sqlx::query_as::<_, UserSetting>( + r#" + INSERT INTO user_settings (owner_id, key, value) + VALUES ($1, $2, $3) + ON CONFLICT (owner_id, key) DO UPDATE + SET value = EXCLUDED.value, updated_at = now() + RETURNING id, owner_id, key, value, created_at, updated_at + "#, + ) + .bind(owner_id) + .bind(key) + .bind(value) + .fetch_one(pool) + .await +} + +/// Delete a user setting. Returns true if a row was deleted. +pub async fn delete_user_setting( + pool: &PgPool, + owner_id: Uuid, + key: &str, +) -> Result<bool, sqlx::Error> { + let result = sqlx::query( + r#" + DELETE FROM user_settings + WHERE owner_id = $1 AND key = $2 + "#, + ) + .bind(owner_id) + .bind(key) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs index 1e025c8..19077c9 100644 --- a/makima/src/orchestration/directive.rs +++ b/makima/src/orchestration/directive.rs @@ -442,8 +442,34 @@ impl DirectiveOrchestrator { let directives = repository::get_directives_needing_replanning(&self.pool).await?; for directive in directives { - if let Err(e) = async { - tracing::info!( + tracing::info!( + directive_id = %directive.id, + title = %directive.title, + "Directive goal updated — spawning re-planning task" + ); + + let existing_steps = + repository::list_directive_steps(&self.pool, directive.id).await?; + let generation = + repository::get_directive_max_generation(&self.pool, directive.id).await? + 1; + 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 let Err(e) = self + .spawn_orchestrator_task( + directive.id, + directive.owner_id, + format!("Re-plan: {}", directive.title), + plan, + directive.repository_url.as_deref(), + directive.base_branch.as_deref(), + ) + .await + { + tracing::warn!( directive_id = %directive.id, title = %directive.title, "Directive goal updated — spawning re-planning task" diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index 4bdb424..b3c433b 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -20,6 +20,7 @@ pub mod mesh_merge; pub mod mesh_supervisor; pub mod mesh_ws; pub mod repository_history; +pub mod settings; pub mod speak; pub mod templates; pub mod voice; diff --git a/makima/src/server/handlers/settings.rs b/makima/src/server/handlers/settings.rs new file mode 100644 index 0000000..ae52d5a --- /dev/null +++ b/makima/src/server/handlers/settings.rs @@ -0,0 +1,196 @@ +//! HTTP handlers for user settings (feature flags / preferences). + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; + +use crate::db::models::{UpsertUserSettingRequest, UserSettingsResponse}; +use crate::db::repository; +use crate::server::auth::Authenticated; +use crate::server::messages::ApiError; +use crate::server::state::SharedState; + +/// List all settings for the authenticated user. +#[utoipa::path( + get, + path = "/api/v1/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 = "Settings" +)] +pub async fn list_settings( + State(state): State<SharedState>, + Authenticated(user): Authenticated, +) -> 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 repository::get_user_settings(pool, user.owner_id).await { + Ok(settings) => Json(UserSettingsResponse { settings }).into_response(), + Err(e) => { + tracing::error!("Failed to list user settings: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Get a specific setting by key. +#[utoipa::path( + get, + path = "/api/v1/settings/{key}", + params( + ("key" = String, Path, description = "Setting key") + ), + responses( + (status = 200, description = "User setting", body = crate::db::models::UserSetting), + (status = 401, description = "Not authenticated", body = ApiError), + (status = 404, description = "Setting not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []) + ), + tag = "Settings" +)] +pub async fn get_setting( + State(state): State<SharedState>, + Authenticated(user): Authenticated, + Path(key): Path<String>, +) -> 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 repository::get_user_setting(pool, user.owner_id, &key).await { + Ok(Some(setting)) => Json(setting).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", format!("Setting '{}' not found", key))), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get user setting: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Upsert a user setting (create or update). +#[utoipa::path( + put, + path = "/api/v1/settings", + request_body = UpsertUserSettingRequest, + responses( + (status = 200, description = "Setting upserted", body = crate::db::models::UserSetting), + (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 = "Settings" +)] +pub async fn upsert_setting( + State(state): State<SharedState>, + Authenticated(user): Authenticated, + Json(req): Json<UpsertUserSettingRequest>, +) -> 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 repository::upsert_user_setting(pool, user.owner_id, &req.key, &req.value).await { + Ok(setting) => Json(setting).into_response(), + Err(e) => { + tracing::error!("Failed to upsert user setting: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Delete a user setting by key. +#[utoipa::path( + delete, + path = "/api/v1/settings/{key}", + params( + ("key" = String, Path, description = "Setting key") + ), + responses( + (status = 200, description = "Setting deleted"), + (status = 401, description = "Not authenticated", body = ApiError), + (status = 404, description = "Setting not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []) + ), + tag = "Settings" +)] +pub async fn delete_setting( + State(state): State<SharedState>, + Authenticated(user): Authenticated, + Path(key): Path<String>, +) -> 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 repository::delete_user_setting(pool, user.owner_id, &key).await { + Ok(true) => StatusCode::OK.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", format!("Setting '{}' not found", key))), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to delete user setting: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index b382f04..025ec85 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -18,7 +18,7 @@ use tower_http::trace::TraceLayer; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; -use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contract_discuss, contracts, daemon_download, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, speak, templates, transcript_analysis, users, versions}; +use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contract_discuss, contracts, daemon_download, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, settings, speak, templates, transcript_analysis, users, versions}; use crate::server::openapi::ApiDoc; use crate::server::state::SharedState; @@ -281,6 +281,15 @@ pub fn make_router(state: SharedState) -> Router { .route("/timeline", get(history::get_timeline)) // Contract type templates (built-in only) .route("/contract-types", get(templates::list_contract_types)) + // User settings (feature flags) endpoints + .route( + "/user-settings", + get(settings::list_settings).put(settings::upsert_setting), + ) + .route( + "/user-settings/{key}", + get(settings::get_setting).delete(settings::delete_setting), + ) // Settings endpoints .route( "/settings/repository-history", |
