summaryrefslogtreecommitdiff
path: root/makima/src
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src')
-rw-r--r--makima/src/db/models.rs27
-rw-r--r--makima/src/db/repository.rs82
-rw-r--r--makima/src/orchestration/directive.rs30
-rw-r--r--makima/src/server/handlers/mod.rs1
-rw-r--r--makima/src/server/handlers/settings.rs196
-rw-r--r--makima/src/server/mod.rs11
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",