diff --git a/apps/elf-api/src/routes/admin_notes.rs b/apps/elf-api/src/routes/admin_notes.rs index c06d0630..b62d887b 100644 --- a/apps/elf-api/src/routes/admin_notes.rs +++ b/apps/elf-api/src/routes/admin_notes.rs @@ -1,119 +1,10 @@ -use crate::routes::{ - self, AdminNoteCorrectionBody, ApiError, AppState, ErrorBody, HeaderMap, Json, JsonRejection, - MemoryCorrectionRequest, MemoryCorrectionResponse, MemoryHistoryGetRequest, - MemoryHistoryResponse, NoteProvenanceBundleResponse, NoteProvenanceGetRequest, Path, - RequestContext, State, StatusCode, Uuid, +mod corrections; +mod read; + +pub(super) use self::{ + corrections::{__path_admin_note_correction_apply, admin_note_correction_apply}, + read::{ + __path_admin_note_history_get, __path_admin_note_provenance_get, admin_note_history_get, + admin_note_provenance_get, + }, }; - -#[utoipa::path( - get, - path = "/v2/admin/notes/{note_id}/provenance", - tag = "admin", - params(("note_id" = Uuid, Path, description = "Note ID.")), - responses( - (status = 200, description = "Note provenance 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 = "Note was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn admin_note_provenance_get( - State(state): State, - headers: HeaderMap, - Path(note_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .note_provenance_get(NoteProvenanceGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - note_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/notes/{note_id}/history", - tag = "admin", - params(("note_id" = Uuid, Path, description = "Note ID.")), - responses( - (status = 200, description = "Memory history timeline.", 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 = "Note was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn admin_note_history_get( - State(state): State, - headers: HeaderMap, - Path(note_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .memory_history_get(MemoryHistoryGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - note_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/admin/notes/{note_id}/corrections", - tag = "admin", - params(("note_id" = Uuid, Path, description = "Note ID.")), - request_body = Value, - responses( - (status = 200, description = "Memory correction was applied.", 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 = "Note was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn admin_note_correction_apply( - 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 - .memory_correction_apply(MemoryCorrectionRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - actor_agent_id: ctx.agent_id, - note_id, - action: payload.action, - reason: payload.reason, - source_ref: payload.source_ref, - restore_version_id: payload.restore_version_id, - }) - .await?; - - Ok(Json(response)) -} diff --git a/apps/elf-api/src/routes/admin_notes/corrections.rs b/apps/elf-api/src/routes/admin_notes/corrections.rs new file mode 100644 index 00000000..fb63b079 --- /dev/null +++ b/apps/elf-api/src/routes/admin_notes/corrections.rs @@ -0,0 +1,54 @@ +use crate::routes::{ + self, AdminNoteCorrectionBody, ApiError, AppState, ErrorBody, HeaderMap, Json, JsonRejection, + MemoryCorrectionRequest, MemoryCorrectionResponse, Path, RequestContext, State, StatusCode, + Uuid, +}; + +#[utoipa::path( + post, + path = "/v2/admin/notes/{note_id}/corrections", + tag = "admin", + params(("note_id" = Uuid, Path, description = "Note ID.")), + request_body = Value, + responses( + (status = 200, description = "Memory correction was applied.", 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 = "Note was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn admin_note_correction_apply( + 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 + .memory_correction_apply(MemoryCorrectionRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + actor_agent_id: ctx.agent_id, + note_id, + action: payload.action, + reason: payload.reason, + source_ref: payload.source_ref, + restore_version_id: payload.restore_version_id, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/admin_notes/read.rs b/apps/elf-api/src/routes/admin_notes/read.rs new file mode 100644 index 00000000..d2d1c403 --- /dev/null +++ b/apps/elf-api/src/routes/admin_notes/read.rs @@ -0,0 +1,68 @@ +use crate::routes::{ + ApiError, AppState, ErrorBody, HeaderMap, Json, MemoryHistoryGetRequest, MemoryHistoryResponse, + NoteProvenanceBundleResponse, NoteProvenanceGetRequest, Path, RequestContext, State, Uuid, +}; + +#[utoipa::path( + get, + path = "/v2/admin/notes/{note_id}/provenance", + tag = "admin", + params(("note_id" = Uuid, Path, description = "Note ID.")), + responses( + (status = 200, description = "Note provenance 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 = "Note was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn admin_note_provenance_get( + State(state): State, + headers: HeaderMap, + Path(note_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .note_provenance_get(NoteProvenanceGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + note_id, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/admin/notes/{note_id}/history", + tag = "admin", + params(("note_id" = Uuid, Path, description = "Note ID.")), + responses( + (status = 200, description = "Memory history timeline.", 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 = "Note was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn admin_note_history_get( + State(state): State, + headers: HeaderMap, + Path(note_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .memory_history_get(MemoryHistoryGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + note_id, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/consolidation.rs b/apps/elf-api/src/routes/consolidation.rs index 3b4a054d..02ae791b 100644 --- a/apps/elf-api/src/routes/consolidation.rs +++ b/apps/elf-api/src/routes/consolidation.rs @@ -1,255 +1,15 @@ -use crate::routes::{ - self, ApiError, AppState, ConsolidationProposalGetRequest, ConsolidationProposalResponse, - ConsolidationProposalReviewBody, ConsolidationProposalReviewRequest, - ConsolidationProposalsListQuery, ConsolidationProposalsListRequest, - ConsolidationProposalsListResponse, ConsolidationRunCreateBody, ConsolidationRunCreateRequest, - ConsolidationRunCreateResponse, ConsolidationRunGetRequest, ConsolidationRunResponse, - ConsolidationRunsListQuery, ConsolidationRunsListRequest, ConsolidationRunsListResponse, - ErrorBody, HeaderMap, Json, JsonRejection, Path, Query, QueryRejection, RequestContext, State, - StatusCode, Uuid, +mod proposals; +mod runs; + +pub(super) use self::{ + proposals::{ + __path_consolidation_proposal_get, __path_consolidation_proposal_review, + __path_consolidation_proposals_list, consolidation_proposal_get, + consolidation_proposal_review, consolidation_proposals_list, + }, + runs::{ + __path_consolidation_run_create, __path_consolidation_run_get, + __path_consolidation_runs_list, consolidation_run_create, consolidation_run_get, + consolidation_runs_list, + }, }; - -#[utoipa::path( - post, - path = "/v2/admin/consolidation/runs", - tag = "consolidation", - request_body = Value, - responses( - (status = 200, description = "Consolidation run was created.", 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 consolidation_run_create( - 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 - .consolidation_run_create(ConsolidationRunCreateRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - job_kind: payload.job_kind, - input_refs: payload.input_refs, - source_snapshot: payload.source_snapshot, - lineage: payload.lineage, - proposals: payload.proposals, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/consolidation/runs", - tag = "consolidation", - params(("limit" = Option, Query, description = "Maximum runs to return.")), - responses( - (status = 200, description = "Consolidation runs.", 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 consolidation_runs_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 - .consolidation_runs_list(ConsolidationRunsListRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - limit: query.limit, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/consolidation/runs/{run_id}", - tag = "consolidation", - params(("run_id" = Uuid, Path, description = "Consolidation run ID.")), - responses( - (status = 200, description = "Consolidation run.", 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 = "Consolidation run was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn consolidation_run_get( - State(state): State, - headers: HeaderMap, - Path(run_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .consolidation_run_get(ConsolidationRunGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - run_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/consolidation/proposals", - tag = "consolidation", - params( - ("run_id" = Option, Query, description = "Optional run filter."), - ("review_state" = Option, Query, description = "Optional review-state filter."), - ("limit" = Option, Query, description = "Maximum proposals to return."), - ), - responses( - (status = 200, description = "Consolidation proposals.", 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 consolidation_proposals_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 - .consolidation_proposals_list(ConsolidationProposalsListRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - run_id: query.run_id, - review_state: query.review_state, - limit: query.limit, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/consolidation/proposals/{proposal_id}", - tag = "consolidation", - params(("proposal_id" = Uuid, Path, description = "Consolidation proposal ID.")), - responses( - (status = 200, description = "Consolidation proposal.", 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 = "Consolidation proposal was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn consolidation_proposal_get( - State(state): State, - headers: HeaderMap, - Path(proposal_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .consolidation_proposal_get(ConsolidationProposalGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - proposal_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/admin/consolidation/proposals/{proposal_id}/review", - tag = "consolidation", - params(("proposal_id" = Uuid, Path, description = "Consolidation proposal ID.")), - request_body = Value, - responses( - (status = 200, description = "Consolidation proposal review action was applied.", 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 = "Consolidation proposal was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn consolidation_proposal_review( - State(state): State, - headers: HeaderMap, - Path(proposal_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 - .consolidation_proposal_review(ConsolidationProposalReviewRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - reviewer_agent_id: ctx.agent_id, - proposal_id, - review_action: payload.action, - review_comment: payload.review_comment, - }) - .await?; - - Ok(Json(response)) -} diff --git a/apps/elf-api/src/routes/consolidation/proposals.rs b/apps/elf-api/src/routes/consolidation/proposals.rs new file mode 100644 index 00000000..97b5951b --- /dev/null +++ b/apps/elf-api/src/routes/consolidation/proposals.rs @@ -0,0 +1,133 @@ +use crate::routes::{ + self, ApiError, AppState, ConsolidationProposalGetRequest, ConsolidationProposalResponse, + ConsolidationProposalReviewBody, ConsolidationProposalReviewRequest, + ConsolidationProposalsListQuery, ConsolidationProposalsListRequest, + ConsolidationProposalsListResponse, ErrorBody, HeaderMap, Json, JsonRejection, Path, Query, + QueryRejection, RequestContext, State, StatusCode, Uuid, +}; + +#[utoipa::path( + get, + path = "/v2/admin/consolidation/proposals", + tag = "consolidation", + params( + ("run_id" = Option, Query, description = "Optional run filter."), + ("review_state" = Option, Query, description = "Optional review-state filter."), + ("limit" = Option, Query, description = "Maximum proposals to return."), + ), + responses( + (status = 200, description = "Consolidation proposals.", 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 consolidation_proposals_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 + .consolidation_proposals_list(ConsolidationProposalsListRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + run_id: query.run_id, + review_state: query.review_state, + limit: query.limit, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/admin/consolidation/proposals/{proposal_id}", + tag = "consolidation", + params(("proposal_id" = Uuid, Path, description = "Consolidation proposal ID.")), + responses( + (status = 200, description = "Consolidation proposal.", 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 = "Consolidation proposal was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn consolidation_proposal_get( + State(state): State, + headers: HeaderMap, + Path(proposal_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .consolidation_proposal_get(ConsolidationProposalGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + proposal_id, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + post, + path = "/v2/admin/consolidation/proposals/{proposal_id}/review", + tag = "consolidation", + params(("proposal_id" = Uuid, Path, description = "Consolidation proposal ID.")), + request_body = Value, + responses( + (status = 200, description = "Consolidation proposal review action was applied.", 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 = "Consolidation proposal was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn consolidation_proposal_review( + State(state): State, + headers: HeaderMap, + Path(proposal_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 + .consolidation_proposal_review(ConsolidationProposalReviewRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + reviewer_agent_id: ctx.agent_id, + proposal_id, + review_action: payload.action, + review_comment: payload.review_comment, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/consolidation/runs.rs b/apps/elf-api/src/routes/consolidation/runs.rs new file mode 100644 index 00000000..46fba198 --- /dev/null +++ b/apps/elf-api/src/routes/consolidation/runs.rs @@ -0,0 +1,126 @@ +use crate::routes::{ + self, ApiError, AppState, ConsolidationRunCreateBody, ConsolidationRunCreateRequest, + ConsolidationRunCreateResponse, ConsolidationRunGetRequest, ConsolidationRunResponse, + ConsolidationRunsListQuery, ConsolidationRunsListRequest, ConsolidationRunsListResponse, + ErrorBody, HeaderMap, Json, JsonRejection, Path, Query, QueryRejection, RequestContext, State, + StatusCode, Uuid, +}; + +#[utoipa::path( + post, + path = "/v2/admin/consolidation/runs", + tag = "consolidation", + request_body = Value, + responses( + (status = 200, description = "Consolidation run was created.", 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 consolidation_run_create( + 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 + .consolidation_run_create(ConsolidationRunCreateRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + job_kind: payload.job_kind, + input_refs: payload.input_refs, + source_snapshot: payload.source_snapshot, + lineage: payload.lineage, + proposals: payload.proposals, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/admin/consolidation/runs", + tag = "consolidation", + params(("limit" = Option, Query, description = "Maximum runs to return.")), + responses( + (status = 200, description = "Consolidation runs.", 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 consolidation_runs_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 + .consolidation_runs_list(ConsolidationRunsListRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + limit: query.limit, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/admin/consolidation/runs/{run_id}", + tag = "consolidation", + params(("run_id" = Uuid, Path, description = "Consolidation run ID.")), + responses( + (status = 200, description = "Consolidation run.", 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 = "Consolidation run was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn consolidation_run_get( + State(state): State, + headers: HeaderMap, + Path(run_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .consolidation_run_get(ConsolidationRunGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + run_id, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/core_memory.rs b/apps/elf-api/src/routes/core_memory.rs index a863aee7..704d9f8f 100644 --- a/apps/elf-api/src/routes/core_memory.rs +++ b/apps/elf-api/src/routes/core_memory.rs @@ -1,229 +1,11 @@ -use crate::routes::{ - self, ApiError, AppState, CoreBlockAttachBody, CoreBlockAttachRequest, CoreBlockAttachResponse, - CoreBlockDetachRequest, CoreBlockDetachResponse, CoreBlockUpsertBody, CoreBlockUpsertRequest, - CoreBlockUpsertResponse, CoreBlocksGetRequest, CoreBlocksResponse, EntityMemoryQuery, - EntityMemoryViewRequest, EntityMemoryViewResponse, ErrorBody, Extension, HeaderMap, Json, - JsonRejection, Path, Query, QueryRejection, RequestContext, SecurityAuthRole, State, - StatusCode, Uuid, +mod admin; +mod read; + +pub(super) use self::{ + admin::{ + __path_admin_core_block_attach, __path_admin_core_block_detach, + __path_admin_core_block_upsert, admin_core_block_attach, admin_core_block_detach, + admin_core_block_upsert, + }, + read::{__path_core_blocks_get, __path_entity_memory_get, core_blocks_get, entity_memory_get}, }; - -#[utoipa::path( - get, - path = "/v2/core-blocks", - tag = "core_blocks", - responses( - (status = 200, description = "Attached core memory blocks.", 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 core_blocks_get( - State(state): State, - headers: HeaderMap, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let read_profile = routes::required_read_profile(&headers)?; - let response = state - .service - .core_blocks_get(CoreBlocksGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - read_profile, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/entity-memory", - tag = "graph", - params( - ("entity_id" = Option, Query, description = "Graph entity id. Exactly one of entity_id or entity_surface is required."), - ("entity_surface" = Option, Query, description = "Canonical or alias entity surface. Exactly one of entity_id or entity_surface is required."), - ), - responses( - (status = 200, description = "Entity-scoped memory authority view.", 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 = "Entity was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn entity_memory_get( - State(state): State, - headers: HeaderMap, - query: Result, QueryRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let read_profile = routes::required_read_profile(&headers)?; - let Query(query) = query.map_err(|err| { - tracing::warn!(error = %err, "Invalid query parameters."); - - ApiError::new( - StatusCode::BAD_REQUEST, - "INVALID_REQUEST", - "Invalid query parameters.".to_string(), - None, - ) - })?; - let response = state - .service - .entity_memory_view(EntityMemoryViewRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - read_profile, - entity_id: query.entity_id, - entity_surface: query.entity_surface, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/admin/core-blocks", - tag = "core_blocks", - request_body = Value, - responses( - (status = 200, description = "Core block 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 = 409, description = "Core block conflict.", body = ErrorBody), - (status = 422, description = "Non-English input rejected.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn admin_core_block_upsert( - 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 - .core_block_upsert(CoreBlockUpsertRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - block_id: payload.block_id, - scope: payload.scope, - key: payload.key, - title: payload.title, - content: payload.content, - source_ref: payload.source_ref, - reason: payload.reason, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/admin/core-blocks/{block_id}/attachments", - tag = "core_blocks", - params(("block_id" = Uuid, Path, description = "Core block ID.")), - request_body = Value, - responses( - (status = 200, description = "Core block was attached.", 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 = "Core block was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn admin_core_block_attach( - State(state): State, - headers: HeaderMap, - Path(block_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 - .core_block_attach(CoreBlockAttachRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - block_id, - target_agent_id: payload.target_agent_id, - read_profile: payload.read_profile, - reason: payload.reason, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - delete, - path = "/v2/admin/core-blocks/attachments/{attachment_id}", - tag = "core_blocks", - params(("attachment_id" = Uuid, Path, description = "Core block attachment ID.")), - responses( - (status = 200, description = "Core block attachment was detached.", 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 admin_core_block_detach( - State(state): State, - headers: HeaderMap, - Path(attachment_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .core_block_detach(CoreBlockDetachRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - attachment_id, - reason: None, - }) - .await?; - - Ok(Json(response)) -} diff --git a/apps/elf-api/src/routes/core_memory/admin.rs b/apps/elf-api/src/routes/core_memory/admin.rs new file mode 100644 index 00000000..1a79b115 --- /dev/null +++ b/apps/elf-api/src/routes/core_memory/admin.rs @@ -0,0 +1,147 @@ +use crate::routes::{ + self, ApiError, AppState, CoreBlockAttachBody, CoreBlockAttachRequest, CoreBlockAttachResponse, + CoreBlockDetachRequest, CoreBlockDetachResponse, CoreBlockUpsertBody, CoreBlockUpsertRequest, + CoreBlockUpsertResponse, ErrorBody, Extension, HeaderMap, Json, JsonRejection, Path, + RequestContext, SecurityAuthRole, State, StatusCode, Uuid, +}; + +#[utoipa::path( + post, + path = "/v2/admin/core-blocks", + tag = "core_blocks", + request_body = Value, + responses( + (status = 200, description = "Core block 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 = 409, description = "Core block conflict.", 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_core_block_upsert( + 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 + .core_block_upsert(CoreBlockUpsertRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + block_id: payload.block_id, + scope: payload.scope, + key: payload.key, + title: payload.title, + content: payload.content, + source_ref: payload.source_ref, + reason: payload.reason, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + post, + path = "/v2/admin/core-blocks/{block_id}/attachments", + tag = "core_blocks", + params(("block_id" = Uuid, Path, description = "Core block ID.")), + request_body = Value, + responses( + (status = 200, description = "Core block was attached.", 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 = "Core block was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn admin_core_block_attach( + State(state): State, + headers: HeaderMap, + Path(block_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 + .core_block_attach(CoreBlockAttachRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + block_id, + target_agent_id: payload.target_agent_id, + read_profile: payload.read_profile, + reason: payload.reason, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + delete, + path = "/v2/admin/core-blocks/attachments/{attachment_id}", + tag = "core_blocks", + params(("attachment_id" = Uuid, Path, description = "Core block attachment ID.")), + responses( + (status = 200, description = "Core block attachment was detached.", 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 admin_core_block_detach( + State(state): State, + headers: HeaderMap, + Path(attachment_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .core_block_detach(CoreBlockDetachRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + attachment_id, + reason: None, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/core_memory/read.rs b/apps/elf-api/src/routes/core_memory/read.rs new file mode 100644 index 00000000..8722a5a8 --- /dev/null +++ b/apps/elf-api/src/routes/core_memory/read.rs @@ -0,0 +1,85 @@ +use crate::routes::{ + self, ApiError, AppState, CoreBlocksGetRequest, CoreBlocksResponse, EntityMemoryQuery, + EntityMemoryViewRequest, EntityMemoryViewResponse, ErrorBody, HeaderMap, Json, Query, + QueryRejection, RequestContext, State, StatusCode, +}; + +#[utoipa::path( + get, + path = "/v2/core-blocks", + tag = "core_blocks", + responses( + (status = 200, description = "Attached core memory blocks.", 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 core_blocks_get( + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let read_profile = routes::required_read_profile(&headers)?; + let response = state + .service + .core_blocks_get(CoreBlocksGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + read_profile, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/entity-memory", + tag = "graph", + params( + ("entity_id" = Option, Query, description = "Graph entity id. Exactly one of entity_id or entity_surface is required."), + ("entity_surface" = Option, Query, description = "Canonical or alias entity surface. Exactly one of entity_id or entity_surface is required."), + ), + responses( + (status = 200, description = "Entity-scoped memory authority view.", 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 = "Entity was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn entity_memory_get( + State(state): State, + headers: HeaderMap, + query: Result, QueryRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let read_profile = routes::required_read_profile(&headers)?; + let Query(query) = query.map_err(|err| { + tracing::warn!(error = %err, "Invalid query parameters."); + + ApiError::new( + StatusCode::BAD_REQUEST, + "INVALID_REQUEST", + "Invalid query parameters.".to_string(), + None, + ) + })?; + let response = state + .service + .entity_memory_view(EntityMemoryViewRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + read_profile, + entity_id: query.entity_id, + entity_surface: query.entity_surface, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/ingestion_profiles.rs b/apps/elf-api/src/routes/ingestion_profiles.rs index 8b5371ce..7b7b4d3e 100644 --- a/apps/elf-api/src/routes/ingestion_profiles.rs +++ b/apps/elf-api/src/routes/ingestion_profiles.rs @@ -1,240 +1,18 @@ -use crate::routes::{ - self, AdminIngestionProfileCreateBody, AdminIngestionProfileCreateRequest, - AdminIngestionProfileDefaultGetRequest, AdminIngestionProfileDefaultResponse, - AdminIngestionProfileDefaultResponseV2, AdminIngestionProfileDefaultSetBody, - AdminIngestionProfileDefaultSetRequest, AdminIngestionProfileGetQuery, - AdminIngestionProfileGetRequest, AdminIngestionProfileListRequest, - AdminIngestionProfileResponse, AdminIngestionProfileVersionsListRequest, - AdminIngestionProfileVersionsListResponse, AdminIngestionProfilesListResponse, ApiError, - AppState, ErrorBody, HeaderMap, Json, JsonRejection, Path, Query, QueryRejection, - RequestContext, State, StatusCode, +mod collection; +mod defaults; +mod versions; + +pub(super) use self::{ + collection::{ + __path_admin_ingestion_profile_create, __path_admin_ingestion_profiles_list, + admin_ingestion_profile_create, admin_ingestion_profiles_list, + }, + defaults::{ + __path_admin_ingestion_profile_default_get, __path_admin_ingestion_profile_default_set, + admin_ingestion_profile_default_get, admin_ingestion_profile_default_set, + }, + versions::{ + __path_admin_ingestion_profile_get, __path_admin_ingestion_profile_versions_list, + admin_ingestion_profile_get, admin_ingestion_profile_versions_list, + }, }; - -#[utoipa::path( - get, - path = "/v2/admin/events/ingestion-profiles", - tag = "admin", - responses( - (status = 200, description = "Ingestion profile versions.", 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_ingestion_profiles_list( - State(state): State, - headers: HeaderMap, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .admin_ingestion_profiles_list(AdminIngestionProfileListRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/admin/events/ingestion-profiles", - tag = "admin", - request_body = Value, - responses( - (status = 200, description = "Ingestion profile version was created.", 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_ingestion_profile_create( - 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 - .admin_ingestion_profile_create(AdminIngestionProfileCreateRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - profile_id: payload.profile_id, - version: payload.version, - profile: payload.profile, - created_by: payload.created_by, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/events/ingestion-profiles/{profile_id}", - tag = "admin", - params( - ("profile_id" = String, Path, description = "Ingestion profile ID."), - ("version" = Option, Query, description = "Optional profile version."), - ), - responses( - (status = 200, description = "Ingestion profile version.", 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 = "Profile was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn admin_ingestion_profile_get( - State(state): State, - headers: HeaderMap, - Path(profile_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 - .admin_ingestion_profile_get(AdminIngestionProfileGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - profile_id, - version: query.version, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/events/ingestion-profiles/{profile_id}/versions", - tag = "admin", - params(("profile_id" = String, Path, description = "Ingestion profile ID.")), - responses( - (status = 200, description = "Versions for one ingestion profile.", 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_ingestion_profile_versions_list( - State(state): State, - headers: HeaderMap, - Path(profile_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .admin_ingestion_profile_versions_list(AdminIngestionProfileVersionsListRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - profile_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/admin/events/ingestion-profiles/default", - tag = "admin", - responses( - ( - status = 200, - description = "Default add_event ingestion profile pointer.", - body = AdminIngestionProfileDefaultResponseV2, - ), - (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_ingestion_profile_default_get( - State(state): State, - headers: HeaderMap, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let response = state - .service - .admin_ingestion_profile_default_get(AdminIngestionProfileDefaultGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - put, - path = "/v2/admin/events/ingestion-profiles/default", - tag = "admin", - request_body = AdminIngestionProfileDefaultSetBody, - responses( - ( - status = 200, - description = "Default add_event ingestion profile pointer was updated.", - body = AdminIngestionProfileDefaultResponseV2, - ), - (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 = "Profile was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn admin_ingestion_profile_default_set( - 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 - .admin_ingestion_profile_default_set(AdminIngestionProfileDefaultSetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - profile_id: payload.profile_id, - version: payload.version, - }) - .await?; - - Ok(Json(response)) -} diff --git a/apps/elf-api/src/routes/ingestion_profiles/collection.rs b/apps/elf-api/src/routes/ingestion_profiles/collection.rs new file mode 100644 index 00000000..e132661a --- /dev/null +++ b/apps/elf-api/src/routes/ingestion_profiles/collection.rs @@ -0,0 +1,78 @@ +use crate::routes::{ + self, AdminIngestionProfileCreateBody, AdminIngestionProfileCreateRequest, + AdminIngestionProfileListRequest, AdminIngestionProfileResponse, + AdminIngestionProfilesListResponse, ApiError, AppState, ErrorBody, HeaderMap, Json, + JsonRejection, RequestContext, State, StatusCode, +}; + +#[utoipa::path( + get, + path = "/v2/admin/events/ingestion-profiles", + tag = "admin", + responses( + (status = 200, description = "Ingestion profile versions.", 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_ingestion_profiles_list( + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .admin_ingestion_profiles_list(AdminIngestionProfileListRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + post, + path = "/v2/admin/events/ingestion-profiles", + tag = "admin", + request_body = Value, + responses( + (status = 200, description = "Ingestion profile version was created.", 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_ingestion_profile_create( + 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 + .admin_ingestion_profile_create(AdminIngestionProfileCreateRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + profile_id: payload.profile_id, + version: payload.version, + profile: payload.profile, + created_by: payload.created_by, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/ingestion_profiles/defaults.rs b/apps/elf-api/src/routes/ingestion_profiles/defaults.rs new file mode 100644 index 00000000..ac7741c9 --- /dev/null +++ b/apps/elf-api/src/routes/ingestion_profiles/defaults.rs @@ -0,0 +1,85 @@ +use crate::routes::{ + self, AdminIngestionProfileDefaultGetRequest, AdminIngestionProfileDefaultResponse, + AdminIngestionProfileDefaultResponseV2, AdminIngestionProfileDefaultSetBody, + AdminIngestionProfileDefaultSetRequest, ApiError, AppState, ErrorBody, HeaderMap, Json, + JsonRejection, RequestContext, State, StatusCode, +}; + +#[utoipa::path( + get, + path = "/v2/admin/events/ingestion-profiles/default", + tag = "admin", + responses( + ( + status = 200, + description = "Default add_event ingestion profile pointer.", + body = AdminIngestionProfileDefaultResponseV2, + ), + (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_ingestion_profile_default_get( + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .admin_ingestion_profile_default_get(AdminIngestionProfileDefaultGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + put, + path = "/v2/admin/events/ingestion-profiles/default", + tag = "admin", + request_body = AdminIngestionProfileDefaultSetBody, + responses( + ( + status = 200, + description = "Default add_event ingestion profile pointer was updated.", + body = AdminIngestionProfileDefaultResponseV2, + ), + (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 = "Profile was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn admin_ingestion_profile_default_set( + 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 + .admin_ingestion_profile_default_set(AdminIngestionProfileDefaultSetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + profile_id: payload.profile_id, + version: payload.version, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/ingestion_profiles/versions.rs b/apps/elf-api/src/routes/ingestion_profiles/versions.rs new file mode 100644 index 00000000..6551cc34 --- /dev/null +++ b/apps/elf-api/src/routes/ingestion_profiles/versions.rs @@ -0,0 +1,84 @@ +use crate::routes::{ + self, AdminIngestionProfileGetQuery, AdminIngestionProfileGetRequest, + AdminIngestionProfileResponse, AdminIngestionProfileVersionsListRequest, + AdminIngestionProfileVersionsListResponse, ApiError, AppState, ErrorBody, HeaderMap, Json, + Path, Query, QueryRejection, RequestContext, State, StatusCode, +}; + +#[utoipa::path( + get, + path = "/v2/admin/events/ingestion-profiles/{profile_id}", + tag = "admin", + params( + ("profile_id" = String, Path, description = "Ingestion profile ID."), + ("version" = Option, Query, description = "Optional profile version."), + ), + responses( + (status = 200, description = "Ingestion profile version.", 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 = "Profile was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn admin_ingestion_profile_get( + State(state): State, + headers: HeaderMap, + Path(profile_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 + .admin_ingestion_profile_get(AdminIngestionProfileGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + profile_id, + version: query.version, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/admin/events/ingestion-profiles/{profile_id}/versions", + tag = "admin", + params(("profile_id" = String, Path, description = "Ingestion profile ID.")), + responses( + (status = 200, description = "Versions for one ingestion profile.", 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_ingestion_profile_versions_list( + State(state): State, + headers: HeaderMap, + Path(profile_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let response = state + .service + .admin_ingestion_profile_versions_list(AdminIngestionProfileVersionsListRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + profile_id, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/search.rs b/apps/elf-api/src/routes/search.rs index 7fb62688..abfa1315 100644 --- a/apps/elf-api/src/routes/search.rs +++ b/apps/elf-api/src/routes/search.rs @@ -1,296 +1,12 @@ +mod create; +mod details; +mod raw; +mod read; mod validation; -use crate::routes::{ - self, ApiError, AppState, ErrorBody, HeaderMap, Json, JsonRejection, Path, Query, - QueryRejection, RequestContext, SearchCreateRequest, SearchCreateResponseV2, SearchDetailsBody, - SearchDetailsRequest, SearchDetailsResponseV2, SearchIndexResponseV2, SearchMode, - SearchRequest, SearchResponse, SearchSessionGetQuery, SearchSessionGetRequest, - SearchTimelineQuery, SearchTimelineRequest, SearchTimelineResponseV2, State, Uuid, +pub(super) use self::{ + create::{__path_searches_create, searches_create}, + details::{__path_searches_notes, searches_notes}, + raw::{__path_searches_raw, searches_raw}, + read::{__path_searches_get, __path_searches_timeline, searches_get, searches_timeline}, }; - -#[utoipa::path( - post, - path = "/v2/searches", - tag = "search", - request_body = Value, - responses( - (status = 200, description = "Search session was created.", 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 searches_create( - 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(validation::invalid_json_payload)?; - - validation::validate_search_create_payload( - &payload, - state.service.cfg.memory.top_k, - state.service.cfg.memory.candidate_k, - )?; - - let mode = payload.mode; - let token_id = - routes::effective_token_id(state.service.cfg.security.auth_mode.as_str(), &headers); - let build_request = || SearchRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - token_id: token_id.clone(), - read_profile, - query: payload.query.clone(), - top_k: payload.top_k, - candidate_k: payload.candidate_k, - filter: payload.filter.clone(), - payload_level: payload.payload_level.unwrap_or_default(), - record_hits: Some(false), - ranking: None, - }; - let response = match mode { - SearchMode::QuickFind => { - let response = state.service.search_quick(build_request()).await?; - - SearchCreateResponseV2 { - mode, - trace_id: response.trace_id, - search_id: response.search_session_id, - expires_at: response.expires_at, - items: response.items, - trajectory_summary: response.trajectory_summary, - query_plan: None, - } - }, - SearchMode::PlannedSearch => { - let response = state.service.search_planned(build_request()).await?; - - SearchCreateResponseV2 { - mode, - trace_id: response.trace_id, - search_id: response.search_session_id, - expires_at: response.expires_at, - items: response.items, - trajectory_summary: response.trajectory_summary, - query_plan: Some(response.query_plan), - } - }, - }; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/searches/{search_id}", - tag = "search", - params( - ("search_id" = Uuid, Path, description = "Search session ID."), - ("payload_level" = Option, Query, description = "Optional payload level."), - ("top_k" = Option, Query, description = "Optional result limit override."), - ("touch" = Option, Query, description = "Whether to extend the session TTL."), - ), - responses( - (status = 200, description = "Search session index view.", 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 = "Search session was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn searches_get( - State(state): State, - headers: HeaderMap, - Path(search_id): Path, - query: Result, QueryRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Query(query) = query.map_err(validation::invalid_query_parameters)?; - let response = state - .service - .search_session_get(SearchSessionGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - search_session_id: search_id, - payload_level: query.payload_level.unwrap_or_default(), - top_k: query.top_k, - touch: query.touch, - }) - .await?; - let mode = if response.query_plan.is_some() { - SearchMode::PlannedSearch - } else { - SearchMode::QuickFind - }; - - Ok(Json(SearchIndexResponseV2 { - mode, - trace_id: response.trace_id, - search_id: response.search_session_id, - expires_at: response.expires_at, - items: response.items, - trajectory_summary: response.trajectory_summary, - query_plan: response.query_plan, - })) -} - -#[utoipa::path( - get, - path = "/v2/searches/{search_id}/timeline", - tag = "search", - params( - ("search_id" = Uuid, Path, description = "Search session ID."), - ("payload_level" = Option, Query, description = "Optional payload level."), - ("group_by" = Option, Query, description = "Timeline grouping mode."), - ), - responses( - (status = 200, description = "Search session timeline.", 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 = "Search session was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn searches_timeline( - State(state): State, - headers: HeaderMap, - Path(search_id): Path, - query: Result, QueryRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Query(query) = query.map_err(validation::invalid_query_parameters)?; - let response = state - .service - .search_timeline(SearchTimelineRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - search_session_id: search_id, - payload_level: query.payload_level.unwrap_or_default(), - group_by: query.group_by, - }) - .await?; - - Ok(Json(SearchTimelineResponseV2 { - search_id: response.search_session_id, - expires_at: response.expires_at, - groups: response.groups, - })) -} - -#[utoipa::path( - post, - path = "/v2/searches/{search_id}/notes", - tag = "search", - params(("search_id" = Uuid, Path, description = "Search session ID.")), - request_body = Value, - responses( - (status = 200, description = "Hydrated search 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 = "Search session was not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn searches_notes( - State(state): State, - headers: HeaderMap, - Path(search_id): Path, - payload: Result, JsonRejection>, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let Json(payload) = payload.map_err(validation::invalid_json_payload)?; - - validation::validate_search_details_payload(&payload)?; - - let response = state - .service - .search_details(SearchDetailsRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - search_session_id: search_id, - payload_level: payload.payload_level.unwrap_or_default(), - note_ids: payload.note_ids, - record_hits: payload.record_hits, - }) - .await?; - - Ok(Json(SearchDetailsResponseV2 { - search_id: response.search_session_id, - expires_at: response.expires_at, - results: response.results, - })) -} - -#[utoipa::path( - post, - path = "/v2/admin/searches/raw", - tag = "search", - request_body = Value, - responses( - (status = 200, description = "Raw admin search response.", 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 searches_raw( - 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(validation::invalid_json_payload)?; - - validation::validate_search_raw_payload( - &payload, - state.service.cfg.memory.top_k, - state.service.cfg.memory.candidate_k, - )?; - - let request = SearchRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - token_id: routes::effective_token_id( - state.service.cfg.security.auth_mode.as_str(), - &headers, - ), - read_profile, - query: payload.query, - filter: payload.filter, - payload_level: payload.payload_level.unwrap_or_default(), - top_k: payload.top_k, - candidate_k: payload.candidate_k, - record_hits: Some(false), - ranking: payload.ranking, - }; - let response = match payload.mode { - SearchMode::QuickFind => state.service.search_raw_quick(request).await?, - SearchMode::PlannedSearch => { - let response = state.service.search_raw_planned(request).await?; - - SearchResponse { - trace_id: response.trace_id, - items: response.items, - trajectory_summary: response.trajectory_summary, - } - }, - }; - - Ok(Json(response)) -} diff --git a/apps/elf-api/src/routes/search/create.rs b/apps/elf-api/src/routes/search/create.rs new file mode 100644 index 00000000..09e74c01 --- /dev/null +++ b/apps/elf-api/src/routes/search/create.rs @@ -0,0 +1,83 @@ +use crate::routes::{ + self, ApiError, AppState, ErrorBody, HeaderMap, Json, JsonRejection, RequestContext, + SearchCreateRequest, SearchCreateResponseV2, SearchMode, SearchRequest, State, + search::validation, +}; + +#[utoipa::path( + post, + path = "/v2/searches", + tag = "search", + request_body = Value, + responses( + (status = 200, description = "Search session was created.", 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 searches_create( + 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(validation::invalid_json_payload)?; + + validation::validate_search_create_payload( + &payload, + state.service.cfg.memory.top_k, + state.service.cfg.memory.candidate_k, + )?; + + let mode = payload.mode; + let token_id = + routes::effective_token_id(state.service.cfg.security.auth_mode.as_str(), &headers); + let build_request = || SearchRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + token_id: token_id.clone(), + read_profile, + query: payload.query.clone(), + top_k: payload.top_k, + candidate_k: payload.candidate_k, + filter: payload.filter.clone(), + payload_level: payload.payload_level.unwrap_or_default(), + record_hits: Some(false), + ranking: None, + }; + let response = match mode { + SearchMode::QuickFind => { + let response = state.service.search_quick(build_request()).await?; + + SearchCreateResponseV2 { + mode, + trace_id: response.trace_id, + search_id: response.search_session_id, + expires_at: response.expires_at, + items: response.items, + trajectory_summary: response.trajectory_summary, + query_plan: None, + } + }, + SearchMode::PlannedSearch => { + let response = state.service.search_planned(build_request()).await?; + + SearchCreateResponseV2 { + mode, + trace_id: response.trace_id, + search_id: response.search_session_id, + expires_at: response.expires_at, + items: response.items, + trajectory_summary: response.trajectory_summary, + query_plan: Some(response.query_plan), + } + }, + }; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/search/details.rs b/apps/elf-api/src/routes/search/details.rs new file mode 100644 index 00000000..2d690acc --- /dev/null +++ b/apps/elf-api/src/routes/search/details.rs @@ -0,0 +1,51 @@ +use crate::routes::{ + ApiError, AppState, ErrorBody, HeaderMap, Json, JsonRejection, Path, RequestContext, + SearchDetailsBody, SearchDetailsRequest, SearchDetailsResponseV2, State, Uuid, + search::validation, +}; + +#[utoipa::path( + post, + path = "/v2/searches/{search_id}/notes", + tag = "search", + params(("search_id" = Uuid, Path, description = "Search session ID.")), + request_body = Value, + responses( + (status = 200, description = "Hydrated search 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 = "Search session was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn searches_notes( + State(state): State, + headers: HeaderMap, + Path(search_id): Path, + payload: Result, JsonRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Json(payload) = payload.map_err(validation::invalid_json_payload)?; + + validation::validate_search_details_payload(&payload)?; + + let response = state + .service + .search_details(SearchDetailsRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + search_session_id: search_id, + payload_level: payload.payload_level.unwrap_or_default(), + note_ids: payload.note_ids, + record_hits: payload.record_hits, + }) + .await?; + + Ok(Json(SearchDetailsResponseV2 { + search_id: response.search_session_id, + expires_at: response.expires_at, + results: response.results, + })) +} diff --git a/apps/elf-api/src/routes/search/raw.rs b/apps/elf-api/src/routes/search/raw.rs new file mode 100644 index 00000000..01893bd3 --- /dev/null +++ b/apps/elf-api/src/routes/search/raw.rs @@ -0,0 +1,66 @@ +use crate::routes::{ + self, ApiError, AppState, ErrorBody, HeaderMap, Json, JsonRejection, RequestContext, + SearchCreateRequest, SearchMode, SearchRequest, SearchResponse, State, search::validation, +}; + +#[utoipa::path( + post, + path = "/v2/admin/searches/raw", + tag = "search", + request_body = Value, + responses( + (status = 200, description = "Raw admin search response.", 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 searches_raw( + 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(validation::invalid_json_payload)?; + + validation::validate_search_raw_payload( + &payload, + state.service.cfg.memory.top_k, + state.service.cfg.memory.candidate_k, + )?; + + let request = SearchRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + token_id: routes::effective_token_id( + state.service.cfg.security.auth_mode.as_str(), + &headers, + ), + read_profile, + query: payload.query, + filter: payload.filter, + payload_level: payload.payload_level.unwrap_or_default(), + top_k: payload.top_k, + candidate_k: payload.candidate_k, + record_hits: Some(false), + ranking: payload.ranking, + }; + let response = match payload.mode { + SearchMode::QuickFind => state.service.search_raw_quick(request).await?, + SearchMode::PlannedSearch => { + let response = state.service.search_raw_planned(request).await?; + + SearchResponse { + trace_id: response.trace_id, + items: response.items, + trajectory_summary: response.trajectory_summary, + } + }, + }; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/search/read.rs b/apps/elf-api/src/routes/search/read.rs new file mode 100644 index 00000000..a2419f60 --- /dev/null +++ b/apps/elf-api/src/routes/search/read.rs @@ -0,0 +1,107 @@ +use crate::routes::{ + ApiError, AppState, ErrorBody, HeaderMap, Json, Path, Query, QueryRejection, RequestContext, + SearchIndexResponseV2, SearchMode, SearchSessionGetQuery, SearchSessionGetRequest, + SearchTimelineQuery, SearchTimelineRequest, SearchTimelineResponseV2, State, Uuid, + search::validation, +}; + +#[utoipa::path( + get, + path = "/v2/searches/{search_id}", + tag = "search", + params( + ("search_id" = Uuid, Path, description = "Search session ID."), + ("payload_level" = Option, Query, description = "Optional payload level."), + ("top_k" = Option, Query, description = "Optional result limit override."), + ("touch" = Option, Query, description = "Whether to extend the session TTL."), + ), + responses( + (status = 200, description = "Search session index view.", 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 = "Search session was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn searches_get( + State(state): State, + headers: HeaderMap, + Path(search_id): Path, + query: Result, QueryRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Query(query) = query.map_err(validation::invalid_query_parameters)?; + let response = state + .service + .search_session_get(SearchSessionGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + search_session_id: search_id, + payload_level: query.payload_level.unwrap_or_default(), + top_k: query.top_k, + touch: query.touch, + }) + .await?; + let mode = if response.query_plan.is_some() { + SearchMode::PlannedSearch + } else { + SearchMode::QuickFind + }; + + Ok(Json(SearchIndexResponseV2 { + mode, + trace_id: response.trace_id, + search_id: response.search_session_id, + expires_at: response.expires_at, + items: response.items, + trajectory_summary: response.trajectory_summary, + query_plan: response.query_plan, + })) +} + +#[utoipa::path( + get, + path = "/v2/searches/{search_id}/timeline", + tag = "search", + params( + ("search_id" = Uuid, Path, description = "Search session ID."), + ("payload_level" = Option, Query, description = "Optional payload level."), + ("group_by" = Option, Query, description = "Timeline grouping mode."), + ), + responses( + (status = 200, description = "Search session timeline.", 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 = "Search session was not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn searches_timeline( + State(state): State, + headers: HeaderMap, + Path(search_id): Path, + query: Result, QueryRejection>, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let Query(query) = query.map_err(validation::invalid_query_parameters)?; + let response = state + .service + .search_timeline(SearchTimelineRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + search_session_id: search_id, + payload_level: query.payload_level.unwrap_or_default(), + group_by: query.group_by, + }) + .await?; + + Ok(Json(SearchTimelineResponseV2 { + search_id: response.search_session_id, + expires_at: response.expires_at, + groups: response.groups, + })) +} diff --git a/apps/elf-api/src/routes/sharing.rs b/apps/elf-api/src/routes/sharing.rs index 69b443de..220e89d9 100644 --- a/apps/elf-api/src/routes/sharing.rs +++ b/apps/elf-api/src/routes/sharing.rs @@ -1,171 +1,10 @@ -use crate::routes::{ - self, ApiError, AppState, ErrorBody, Extension, HeaderMap, Json, JsonRejection, Path, - RequestContext, SecurityAuthRole, ShareScope, SpaceGrantItemV2, SpaceGrantRevokeRequest, - SpaceGrantRevokeResponse, SpaceGrantUpsertBody, SpaceGrantUpsertRequest, - SpaceGrantUpsertResponseV2, SpaceGrantsListRequest, SpaceGrantsListResponseV2, State, - StatusCode, +mod read; +mod write; + +pub(super) use self::{ + read::{__path_space_grants_list, space_grants_list}, + write::{ + __path_space_grant_revoke, __path_space_grant_upsert, space_grant_revoke, + space_grant_upsert, + }, }; - -#[utoipa::path( - get, - path = "/v2/spaces/{space}/grants", - tag = "notes", - params(("space" = String, Path, description = "Shared space name.")), - responses( - (status = 200, description = "Space grants.", 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 space_grants_list( - State(state): State, - headers: HeaderMap, - Path(space): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let scope = routes::parse_space(space.as_str())?; - let response = state - .service - .space_grants_list(SpaceGrantsListRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - scope, - }) - .await?; - - Ok(Json(SpaceGrantsListResponseV2 { - grants: response - .grants - .into_iter() - .map(|item| SpaceGrantItemV2 { - space: routes::format_space(item.scope).to_string(), - grantee_kind: item.grantee_kind, - grantee_agent_id: item.grantee_agent_id, - granted_by_agent_id: item.granted_by_agent_id, - granted_at: item.granted_at, - }) - .collect(), - })) -} - -#[utoipa::path( - post, - path = "/v2/spaces/{space}/grants", - tag = "notes", - params(("space" = String, Path, description = "Shared space name.")), - request_body = Value, - responses( - (status = 200, description = "Space grant was upserted.", 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 space_grant_upsert( - State(state): State, - headers: HeaderMap, - role: Option>, - Path(space): 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 scope = routes::parse_space(space.as_str())?; - let role = role.map(|Extension(role)| role); - - if matches!(scope, ShareScope::OrgShared) { - routes::require_admin_for_org_shared_writes( - state.service.cfg.security.auth_mode.as_str(), - role, - )?; - } - - let response = state - .service - .space_grant_upsert(SpaceGrantUpsertRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - scope, - grantee_kind: payload.grantee_kind, - grantee_agent_id: payload.grantee_agent_id, - }) - .await?; - - Ok(Json(SpaceGrantUpsertResponseV2 { - space: routes::format_scope(response.scope.as_str())?.to_string(), - grantee_kind: response.grantee_kind, - grantee_agent_id: response.grantee_agent_id, - granted: response.granted, - })) -} - -#[utoipa::path( - post, - path = "/v2/spaces/{space}/grants/revoke", - tag = "notes", - params(("space" = String, Path, description = "Shared space name.")), - request_body = Value, - responses( - (status = 200, description = "Space grant was revoked.", 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 space_grant_revoke( - State(state): State, - headers: HeaderMap, - role: Option>, - Path(space): 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 scope = routes::parse_space(space.as_str())?; - let role = role.map(|Extension(role)| role); - - if matches!(scope, ShareScope::OrgShared) { - routes::require_admin_for_org_shared_writes( - state.service.cfg.security.auth_mode.as_str(), - role, - )?; - } - - let response = state - .service - .space_grant_revoke(SpaceGrantRevokeRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - scope, - grantee_kind: payload.grantee_kind, - grantee_agent_id: payload.grantee_agent_id, - }) - .await?; - - Ok(Json(response)) -} diff --git a/apps/elf-api/src/routes/sharing/read.rs b/apps/elf-api/src/routes/sharing/read.rs new file mode 100644 index 00000000..2cc07599 --- /dev/null +++ b/apps/elf-api/src/routes/sharing/read.rs @@ -0,0 +1,49 @@ +use crate::routes::{ + self, ApiError, AppState, ErrorBody, HeaderMap, Json, Path, RequestContext, SpaceGrantItemV2, + SpaceGrantsListRequest, SpaceGrantsListResponseV2, State, +}; + +#[utoipa::path( + get, + path = "/v2/spaces/{space}/grants", + tag = "notes", + params(("space" = String, Path, description = "Shared space name.")), + responses( + (status = 200, description = "Space grants.", 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 space_grants_list( + State(state): State, + headers: HeaderMap, + Path(space): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let scope = routes::parse_space(space.as_str())?; + let response = state + .service + .space_grants_list(SpaceGrantsListRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + scope, + }) + .await?; + + Ok(Json(SpaceGrantsListResponseV2 { + grants: response + .grants + .into_iter() + .map(|item| SpaceGrantItemV2 { + space: routes::format_space(item.scope).to_string(), + grantee_kind: item.grantee_kind, + grantee_agent_id: item.grantee_agent_id, + granted_by_agent_id: item.granted_by_agent_id, + granted_at: item.granted_at, + }) + .collect(), + })) +} diff --git a/apps/elf-api/src/routes/sharing/write.rs b/apps/elf-api/src/routes/sharing/write.rs new file mode 100644 index 00000000..5a37fc00 --- /dev/null +++ b/apps/elf-api/src/routes/sharing/write.rs @@ -0,0 +1,125 @@ +use crate::routes::{ + self, ApiError, AppState, ErrorBody, Extension, HeaderMap, Json, JsonRejection, Path, + RequestContext, SecurityAuthRole, ShareScope, SpaceGrantRevokeRequest, + SpaceGrantRevokeResponse, SpaceGrantUpsertBody, SpaceGrantUpsertRequest, + SpaceGrantUpsertResponseV2, State, StatusCode, +}; + +#[utoipa::path( + post, + path = "/v2/spaces/{space}/grants", + tag = "notes", + params(("space" = String, Path, description = "Shared space name.")), + request_body = Value, + responses( + (status = 200, description = "Space grant was upserted.", 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 space_grant_upsert( + State(state): State, + headers: HeaderMap, + role: Option>, + Path(space): 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 scope = routes::parse_space(space.as_str())?; + let role = role.map(|Extension(role)| role); + + if matches!(scope, ShareScope::OrgShared) { + routes::require_admin_for_org_shared_writes( + state.service.cfg.security.auth_mode.as_str(), + role, + )?; + } + + let response = state + .service + .space_grant_upsert(SpaceGrantUpsertRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + scope, + grantee_kind: payload.grantee_kind, + grantee_agent_id: payload.grantee_agent_id, + }) + .await?; + + Ok(Json(SpaceGrantUpsertResponseV2 { + space: routes::format_scope(response.scope.as_str())?.to_string(), + grantee_kind: response.grantee_kind, + grantee_agent_id: response.grantee_agent_id, + granted: response.granted, + })) +} + +#[utoipa::path( + post, + path = "/v2/spaces/{space}/grants/revoke", + tag = "notes", + params(("space" = String, Path, description = "Shared space name.")), + request_body = Value, + responses( + (status = 200, description = "Space grant was revoked.", 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 space_grant_revoke( + State(state): State, + headers: HeaderMap, + role: Option>, + Path(space): 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 scope = routes::parse_space(space.as_str())?; + let role = role.map(|Extension(role)| role); + + if matches!(scope, ShareScope::OrgShared) { + routes::require_admin_for_org_shared_writes( + state.service.cfg.security.auth_mode.as_str(), + role, + )?; + } + + let response = state + .service + .space_grant_revoke(SpaceGrantRevokeRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + scope, + grantee_kind: payload.grantee_kind, + grantee_agent_id: payload.grantee_agent_id, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/work_journal.rs b/apps/elf-api/src/routes/work_journal.rs index f3a6f904..2a983b38 100644 --- a/apps/elf-api/src/routes/work_journal.rs +++ b/apps/elf-api/src/routes/work_journal.rs @@ -1,152 +1,10 @@ -use crate::routes::{ - self, ApiError, AppState, ErrorBody, Extension, HeaderMap, Json, JsonRejection, Path, - RequestContext, SecurityAuthRole, State, StatusCode, Uuid, WorkJournalEntryCreateBody, - WorkJournalEntryCreateRequest, WorkJournalEntryCreateResponse, WorkJournalEntryGetRequest, - WorkJournalEntryResponse, WorkJournalSessionReadbackBody, WorkJournalSessionReadbackRequest, - WorkJournalSessionReadbackResponse, +mod entries; +mod readback; + +pub(super) use self::{ + entries::{ + __path_work_journal_entry_create, __path_work_journal_entry_get, work_journal_entry_create, + work_journal_entry_get, + }, + readback::{__path_work_journal_session_readback, work_journal_session_readback}, }; - -#[utoipa::path( - post, - path = "/v2/work-journal/entries", - tag = "work_journal", - request_body = Value, - responses( - (status = 200, description = "Work Journal entry 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 work_journal_entry_create( - 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 - .work_journal_entry_create(WorkJournalEntryCreateRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - entry_id: payload.entry_id, - scope: payload.scope, - session_id: payload.session_id, - family: payload.family, - title: payload.title, - body: payload.body, - source_refs: payload.source_refs, - write_policy: payload.write_policy, - explicit_next_steps: payload.explicit_next_steps, - inferred_next_steps: payload.inferred_next_steps, - rejected_options: payload.rejected_options, - promotion_boundary: payload.promotion_boundary, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - get, - path = "/v2/work-journal/entries/{entry_id}", - tag = "work_journal", - responses( - (status = 200, description = "Work Journal entry metadata.", 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 = "Work Journal entry not found.", body = ErrorBody), - (status = 500, description = "Internal error.", body = ErrorBody), - ) -)] -pub(super) async fn work_journal_entry_get( - State(state): State, - headers: HeaderMap, - Path(entry_id): Path, -) -> Result, ApiError> { - let ctx = RequestContext::from_headers(&headers)?; - let read_profile = routes::required_read_profile(&headers)?; - let response = state - .service - .work_journal_entry_get(WorkJournalEntryGetRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - read_profile, - entry_id, - }) - .await?; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/v2/work-journal/readback", - tag = "work_journal", - request_body = Value, - responses( - (status = 200, description = "Work Journal session readback.", 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 work_journal_session_readback( - 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 - .work_journal_session_readback(WorkJournalSessionReadbackRequest { - tenant_id: ctx.tenant_id, - project_id: ctx.project_id, - agent_id: ctx.agent_id, - read_profile, - session_id: payload.session_id, - families: payload.families, - limit: payload.limit, - }) - .await?; - - Ok(Json(response)) -} diff --git a/apps/elf-api/src/routes/work_journal/entries.rs b/apps/elf-api/src/routes/work_journal/entries.rs new file mode 100644 index 00000000..61f89686 --- /dev/null +++ b/apps/elf-api/src/routes/work_journal/entries.rs @@ -0,0 +1,104 @@ +use crate::routes::{ + self, ApiError, AppState, ErrorBody, Extension, HeaderMap, Json, JsonRejection, Path, + RequestContext, SecurityAuthRole, State, StatusCode, Uuid, WorkJournalEntryCreateBody, + WorkJournalEntryCreateRequest, WorkJournalEntryCreateResponse, WorkJournalEntryGetRequest, + WorkJournalEntryResponse, +}; + +#[utoipa::path( + post, + path = "/v2/work-journal/entries", + tag = "work_journal", + request_body = Value, + responses( + (status = 200, description = "Work Journal entry 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 work_journal_entry_create( + 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 + .work_journal_entry_create(WorkJournalEntryCreateRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + entry_id: payload.entry_id, + scope: payload.scope, + session_id: payload.session_id, + family: payload.family, + title: payload.title, + body: payload.body, + source_refs: payload.source_refs, + write_policy: payload.write_policy, + explicit_next_steps: payload.explicit_next_steps, + inferred_next_steps: payload.inferred_next_steps, + rejected_options: payload.rejected_options, + promotion_boundary: payload.promotion_boundary, + }) + .await?; + + Ok(Json(response)) +} + +#[utoipa::path( + get, + path = "/v2/work-journal/entries/{entry_id}", + tag = "work_journal", + responses( + (status = 200, description = "Work Journal entry metadata.", 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 = "Work Journal entry not found.", body = ErrorBody), + (status = 500, description = "Internal error.", body = ErrorBody), + ) +)] +pub(in crate::routes) async fn work_journal_entry_get( + State(state): State, + headers: HeaderMap, + Path(entry_id): Path, +) -> Result, ApiError> { + let ctx = RequestContext::from_headers(&headers)?; + let read_profile = routes::required_read_profile(&headers)?; + let response = state + .service + .work_journal_entry_get(WorkJournalEntryGetRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + read_profile, + entry_id, + }) + .await?; + + Ok(Json(response)) +} diff --git a/apps/elf-api/src/routes/work_journal/readback.rs b/apps/elf-api/src/routes/work_journal/readback.rs new file mode 100644 index 00000000..5d12af18 --- /dev/null +++ b/apps/elf-api/src/routes/work_journal/readback.rs @@ -0,0 +1,52 @@ +use crate::routes::{ + self, ApiError, AppState, ErrorBody, HeaderMap, Json, JsonRejection, RequestContext, State, + StatusCode, WorkJournalSessionReadbackBody, WorkJournalSessionReadbackRequest, + WorkJournalSessionReadbackResponse, +}; + +#[utoipa::path( + post, + path = "/v2/work-journal/readback", + tag = "work_journal", + request_body = Value, + responses( + (status = 200, description = "Work Journal session readback.", 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 work_journal_session_readback( + 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 + .work_journal_session_readback(WorkJournalSessionReadbackRequest { + tenant_id: ctx.tenant_id, + project_id: ctx.project_id, + agent_id: ctx.agent_id, + read_profile, + session_id: payload.session_id, + families: payload.families, + limit: payload.limit, + }) + .await?; + + Ok(Json(response)) +}