From 94ab62cf87fe7f0e941328096b566accd2aba645 Mon Sep 17 00:00:00 2001 From: soryu Date: Tue, 10 Feb 2026 23:29:43 +0000 Subject: WIP: heartbeat checkpoint --- .../20260211000000_create_directive_memories.sql | 16 + makima/src/db/models.rs | 41 +++ makima/src/db/repository.rs | 132 ++++++- makima/src/server/handlers/directives.rs | 395 ++++++++++++++++++++- makima/src/server/mod.rs | 4 + makima/src/server/openapi.rs | 21 +- 6 files changed, 597 insertions(+), 12 deletions(-) create mode 100644 makima/migrations/20260211000000_create_directive_memories.sql diff --git a/makima/migrations/20260211000000_create_directive_memories.sql b/makima/migrations/20260211000000_create_directive_memories.sql new file mode 100644 index 0000000..5aae339 --- /dev/null +++ b/makima/migrations/20260211000000_create_directive_memories.sql @@ -0,0 +1,16 @@ +-- Directive memory system: persistent key-value storage for directives. +-- Allows directives to store and retrieve context across sessions. + +CREATE TABLE directive_memories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + directive_id UUID NOT NULL REFERENCES directives(id) ON DELETE CASCADE, + key VARCHAR(255) NOT NULL, + value TEXT NOT NULL, + category VARCHAR(100), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (directive_id, key) +); + +CREATE INDEX idx_directive_memories_directive_id ON directive_memories(directive_id); +CREATE INDEX idx_directive_memories_category ON directive_memories(directive_id, category); diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 9159fd5..ec96b27 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -2833,3 +2833,44 @@ pub struct UpdateDirectiveStepRequest { pub task_id: Option, pub order_index: Option, } + +// ============================================================================= +// Directive Memory Types +// ============================================================================= + +/// A memory entry for a directive — persistent key-value storage. +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DirectiveMemory { + pub id: Uuid, + pub directive_id: Uuid, + pub key: String, + pub value: String, + pub category: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// Request to set (upsert) a single directive memory entry. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SetDirectiveMemoryRequest { + pub key: String, + pub value: String, + pub category: Option, +} + +/// Request to batch set multiple directive memory entries. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct BatchSetDirectiveMemoryRequest { + pub memories: Vec, +} + +/// Response for listing directive memories. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DirectiveMemoryListResponse { + pub memories: Vec, + pub total: i64, +} diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 930a73e..1b058e7 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -11,9 +11,9 @@ use super::models::{ ContractTypeTemplateRecord, ConversationMessage, ConversationSnapshot, CreateContractRequest, CreateFileRequest, CreateTaskRequest, CreateTemplateRequest, Daemon, DaemonTaskAssignment, DaemonWithCapacity, - DeliverableDefinition, Directive, DirectiveStep, DirectiveSummary, - CreateDirectiveRequest, CreateDirectiveStepRequest, UpdateDirectiveRequest, - UpdateDirectiveStepRequest, + DeliverableDefinition, Directive, DirectiveMemory, DirectiveStep, DirectiveSummary, + CreateDirectiveRequest, CreateDirectiveStepRequest, SetDirectiveMemoryRequest, + UpdateDirectiveRequest, UpdateDirectiveStepRequest, File, FileSummary, FileVersion, HistoryEvent, HistoryQueryFilters, MeshChatConversation, MeshChatMessageRecord, PhaseChangeResult, PhaseConfig, PhaseDefinition, SupervisorHeartbeatRecord, SupervisorState, @@ -5612,3 +5612,129 @@ pub async fn get_directive_max_generation( .await?; Ok(row.0.unwrap_or(0)) } + +// ============================================================================= +// Directive Memory CRUD +// ============================================================================= + +/// List all memories for a directive, optionally filtered by category. +pub async fn list_directive_memories( + pool: &PgPool, + directive_id: Uuid, + category: Option<&str>, +) -> Result, sqlx::Error> { + match category { + Some(cat) => { + sqlx::query_as::<_, DirectiveMemory>( + r#" + SELECT * FROM directive_memories + WHERE directive_id = $1 AND category = $2 + ORDER BY key + "#, + ) + .bind(directive_id) + .bind(cat) + .fetch_all(pool) + .await + } + None => { + sqlx::query_as::<_, DirectiveMemory>( + r#" + SELECT * FROM directive_memories + WHERE directive_id = $1 + ORDER BY key + "#, + ) + .bind(directive_id) + .fetch_all(pool) + .await + } + } +} + +/// Get a single memory entry by directive ID and key. +pub async fn get_directive_memory( + pool: &PgPool, + directive_id: Uuid, + key: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, DirectiveMemory>( + r#" + SELECT * FROM directive_memories + WHERE directive_id = $1 AND key = $2 + "#, + ) + .bind(directive_id) + .bind(key) + .fetch_optional(pool) + .await +} + +/// Set (upsert) a single memory entry for a directive. +pub async fn set_directive_memory( + pool: &PgPool, + directive_id: Uuid, + req: &SetDirectiveMemoryRequest, +) -> Result { + sqlx::query_as::<_, DirectiveMemory>( + r#" + INSERT INTO directive_memories (directive_id, key, value, category) + VALUES ($1, $2, $3, $4) + ON CONFLICT (directive_id, key) + DO UPDATE SET value = EXCLUDED.value, + category = EXCLUDED.category, + updated_at = NOW() + RETURNING * + "#, + ) + .bind(directive_id) + .bind(&req.key) + .bind(&req.value) + .bind(&req.category) + .fetch_one(pool) + .await +} + +/// Batch set multiple memory entries for a directive. +pub async fn batch_set_directive_memories( + pool: &PgPool, + directive_id: Uuid, + memories: &[SetDirectiveMemoryRequest], +) -> Result, sqlx::Error> { + let mut results = Vec::with_capacity(memories.len()); + for mem in memories { + let result = set_directive_memory(pool, directive_id, mem).await?; + results.push(result); + } + Ok(results) +} + +/// Delete a single memory entry by directive ID and key. +pub async fn delete_directive_memory( + pool: &PgPool, + directive_id: Uuid, + key: &str, +) -> Result { + let result = sqlx::query( + r#"DELETE FROM directive_memories WHERE directive_id = $1 AND key = $2"#, + ) + .bind(directive_id) + .bind(key) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +/// Clear all memory entries for a directive. +pub async fn clear_directive_memories( + pool: &PgPool, + directive_id: Uuid, +) -> Result { + let result = sqlx::query( + r#"DELETE FROM directive_memories WHERE directive_id = $1"#, + ) + .bind(directive_id) + .execute(pool) + .await?; + Ok(result.rows_affected()) +} diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs index d48ff74..f624d82 100644 --- a/makima/src/server/handlers/directives.rs +++ b/makima/src/server/handlers/directives.rs @@ -1,23 +1,31 @@ //! HTTP handlers for directive CRUD and DAG progression. use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, Json, }; +use serde::Deserialize; use uuid::Uuid; use crate::db::models::{ - CreateDirectiveRequest, CreateDirectiveStepRequest, Directive, DirectiveListResponse, - DirectiveStep, DirectiveWithSteps, UpdateDirectiveRequest, UpdateDirectiveStepRequest, - UpdateGoalRequest, + BatchSetDirectiveMemoryRequest, CreateDirectiveRequest, CreateDirectiveStepRequest, + Directive, DirectiveListResponse, DirectiveMemory, DirectiveMemoryListResponse, + DirectiveStep, DirectiveWithSteps, SetDirectiveMemoryRequest, UpdateDirectiveRequest, + UpdateDirectiveStepRequest, UpdateGoalRequest, }; use crate::db::repository; use crate::server::auth::Authenticated; use crate::server::messages::ApiError; use crate::server::state::SharedState; +/// Query parameters for the memory list endpoint. +#[derive(Debug, Deserialize)] +pub struct MemoryListQuery { + pub category: Option, +} + // ============================================================================= // Directive CRUD // ============================================================================= @@ -839,3 +847,382 @@ pub async fn update_goal( } } } + +// ============================================================================= +// Directive Memory CRUD +// ============================================================================= + +/// List all memories for a directive, optionally filtered by category. +#[utoipa::path( + get, + path = "/api/v1/directives/{id}/memories", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("category" = Option, Query, description = "Filter by category"), + ), + responses( + (status = 200, description = "List of memories", body = DirectiveMemoryListResponse), + (status = 404, description = "Directive not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn list_memories( + State(state): State, + Authenticated(auth): Authenticated, + Path(id): Path, + Query(query): Query, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify directive ownership + match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::list_directive_memories(pool, id, query.category.as_deref()).await { + Ok(memories) => { + let total = memories.len() as i64; + Json(DirectiveMemoryListResponse { memories, total }).into_response() + } + Err(e) => { + tracing::error!("Failed to list memories: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("LIST_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Get a single memory entry by key. +#[utoipa::path( + get, + path = "/api/v1/directives/{id}/memories/{key}", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("key" = String, Path, description = "Memory key"), + ), + responses( + (status = 200, description = "Memory entry", body = DirectiveMemory), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn get_memory( + State(state): State, + Authenticated(auth): Authenticated, + Path((id, key)): Path<(Uuid, 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(); + }; + + // Verify directive ownership + match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::get_directive_memory(pool, id, &key).await { + Ok(Some(memory)) => Json(memory).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Memory entry not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get memory: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Set (upsert) a single memory entry. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/memories", + params(("id" = Uuid, Path, description = "Directive ID")), + request_body = SetDirectiveMemoryRequest, + responses( + (status = 200, description = "Memory entry set", body = DirectiveMemory), + (status = 404, description = "Directive not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn set_memory( + State(state): State, + Authenticated(auth): Authenticated, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify directive ownership + match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::set_directive_memory(pool, id, &req).await { + Ok(memory) => Json(memory).into_response(), + Err(e) => { + tracing::error!("Failed to set memory: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("SET_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Batch set multiple memory entries. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/memories/batch", + params(("id" = Uuid, Path, description = "Directive ID")), + request_body = BatchSetDirectiveMemoryRequest, + responses( + (status = 200, description = "Memory entries set", body = Vec), + (status = 404, description = "Directive not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn batch_set_memories( + State(state): State, + Authenticated(auth): Authenticated, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify directive ownership + match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::batch_set_directive_memories(pool, id, &req.memories).await { + Ok(memories) => Json(memories).into_response(), + Err(e) => { + tracing::error!("Failed to batch set memories: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("SET_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Delete a single memory entry by key. +#[utoipa::path( + delete, + path = "/api/v1/directives/{id}/memories/{key}", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("key" = String, Path, description = "Memory key"), + ), + responses( + (status = 204, description = "Deleted"), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn delete_memory( + State(state): State, + Authenticated(auth): Authenticated, + Path((id, key)): Path<(Uuid, 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(); + }; + + // Verify directive ownership + match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::delete_directive_memory(pool, id, &key).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Memory entry not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to delete memory: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DELETE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Clear all memories for a directive. +#[utoipa::path( + delete, + path = "/api/v1/directives/{id}/memories", + params(("id" = Uuid, Path, description = "Directive ID")), + responses( + (status = 204, description = "All memories cleared"), + (status = 404, description = "Directive not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn clear_memories( + State(state): State, + Authenticated(auth): Authenticated, + Path(id): Path, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify directive ownership + match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::clear_directive_memories(pool, id).await { + Ok(_) => StatusCode::NO_CONTENT.into_response(), + Err(e) => { + tracing::error!("Failed to clear memories: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("CLEAR_FAILED", &e.to_string())), + ) + .into_response() + } + } +} diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index 4cb4296..b380508 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -237,6 +237,10 @@ pub fn make_router(state: SharedState) -> Router { .route("/directives/{id}/steps/{step_id}/fail", post(directives::fail_step)) .route("/directives/{id}/steps/{step_id}/skip", post(directives::skip_step)) .route("/directives/{id}/goal", put(directives::update_goal)) + // Directive memory endpoints + .route("/directives/{id}/memories", get(directives::list_memories).post(directives::set_memory).delete(directives::clear_memories)) + .route("/directives/{id}/memories/batch", post(directives::batch_set_memories)) + .route("/directives/{id}/memories/{key}", get(directives::get_memory).delete(directives::delete_memory)) // Timeline endpoint (unified history for user) .route("/timeline", get(history::get_timeline)) // Contract type templates (built-in only) diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index ddc2db5..f049759 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -3,21 +3,21 @@ use utoipa::OpenApi; use crate::db::models::{ - AddLocalRepositoryRequest, AddRemoteRepositoryRequest, BranchInfo, BranchListResponse, - BranchTaskRequest, BranchTaskResponse, + AddLocalRepositoryRequest, AddRemoteRepositoryRequest, BatchSetDirectiveMemoryRequest, + BranchInfo, BranchListResponse, BranchTaskRequest, BranchTaskResponse, ChangePhaseRequest, Contract, ContractChatHistoryResponse, ContractChatMessageRecord, ContractEvent, ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations, CreateContractRequest, CreateDirectiveRequest, CreateDirectiveStepRequest, CreateFileRequest, CreateManagedRepositoryRequest, CreateTaskRequest, Daemon, DaemonDirectoriesResponse, - DaemonDirectory, DaemonListResponse, Directive, DirectiveListResponse, DirectiveStep, - DirectiveSummary, DirectiveWithSteps, + DaemonDirectory, DaemonListResponse, Directive, DirectiveListResponse, DirectiveMemory, + DirectiveMemoryListResponse, DirectiveStep, DirectiveSummary, DirectiveWithSteps, File, FileListResponse, FileSummary, MergeCommitRequest, MergeCompleteCheckResponse, MergeResolveRequest, MergeResultResponse, MergeSkipRequest, MergeStartRequest, MergeStatusResponse, MeshChatConversation, MeshChatHistoryResponse, MeshChatMessageRecord, RepositoryHistoryEntry, RepositoryHistoryListResponse, RepositorySuggestionsQuery, SendMessageRequest, - Task, + SetDirectiveMemoryRequest, Task, TaskEventListResponse, TaskListResponse, TaskSummary, TaskWithSubtasks, TranscriptEntry, UpdateContractRequest, UpdateDirectiveRequest, UpdateDirectiveStepRequest, UpdateFileRequest, UpdateGoalRequest, UpdateTaskRequest, @@ -123,6 +123,13 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage directives::fail_step, directives::skip_step, directives::update_goal, + // Directive memory endpoints + directives::list_memories, + directives::get_memory, + directives::set_memory, + directives::batch_set_memories, + directives::delete_memory, + directives::clear_memories, // Repository history/settings endpoints repository_history::list_repository_history, repository_history::get_repository_suggestions, @@ -219,6 +226,10 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage UpdateGoalRequest, CreateDirectiveStepRequest, UpdateDirectiveStepRequest, + DirectiveMemory, + DirectiveMemoryListResponse, + SetDirectiveMemoryRequest, + BatchSetDirectiveMemoryRequest, // Repository history schemas RepositoryHistoryEntry, RepositoryHistoryListResponse, -- cgit v1.2.3