diff --git a/apps/elf-api/src/routes.rs b/apps/elf-api/src/routes.rs index 48a3db80..1deda8cb 100644 --- a/apps/elf-api/src/routes.rs +++ b/apps/elf-api/src/routes.rs @@ -14,6 +14,7 @@ mod ingestion_profiles; mod knowledge; mod notes; mod recall; +mod route_builder; mod search; mod sharing; mod support; @@ -24,23 +25,23 @@ mod work_journal; pub use self::{ contract::{ApiDoc, OPENAPI_JSON_PATH, SCALAR_DOCS_PATH, contract_router}, + route_builder::{admin_router, router}, viewer::ADMIN_VIEWER_PATH, }; use axum::{ - Json, Router, + Json, body::{self, Body}, extract::{ - DefaultBodyLimit, Extension, Path, Query, State, + Extension, Path, Query, State, rejection::{JsonRejection, QueryRejection}, }, http::{ HeaderMap, Request, StatusCode, header::{CONTENT_LENGTH, CONTENT_TYPE}, }, - middleware::{self, Next}, + middleware::Next, response::{IntoResponse, Response}, - routing, }; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -48,21 +49,6 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use uuid::Uuid; use crate::state::AppState; -use admin_notes::{admin_note_correction_apply, admin_note_history_get, admin_note_provenance_get}; -use admin_ops::rebuild_qdrant; -use consolidation::{ - consolidation_proposal_get, consolidation_proposal_review, consolidation_proposals_list, - consolidation_run_create, consolidation_run_get, consolidation_runs_list, -}; -use core_memory::{ - admin_core_block_attach, admin_core_block_detach, admin_core_block_upsert, core_blocks_get, - entity_memory_get, -}; -use docs::{ - admin_docs_excerpts_get, admin_docs_get, admin_docs_search_l0, docs_delete, docs_excerpts_get, - docs_get, docs_put, docs_search_l0, -}; -use dreaming::dreaming_review_queue; use elf_config::{SecurityAuthKey, SecurityAuthRole}; use elf_domain::{ consolidation::{ @@ -116,39 +102,16 @@ use elf_service::{ WorkJournalEntryFamily, WorkJournalEntryGetRequest, WorkJournalEntryResponse, WorkJournalSessionReadbackRequest, WorkJournalSessionReadbackResponse, search::TraceBundleMode, }; -use events::events_ingest; -use graph::{ - admin_graph_predicate_alias_add, admin_graph_predicate_aliases_list, - admin_graph_predicate_patch, admin_graph_predicates_list, graph_query, graph_report, -}; -use health::health; -use ingestion_profiles::{ - admin_ingestion_profile_create, admin_ingestion_profile_default_get, - admin_ingestion_profile_default_set, admin_ingestion_profile_get, - admin_ingestion_profile_versions_list, admin_ingestion_profiles_list, -}; -use knowledge::{ - knowledge_page_get, knowledge_page_lint, knowledge_page_rebuild, knowledge_pages_list, - knowledge_pages_search, knowledge_pages_watch_rebuild, -}; -use notes::{ - notes_delete, notes_get, notes_ingest, notes_list, notes_patch, notes_publish, notes_unpublish, -}; -use recall::{admin_recall_debug_panel, recall_debug_panel}; -use search::{searches_create, searches_get, searches_notes, searches_raw, searches_timeline}; -use sharing::{space_grant_revoke, space_grant_upsert, space_grants_list}; use support::{ - ApiError, EntityMemoryQuery, RequestContext, SearchMode, admin_auth_middleware, - api_auth_middleware, effective_token_id, empty_json_object, format_scope, format_space, - json_error, parse_optional_rfc3339, parse_space, require_admin_for_org_shared_writes, - required_read_profile, + ApiError, EntityMemoryQuery, RequestContext, SearchMode, effective_token_id, empty_json_object, + format_scope, format_space, json_error, parse_optional_rfc3339, parse_space, + require_admin_for_org_shared_writes, required_read_profile, }; #[cfg(test)] use support::{ apply_auth_key_context, inject_request_id_into_json_body, parse_request_id_from_headers, resolve_auth_key, sanitize_trusted_token_header, }; -use trace::{trace_bundle_get, trace_get, trace_item_get, trace_recent_list, trace_trajectory_get}; use types::{ AdminGraphPredicateAliasAddBody, AdminGraphPredicatePatchBody, AdminGraphPredicatesListQuery, AdminIngestionProfileCreateBody, AdminIngestionProfileDefaultResponseV2, @@ -166,10 +129,6 @@ use types::{ WorkJournalSessionReadbackBody, }; #[cfg(test)] use viewer::VIEWER_HTML; -use viewer::admin_viewer; -use work_journal::{ - work_journal_entry_create, work_journal_entry_get, work_journal_session_readback, -}; const HEADER_TENANT_ID: &str = "X-ELF-Tenant-Id"; const HEADER_PROJECT_ID: &str = "X-ELF-Project-Id"; @@ -191,145 +150,4 @@ const MAX_TOP_K: u32 = 100; const MAX_CANDIDATE_K: u32 = 1_000; const MAX_ERROR_LOG_CHARS: usize = 1_024; -/// Builds the authenticated public API router. -pub fn router(state: AppState) -> Router { - let auth_state = state.clone(); - let api_router = Router::new() - .route("/health", routing::get(health)) - .route("/v2/notes/ingest", routing::post(notes_ingest)) - .route("/v2/events/ingest", routing::post(events_ingest)) - .route("/v2/core-blocks", routing::get(core_blocks_get)) - .route("/v2/entity-memory", routing::get(entity_memory_get)) - .route("/v2/recall-debug/panel", routing::post(recall_debug_panel)) - .route("/v2/searches", routing::post(searches_create)) - .route("/v2/searches/{search_id}", routing::get(searches_get)) - .route("/v2/searches/{search_id}/timeline", routing::get(searches_timeline)) - .route("/v2/searches/{search_id}/notes", routing::post(searches_notes)) - .route("/v2/graph/query", routing::post(graph_query)) - .route("/v2/graph/report", routing::post(graph_report)) - .route("/v2/notes", routing::get(notes_list)) - .route( - "/v2/notes/{note_id}", - routing::get(notes_get).patch(notes_patch).delete(notes_delete), - ) - .route("/v2/notes/{note_id}/publish", routing::post(notes_publish)) - .route("/v2/notes/{note_id}/unpublish", routing::post(notes_unpublish)) - .route("/v2/work-journal/entries", routing::post(work_journal_entry_create)) - .route("/v2/work-journal/entries/{entry_id}", routing::get(work_journal_entry_get)) - .route("/v2/work-journal/readback", routing::post(work_journal_session_readback)) - .route( - "/v2/spaces/{space}/grants", - routing::get(space_grants_list).post(space_grant_upsert), - ) - .route("/v2/spaces/{space}/grants/revoke", routing::post(space_grant_revoke)) - .with_state(state.clone()) - .layer(DefaultBodyLimit::max(MAX_REQUEST_BYTES)); - let docs_router = Router::new() - .route("/v2/docs", routing::post(docs_put)) - .route("/v2/docs/{doc_id}", routing::get(docs_get).delete(docs_delete)) - .route("/v2/docs/search/l0", routing::post(docs_search_l0)) - .route("/v2/docs/excerpts", routing::post(docs_excerpts_get)) - .with_state(state) - .layer(DefaultBodyLimit::max(MAX_DOC_REQUEST_BYTES)); - - Router::new() - .merge(contract_router()) - .merge(api_router) - .merge(docs_router) - .layer(middleware::from_fn_with_state(auth_state, api_auth_middleware)) -} - -/// Builds the authenticated admin API router. -pub fn admin_router(state: AppState) -> Router { - let auth_state = state.clone(); - let protected_router = Router::new() - .route("/v2/admin/searches", routing::post(searches_create)) - .route("/v2/admin/searches/{search_id}", routing::get(searches_get)) - .route("/v2/admin/searches/{search_id}/timeline", routing::get(searches_timeline)) - .route("/v2/admin/searches/{search_id}/notes", routing::post(searches_notes)) - .route("/v2/admin/core-blocks", routing::post(admin_core_block_upsert)) - .route( - "/v2/admin/core-blocks/{block_id}/attachments", - routing::post(admin_core_block_attach), - ) - .route( - "/v2/admin/core-blocks/attachments/{attachment_id}", - routing::delete(admin_core_block_detach), - ) - .route("/v2/admin/docs/search/l0", routing::post(admin_docs_search_l0)) - .route("/v2/admin/docs/excerpts", routing::post(admin_docs_excerpts_get)) - .route("/v2/admin/docs/{doc_id}", routing::get(admin_docs_get)) - .route("/v2/admin/notes", routing::get(notes_list)) - .route("/v2/admin/notes/{note_id}", routing::get(notes_get)) - .route( - "/v2/admin/events/ingestion-profiles/default", - routing::get(admin_ingestion_profile_default_get) - .put(admin_ingestion_profile_default_set), - ) - .route( - "/v2/admin/events/ingestion-profiles/{profile_id}/versions", - routing::get(admin_ingestion_profile_versions_list), - ) - .route( - "/v2/admin/events/ingestion-profiles/{profile_id}", - routing::get(admin_ingestion_profile_get), - ) - .route( - "/v2/admin/events/ingestion-profiles", - routing::get(admin_ingestion_profiles_list).post(admin_ingestion_profile_create), - ) - .route( - "/v2/admin/consolidation/runs", - routing::get(consolidation_runs_list).post(consolidation_run_create), - ) - .route("/v2/admin/consolidation/runs/{run_id}", routing::get(consolidation_run_get)) - .route("/v2/admin/consolidation/proposals", routing::get(consolidation_proposals_list)) - .route( - "/v2/admin/consolidation/proposals/{proposal_id}", - routing::get(consolidation_proposal_get), - ) - .route( - "/v2/admin/consolidation/proposals/{proposal_id}/review", - routing::post(consolidation_proposal_review), - ) - .route("/v2/admin/dreaming/review-queue", routing::get(dreaming_review_queue)) - .route("/v2/admin/recall-debug/panel", routing::post(admin_recall_debug_panel)) - .route("/v2/admin/knowledge/pages", routing::get(knowledge_pages_list)) - .route("/v2/admin/knowledge/pages/rebuild", routing::post(knowledge_page_rebuild)) - .route( - "/v2/admin/knowledge/pages/rebuild-changed-sources", - routing::post(knowledge_pages_watch_rebuild), - ) - .route("/v2/admin/knowledge/pages/search", routing::post(knowledge_pages_search)) - .route("/v2/admin/knowledge/pages/{page_id}", routing::get(knowledge_page_get)) - .route("/v2/admin/knowledge/pages/{page_id}/lint", routing::post(knowledge_page_lint)) - .route("/v2/admin/qdrant/rebuild", routing::post(rebuild_qdrant)) - .route("/v2/admin/searches/raw", routing::post(searches_raw)) - .route("/v2/admin/traces/recent", routing::get(trace_recent_list)) - .route("/v2/admin/traces/{trace_id}", routing::get(trace_get)) - .route("/v2/admin/traces/{trace_id}/bundle", routing::get(trace_bundle_get)) - .route("/v2/admin/trajectories/{trace_id}", routing::get(trace_trajectory_get)) - .route("/v2/admin/trace-items/{item_id}", routing::get(trace_item_get)) - .route("/v2/admin/graph/predicates", routing::get(admin_graph_predicates_list)) - .route( - "/v2/admin/graph/predicates/{predicate_id}", - routing::patch(admin_graph_predicate_patch), - ) - .route( - "/v2/admin/graph/predicates/{predicate_id}/aliases", - routing::post(admin_graph_predicate_alias_add).get(admin_graph_predicate_aliases_list), - ) - .route("/v2/admin/notes/{note_id}/provenance", routing::get(admin_note_provenance_get)) - .route("/v2/admin/notes/{note_id}/history", routing::get(admin_note_history_get)) - .route("/v2/admin/notes/{note_id}/corrections", routing::post(admin_note_correction_apply)) - .with_state(state) - .layer(DefaultBodyLimit::max(MAX_REQUEST_BYTES)) - .layer(middleware::from_fn_with_state(auth_state, admin_auth_middleware)); - - Router::new() - .route(ADMIN_VIEWER_PATH, routing::get(admin_viewer)) - .route("/", routing::get(admin_viewer)) - .merge(protected_router) -} - #[cfg(test)] mod tests; diff --git a/apps/elf-api/src/routes/docs.rs b/apps/elf-api/src/routes/docs.rs index 06aeeb50..da705aad 100644 --- a/apps/elf-api/src/routes/docs.rs +++ b/apps/elf-api/src/routes/docs.rs @@ -1,289 +1,16 @@ +mod excerpts; +mod read; mod search_l0; - -use crate::routes::{ - self, ApiError, AppState, DocsDeleteRequest, DocsDeleteResponse, DocsExcerptResponse, - DocsExcerptsGetBody, DocsExcerptsGetRequest, DocsGetRequest, DocsGetResponse, DocsPutBody, - DocsPutRequest, DocsPutResponse, DocsSearchL0Body, DocsSearchL0Response, ErrorBody, Extension, - HeaderMap, Json, JsonRejection, Path, RequestContext, SecurityAuthRole, State, StatusCode, - Uuid, +mod write; + +pub(super) use self::{ + excerpts::{ + __path_admin_docs_excerpts_get, __path_docs_excerpts_get, admin_docs_excerpts_get, + docs_excerpts_get, + }, + read::{__path_admin_docs_get, __path_docs_get, admin_docs_get, docs_get}, + search_l0::{ + __path_admin_docs_search_l0, __path_docs_search_l0, admin_docs_search_l0, docs_search_l0, + }, + write::{__path_docs_delete, __path_docs_put, docs_delete, docs_put}, }; - -#[utoipa::path( - post, - path = "/v2/docs", - tag = "docs", - request_body = Value, - responses( - (status = 200, description = "Document was stored.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 422, description = "Non-English input rejected.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn docs_put( - State(state): State, - headers: HeaderMap, - role: Option>, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid request payload.", - None, - ) - })?; - let role = role.map(|Extension(role)| role); - - if payload.scope.trim() == "org_shared" { - routes::require_admin_for_org_shared_writes( - state.service.cfg.security.auth_mode.as_str(), - role, - )?; - } - - let response = state - .service - .docs_put(DocsPutRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - scope: payload.scope, - doc_type: payload.doc_type.map(|doc_type| doc_type.as_str().to_string()), - title: payload.title, - source_ref: payload.source_ref, - write_policy: payload.write_policy, - content: payload.content, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/docs/{doc_id}", - tag = "docs", - params(("doc_id" = Uuid, Path, description = "Document ID.")), - responses( - (status = 200, description = "Document was fetched.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 404, description = "Document was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn docs_get( - State(state): State, - headers: HeaderMap, - Path(doc_id): Path, -) -> Result, ApiError> { - docs_get_inner(state, headers, doc_id).await -} - -#[utoipa::path( - get, - path = "/v2/admin/docs/{doc_id}", - tag = "admin", - params(("doc_id" = Uuid, Path, description = "Document ID.")), - responses( - (status = 200, description = "Document was fetched through the admin mirror.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Document was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn admin_docs_get( - State(state): State, - headers: HeaderMap, - Path(doc_id): Path, -) -> Result, ApiError> { - docs_get_inner(state, headers, doc_id).await -} - -pub(super) async fn docs_get_inner( - state: AppState, - headers: HeaderMap, - doc_id: Uuid, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let read_profile = routes::required_read_profile(&headers)?; - let response = state - .service - .docs_get(DocsGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - read_profile, - doc_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - delete, - path = "/v2/docs/{doc_id}", - tag = "docs", - params(("doc_id" = Uuid, Path, description = "Document ID.")), - responses( - (status = 200, description = "Document was deleted.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 404, description = "Document was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn docs_delete( - State(state): State, - headers: HeaderMap, - Path(doc_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .docs_delete(DocsDeleteRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - doc_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/docs/search/l0", - tag = "docs", - request_body = Value, - responses( - (status = 200, description = "L0 document search results.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 422, description = "Non-English input rejected.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn docs_search_l0( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - search_l0::docs_search_l0_inner(state, headers, payload).await -} - -#[utoipa::path( - post, - path = "/v2/admin/docs/search/l0", - tag = "admin", - request_body = Value, - responses( - (status = 200, description = "L0 document search results through the admin mirror.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 422, description = "Non-English input rejected.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn admin_docs_search_l0( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - search_l0::docs_search_l0_inner(state, headers, payload).await -} - -#[utoipa::path( - post, - path = "/v2/docs/excerpts", - tag = "docs", - request_body = Value, - responses( - (status = 200, description = "Document excerpt result.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 404, description = "Document or excerpt was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn docs_excerpts_get( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - docs_excerpts_get_inner(state, headers, payload).await -} - -#[utoipa::path( - post, - path = "/v2/admin/docs/excerpts", - tag = "admin", - request_body = Value, - responses( - (status = 200, description = "Document excerpt result through the admin mirror.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Document or excerpt was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn admin_docs_excerpts_get( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - docs_excerpts_get_inner(state, headers, payload).await -} - -pub(super) async fn docs_excerpts_get_inner( - state: AppState, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let read_profile = routes::required_read_profile(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid request payload.", - None, - ) - })?; - let response = state - .service - .docs_excerpts_get(DocsExcerptsGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - read_profile, - doc_id: payload.doc_id, - level: payload.level, - chunk_id: payload.chunk_id, - quote: payload.quote, - position: payload.position, - explain: payload.explain, - }) - .await?; - - Ok(Json(response)) -} diff --git a/apps/elf-api/src/routes/docs/excerpts.rs b/apps/elf-api/src/routes/docs/excerpts.rs new file mode 100644 index 00000000..6218eb50 --- /dev/null +++ b/apps/elf-api/src/routes/docs/excerpts.rs @@ -0,0 +1,84 @@ +use crate::routes::{ + self, ApiError, AppState, DocsExcerptResponse, DocsExcerptsGetBody, DocsExcerptsGetRequest, + ErrorBody, HeaderMap, Json, JsonRejection, RequestContext, State, StatusCode, +}; + +#[utoipa::path( + post, + path = "/v2/docs/excerpts", + tag = "docs", + request_body = Value, + responses( + (status = 200, description = "Document excerpt result.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Document or excerpt was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn docs_excerpts_get( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + docs_excerpts_get_inner(state, headers, payload).await +} + +#[utoipa::path( + post, + path = "/v2/admin/docs/excerpts", + tag = "admin", + request_body = Value, + responses( + (status = 200, description = "Document excerpt result through the admin mirror.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Document or excerpt was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn admin_docs_excerpts_get( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + docs_excerpts_get_inner(state, headers, payload).await +} + +async fn docs_excerpts_get_inner( + state: AppState, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let read_profile = routes::required_read_profile(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let response = state + .service + .docs_excerpts_get(DocsExcerptsGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + read_profile, + doc_id: payload.doc_id, + level: payload.level, + chunk_id: payload.chunk_id, + quote: payload.quote, + position: payload.position, + explain: payload.explain, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/docs/read.rs b/apps/elf-api/src/routes/docs/read.rs new file mode 100644 index 00000000..544c4bb4 --- /dev/null +++ b/apps/elf-api/src/routes/docs/read.rs @@ -0,0 +1,69 @@ +use crate::routes::{ + self, ApiError, AppState, DocsGetRequest, DocsGetResponse, ErrorBody, HeaderMap, Json, Path, + RequestContext, State, Uuid, +}; + +#[utoipa::path( + get, + path = "/v2/docs/{doc_id}", + tag = "docs", + params(("doc_id" = Uuid, Path, description = "Document ID.")), + responses( + (status = 200, description = "Document was fetched.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Document was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn docs_get( + State(state): State, + headers: HeaderMap, + Path(doc_id): Path, +) -> Result, ApiError> { + docs_get_inner(state, headers, doc_id).await +} + +#[utoipa::path( + get, + path = "/v2/admin/docs/{doc_id}", + tag = "admin", + params(("doc_id" = Uuid, Path, description = "Document ID.")), + responses( + (status = 200, description = "Document was fetched through the admin mirror.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Document was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn admin_docs_get( + State(state): State, + headers: HeaderMap, + Path(doc_id): Path, +) -> Result, ApiError> { + docs_get_inner(state, headers, doc_id).await +} + +async fn docs_get_inner( + state: AppState, + headers: HeaderMap, + doc_id: Uuid, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let read_profile = routes::required_read_profile(&headers)?; + let response = state + .service + .docs_get(DocsGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + read_profile, + doc_id, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/docs/search_l0.rs b/apps/elf-api/src/routes/docs/search_l0.rs index 771a43fe..0ceba7cd 100644 --- a/apps/elf-api/src/routes/docs/search_l0.rs +++ b/apps/elf-api/src/routes/docs/search_l0.rs @@ -1,9 +1,53 @@ use crate::routes::{ self, ApiError, AppState, DOC_STATUSES, DocsSearchL0Body, DocsSearchL0Request, - DocsSearchL0Response, HeaderMap, Json, JsonRejection, MAX_QUERY_CHARS, RequestContext, - StatusCode, + DocsSearchL0Response, ErrorBody, HeaderMap, Json, JsonRejection, MAX_QUERY_CHARS, + RequestContext, State, StatusCode, }; +#[utoipa::path( + post, + path = "/v2/docs/search/l0", + tag = "docs", + request_body = Value, + responses( + (status = 200, description = "L0 document search results.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn docs_search_l0( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + docs_search_l0_inner(state, headers, payload).await +} + +#[utoipa::path( + post, + path = "/v2/admin/docs/search/l0", + tag = "admin", + request_body = Value, + responses( + (status = 200, description = "L0 document search results through the admin mirror.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn admin_docs_search_l0( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + docs_search_l0_inner(state, headers, payload).await +} + pub(super) async fn docs_search_l0_inner( state: AppState, headers: HeaderMap, diff --git a/apps/elf-api/src/routes/docs/write.rs b/apps/elf-api/src/routes/docs/write.rs new file mode 100644 index 00000000..7f5faf9a --- /dev/null +++ b/apps/elf-api/src/routes/docs/write.rs @@ -0,0 +1,96 @@ +use crate::routes::{ + self, ApiError, AppState, DocsDeleteRequest, DocsDeleteResponse, DocsPutBody, DocsPutRequest, + DocsPutResponse, ErrorBody, Extension, HeaderMap, Json, JsonRejection, Path, RequestContext, + SecurityAuthRole, State, StatusCode, Uuid, +}; + +#[utoipa::path( + post, + path = "/v2/docs", + tag = "docs", + request_body = Value, + responses( + (status = 200, description = "Document was stored.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn docs_put( + State(state): State, + headers: HeaderMap, + role: Option>, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let role = role.map(|Extension(role)| role); + + if payload.scope.trim() == "org_shared" { + routes::require_admin_for_org_shared_writes( + state.service.cfg.security.auth_mode.as_str(), + role, + )?; + } + + let response = state + .service + .docs_put(DocsPutRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + scope: payload.scope, + doc_type: payload.doc_type.map(|doc_type| doc_type.as_str().to_string()), + title: payload.title, + source_ref: payload.source_ref, + write_policy: payload.write_policy, + content: payload.content, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + delete, + path = "/v2/docs/{doc_id}", + tag = "docs", + params(("doc_id" = Uuid, Path, description = "Document ID.")), + responses( + (status = 200, description = "Document was deleted.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Document was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn docs_delete( + State(state): State, + headers: HeaderMap, + Path(doc_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .docs_delete(DocsDeleteRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + doc_id, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/graph.rs b/apps/elf-api/src/routes/graph.rs index 9dcf3004..df1c0791 100644 --- a/apps/elf-api/src/routes/graph.rs +++ b/apps/elf-api/src/routes/graph.rs @@ -1,288 +1,12 @@ -use crate::routes::{ - self, AdminGraphPredicateAliasAddBody, AdminGraphPredicateAliasAddRequest, - AdminGraphPredicateAliasesListRequest, AdminGraphPredicateAliasesResponse, - AdminGraphPredicatePatchBody, AdminGraphPredicatePatchRequest, AdminGraphPredicateResponse, - AdminGraphPredicatesListQuery, AdminGraphPredicatesListRequest, - AdminGraphPredicatesListResponse, ApiError, AppState, ErrorBody, GraphQueryBody, - GraphQueryRequest, GraphQueryResponse, GraphReportBody, GraphReportRequest, - GraphReportResponse, HeaderMap, Json, JsonRejection, Path, Query, QueryRejection, - RequestContext, State, StatusCode, Uuid, +mod predicates; +mod query; + +pub(super) use self::{ + predicates::{ + __path_admin_graph_predicate_alias_add, __path_admin_graph_predicate_aliases_list, + __path_admin_graph_predicate_patch, __path_admin_graph_predicates_list, + admin_graph_predicate_alias_add, admin_graph_predicate_aliases_list, + admin_graph_predicate_patch, admin_graph_predicates_list, + }, + query::{__path_graph_query, __path_graph_report, graph_query, graph_report}, }; - -#[utoipa::path( - post, - path = "/v2/graph/query", - tag = "graph", - request_body = Value, - responses( - (status = 200, description = "Graph facts matching the query.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 422, description = "Non-English input rejected.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn graph_query( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let read_profile = routes::required_read_profile(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid request payload.", - None, - ) - })?; - let as_of = routes::parse_optional_rfc3339(payload.as_of.as_ref(), "$.as_of")?; - let response = state - .service - .graph_query(GraphQueryRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - read_profile, - subject: payload.subject, - predicate: payload.predicate, - scopes: payload.scopes, - as_of, - limit: payload.limit, - explain: payload.explain, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/graph/report", - tag = "graph", - request_body = Value, - responses( - (status = 200, description = "Source-backed graph topic-map report.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 422, description = "Non-English input rejected.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn graph_report( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let read_profile = routes::required_read_profile(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid request payload.", - None, - ) - })?; - let as_of = routes::parse_optional_rfc3339(payload.as_of.as_ref(), "$.as_of")?; - let response = state - .service - .graph_report(GraphReportRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - read_profile, - subject: payload.subject, - predicate: payload.predicate, - scopes: payload.scopes, - as_of, - limit: payload.limit, - explain: payload.explain, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/graph/predicates", - tag = "graph", - params(("scope" = Option, Query, description = "Predicate scope filter.")), - responses( - (status = 200, description = "Graph predicates.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn admin_graph_predicates_list( - State(state): State, - headers: HeaderMap, - query: Result, QueryRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Query(query) = query.map_err(|err| { - tracing::warn!(error = %err, "Invalid query parameters."); - - routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid query parameters.".to_string(), - None, - ) - })?; - let response = state - .service - .admin_graph_predicates_list(AdminGraphPredicatesListRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - scope: query.scope, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - patch, - path = "/v2/admin/graph/predicates/{predicate_id}", - tag = "graph", - params(("predicate_id" = Uuid, Path, description = "Predicate ID.")), - request_body = Value, - responses( - (status = 200, description = "Graph predicate was updated.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Predicate was not found.", body = ErrorBody), - (status = 409, description = "Predicate update conflicted.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn admin_graph_predicate_patch( - State(state): State, - headers: HeaderMap, - Path(predicate_id): Path, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid request payload.", - None, - ) - })?; - let token_id = - routes::effective_token_id(state.service.cfg.security.auth_mode.as_str(), &headers); - let response = state - .service - .admin_graph_predicate_patch(AdminGraphPredicatePatchRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - token_id, - predicate_id, - status: payload.status, - cardinality: payload.cardinality, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/admin/graph/predicates/{predicate_id}/aliases", - tag = "graph", - params(("predicate_id" = Uuid, Path, description = "Predicate ID.")), - request_body = Value, - responses( - (status = 200, description = "Graph predicate alias was added.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Predicate was not found.", body = ErrorBody), - (status = 409, description = "Predicate update conflicted.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn admin_graph_predicate_alias_add( - State(state): State, - headers: HeaderMap, - Path(predicate_id): Path, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid request payload.", - None, - ) - })?; - let token_id = - routes::effective_token_id(state.service.cfg.security.auth_mode.as_str(), &headers); - let response = state - .service - .admin_graph_predicate_alias_add(AdminGraphPredicateAliasAddRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - token_id, - predicate_id, - alias: payload.alias, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/graph/predicates/{predicate_id}/aliases", - tag = "graph", - params(("predicate_id" = Uuid, Path, description = "Predicate ID.")), - responses( - (status = 200, description = "Graph predicate aliases.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Predicate was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn admin_graph_predicate_aliases_list( - State(state): State, - headers: HeaderMap, - Path(predicate_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .admin_graph_predicate_aliases_list(AdminGraphPredicateAliasesListRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - predicate_id, - }) - .await?; - - Ok(Json(response)) -} diff --git a/apps/elf-api/src/routes/graph/predicates.rs b/apps/elf-api/src/routes/graph/predicates.rs new file mode 100644 index 00000000..bdd39a37 --- /dev/null +++ b/apps/elf-api/src/routes/graph/predicates.rs @@ -0,0 +1,184 @@ +use crate::routes::{ + self, AdminGraphPredicateAliasAddBody, AdminGraphPredicateAliasAddRequest, + AdminGraphPredicateAliasesListRequest, AdminGraphPredicateAliasesResponse, + AdminGraphPredicatePatchBody, AdminGraphPredicatePatchRequest, AdminGraphPredicateResponse, + AdminGraphPredicatesListQuery, AdminGraphPredicatesListRequest, + AdminGraphPredicatesListResponse, ApiError, AppState, ErrorBody, HeaderMap, Json, + JsonRejection, Path, Query, QueryRejection, RequestContext, State, StatusCode, Uuid, +}; + +#[utoipa::path( + get, + path = "/v2/admin/graph/predicates", + tag = "graph", + params(("scope" = Option, Query, description = "Predicate scope filter.")), + responses( + (status = 200, description = "Graph predicates.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn admin_graph_predicates_list( + State(state): State, + headers: HeaderMap, + query: Result, QueryRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Query(query) = query.map_err(|err| { + tracing::warn!(error = %err, "Invalid query parameters."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid query parameters.".to_string(), + None, + ) + })?; + let response = state + .service + .admin_graph_predicates_list(AdminGraphPredicatesListRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + scope: query.scope, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + patch, + path = "/v2/admin/graph/predicates/{predicate_id}", + tag = "graph", + params(("predicate_id" = Uuid, Path, description = "Predicate ID.")), + request_body = Value, + responses( + (status = 200, description = "Graph predicate was updated.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Predicate was not found.", body = ErrorBody), + (status = 409, description = "Predicate update conflicted.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn admin_graph_predicate_patch( + State(state): State, + headers: HeaderMap, + Path(predicate_id): Path, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let token_id = + routes::effective_token_id(state.service.cfg.security.auth_mode.as_str(), &headers); + let response = state + .service + .admin_graph_predicate_patch(AdminGraphPredicatePatchRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + token_id, + predicate_id, + status: payload.status, + cardinality: payload.cardinality, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + post, + path = "/v2/admin/graph/predicates/{predicate_id}/aliases", + tag = "graph", + params(("predicate_id" = Uuid, Path, description = "Predicate ID.")), + request_body = Value, + responses( + (status = 200, description = "Graph predicate alias was added.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Predicate was not found.", body = ErrorBody), + (status = 409, description = "Predicate update conflicted.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn admin_graph_predicate_alias_add( + State(state): State, + headers: HeaderMap, + Path(predicate_id): Path, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let token_id = + routes::effective_token_id(state.service.cfg.security.auth_mode.as_str(), &headers); + let response = state + .service + .admin_graph_predicate_alias_add(AdminGraphPredicateAliasAddRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + token_id, + predicate_id, + alias: payload.alias, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/admin/graph/predicates/{predicate_id}/aliases", + tag = "graph", + params(("predicate_id" = Uuid, Path, description = "Predicate ID.")), + responses( + (status = 200, description = "Graph predicate aliases.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Predicate was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn admin_graph_predicate_aliases_list( + State(state): State, + headers: HeaderMap, + Path(predicate_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .admin_graph_predicate_aliases_list(AdminGraphPredicateAliasesListRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + predicate_id, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/graph/query.rs b/apps/elf-api/src/routes/graph/query.rs new file mode 100644 index 00000000..e4a2d6b3 --- /dev/null +++ b/apps/elf-api/src/routes/graph/query.rs @@ -0,0 +1,107 @@ +use crate::routes::{ + self, ApiError, AppState, ErrorBody, GraphQueryBody, GraphQueryRequest, GraphQueryResponse, + GraphReportBody, GraphReportRequest, GraphReportResponse, HeaderMap, Json, JsonRejection, + RequestContext, State, StatusCode, +}; + +#[utoipa::path( + post, + path = "/v2/graph/query", + tag = "graph", + request_body = Value, + responses( + (status = 200, description = "Graph facts matching the query.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn graph_query( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let read_profile = routes::required_read_profile(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let as_of = routes::parse_optional_rfc3339(payload.as_of.as_ref(), "$.as_of")?; + let response = state + .service + .graph_query(GraphQueryRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + read_profile, + subject: payload.subject, + predicate: payload.predicate, + scopes: payload.scopes, + as_of, + limit: payload.limit, + explain: payload.explain, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + post, + path = "/v2/graph/report", + tag = "graph", + request_body = Value, + responses( + (status = 200, description = "Source-backed graph topic-map report.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn graph_report( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let read_profile = routes::required_read_profile(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let as_of = routes::parse_optional_rfc3339(payload.as_of.as_ref(), "$.as_of")?; + let response = state + .service + .graph_report(GraphReportRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + read_profile, + subject: payload.subject, + predicate: payload.predicate, + scopes: payload.scopes, + as_of, + limit: payload.limit, + explain: payload.explain, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/knowledge.rs b/apps/elf-api/src/routes/knowledge.rs index e9ee31a3..45442547 100644 --- a/apps/elf-api/src/routes/knowledge.rs +++ b/apps/elf-api/src/routes/knowledge.rs @@ -1,270 +1,15 @@ -use crate::routes::{ - self, ApiError, AppState, ErrorBody, HeaderMap, Json, JsonRejection, - KnowledgePageChangedSource, KnowledgePageGetRequest, KnowledgePageLintRequest, - KnowledgePageLintResponse, KnowledgePageRebuildBody, KnowledgePageRebuildRequest, - KnowledgePageRebuildResponse, KnowledgePageResponse, KnowledgePageSearchRequest, - KnowledgePageSearchResponse, KnowledgePageWatchRebuildBody, KnowledgePageWatchRebuildRequest, - KnowledgePageWatchRebuildResponse, KnowledgePagesListQuery, KnowledgePagesListRequest, - KnowledgePagesListResponse, KnowledgePagesSearchBody, Path, Query, QueryRejection, - RequestContext, State, StatusCode, Uuid, +mod read; +mod rebuild; +mod search; + +pub(super) use self::{ + read::{ + __path_knowledge_page_get, __path_knowledge_page_lint, __path_knowledge_pages_list, + knowledge_page_get, knowledge_page_lint, knowledge_pages_list, + }, + rebuild::{ + __path_knowledge_page_rebuild, __path_knowledge_pages_watch_rebuild, + knowledge_page_rebuild, knowledge_pages_watch_rebuild, + }, + search::{__path_knowledge_pages_search, knowledge_pages_search}, }; - -#[utoipa::path( - post, - path = "/v2/admin/knowledge/pages/rebuild", - tag = "knowledge", - request_body = Value, - responses( - (status = 200, description = "Knowledge page was rebuilt.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn knowledge_page_rebuild( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid request payload.", - None, - ) - })?; - let response = state - .service - .knowledge_page_rebuild(KnowledgePageRebuildRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - page_kind: payload.page_kind, - page_key: payload.page_key, - title: payload.title, - doc_ids: payload.doc_ids, - doc_chunk_ids: payload.doc_chunk_ids, - note_ids: payload.note_ids, - event_ids: payload.event_ids, - relation_ids: payload.relation_ids, - proposal_ids: payload.proposal_ids, - provider_metadata: payload.provider_metadata, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/admin/knowledge/pages/rebuild-changed-sources", - tag = "knowledge", - request_body = Value, - responses( - (status = 200, description = "Affected knowledge pages were rebuilt.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn knowledge_pages_watch_rebuild( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid request payload.", - None, - ) - })?; - let changed_sources = payload - .changed_sources - .into_iter() - .map(|source| KnowledgePageChangedSource { - source_kind: source.source_kind, - source_id: source.source_id, - }) - .collect(); - let response = state - .service - .knowledge_pages_watch_rebuild(KnowledgePageWatchRebuildRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - changed_sources, - page_kind: payload.page_kind, - limit: payload.limit, - generate_memory_candidates: payload.generate_memory_candidates.unwrap_or(true), - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/knowledge/pages", - tag = "knowledge", - params( - ("page_kind" = Option, Query, description = "Optional page-kind filter."), - ("limit" = Option, Query, description = "Maximum pages to return."), - ), - responses( - (status = 200, description = "Knowledge pages.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn knowledge_pages_list( - State(state): State, - headers: HeaderMap, - query: Result, QueryRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Query(query) = query.map_err(|err| { - tracing::warn!(error = %err, "Invalid query parameters."); - - routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid query parameters.".to_string(), - None, - ) - })?; - let response = state - .service - .knowledge_pages_list(KnowledgePagesListRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - page_kind: query.page_kind, - limit: query.limit, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/admin/knowledge/pages/search", - tag = "knowledge", - request_body = Value, - responses( - (status = 200, description = "Knowledge page section search results.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 422, description = "Non-English input rejected.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn knowledge_pages_search( - State(state): State, - headers: HeaderMap, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let read_profile = routes::required_read_profile(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid request payload.", - None, - ) - })?; - let response = state - .service - .knowledge_pages_search(KnowledgePageSearchRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - read_profile, - query: payload.query, - page_kind: payload.page_kind, - limit: payload.limit, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/knowledge/pages/{page_id}", - tag = "knowledge", - params(("page_id" = Uuid, Path, description = "Knowledge page ID.")), - responses( - (status = 200, description = "Knowledge page.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Knowledge page was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn knowledge_page_get( - State(state): State, - headers: HeaderMap, - Path(page_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .knowledge_page_get(KnowledgePageGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - page_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/admin/knowledge/pages/{page_id}/lint", - tag = "knowledge", - params(("page_id" = Uuid, Path, description = "Knowledge page ID.")), - responses( - (status = 200, description = "Knowledge page lint findings.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Knowledge page was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn knowledge_page_lint( - State(state): State, - headers: HeaderMap, - Path(page_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .knowledge_page_lint(KnowledgePageLintRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - page_id, - }) - .await?; - - Ok(Json(response)) -} diff --git a/apps/elf-api/src/routes/knowledge/read.rs b/apps/elf-api/src/routes/knowledge/read.rs new file mode 100644 index 00000000..e17ab12e --- /dev/null +++ b/apps/elf-api/src/routes/knowledge/read.rs @@ -0,0 +1,115 @@ +use crate::routes::{ + self, ApiError, AppState, ErrorBody, HeaderMap, Json, KnowledgePageGetRequest, + KnowledgePageLintRequest, KnowledgePageLintResponse, KnowledgePageResponse, + KnowledgePagesListQuery, KnowledgePagesListRequest, KnowledgePagesListResponse, Path, Query, + QueryRejection, RequestContext, State, StatusCode, Uuid, +}; + +#[utoipa::path( + get, + path = "/v2/admin/knowledge/pages", + tag = "knowledge", + params( + ("page_kind" = Option, Query, description = "Optional page-kind filter."), + ("limit" = Option, Query, description = "Maximum pages to return."), + ), + responses( + (status = 200, description = "Knowledge pages.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn knowledge_pages_list( + State(state): State, + headers: HeaderMap, + query: Result, QueryRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Query(query) = query.map_err(|err| { + tracing::warn!(error = %err, "Invalid query parameters."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid query parameters.".to_string(), + None, + ) + })?; + let response = state + .service + .knowledge_pages_list(KnowledgePagesListRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + page_kind: query.page_kind, + limit: query.limit, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/admin/knowledge/pages/{page_id}", + tag = "knowledge", + params(("page_id" = Uuid, Path, description = "Knowledge page ID.")), + responses( + (status = 200, description = "Knowledge page.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Knowledge page was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn knowledge_page_get( + State(state): State, + headers: HeaderMap, + Path(page_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .knowledge_page_get(KnowledgePageGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + page_id, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + post, + path = "/v2/admin/knowledge/pages/{page_id}/lint", + tag = "knowledge", + params(("page_id" = Uuid, Path, description = "Knowledge page ID.")), + responses( + (status = 200, description = "Knowledge page lint findings.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Knowledge page was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn knowledge_page_lint( + State(state): State, + headers: HeaderMap, + Path(page_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .knowledge_page_lint(KnowledgePageLintRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + page_id, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/knowledge/rebuild.rs b/apps/elf-api/src/routes/knowledge/rebuild.rs new file mode 100644 index 00000000..a4fe4dbe --- /dev/null +++ b/apps/elf-api/src/routes/knowledge/rebuild.rs @@ -0,0 +1,110 @@ +use crate::routes::{ + self, ApiError, AppState, ErrorBody, HeaderMap, Json, JsonRejection, + KnowledgePageChangedSource, KnowledgePageRebuildBody, KnowledgePageRebuildRequest, + KnowledgePageRebuildResponse, KnowledgePageWatchRebuildBody, KnowledgePageWatchRebuildRequest, + KnowledgePageWatchRebuildResponse, RequestContext, State, StatusCode, +}; + +#[utoipa::path( + post, + path = "/v2/admin/knowledge/pages/rebuild", + tag = "knowledge", + request_body = Value, + responses( + (status = 200, description = "Knowledge page was rebuilt.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn knowledge_page_rebuild( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let response = state + .service + .knowledge_page_rebuild(KnowledgePageRebuildRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + page_kind: payload.page_kind, + page_key: payload.page_key, + title: payload.title, + doc_ids: payload.doc_ids, + doc_chunk_ids: payload.doc_chunk_ids, + note_ids: payload.note_ids, + event_ids: payload.event_ids, + relation_ids: payload.relation_ids, + proposal_ids: payload.proposal_ids, + provider_metadata: payload.provider_metadata, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + post, + path = "/v2/admin/knowledge/pages/rebuild-changed-sources", + tag = "knowledge", + request_body = Value, + responses( + (status = 200, description = "Affected knowledge pages were rebuilt.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn knowledge_pages_watch_rebuild( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let changed_sources = payload + .changed_sources + .into_iter() + .map(|source| KnowledgePageChangedSource { + source_kind: source.source_kind, + source_id: source.source_id, + }) + .collect(); + let response = state + .service + .knowledge_pages_watch_rebuild(KnowledgePageWatchRebuildRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + changed_sources, + page_kind: payload.page_kind, + limit: payload.limit, + generate_memory_candidates: payload.generate_memory_candidates.unwrap_or(true), + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/knowledge/search.rs b/apps/elf-api/src/routes/knowledge/search.rs new file mode 100644 index 00000000..eed4b663 --- /dev/null +++ b/apps/elf-api/src/routes/knowledge/search.rs @@ -0,0 +1,52 @@ +use crate::routes::{ + self, ApiError, AppState, ErrorBody, HeaderMap, Json, JsonRejection, + KnowledgePageSearchRequest, KnowledgePageSearchResponse, KnowledgePagesSearchBody, + RequestContext, State, StatusCode, +}; + +#[utoipa::path( + post, + path = "/v2/admin/knowledge/pages/search", + tag = "knowledge", + request_body = Value, + responses( + (status = 200, description = "Knowledge page section search results.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn knowledge_pages_search( + State(state): State, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let read_profile = routes::required_read_profile(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let response = state + .service + .knowledge_pages_search(KnowledgePageSearchRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + read_profile, + query: payload.query, + page_kind: payload.page_kind, + limit: payload.limit, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/notes.rs b/apps/elf-api/src/routes/notes.rs index 05fe623e..de141384 100644 --- a/apps/elf-api/src/routes/notes.rs +++ b/apps/elf-api/src/routes/notes.rs @@ -1,290 +1,11 @@ +mod ingest; mod publish; - -use crate::routes::{ - self, AddNoteRequest, AddNoteResponse, ApiError, AppState, DeleteRequest, DeleteResponse, - ErrorBody, Extension, HeaderMap, Json, JsonRejection, ListRequest, ListResponse, - MAX_NOTES_PER_INGEST, NoteFetchRequest, NoteFetchResponse, NotePatchRequest, - NotesIngestRequest, NotesListQuery, Path, PublishResponseV2, Query, QueryRejection, - RequestContext, SecurityAuthRole, ShareScopeBody, State, StatusCode, UpdateRequest, - UpdateResponse, Uuid, +mod read; +mod write; + +pub(super) use self::{ + ingest::{__path_notes_ingest, notes_ingest}, + publish::{__path_notes_publish, __path_notes_unpublish, notes_publish, notes_unpublish}, + read::{__path_notes_get, __path_notes_list, notes_get, notes_list}, + write::{__path_notes_delete, __path_notes_patch, notes_delete, notes_patch}, }; - -#[utoipa::path( - post, - path = "/v2/notes/ingest", - tag = "notes", - request_body = Value, - responses( - (status = 200, description = "Notes were processed.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 422, description = "Non-English input rejected.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn notes_ingest( - State(state): State, - headers: HeaderMap, - role: Option>, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid request payload.", - None, - ) - })?; - let role = role.map(|Extension(role)| role); - - if payload.scope.trim() == "org_shared" { - routes::require_admin_for_org_shared_writes( - state.service.cfg.security.auth_mode.as_str(), - role, - )?; - } - if payload.notes.len() > MAX_NOTES_PER_INGEST { - return Err(routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Notes list is too large.", - Some(vec!["$.notes".to_string()]), - )); - } - - let response = state - .service - .add_note(AddNoteRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - scope: payload.scope, - notes: payload.notes, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/notes", - tag = "notes", - params( - ("scope" = Option, Query, description = "Optional note scope filter."), - ("status" = Option, Query, description = "Optional note status filter."), - ("type" = Option, Query, description = "Optional note type filter."), - ), - responses( - (status = 200, description = "Notes visible to the caller.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn notes_list( - State(state): State, - headers: HeaderMap, - query: Result, QueryRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Query(query) = query.map_err(|err| { - tracing::warn!(error = %err, "Invalid query parameters."); - - routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid query parameters.".to_string(), - None, - ) - })?; - let response = state - .service - .list(ListRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: Some(ctx.agent_id), - scope: query.scope, - status: query.status, - r#type: query.r#type, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/notes/{note_id}", - tag = "notes", - params(("note_id" = Uuid, Path, description = "Note ID.")), - responses( - (status = 200, description = "Note details.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 404, description = "Note was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn notes_get( - State(state): State, - headers: HeaderMap, - Path(note_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .get_note(NoteFetchRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - note_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - patch, - path = "/v2/notes/{note_id}", - tag = "notes", - params(("note_id" = Uuid, Path, description = "Note ID.")), - request_body = Value, - responses( - (status = 200, description = "Note was updated.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 404, description = "Note was not found.", body = ErrorBody), - (status = 422, description = "Non-English input rejected.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn notes_patch( - State(state): State, - headers: HeaderMap, - Path(note_id): Path, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(|err| { - tracing::warn!(error = %err, "Invalid request payload."); - - routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid request payload.", - None, - ) - })?; - let response = state - .service - .update(UpdateRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - note_id, - text: payload.text, - importance: payload.importance, - confidence: payload.confidence, - ttl_days: payload.ttl_days, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - delete, - path = "/v2/notes/{note_id}", - tag = "notes", - params(("note_id" = Uuid, Path, description = "Note ID.")), - responses( - (status = 200, description = "Note was deleted.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 404, description = "Note was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn notes_delete( - State(state): State, - headers: HeaderMap, - Path(note_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .delete(DeleteRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - note_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/notes/{note_id}/publish", - tag = "notes", - params(("note_id" = Uuid, Path, description = "Note ID.")), - request_body = Value, - responses( - (status = 200, description = "Note was published to a shared space.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 404, description = "Note was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn notes_publish( - State(state): State, - headers: HeaderMap, - role: Option>, - Path(note_id): Path, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let role = role.map(|Extension(role)| role); - - publish::notes_publish_inner(state, headers, role, note_id, payload).await -} - -#[utoipa::path( - post, - path = "/v2/notes/{note_id}/unpublish", - tag = "notes", - params(("note_id" = Uuid, Path, description = "Note ID.")), - request_body = Value, - responses( - (status = 200, description = "Note was returned to private scope.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Scope denied.", body = ErrorBody), - (status = 404, description = "Note was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn notes_unpublish( - State(state): State, - headers: HeaderMap, - role: Option>, - Path(note_id): Path, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let role = role.map(|Extension(role)| role); - - publish::notes_unpublish_inner(state, headers, role, note_id, payload).await -} diff --git a/apps/elf-api/src/routes/notes/ingest.rs b/apps/elf-api/src/routes/notes/ingest.rs new file mode 100644 index 00000000..ae8279b4 --- /dev/null +++ b/apps/elf-api/src/routes/notes/ingest.rs @@ -0,0 +1,67 @@ +use crate::routes::{ + self, AddNoteRequest, AddNoteResponse, ApiError, AppState, ErrorBody, Extension, HeaderMap, + Json, JsonRejection, MAX_NOTES_PER_INGEST, NotesIngestRequest, RequestContext, + SecurityAuthRole, State, StatusCode, +}; + +#[utoipa::path( + post, + path = "/v2/notes/ingest", + tag = "notes", + request_body = Value, + responses( + (status = 200, description = "Notes were processed.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn notes_ingest( + State(state): State, + headers: HeaderMap, + role: Option>, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let role = role.map(|Extension(role)| role); + + if payload.scope.trim() == "org_shared" { + routes::require_admin_for_org_shared_writes( + state.service.cfg.security.auth_mode.as_str(), + role, + )?; + } + if payload.notes.len() > MAX_NOTES_PER_INGEST { + return Err(routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Notes list is too large.", + Some(vec!["$.notes".to_string()]), + )); + } + + let response = state + .service + .add_note(AddNoteRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + scope: payload.scope, + notes: payload.notes, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/notes/publish.rs b/apps/elf-api/src/routes/notes/publish.rs index 4130ab98..7ea59bbc 100644 --- a/apps/elf-api/src/routes/notes/publish.rs +++ b/apps/elf-api/src/routes/notes/publish.rs @@ -1,9 +1,63 @@ use crate::routes::{ - self, ApiError, AppState, HeaderMap, Json, JsonRejection, PublishNoteRequest, - PublishResponseV2, RequestContext, SecurityAuthRole, ShareScope, ShareScopeBody, StatusCode, - UnpublishNoteRequest, Uuid, + self, ApiError, AppState, ErrorBody, Extension, HeaderMap, Json, JsonRejection, Path, + PublishNoteRequest, PublishResponseV2, RequestContext, SecurityAuthRole, ShareScope, + ShareScopeBody, State, StatusCode, UnpublishNoteRequest, Uuid, }; +#[utoipa::path( + post, + path = "/v2/notes/{note_id}/publish", + tag = "notes", + params(("note_id" = Uuid, Path, description = "Note ID.")), + request_body = Value, + responses( + (status = 200, description = "Note was published to a shared space.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Note was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn notes_publish( + State(state): State, + headers: HeaderMap, + role: Option>, + Path(note_id): Path, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let role = role.map(|Extension(role)| role); + + notes_publish_inner(state, headers, role, note_id, payload).await +} + +#[utoipa::path( + post, + path = "/v2/notes/{note_id}/unpublish", + tag = "notes", + params(("note_id" = Uuid, Path, description = "Note ID.")), + request_body = Value, + responses( + (status = 200, description = "Note was returned to private scope.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Note was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn notes_unpublish( + State(state): State, + headers: HeaderMap, + role: Option>, + Path(note_id): Path, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let role = role.map(|Extension(role)| role); + + notes_unpublish_inner(state, headers, role, note_id, payload).await +} + pub(super) async fn notes_publish_inner( state: AppState, headers: HeaderMap, diff --git a/apps/elf-api/src/routes/notes/read.rs b/apps/elf-api/src/routes/notes/read.rs new file mode 100644 index 00000000..4bef4e6d --- /dev/null +++ b/apps/elf-api/src/routes/notes/read.rs @@ -0,0 +1,86 @@ +use crate::routes::{ + self, ApiError, AppState, ErrorBody, HeaderMap, Json, ListRequest, ListResponse, + NoteFetchRequest, NoteFetchResponse, NotesListQuery, Path, Query, QueryRejection, + RequestContext, State, StatusCode, Uuid, +}; + +#[utoipa::path( + get, + path = "/v2/notes", + tag = "notes", + params( + ("scope" = Option, Query, description = "Optional note scope filter."), + ("status" = Option, Query, description = "Optional note status filter."), + ("type" = Option, Query, description = "Optional note type filter."), + ), + responses( + (status = 200, description = "Notes visible to the caller.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn notes_list( + State(state): State, + headers: HeaderMap, + query: Result, QueryRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Query(query) = query.map_err(|err| { + tracing::warn!(error = %err, "Invalid query parameters."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid query parameters.".to_string(), + None, + ) + })?; + let response = state + .service + .list(ListRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: Some(ctx.agent_id), + scope: query.scope, + status: query.status, + r#type: query.r#type, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/notes/{note_id}", + tag = "notes", + params(("note_id" = Uuid, Path, description = "Note ID.")), + responses( + (status = 200, description = "Note details.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Note was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn notes_get( + State(state): State, + headers: HeaderMap, + Path(note_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .get_note(NoteFetchRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + note_id, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/notes/write.rs b/apps/elf-api/src/routes/notes/write.rs new file mode 100644 index 00000000..937f193f --- /dev/null +++ b/apps/elf-api/src/routes/notes/write.rs @@ -0,0 +1,88 @@ +use crate::routes::{ + self, ApiError, AppState, DeleteRequest, DeleteResponse, ErrorBody, HeaderMap, Json, + JsonRejection, NotePatchRequest, Path, RequestContext, State, StatusCode, UpdateRequest, + UpdateResponse, Uuid, +}; + +#[utoipa::path( + patch, + path = "/v2/notes/{note_id}", + tag = "notes", + params(("note_id" = Uuid, Path, description = "Note ID.")), + request_body = Value, + responses( + (status = 200, description = "Note was updated.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Note was not found.", body = ErrorBody), + (status = 422, description = "Non-English input rejected.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn notes_patch( + State(state): State, + headers: HeaderMap, + Path(note_id): Path, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(|err| { + tracing::warn!(error = %err, "Invalid request payload."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid request payload.", + None, + ) + })?; + let response = state + .service + .update(UpdateRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + note_id, + text: payload.text, + importance: payload.importance, + confidence: payload.confidence, + ttl_days: payload.ttl_days, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + delete, + path = "/v2/notes/{note_id}", + tag = "notes", + params(("note_id" = Uuid, Path, description = "Note ID.")), + responses( + (status = 200, description = "Note was deleted.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Scope denied.", body = ErrorBody), + (status = 404, description = "Note was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn notes_delete( + State(state): State, + headers: HeaderMap, + Path(note_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .delete(DeleteRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + note_id, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/route_builder.rs b/apps/elf-api/src/routes/route_builder.rs new file mode 100644 index 00000000..46a695c5 --- /dev/null +++ b/apps/elf-api/src/routes/route_builder.rs @@ -0,0 +1,271 @@ +use axum::{Router, extract::DefaultBodyLimit, middleware, routing}; + +use crate::{ + routes::{self, ADMIN_VIEWER_PATH, MAX_DOC_REQUEST_BYTES, MAX_REQUEST_BYTES}, + state::AppState, +}; + +/// Builds the authenticated public API router. +pub fn router(state: AppState) -> Router { + let auth_state = state.clone(); + + Router::new() + .merge(routes::contract_router()) + .merge( + public_api_router() + .with_state(state.clone()) + .layer(DefaultBodyLimit::max(MAX_REQUEST_BYTES)), + ) + .merge( + docs_api_router().with_state(state).layer(DefaultBodyLimit::max(MAX_DOC_REQUEST_BYTES)), + ) + .layer(middleware::from_fn_with_state(auth_state, routes::support::api_auth_middleware)) +} + +/// Builds the authenticated admin API router. +pub fn admin_router(state: AppState) -> Router { + let auth_state = state.clone(); + let protected_router = Router::new() + .merge(admin_search_routes()) + .merge(admin_core_routes()) + .merge(admin_docs_routes()) + .merge(admin_notes_routes()) + .merge(admin_ingestion_profile_routes()) + .merge(admin_consolidation_routes()) + .merge(admin_knowledge_routes()) + .merge(admin_trace_routes()) + .merge(admin_graph_routes()) + .merge(admin_ops_routes()) + .with_state(state) + .layer(DefaultBodyLimit::max(MAX_REQUEST_BYTES)) + .layer(middleware::from_fn_with_state(auth_state, routes::support::admin_auth_middleware)); + + Router::new() + .route(ADMIN_VIEWER_PATH, routing::get(routes::viewer::admin_viewer)) + .route("/", routing::get(routes::viewer::admin_viewer)) + .merge(protected_router) +} + +fn public_api_router() -> Router { + Router::new() + .route("/health", routing::get(routes::health::health)) + .route("/v2/notes/ingest", routing::post(routes::notes::notes_ingest)) + .route("/v2/events/ingest", routing::post(routes::events::events_ingest)) + .route("/v2/core-blocks", routing::get(routes::core_memory::core_blocks_get)) + .route("/v2/entity-memory", routing::get(routes::core_memory::entity_memory_get)) + .route("/v2/recall-debug/panel", routing::post(routes::recall::recall_debug_panel)) + .route("/v2/searches", routing::post(routes::search::searches_create)) + .route("/v2/searches/{search_id}", routing::get(routes::search::searches_get)) + .route("/v2/searches/{search_id}/timeline", routing::get(routes::search::searches_timeline)) + .route("/v2/searches/{search_id}/notes", routing::post(routes::search::searches_notes)) + .route("/v2/graph/query", routing::post(routes::graph::graph_query)) + .route("/v2/graph/report", routing::post(routes::graph::graph_report)) + .route("/v2/notes", routing::get(routes::notes::notes_list)) + .route( + "/v2/notes/{note_id}", + routing::get(routes::notes::notes_get) + .patch(routes::notes::notes_patch) + .delete(routes::notes::notes_delete), + ) + .route("/v2/notes/{note_id}/publish", routing::post(routes::notes::notes_publish)) + .route("/v2/notes/{note_id}/unpublish", routing::post(routes::notes::notes_unpublish)) + .route( + "/v2/work-journal/entries", + routing::post(routes::work_journal::work_journal_entry_create), + ) + .route( + "/v2/work-journal/entries/{entry_id}", + routing::get(routes::work_journal::work_journal_entry_get), + ) + .route( + "/v2/work-journal/readback", + routing::post(routes::work_journal::work_journal_session_readback), + ) + .route( + "/v2/spaces/{space}/grants", + routing::get(routes::sharing::space_grants_list) + .post(routes::sharing::space_grant_upsert), + ) + .route( + "/v2/spaces/{space}/grants/revoke", + routing::post(routes::sharing::space_grant_revoke), + ) +} + +fn docs_api_router() -> Router { + Router::new() + .route("/v2/docs", routing::post(routes::docs::docs_put)) + .route( + "/v2/docs/{doc_id}", + routing::get(routes::docs::docs_get).delete(routes::docs::docs_delete), + ) + .route("/v2/docs/search/l0", routing::post(routes::docs::docs_search_l0)) + .route("/v2/docs/excerpts", routing::post(routes::docs::docs_excerpts_get)) +} + +fn admin_search_routes() -> Router { + Router::new() + .route("/v2/admin/searches", routing::post(routes::search::searches_create)) + .route("/v2/admin/searches/raw", routing::post(routes::search::searches_raw)) + .route("/v2/admin/searches/{search_id}", routing::get(routes::search::searches_get)) + .route( + "/v2/admin/searches/{search_id}/timeline", + routing::get(routes::search::searches_timeline), + ) + .route( + "/v2/admin/searches/{search_id}/notes", + routing::post(routes::search::searches_notes), + ) +} + +fn admin_core_routes() -> Router { + Router::new() + .route("/v2/admin/core-blocks", routing::post(routes::core_memory::admin_core_block_upsert)) + .route( + "/v2/admin/core-blocks/{block_id}/attachments", + routing::post(routes::core_memory::admin_core_block_attach), + ) + .route( + "/v2/admin/core-blocks/attachments/{attachment_id}", + routing::delete(routes::core_memory::admin_core_block_detach), + ) + .route( + "/v2/admin/recall-debug/panel", + routing::post(routes::recall::admin_recall_debug_panel), + ) +} + +fn admin_docs_routes() -> Router { + Router::new() + .route("/v2/admin/docs/search/l0", routing::post(routes::docs::admin_docs_search_l0)) + .route("/v2/admin/docs/excerpts", routing::post(routes::docs::admin_docs_excerpts_get)) + .route("/v2/admin/docs/{doc_id}", routing::get(routes::docs::admin_docs_get)) +} + +fn admin_notes_routes() -> Router { + Router::new() + .route("/v2/admin/notes", routing::get(routes::notes::notes_list)) + .route("/v2/admin/notes/{note_id}", routing::get(routes::notes::notes_get)) + .route( + "/v2/admin/notes/{note_id}/provenance", + routing::get(routes::admin_notes::admin_note_provenance_get), + ) + .route( + "/v2/admin/notes/{note_id}/history", + routing::get(routes::admin_notes::admin_note_history_get), + ) + .route( + "/v2/admin/notes/{note_id}/corrections", + routing::post(routes::admin_notes::admin_note_correction_apply), + ) +} + +fn admin_ingestion_profile_routes() -> Router { + Router::new() + .route( + "/v2/admin/events/ingestion-profiles/default", + routing::get(routes::ingestion_profiles::admin_ingestion_profile_default_get) + .put(routes::ingestion_profiles::admin_ingestion_profile_default_set), + ) + .route( + "/v2/admin/events/ingestion-profiles/{profile_id}/versions", + routing::get(routes::ingestion_profiles::admin_ingestion_profile_versions_list), + ) + .route( + "/v2/admin/events/ingestion-profiles/{profile_id}", + routing::get(routes::ingestion_profiles::admin_ingestion_profile_get), + ) + .route( + "/v2/admin/events/ingestion-profiles", + routing::get(routes::ingestion_profiles::admin_ingestion_profiles_list) + .post(routes::ingestion_profiles::admin_ingestion_profile_create), + ) +} + +fn admin_consolidation_routes() -> Router { + Router::new() + .route( + "/v2/admin/consolidation/runs", + routing::get(routes::consolidation::consolidation_runs_list) + .post(routes::consolidation::consolidation_run_create), + ) + .route( + "/v2/admin/consolidation/runs/{run_id}", + routing::get(routes::consolidation::consolidation_run_get), + ) + .route( + "/v2/admin/consolidation/proposals", + routing::get(routes::consolidation::consolidation_proposals_list), + ) + .route( + "/v2/admin/consolidation/proposals/{proposal_id}", + routing::get(routes::consolidation::consolidation_proposal_get), + ) + .route( + "/v2/admin/consolidation/proposals/{proposal_id}/review", + routing::post(routes::consolidation::consolidation_proposal_review), + ) + .route( + "/v2/admin/dreaming/review-queue", + routing::get(routes::dreaming::dreaming_review_queue), + ) +} + +fn admin_knowledge_routes() -> Router { + Router::new() + .route("/v2/admin/knowledge/pages", routing::get(routes::knowledge::knowledge_pages_list)) + .route( + "/v2/admin/knowledge/pages/rebuild", + routing::post(routes::knowledge::knowledge_page_rebuild), + ) + .route( + "/v2/admin/knowledge/pages/rebuild-changed-sources", + routing::post(routes::knowledge::knowledge_pages_watch_rebuild), + ) + .route( + "/v2/admin/knowledge/pages/search", + routing::post(routes::knowledge::knowledge_pages_search), + ) + .route( + "/v2/admin/knowledge/pages/{page_id}", + routing::get(routes::knowledge::knowledge_page_get), + ) + .route( + "/v2/admin/knowledge/pages/{page_id}/lint", + routing::post(routes::knowledge::knowledge_page_lint), + ) +} + +fn admin_trace_routes() -> Router { + Router::new() + .route("/v2/admin/traces/recent", routing::get(routes::trace::trace_recent_list)) + .route("/v2/admin/traces/{trace_id}", routing::get(routes::trace::trace_get)) + .route("/v2/admin/traces/{trace_id}/bundle", routing::get(routes::trace::trace_bundle_get)) + .route( + "/v2/admin/trajectories/{trace_id}", + routing::get(routes::trace::trace_trajectory_get), + ) + .route("/v2/admin/trace-items/{item_id}", routing::get(routes::trace::trace_item_get)) +} + +fn admin_graph_routes() -> Router { + Router::new() + .route( + "/v2/admin/graph/predicates", + routing::get(routes::graph::admin_graph_predicates_list), + ) + .route( + "/v2/admin/graph/predicates/{predicate_id}", + routing::patch(routes::graph::admin_graph_predicate_patch), + ) + .route( + "/v2/admin/graph/predicates/{predicate_id}/aliases", + routing::post(routes::graph::admin_graph_predicate_alias_add) + .get(routes::graph::admin_graph_predicate_aliases_list), + ) +} + +fn admin_ops_routes() -> Router { + Router::new() + .route("/v2/admin/qdrant/rebuild", routing::post(routes::admin_ops::rebuild_qdrant)) +} diff --git a/apps/elf-api/src/routes/trace.rs b/apps/elf-api/src/routes/trace.rs index 85b96f14..69b265e5 100644 --- a/apps/elf-api/src/routes/trace.rs +++ b/apps/elf-api/src/routes/trace.rs @@ -1,231 +1,12 @@ -use crate::routes::{ - self, ApiError, AppState, ErrorBody, HeaderMap, Json, Path, Query, QueryRejection, - RequestContext, SearchExplainRequest, SearchExplainResponse, SearchTrajectoryResponse, State, - StatusCode, TraceBundleGetQuery, TraceBundleGetRequest, TraceBundleResponse, TraceGetRequest, - TraceGetResponse, TraceRecentListQuery, TraceRecentListRequest, TraceRecentListResponse, - TraceTrajectoryGetRequest, Uuid, +mod explain; +mod read; + +pub(super) use self::{ + explain::{ + __path_trace_item_get, __path_trace_trajectory_get, trace_item_get, trace_trajectory_get, + }, + read::{ + __path_trace_bundle_get, __path_trace_get, __path_trace_recent_list, trace_bundle_get, + trace_get, trace_recent_list, + }, }; - -#[utoipa::path( - get, - path = "/v2/admin/traces/{trace_id}", - tag = "admin", - params(("trace_id" = Uuid, Path, description = "Search trace ID.")), - responses( - (status = 200, description = "Search trace bundle without full stage internals.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Trace was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn trace_get( - State(state): State, - headers: HeaderMap, - Path(trace_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .trace_get(TraceGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - trace_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/traces/recent", - tag = "admin", - params( - ("limit" = Option, Query, description = "Page size."), - ("cursor_created_at" = Option, Query, description = "Created-at page cursor."), - ("cursor_trace_id" = Option, Query, description = "Trace ID page cursor."), - ("agent_id" = Option, Query, description = "Optional trace creator filter."), - ("read_profile" = Option, Query, description = "Optional read profile filter."), - ("created_after" = Option, Query, description = "Strict lower created_at bound."), - ("created_before" = Option, Query, description = "Strict upper created_at bound."), - ), - responses( - (status = 200, description = "Recent search traces.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn trace_recent_list( - State(state): State, - headers: HeaderMap, - query: Result, QueryRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Query(query) = query.map_err(|err| { - tracing::warn!(error = %err, "Invalid query parameters."); - - routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid query parameters.".to_string(), - None, - ) - })?; - let cursor_created_at = - routes::parse_optional_rfc3339(query.cursor_created_at.as_ref(), "$.cursor_created_at")?; - let cursor_trace_id = query.cursor_trace_id; - let created_after = - routes::parse_optional_rfc3339(query.created_after.as_ref(), "$.created_after")?; - let created_before = - routes::parse_optional_rfc3339(query.created_before.as_ref(), "$.created_before")?; - - if cursor_created_at.is_some() != cursor_trace_id.is_some() { - return Err(routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "cursor_created_at and cursor_trace_id must be both set or both omitted.".to_string(), - Some(vec!["$.cursor_created_at".to_string(), "$.cursor_trace_id".to_string()]), - )); - } - - let response = state - .service - .trace_recent_list(TraceRecentListRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - limit: query.limit, - cursor_created_at, - cursor_trace_id, - agent_id_filter: query.agent_id, - read_profile: query.read_profile, - created_after, - created_before, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/trajectories/{trace_id}", - tag = "admin", - params(("trace_id" = Uuid, Path, description = "Search trace ID.")), - responses( - (status = 200, description = "Search trace retrieval trajectory.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Trace was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn trace_trajectory_get( - State(state): State, - headers: HeaderMap, - Path(trace_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .trace_trajectory_get(TraceTrajectoryGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - trace_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/trace-items/{item_id}", - tag = "admin", - params(("item_id" = Uuid, Path, description = "Trace item/result handle ID.")), - responses( - (status = 200, description = "Search trace item explain payload.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Trace item was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn trace_item_get( - State(state): State, - headers: HeaderMap, - Path(item_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .search_explain(SearchExplainRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - result_handle: item_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/traces/{trace_id}/bundle", - tag = "admin", - params( - ("trace_id" = Uuid, Path, description = "Search trace ID."), - ("mode" = Option, Query, description = "bounded or full."), - ("stage_items_limit" = Option, Query, description = "Maximum stage items."), - ("candidates_limit" = Option, Query, description = "Maximum candidate snapshot items."), - ), - responses( - (status = 200, description = "Search trace bundle.", body = Value), - (status = 400, description = "Invalid request.", body = ErrorBody), - (status = 401, description = "Authentication required.", body = ErrorBody), - (status = 403, description = "Admin access required.", body = ErrorBody), - (status = 404, description = "Trace was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn trace_bundle_get( - State(state): State, - headers: HeaderMap, - Path(trace_id): Path, - query: Result, QueryRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Query(query) = query.map_err(|err| { - tracing::warn!(error = %err, "Invalid query parameters."); - - routes::json_error( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid query parameters.".to_string(), - None, - ) - })?; - let response = state - .service - .trace_bundle_get(TraceBundleGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - trace_id, - mode: query.mode.unwrap_or_default(), - stage_items_limit: query.stage_items_limit, - candidates_limit: query.candidates_limit, - }) - .await?; - - Ok(Json(response)) -} diff --git a/apps/elf-api/src/routes/trace/explain.rs b/apps/elf-api/src/routes/trace/explain.rs new file mode 100644 index 00000000..8ab66b36 --- /dev/null +++ b/apps/elf-api/src/routes/trace/explain.rs @@ -0,0 +1,70 @@ +use crate::routes::{ + ApiError, AppState, ErrorBody, HeaderMap, Json, Path, RequestContext, SearchExplainRequest, + SearchExplainResponse, SearchTrajectoryResponse, State, TraceTrajectoryGetRequest, Uuid, +}; + +#[utoipa::path( + get, + path = "/v2/admin/trajectories/{trace_id}", + tag = "admin", + params(("trace_id" = Uuid, Path, description = "Search trace ID.")), + responses( + (status = 200, description = "Search trace retrieval trajectory.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Trace was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn trace_trajectory_get( + State(state): State, + headers: HeaderMap, + Path(trace_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .trace_trajectory_get(TraceTrajectoryGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + trace_id, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/admin/trace-items/{item_id}", + tag = "admin", + params(("item_id" = Uuid, Path, description = "Trace item/result handle ID.")), + responses( + (status = 200, description = "Search trace item explain payload.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Trace item was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn trace_item_get( + State(state): State, + headers: HeaderMap, + Path(item_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .search_explain(SearchExplainRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + result_handle: item_id, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/trace/read.rs b/apps/elf-api/src/routes/trace/read.rs new file mode 100644 index 00000000..78f69d75 --- /dev/null +++ b/apps/elf-api/src/routes/trace/read.rs @@ -0,0 +1,164 @@ +use crate::routes::{ + self, ApiError, AppState, ErrorBody, HeaderMap, Json, Path, Query, QueryRejection, + RequestContext, State, StatusCode, TraceBundleGetQuery, TraceBundleGetRequest, + TraceBundleResponse, TraceGetRequest, TraceGetResponse, TraceRecentListQuery, + TraceRecentListRequest, TraceRecentListResponse, Uuid, +}; + +#[utoipa::path( + get, + path = "/v2/admin/traces/{trace_id}", + tag = "admin", + params(("trace_id" = Uuid, Path, description = "Search trace ID.")), + responses( + (status = 200, description = "Search trace bundle without full stage internals.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Trace was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn trace_get( + State(state): State, + headers: HeaderMap, + Path(trace_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .trace_get(TraceGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + trace_id, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/admin/traces/recent", + tag = "admin", + params( + ("limit" = Option, Query, description = "Page size."), + ("cursor_created_at" = Option, Query, description = "Created-at page cursor."), + ("cursor_trace_id" = Option, Query, description = "Trace ID page cursor."), + ("agent_id" = Option, Query, description = "Optional trace creator filter."), + ("read_profile" = Option, Query, description = "Optional read profile filter."), + ("created_after" = Option, Query, description = "Strict lower created_at bound."), + ("created_before" = Option, Query, description = "Strict upper created_at bound."), + ), + responses( + (status = 200, description = "Recent search traces.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn trace_recent_list( + State(state): State, + headers: HeaderMap, + query: Result, QueryRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Query(query) = query.map_err(|err| { + tracing::warn!(error = %err, "Invalid query parameters."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid query parameters.".to_string(), + None, + ) + })?; + let cursor_created_at = + routes::parse_optional_rfc3339(query.cursor_created_at.as_ref(), "$.cursor_created_at")?; + let cursor_trace_id = query.cursor_trace_id; + let created_after = + routes::parse_optional_rfc3339(query.created_after.as_ref(), "$.created_after")?; + let created_before = + routes::parse_optional_rfc3339(query.created_before.as_ref(), "$.created_before")?; + + if cursor_created_at.is_some() != cursor_trace_id.is_some() { + return Err(routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "cursor_created_at and cursor_trace_id must be both set or both omitted.".to_string(), + Some(vec!["$.cursor_created_at".to_string(), "$.cursor_trace_id".to_string()]), + )); + } + + let response = state + .service + .trace_recent_list(TraceRecentListRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + limit: query.limit, + cursor_created_at, + cursor_trace_id, + agent_id_filter: query.agent_id, + read_profile: query.read_profile, + created_after, + created_before, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/admin/traces/{trace_id}/bundle", + tag = "admin", + params( + ("trace_id" = Uuid, Path, description = "Search trace ID."), + ("mode" = Option, Query, description = "bounded or full."), + ("stage_items_limit" = Option, Query, description = "Maximum stage items."), + ("candidates_limit" = Option, Query, description = "Maximum candidate snapshot items."), + ), + responses( + (status = 200, description = "Search trace bundle.", body = Value), + (status = 400, description = "Invalid request.", body = ErrorBody), + (status = 401, description = "Authentication required.", body = ErrorBody), + (status = 403, description = "Admin access required.", body = ErrorBody), + (status = 404, description = "Trace was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn trace_bundle_get( + State(state): State, + headers: HeaderMap, + Path(trace_id): Path, + query: Result, QueryRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Query(query) = query.map_err(|err| { + tracing::warn!(error = %err, "Invalid query parameters."); + + routes::json_error( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid query parameters.".to_string(), + None, + ) + })?; + let response = state + .service + .trace_bundle_get(TraceBundleGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + trace_id, + mode: query.mode.unwrap_or_default(), + stage_items_limit: query.stage_items_limit, + candidates_limit: query.candidates_limit, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-eval/tests/real_world_job_benchmark/trace_replay_reports.rs b/apps/elf-eval/tests/real_world_job_benchmark/trace_replay_reports.rs index a5fcba97..bbb9717a 100644 --- a/apps/elf-eval/tests/real_world_job_benchmark/trace_replay_reports.rs +++ b/apps/elf-eval/tests/real_world_job_benchmark/trace_replay_reports.rs @@ -25,6 +25,14 @@ fn mcp_server_sources(workspace: &Path) -> Result { Ok(source) } +fn api_route_sources(workspace: &Path) -> Result { + let mut source = fs::read_to_string(workspace.join("apps/elf-api/src/routes.rs"))?; + + append_rust_sources(workspace.join("apps/elf-api/src/routes").as_path(), &mut source)?; + + Ok(source) +} + fn append_rust_sources(dir: &Path, source: &mut String) -> Result<()> { let mut entries = Vec::new(); @@ -53,8 +61,7 @@ fn graph_topic_map_report_wires_source_backed_graph_lite_readback() -> Result<() let readme = fs::read_to_string(support::readme_path()?)?; let workspace = support::workspace_root()?; let graph_report_service = graph_report_service_sources(&workspace)?; - let api_routes = - fs::read_to_string(support::workspace_root()?.join("apps/elf-api/src/routes.rs"))?; + let api_routes = api_route_sources(&workspace)?; let mcp_server = mcp_server_sources(&workspace)?; let graph_spec = fs::read_to_string( support::workspace_root()?.join("docs/spec/system_graph_memory_postgres_v1.md"),