1use std::convert::Infallible;
43use std::net::SocketAddr;
44use std::str::FromStr;
45use std::sync::Arc;
46use std::time::Duration;
47
48use axum::extract::{FromRequestParts, Path, Query, State};
49use axum::http::request::Parts;
50use axum::http::{HeaderValue, Method, StatusCode};
51use axum::response::sse::{Event, KeepAlive, Sse};
52use axum::response::{IntoResponse, Response};
53use axum::routing::{get, post};
54use axum::{Json, Router};
55use futures::Stream;
56use serde::{Deserialize, Serialize};
57use solo_core::{
58 Confidence, DocumentId, EncodingContext, Episode, InvalidateEvent, MemoryId, TenantId,
59 Tier,
60};
61use solo_storage::{TenantHandle, TenantRegistry};
62use tokio::sync::broadcast;
63use tower_http::cors::{AllowOrigin, CorsLayer};
64use tower_http::trace::TraceLayer;
65
66use crate::auth::{AuthConfig, AuthenticatedPrincipal, middleware::AuthValidator};
67
68#[derive(Clone)]
72pub struct SoloHttpState {
73 pub registry: Arc<TenantRegistry>,
75 pub default_tenant: TenantId,
78 pub user_aliases: Arc<Vec<String>>,
85}
86
87pub const TENANT_HEADER: &str = "x-solo-tenant";
90
91pub struct TenantExtractor(pub Arc<TenantHandle>);
107
108impl<S> FromRequestParts<S> for TenantExtractor
109where
110 SoloHttpState: FromRef<S>,
111 S: Send + Sync,
112{
113 type Rejection = ApiError;
114
115 async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
116 let state = SoloHttpState::from_ref(state);
117 let resolved = if let Some(principal) = parts.extensions.get::<AuthenticatedPrincipal>()
124 && let Some(claim) = principal.tenant_claim.clone()
125 {
126 claim
127 } else {
128 match parts.headers.get(TENANT_HEADER) {
129 None => state.default_tenant.clone(),
130 Some(raw) => {
131 let s = raw.to_str().map_err(|e| {
132 ApiError::bad_request(format!(
133 "{TENANT_HEADER}: header value must be ASCII ({e})"
134 ))
135 })?;
136 TenantId::new(s.to_string()).map_err(|e| {
137 ApiError::bad_request(format!("{TENANT_HEADER}: invalid tenant id: {e}"))
138 })?
139 }
140 }
141 };
142 let handle = state.registry.get_or_open(&resolved).await.map_err(|e| {
143 use solo_core::Error;
145 match &e {
146 Error::NotFound(_) => ApiError::not_found(e.to_string()),
147 Error::InvalidInput(_) => ApiError::bad_request(e.to_string()),
148 _ => ApiError::internal(e.to_string()),
149 }
150 })?;
151 Ok(TenantExtractor(handle))
152 }
153}
154
155use axum::extract::FromRef;
156
157pub struct AuditPrincipal(pub Option<String>);
162
163impl<S> FromRequestParts<S> for AuditPrincipal
164where
165 S: Send + Sync,
166{
167 type Rejection = std::convert::Infallible;
168
169 async fn from_request_parts(
170 parts: &mut Parts,
171 _state: &S,
172 ) -> Result<Self, Self::Rejection> {
173 Ok(AuditPrincipal(
174 parts
175 .extensions
176 .get::<AuthenticatedPrincipal>()
177 .map(|p| p.subject.clone()),
178 ))
179 }
180}
181
182pub struct MaybePrincipal(pub Option<AuthenticatedPrincipal>);
195
196impl<S> FromRequestParts<S> for MaybePrincipal
197where
198 S: Send + Sync,
199{
200 type Rejection = std::convert::Infallible;
201
202 async fn from_request_parts(
203 parts: &mut Parts,
204 _state: &S,
205 ) -> Result<Self, Self::Rejection> {
206 Ok(MaybePrincipal(
207 parts
208 .extensions
209 .get::<AuthenticatedPrincipal>()
210 .cloned(),
211 ))
212 }
213}
214
215pub fn router_with_auth(state: SoloHttpState, bearer_token: Option<String>) -> Router {
224 let auth = bearer_token.map(|token| AuthConfig::Bearer { token });
225 router_with_auth_config(state, auth)
226}
227
228pub fn router_with_auth_config(state: SoloHttpState, auth: Option<AuthConfig>) -> Router {
239 let cors = build_cors_layer();
240 let public = Router::new()
248 .route("/health", get(|| async { "ok" }))
249 .route("/openapi.json", get(openapi_handler));
250
251 let authed = Router::new()
252 .route("/memory", post(remember_handler))
253 .route("/memory/search", post(recall_handler))
254 .route("/memory/consolidate", post(consolidate_handler))
255 .route("/memory/{id}", get(inspect_handler).delete(forget_handler))
256 .route("/backup", post(backup_handler))
257 .route("/memory/themes", get(themes_handler))
261 .route("/memory/facts_about", get(facts_about_handler))
262 .route("/memory/contradictions", get(contradictions_handler))
263 .route(
268 "/memory/clusters/{cluster_id}",
269 get(inspect_cluster_handler),
270 )
271 .route(
278 "/memory/documents/search",
279 post(search_docs_handler),
280 )
281 .route(
282 "/memory/documents",
283 post(ingest_document_handler).get(list_documents_handler),
284 )
285 .route(
286 "/memory/documents/{id}",
287 get(inspect_document_handler).delete(forget_document_handler),
288 )
289 .route("/v1/graph/expand", get(graph_expand_handler))
294 .route("/v1/graph/nodes", get(graph_nodes_handler))
298 .route("/v1/graph/edges", get(graph_edges_handler))
299 .route("/v1/graph/inspect/{id}", get(graph_inspect_handler))
302 .route("/v1/graph/neighbors/{id}", get(graph_neighbors_handler))
306 .route("/v1/graph/stream", get(graph_stream_handler))
313 .route("/v1/tenants", get(tenants_list_handler))
322 .route("/mcp", post(mcp_http_post_handler).get(mcp_http_get_handler))
331 .with_state(state.clone());
332
333 let authed = if let Some(cfg) = auth {
334 let validator = Arc::new(AuthValidator::from_config(
338 &cfg,
339 state.default_tenant.clone(),
340 ));
341 authed.layer(axum::middleware::from_fn_with_state(
342 validator,
343 crate::auth::middleware::auth_middleware,
344 ))
345 } else {
346 authed
347 };
348
349 public
350 .merge(authed)
351 .layer(cors)
352 .layer(TraceLayer::new_for_http())
353}
354
355pub fn router(state: SoloHttpState) -> Router {
357 router_with_auth_config(state, None)
358}
359
360fn build_cors_layer() -> CorsLayer {
361 CorsLayer::new()
375 .allow_origin(AllowOrigin::predicate(|origin: &HeaderValue, _req| {
376 origin
377 .to_str()
378 .map(is_localhost_origin)
379 .unwrap_or(false)
380 }))
381 .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
382 .allow_headers([
383 axum::http::header::CONTENT_TYPE,
384 axum::http::header::AUTHORIZATION,
385 axum::http::HeaderName::from_static("x-solo-tenant"),
390 axum::http::HeaderName::from_static("mcp-session-id"),
399 ])
400}
401
402fn is_localhost_origin(origin: &str) -> bool {
406 let rest = origin
407 .strip_prefix("http://")
408 .or_else(|| origin.strip_prefix("https://"));
409 let host = match rest {
410 Some(r) => r,
411 None => return false,
412 };
413 let host = host.split('/').next().unwrap_or(host);
415 let host = if let Some(idx) = host.rfind(':') {
417 if host.starts_with('[') {
419 host.find(']')
421 .map(|i| &host[..=i])
422 .unwrap_or(host)
423 } else {
424 &host[..idx]
425 }
426 } else {
427 host
428 };
429 matches!(host, "localhost" | "127.0.0.1" | "[::1]")
430}
431
432pub async fn serve_http(
438 addr: SocketAddr,
439 state: SoloHttpState,
440 bearer_token: Option<String>,
441 shutdown: impl std::future::Future<Output = ()> + Send + 'static,
442) -> std::io::Result<()> {
443 let auth = bearer_token.map(|token| AuthConfig::Bearer { token });
444 serve_http_with_auth_config(addr, state, auth, shutdown).await
445}
446
447pub async fn serve_http_with_auth_config(
451 addr: SocketAddr,
452 state: SoloHttpState,
453 auth: Option<AuthConfig>,
454 shutdown: impl std::future::Future<Output = ()> + Send + 'static,
455) -> std::io::Result<()> {
456 let auth_kind = match &auth {
457 Some(AuthConfig::Bearer { .. }) => "bearer",
458 Some(AuthConfig::Oidc { .. }) => "oidc",
459 None => "none",
460 };
461 let app = router_with_auth_config(state, auth);
462 let listener = tokio::net::TcpListener::bind(addr).await?;
463 tracing::info!(%addr, auth = auth_kind, "solo http: listening");
464 axum::serve(listener, app)
465 .with_graceful_shutdown(shutdown)
466 .await
467}
468
469async fn openapi_handler() -> Json<serde_json::Value> {
483 Json(openapi_spec())
484}
485
486pub fn openapi_spec() -> serde_json::Value {
490 serde_json::json!({
491 "openapi": "3.1.0",
492 "info": {
493 "title": "Solo HTTP API",
494 "description":
495 "Local-first personal memory daemon. The HTTP transport \
496 mirrors the four MCP tools (memory_remember / recall / \
497 inspect / forget). Default deployment is loopback-only \
498 (127.0.0.1); LAN-bound deployments require a bearer \
499 token via `solo http-serve --bind <ip> --bearer-token-file <path>`.",
500 "version": env!("CARGO_PKG_VERSION"),
501 "license": { "name": "Apache-2.0" }
502 },
503 "servers": [
504 { "url": "http://127.0.0.1:7437", "description": "Default loopback (replace port with your --http-port)" }
505 ],
506 "components": {
507 "securitySchemes": {
508 "bearerAuth": {
509 "type": "http",
510 "scheme": "bearer",
511 "description":
512 "Bearer-token auth. Required only on LAN-bound deployments \
513 (`solo http-serve --bind <non-loopback> --bearer-token-file <path>`); \
514 the default `127.0.0.1` deployment is unauthenticated. \
515 `GET /health` and `GET /openapi.json` are exempt from auth even \
516 on bearer-protected instances."
517 }
518 },
519 "schemas": {
520 "RememberRequest": {
521 "type": "object",
522 "required": ["content"],
523 "properties": {
524 "content": { "type": "string", "minLength": 1, "description": "Episode content to embed + store." },
525 "source_type": { "type": "string", "description": "Free-form source tag (e.g. `user_message`, `tool_output`). Defaults to `user_message`." },
526 "source_id": { "type": "string", "description": "Optional upstream ID for traceability." }
527 },
528 "additionalProperties": false
529 },
530 "RememberResponse": {
531 "type": "object",
532 "required": ["memory_id"],
533 "properties": {
534 "memory_id": { "type": "string", "format": "uuid", "description": "UUID v7 assigned to the new episode." }
535 }
536 },
537 "RecallRequest": {
538 "type": "object",
539 "required": ["query"],
540 "properties": {
541 "query": { "type": "string", "minLength": 1, "description": "Natural-language query; embedded by the same model as stored episodes." },
542 "limit": { "type": "integer", "minimum": 1, "maximum": 50, "default": 5, "description": "Max number of hits to return." }
543 },
544 "additionalProperties": false
545 },
546 "RecallResult": {
547 "type": "object",
548 "description":
549 "Recall response. Fields are stable across v0.1 but not exhaustively documented here — \
550 see `solo_query::RecallResult` in the source for the canonical shape. \
551 Treat as a forward-compatible JSON object.",
552 "additionalProperties": true
553 },
554 "ConsolidationScope": {
555 "type": "object",
556 "description": "Filter + flags for consolidation. All fields optional; empty body = unbounded defaults.",
557 "properties": {
558 "window_days": { "type": "integer", "nullable": true, "description": "Restrict to memories with ts_ms >= now - window_days * 86400000. Null/omitted = unbounded." },
559 "force_merge": { "type": "boolean", "default": false, "description": "Run the existing-vs-existing merge + abstraction-regen passes even with zero unclustered candidates. Drift catch-up on quiet corpora. Added in 0.3.1." }
560 },
561 "additionalProperties": false
562 },
563 "ConsolidationReport": {
564 "type": "object",
565 "required": [
566 "episodes_seen", "clusters_built", "clusters_merged",
567 "clusters_absorbed", "existing_clusters_merged",
568 "episodes_clustered", "abstractions_built",
569 "abstractions_regenerated", "triples_built",
570 "contradictions_found"
571 ],
572 "properties": {
573 "episodes_seen": { "type": "integer", "minimum": 0 },
574 "clusters_built": { "type": "integer", "minimum": 0, "description": "Brand-new clusters that survived to be persisted (post in-run-merge, post cross-run-absorb)." },
575 "clusters_merged": { "type": "integer", "minimum": 0, "description": "In-run merge: clusters absorbed into a sibling within this consolidate run (cross-UTC-bucket case). Counts losers." },
576 "clusters_absorbed": { "type": "integer", "minimum": 0, "description": "Cross-run absorb: freshly-built clusters folded into a pre-existing DB cluster with a similar centroid. Counts new-side clusters." },
577 "existing_clusters_merged": { "type": "integer", "minimum": 0, "description": "Existing-vs-existing merge: pre-existing DB clusters that drifted toward each other and now coalesce. Counts losers." },
578 "episodes_clustered": { "type": "integer", "minimum": 0 },
579 "abstractions_built": { "type": "integer", "minimum": 0, "description": "Fresh abstractions persisted for newly-built clusters. 0 when no LlmClient is wired." },
580 "abstractions_regenerated": { "type": "integer", "minimum": 0, "description": "Existing clusters whose stale abstractions were dropped and rebuilt because absorb or existing-merge changed their episode set. 0 without an LlmClient." },
581 "triples_built": { "type": "integer", "minimum": 0 },
582 "contradictions_found": { "type": "integer", "minimum": 0 }
583 }
584 },
585 "EpisodeRecord": {
586 "type": "object",
587 "description":
588 "Inspect response: full episode record. Fields are stable across v0.1 but not \
589 exhaustively documented here — see `solo_query::EpisodeRecord` in the source. \
590 Treat as a forward-compatible JSON object.",
591 "additionalProperties": true
592 },
593 "ThemeHit": {
594 "type": "object",
595 "description":
596 "One cluster + its (optional) abstraction. Returned by GET /memory/themes. \
597 See `solo_query::ThemeHit` for the canonical shape: cluster_id, \
598 abstraction_id?, abstraction_text?, episode_count, coherence, created_at_ms.",
599 "additionalProperties": true
600 },
601 "FactHit": {
602 "type": "object",
603 "description":
604 "One Steward-extracted SPO triple. Returned by GET /memory/facts_about. \
605 See `solo_query::FactHit` for fields: triple_id, subject_id, predicate, \
606 object_id, object_kind, valid_from_ms, valid_to_ms?, confidence, cluster_id?.",
607 "additionalProperties": true
608 },
609 "ContradictionHit": {
610 "type": "object",
611 "description":
612 "One Steward-flagged contradiction with each side's triple LEFT JOIN'd in. \
613 Returned by GET /memory/contradictions. See `solo_query::ContradictionHit`: \
614 a_id, b_id, kind, explanation, detected_at_ms, a_triple?, b_triple?.",
615 "additionalProperties": true
616 },
617 "ClusterRecord": {
618 "type": "object",
619 "description":
620 "Snapshot of one cluster — its row, optional abstraction, and source episodes \
621 (content truncated to 200 chars unless ?full_content=true). Returned by \
622 GET /memory/clusters/{cluster_id}. See `solo_query::ClusterRecord`.",
623 "additionalProperties": true
624 },
625 "IngestDocumentRequest": {
626 "type": "object",
627 "required": ["path"],
628 "properties": {
629 "path": {
630 "type": "string",
631 "minLength": 1,
632 "description":
633 "Server-side absolute path to the file to ingest. The file must be \
634 readable by the Solo process. Supported formats: plaintext / \
635 markdown / code, HTML, PDF."
636 }
637 },
638 "additionalProperties": false
639 },
640 "IngestReport": {
641 "type": "object",
642 "description":
643 "Returned by POST /memory/documents. Reports the document id assigned, \
644 the number of chunks persisted + embedded, the total byte size, and a \
645 `deduped` flag (true when the same content_hash was already present and \
646 the existing doc_id was returned unchanged). See `solo_storage::IngestReport`.",
647 "required": ["doc_id", "chunks_persisted", "bytes_ingested", "deduped"],
648 "properties": {
649 "doc_id": { "type": "string", "format": "uuid" },
650 "chunks_persisted": { "type": "integer", "minimum": 0 },
651 "bytes_ingested": { "type": "integer", "minimum": 0, "format": "int64" },
652 "deduped": { "type": "boolean" }
653 },
654 "additionalProperties": false
655 },
656 "ForgetDocumentReport": {
657 "type": "object",
658 "description":
659 "Returned by DELETE /memory/documents/{id}. Reports the doc_id soft-deleted \
660 and how many chunk rowids were tombstoned in the HNSW index. The chunk rows \
661 themselves survive in SQL for forensic value. See `solo_storage::ForgetDocumentReport`.",
662 "required": ["doc_id", "chunks_tombstoned"],
663 "properties": {
664 "doc_id": { "type": "string", "format": "uuid" },
665 "chunks_tombstoned": { "type": "integer", "minimum": 0 }
666 },
667 "additionalProperties": false
668 },
669 "SearchDocsRequest": {
670 "type": "object",
671 "required": ["query"],
672 "properties": {
673 "query": { "type": "string", "minLength": 1 },
674 "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 }
675 },
676 "additionalProperties": false
677 },
678 "DocSearchHit": {
679 "type": "object",
680 "description":
681 "One chunk hit + parent-doc context. Fields per `solo_query::DocSearchHit`: \
682 chunk_id, doc_id, doc_title?, doc_source?, doc_mime_type?, chunk_index, \
683 content, cos_distance, start_offset, end_offset.",
684 "additionalProperties": true
685 },
686 "DocumentInspectResult": {
687 "type": "object",
688 "description":
689 "Returned by GET /memory/documents/{id}. A `document` record (full metadata) \
690 plus an ordered list of chunk summaries (each preview truncated to 200 \
691 chars). See `solo_query::DocumentInspectResult`.",
692 "additionalProperties": true
693 },
694 "DocumentSummary": {
695 "type": "object",
696 "description":
697 "One row from GET /memory/documents. Fields per `solo_query::DocumentSummary`: \
698 doc_id, title?, source?, mime_type?, ingested_at_ms, chunk_count, status.",
699 "additionalProperties": true
700 },
701 "ApiError": {
702 "type": "object",
703 "required": ["error", "status"],
704 "properties": {
705 "error": { "type": "string" },
706 "status": { "type": "integer", "minimum": 400, "maximum": 599 }
707 }
708 }
709 }
710 },
711 "paths": {
712 "/health": {
713 "get": {
714 "summary": "Liveness probe",
715 "description": "Returns plain text `ok`. Always unauthenticated.",
716 "responses": {
717 "200": {
718 "description": "Server is up.",
719 "content": { "text/plain": { "schema": { "type": "string", "example": "ok" } } }
720 }
721 }
722 }
723 },
724 "/openapi.json": {
725 "get": {
726 "summary": "Self-describing OpenAPI 3.1 spec",
727 "description": "Returns this document. Always unauthenticated.",
728 "responses": {
729 "200": {
730 "description": "OpenAPI 3.1 document.",
731 "content": { "application/json": { "schema": { "type": "object" } } }
732 }
733 }
734 }
735 },
736 "/memory": {
737 "post": {
738 "summary": "Remember (store an episode)",
739 "description": "Equivalent to MCP tool `memory_remember`.",
740 "security": [{ "bearerAuth": [] }, {}],
741 "requestBody": {
742 "required": true,
743 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RememberRequest" } } }
744 },
745 "responses": {
746 "200": {
747 "description": "Memory stored; returns the new MemoryId.",
748 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RememberResponse" } } }
749 },
750 "400": { "description": "Bad request (e.g. empty content).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
751 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
752 }
753 }
754 },
755 "/memory/search": {
756 "post": {
757 "summary": "Recall (vector search)",
758 "description": "Equivalent to MCP tool `memory_recall`. Embeds the query, runs HNSW search, returns the top-K hits in cosine-distance order.",
759 "security": [{ "bearerAuth": [] }, {}],
760 "requestBody": {
761 "required": true,
762 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecallRequest" } } }
763 },
764 "responses": {
765 "200": {
766 "description": "Search results.",
767 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecallResult" } } }
768 },
769 "400": { "description": "Bad request (e.g. empty query).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
770 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
771 }
772 }
773 },
774 "/memory/consolidate": {
775 "post": {
776 "summary": "Run a consolidation pass (clustering + abstraction)",
777 "description":
778 "Idempotent. Triggers the SWS-equivalent clustering pass; if a `Steward` LLM is wired \
779 on the server, also runs the REM-equivalent abstraction pass that populates \
780 `semantic_abstractions` and `triples`. Empty request body = default scope (unbounded \
781 window). Equivalent to the `solo consolidate` CLI.",
782 "security": [{ "bearerAuth": [] }, {}],
783 "requestBody": {
784 "required": false,
785 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConsolidationScope" } } }
786 },
787 "responses": {
788 "200": {
789 "description": "Consolidation complete; report counts the work done.",
790 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConsolidationReport" } } }
791 },
792 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
793 }
794 }
795 },
796 "/backup": {
797 "post": {
798 "summary": "Online encrypted backup",
799 "description":
800 "Run an online SQLCipher backup of the live data dir to a server-side path. \
801 The destination file is encrypted with the same Argon2id-derived raw key as \
802 the source, so it restores under the same passphrase + a copy of the source's \
803 `solo.config.toml`. Hot — the backup runs against the writer's existing \
804 connection without taking the lockfile, so the daemon keeps serving reads + \
805 writes during the operation. v0.3.2+.",
806 "security": [{ "bearerAuth": [] }, {}],
807 "requestBody": {
808 "required": true,
809 "content": { "application/json": { "schema": {
810 "type": "object",
811 "properties": {
812 "to": { "type": "string", "description": "Server-side absolute path for the backup file." },
813 "force": { "type": "boolean", "description": "Overwrite an existing destination file. Default false.", "default": false }
814 },
815 "required": ["to"]
816 } } }
817 },
818 "responses": {
819 "200": {
820 "description": "Backup complete; reports the destination path + elapsed milliseconds.",
821 "content": { "application/json": { "schema": {
822 "type": "object",
823 "properties": {
824 "path": { "type": "string" },
825 "elapsed_ms": { "type": "integer", "format": "int64" }
826 }
827 } } }
828 },
829 "400": { "description": "Destination invalid, exists without force, or its parent doesn't exist." },
830 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." },
831 "500": { "description": "Backup failed (disk full, permission denied, etc.)." }
832 }
833 }
834 },
835 "/memory/{id}": {
836 "get": {
837 "summary": "Inspect a memory by ID",
838 "description": "Equivalent to MCP tool `memory_inspect`.",
839 "security": [{ "bearerAuth": [] }, {}],
840 "parameters": [{
841 "name": "id",
842 "in": "path",
843 "required": true,
844 "schema": { "type": "string", "format": "uuid" },
845 "description": "MemoryId (UUID v7)."
846 }],
847 "responses": {
848 "200": {
849 "description": "Episode record.",
850 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EpisodeRecord" } } }
851 },
852 "400": { "description": "Malformed ID.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
853 "404": { "description": "No such memory.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
854 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
855 }
856 },
857 "delete": {
858 "summary": "Forget (soft-delete) a memory by ID",
859 "description":
860 "Equivalent to MCP tool `memory_forget`. Soft-delete: flips `episodes.status = 'forgotten'` \
861 and tombstones the HNSW vector. The row + embedding are preserved for forensics; \
862 re-running `solo reembed` after this does NOT restore visibility.",
863 "security": [{ "bearerAuth": [] }, {}],
864 "parameters": [
865 { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } },
866 { "name": "reason", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Free-form reason logged via tracing (not yet persisted to the DB)." }
867 ],
868 "responses": {
869 "204": { "description": "Forgotten (or already forgotten — idempotent)." },
870 "400": { "description": "Malformed ID.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
871 "404": { "description": "No such memory.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
872 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
873 }
874 }
875 },
876 "/memory/themes": {
877 "get": {
878 "summary": "List recent cluster themes",
879 "description":
880 "Equivalent to MCP tool `memory_themes`. List cluster abstractions ordered by \
881 most-recent first. Use to surface 'what has the user been thinking about lately' \
882 without paging through individual episodes. v0.4.0+.",
883 "security": [{ "bearerAuth": [] }, {}],
884 "parameters": [
885 { "name": "window_days", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1 }, "description": "Optional time window. Omit for unfiltered (all-time, most-recent first)." },
886 { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
887 ],
888 "responses": {
889 "200": {
890 "description": "Array of ThemeHits (possibly empty).",
891 "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ThemeHit" } } } }
892 },
893 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
894 }
895 }
896 },
897 "/memory/facts_about": {
898 "get": {
899 "summary": "Query the SPO knowledge graph by subject",
900 "description":
901 "Equivalent to MCP tool `memory_facts_about`. Query Steward-extracted triples by \
902 subject + optional predicate + optional time window. Subject is required \
903 (predicate-only scans not supported). Pass `include_as_object=true` (v0.5.1+) \
904 to also surface rows where `subject` appears as the object. v0.4.0+.",
905 "security": [{ "bearerAuth": [] }, {}],
906 "parameters": [
907 { "name": "subject", "in": "query", "required": true, "schema": { "type": "string", "minLength": 1 }, "description": "Subject id to query (e.g. `Sam`)." },
908 { "name": "predicate", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Optional predicate filter (e.g. `works_at`)." },
909 { "name": "since_ms", "in": "query", "required": false, "schema": { "type": "integer" }, "description": "Optional valid_from_ms lower bound (epoch ms)." },
910 { "name": "until_ms", "in": "query", "required": false, "schema": { "type": "integer" }, "description": "Optional valid_to_ms upper bound (epoch ms). NULL upper bounds (still-valid facts) pass through." },
911 { "name": "include_as_object", "in": "query", "required": false, "schema": { "type": "boolean", "default": false }, "description": "If true, also match rows where `subject` appears as the object (e.g. surface 'Sam pushes back on PRs about Maya' under subject='Maya'). Default false. v0.5.1+." },
912 { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
913 ],
914 "responses": {
915 "200": {
916 "description": "Array of FactHits (possibly empty).",
917 "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/FactHit" } } } }
918 },
919 "400": { "description": "Bad request (e.g. empty subject).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
920 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
921 }
922 }
923 },
924 "/memory/contradictions": {
925 "get": {
926 "summary": "List Steward-flagged contradictions",
927 "description":
928 "Equivalent to MCP tool `memory_contradictions`. Each result includes both \
929 sides' triple SPO via LEFT JOIN for context. v0.4.0+.",
930 "security": [{ "bearerAuth": [] }, {}],
931 "parameters": [
932 { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
933 ],
934 "responses": {
935 "200": {
936 "description": "Array of ContradictionHits (possibly empty).",
937 "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ContradictionHit" } } } }
938 },
939 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
940 }
941 }
942 },
943 "/memory/clusters/{cluster_id}": {
944 "get": {
945 "summary": "Inspect a single cluster",
946 "description":
947 "Equivalent to MCP tool `memory_inspect_cluster`. Returns the cluster row, \
948 its (optional) abstraction, and its source episodes. By default each \
949 episode's `content` is truncated to 200 chars with a trailing `…`. Pass \
950 `?full_content=true` to get verbatim episode content. v0.5.0+.",
951 "security": [{ "bearerAuth": [] }, {}],
952 "parameters": [
953 { "name": "cluster_id", "in": "path", "required": true, "schema": { "type": "string", "minLength": 1 }, "description": "Cluster id (from a previous GET /memory/themes response)." },
954 { "name": "full_content", "in": "query", "required": false, "schema": { "type": "boolean", "default": false }, "description": "If true, return episode content verbatim. Default false (truncate to 200 chars + ellipsis)." }
955 ],
956 "responses": {
957 "200": {
958 "description": "Cluster snapshot.",
959 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ClusterRecord" } } }
960 },
961 "400": { "description": "Bad request (e.g. empty cluster_id).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
962 "404": { "description": "No such cluster.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
963 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
964 }
965 }
966 },
967 "/memory/documents": {
968 "post": {
969 "summary": "Ingest a document",
970 "description":
971 "Equivalent to MCP tool `memory_ingest_document`. Reads the file at the \
972 supplied server-side path, parses + chunks + embeds, and persists under \
973 `documents` + `document_chunks`. Returns the new doc_id, chunk count, and \
974 a `deduped` flag (true when an existing document with the same content_hash \
975 was returned without re-embedding). v0.7.0+.",
976 "security": [{ "bearerAuth": [] }, {}],
977 "requestBody": {
978 "required": true,
979 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IngestDocumentRequest" } } }
980 },
981 "responses": {
982 "200": {
983 "description": "Document ingested (or deduplicated).",
984 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IngestReport" } } }
985 },
986 "400": { "description": "Bad request (e.g. empty path, file unreadable, parse error).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
987 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
988 }
989 },
990 "get": {
991 "summary": "List ingested documents (paginated)",
992 "description":
993 "Equivalent to MCP tool `memory_list_documents`. Returns a paginated index, \
994 newest first. Forgotten documents are hidden by default; pass \
995 `?include_forgotten=true` to see them too. v0.7.0+.",
996 "security": [{ "bearerAuth": [] }, {}],
997 "parameters": [
998 { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 } },
999 { "name": "offset", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 0, "default": 0 } },
1000 { "name": "include_forgotten", "in": "query", "required": false, "schema": { "type": "boolean", "default": false } }
1001 ],
1002 "responses": {
1003 "200": {
1004 "description": "Array of DocumentSummary (possibly empty).",
1005 "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/DocumentSummary" } } } }
1006 },
1007 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
1008 }
1009 }
1010 },
1011 "/memory/documents/search": {
1012 "post": {
1013 "summary": "Vector search across document chunks",
1014 "description":
1015 "Equivalent to MCP tool `memory_search_docs`. Embeds the query and returns \
1016 up to `limit` matching chunks, best match first, each annotated with the \
1017 parent document's title + source path. Forgotten documents are excluded. \
1018 v0.7.0+.",
1019 "security": [{ "bearerAuth": [] }, {}],
1020 "requestBody": {
1021 "required": true,
1022 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SearchDocsRequest" } } }
1023 },
1024 "responses": {
1025 "200": {
1026 "description": "Array of DocSearchHits (possibly empty).",
1027 "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/DocSearchHit" } } } }
1028 },
1029 "400": { "description": "Bad request (e.g. empty query).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
1030 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
1031 }
1032 }
1033 },
1034 "/memory/documents/{id}": {
1035 "get": {
1036 "summary": "Inspect one document",
1037 "description":
1038 "Equivalent to MCP tool `memory_inspect_document`. Returns the document's \
1039 metadata plus a preview of every chunk (truncated to 200 chars). v0.7.0+.",
1040 "security": [{ "bearerAuth": [] }, {}],
1041 "parameters": [
1042 { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "DocumentId (UUID v7)." }
1043 ],
1044 "responses": {
1045 "200": {
1046 "description": "Document inspection result.",
1047 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DocumentInspectResult" } } }
1048 },
1049 "400": { "description": "Malformed id.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
1050 "404": { "description": "No such document.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
1051 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
1052 }
1053 },
1054 "delete": {
1055 "summary": "Forget (soft-delete) one document",
1056 "description":
1057 "Equivalent to MCP tool `memory_forget_document`. Flips `documents.status` \
1058 to `forgotten` and tombstones every chunk's HNSW rowid. The chunk rows \
1059 survive in SQL for forensic value. v0.7.0+.",
1060 "security": [{ "bearerAuth": [] }, {}],
1061 "parameters": [
1062 { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
1063 ],
1064 "responses": {
1065 "200": {
1066 "description": "Document soft-deleted; report counts chunks tombstoned.",
1067 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ForgetDocumentReport" } } }
1068 },
1069 "400": { "description": "Malformed id.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
1070 "404": { "description": "No such document.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
1071 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
1072 }
1073 }
1074 }
1075 }
1076 })
1077}
1078
1079#[derive(Debug, Deserialize)]
1084struct RememberBody {
1085 content: String,
1086 #[serde(default)]
1087 source_type: Option<String>,
1088 #[serde(default)]
1089 source_id: Option<String>,
1090}
1091
1092#[derive(Debug, Serialize)]
1093struct RememberResponse {
1094 memory_id: String,
1095}
1096
1097async fn remember_handler(
1098 TenantExtractor(tenant): TenantExtractor,
1099 AuditPrincipal(principal): AuditPrincipal,
1100 Json(body): Json<RememberBody>,
1101) -> Result<Json<RememberResponse>, ApiError> {
1102 let content = body.content.trim_end().to_string();
1103 if content.is_empty() {
1104 return Err(ApiError::bad_request("content must not be empty"));
1105 }
1106 let embedding = tenant.embedder().embed(&content).await.map_err(ApiError::from)?;
1107 let episode = Episode {
1108 memory_id: MemoryId::new(),
1109 ts_ms: chrono::Utc::now().timestamp_millis(),
1110 source_type: body.source_type.unwrap_or_else(|| "user_message".into()),
1111 source_id: body.source_id,
1112 content,
1113 encoding_context: EncodingContext::default(),
1114 provenance: None,
1115 confidence: Confidence::new(0.9).unwrap(),
1116 strength: 0.5,
1117 salience: 0.5,
1118 tier: Tier::Hot,
1119 };
1120 let mid = tenant
1121 .write()
1122 .remember_as(principal, episode, embedding)
1123 .await
1124 .map_err(ApiError::from)?;
1125 Ok(Json(RememberResponse {
1126 memory_id: mid.to_string(),
1127 }))
1128}
1129
1130#[derive(Debug, Deserialize)]
1131struct RecallBody {
1132 query: String,
1133 #[serde(default = "default_limit")]
1134 limit: usize,
1135}
1136
1137fn default_limit() -> usize {
1138 5
1139}
1140
1141async fn recall_handler(
1142 TenantExtractor(tenant): TenantExtractor,
1143 AuditPrincipal(principal): AuditPrincipal,
1144 Json(body): Json<RecallBody>,
1145) -> Result<Json<solo_query::RecallResult>, ApiError> {
1146 let result = solo_query::run_recall(tenant.as_ref(), principal, &body.query, body.limit)
1150 .await
1151 .map_err(ApiError::from)?;
1152 Ok(Json(result))
1153}
1154
1155async fn inspect_handler(
1156 TenantExtractor(tenant): TenantExtractor,
1157 AuditPrincipal(principal): AuditPrincipal,
1158 Path(id): Path<String>,
1159) -> Result<Json<solo_query::EpisodeRecord>, ApiError> {
1160 let mid = MemoryId::from_str(&id)
1161 .map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
1162 let row = solo_query::inspect_one(tenant.read(), tenant.audit(), principal, mid)
1163 .await
1164 .map_err(ApiError::from)?;
1165 Ok(Json(row))
1166}
1167
1168#[derive(Debug, Deserialize)]
1175struct ThemesQuery {
1176 #[serde(default)]
1177 window_days: Option<i64>,
1178 #[serde(default = "default_limit")]
1179 limit: usize,
1180}
1181
1182async fn themes_handler(
1183 TenantExtractor(tenant): TenantExtractor,
1184 AuditPrincipal(principal): AuditPrincipal,
1185 Query(q): Query<ThemesQuery>,
1186) -> Result<Json<Vec<solo_query::ThemeHit>>, ApiError> {
1187 let hits = solo_query::themes(
1188 tenant.read(),
1189 tenant.audit(),
1190 principal,
1191 q.window_days,
1192 q.limit,
1193 )
1194 .await
1195 .map_err(ApiError::from)?;
1196 Ok(Json(hits))
1197}
1198
1199#[derive(Debug, Deserialize)]
1200struct FactsAboutQuery {
1201 subject: String,
1202 #[serde(default)]
1203 predicate: Option<String>,
1204 #[serde(default)]
1205 since_ms: Option<i64>,
1206 #[serde(default)]
1207 until_ms: Option<i64>,
1208 #[serde(default)]
1211 include_as_object: bool,
1212 #[serde(default = "default_limit")]
1213 limit: usize,
1214}
1215
1216async fn facts_about_handler(
1217 State(s): State<SoloHttpState>,
1218 TenantExtractor(tenant): TenantExtractor,
1219 AuditPrincipal(principal): AuditPrincipal,
1220 Query(q): Query<FactsAboutQuery>,
1221) -> Result<Json<Vec<solo_query::FactHit>>, ApiError> {
1222 if q.subject.trim().is_empty() {
1223 return Err(ApiError::bad_request("subject must not be empty"));
1224 }
1225 let hits = solo_query::facts_about(
1226 tenant.read(),
1227 tenant.audit(),
1228 principal,
1229 &q.subject,
1230 &s.user_aliases,
1231 q.include_as_object,
1232 q.predicate.as_deref(),
1233 q.since_ms,
1234 q.until_ms,
1235 q.limit,
1236 )
1237 .await
1238 .map_err(ApiError::from)?;
1239 Ok(Json(hits))
1240}
1241
1242#[derive(Debug, Deserialize)]
1243struct ContradictionsQuery {
1244 #[serde(default = "default_limit")]
1245 limit: usize,
1246}
1247
1248async fn contradictions_handler(
1249 TenantExtractor(tenant): TenantExtractor,
1250 AuditPrincipal(principal): AuditPrincipal,
1251 Query(q): Query<ContradictionsQuery>,
1252) -> Result<Json<Vec<solo_query::ContradictionHit>>, ApiError> {
1253 let hits = solo_query::contradictions(tenant.read(), tenant.audit(), principal, q.limit)
1254 .await
1255 .map_err(ApiError::from)?;
1256 Ok(Json(hits))
1257}
1258
1259#[derive(Debug, Deserialize, Default)]
1260struct InspectClusterQuery {
1261 #[serde(default)]
1265 full_content: bool,
1266}
1267
1268async fn inspect_cluster_handler(
1269 TenantExtractor(tenant): TenantExtractor,
1270 AuditPrincipal(principal): AuditPrincipal,
1271 Path(cluster_id): Path<String>,
1272 Query(q): Query<InspectClusterQuery>,
1273) -> Result<Json<solo_query::ClusterRecord>, ApiError> {
1274 if cluster_id.trim().is_empty() {
1275 return Err(ApiError::bad_request("cluster_id must not be empty"));
1276 }
1277 let record = solo_query::inspect_cluster(
1278 tenant.read(),
1279 tenant.audit(),
1280 principal,
1281 &cluster_id,
1282 q.full_content,
1283 )
1284 .await
1285 .map_err(ApiError::from)?;
1286 Ok(Json(record))
1287}
1288
1289#[derive(Debug, Deserialize)]
1294struct IngestDocumentBody {
1295 path: String,
1298}
1299
1300async fn ingest_document_handler(
1301 TenantExtractor(tenant): TenantExtractor,
1302 AuditPrincipal(principal): AuditPrincipal,
1303 Json(body): Json<IngestDocumentBody>,
1304) -> Result<Json<solo_storage::IngestReport>, ApiError> {
1305 if body.path.trim().is_empty() {
1306 return Err(ApiError::bad_request("path must not be empty"));
1307 }
1308 let path = std::path::PathBuf::from(body.path);
1309 let chunk_config = solo_storage::document::ChunkConfig::default();
1310 let report = tenant
1311 .write()
1312 .ingest_document_as(principal, path, chunk_config)
1313 .await
1314 .map_err(ApiError::from)?;
1315 Ok(Json(report))
1316}
1317
1318#[derive(Debug, Deserialize)]
1319struct SearchDocsBody {
1320 query: String,
1321 #[serde(default = "default_limit")]
1322 limit: usize,
1323}
1324
1325async fn search_docs_handler(
1326 TenantExtractor(tenant): TenantExtractor,
1327 AuditPrincipal(principal): AuditPrincipal,
1328 Json(body): Json<SearchDocsBody>,
1329) -> Result<Json<Vec<solo_query::DocSearchHit>>, ApiError> {
1330 let hits = solo_query::run_doc_search(tenant.as_ref(), principal, &body.query, body.limit)
1331 .await
1332 .map_err(ApiError::from)?;
1333 Ok(Json(hits))
1334}
1335
1336async fn inspect_document_handler(
1337 TenantExtractor(tenant): TenantExtractor,
1338 AuditPrincipal(principal): AuditPrincipal,
1339 Path(id): Path<String>,
1340) -> Result<Json<solo_query::DocumentInspectResult>, ApiError> {
1341 let doc_id = DocumentId::from_str(&id)
1342 .map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
1343 let result_opt =
1344 solo_query::inspect_document(tenant.read(), tenant.audit(), principal, &doc_id)
1345 .await
1346 .map_err(ApiError::from)?;
1347 match result_opt {
1348 Some(record) => Ok(Json(record)),
1349 None => Err(ApiError::not_found(format!("document {doc_id} not found"))),
1350 }
1351}
1352
1353#[derive(Debug, Deserialize)]
1354struct ListDocumentsQuery {
1355 #[serde(default = "default_list_documents_limit")]
1356 limit: usize,
1357 #[serde(default)]
1358 offset: usize,
1359 #[serde(default)]
1360 include_forgotten: bool,
1361}
1362
1363fn default_list_documents_limit() -> usize {
1364 20
1365}
1366
1367async fn list_documents_handler(
1368 TenantExtractor(tenant): TenantExtractor,
1369 AuditPrincipal(principal): AuditPrincipal,
1370 Query(q): Query<ListDocumentsQuery>,
1371) -> Result<Json<Vec<solo_query::DocumentSummary>>, ApiError> {
1372 let rows = solo_query::list_documents(
1373 tenant.read(),
1374 tenant.audit(),
1375 principal,
1376 q.limit,
1377 q.offset,
1378 q.include_forgotten,
1379 )
1380 .await
1381 .map_err(ApiError::from)?;
1382 Ok(Json(rows))
1383}
1384
1385async fn forget_document_handler(
1386 TenantExtractor(tenant): TenantExtractor,
1387 AuditPrincipal(principal): AuditPrincipal,
1388 Path(id): Path<String>,
1389) -> Result<Json<solo_storage::ForgetDocumentReport>, ApiError> {
1390 let doc_id = DocumentId::from_str(&id)
1391 .map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
1392 let report = tenant
1393 .write()
1394 .forget_document_as(principal, doc_id)
1395 .await
1396 .map_err(ApiError::from)?;
1397 Ok(Json(report))
1398}
1399
1400#[derive(Debug, Deserialize)]
1401struct ForgetQuery {
1402 #[serde(default)]
1403 reason: Option<String>,
1404}
1405
1406async fn forget_handler(
1407 TenantExtractor(tenant): TenantExtractor,
1408 AuditPrincipal(principal): AuditPrincipal,
1409 Path(id): Path<String>,
1410 Query(q): Query<ForgetQuery>,
1411) -> Result<StatusCode, ApiError> {
1412 let mid = MemoryId::from_str(&id).map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
1413 let reason = q.reason.unwrap_or_else(|| "http".into());
1414 tenant
1415 .write()
1416 .forget_as(principal, mid, reason)
1417 .await
1418 .map_err(ApiError::from)?;
1419 Ok(StatusCode::NO_CONTENT)
1420}
1421
1422async fn consolidate_handler(
1423 TenantExtractor(tenant): TenantExtractor,
1424 AuditPrincipal(principal): AuditPrincipal,
1425 body: axum::body::Bytes,
1426) -> Result<Json<solo_storage::ConsolidationReport>, ApiError> {
1427 let scope = if body.is_empty() {
1433 solo_storage::ConsolidationScope::default()
1434 } else {
1435 serde_json::from_slice(&body)
1436 .map_err(|e| ApiError::bad_request(format!("invalid JSON: {e}")))?
1437 };
1438 let report = tenant
1439 .write()
1440 .consolidate_as(principal, scope)
1441 .await
1442 .map_err(ApiError::from)?;
1443 Ok(Json(report))
1444}
1445
1446#[derive(Debug, Deserialize)]
1447struct BackupBody {
1448 to: String,
1452 #[serde(default)]
1453 force: bool,
1454}
1455
1456#[derive(Debug, Serialize)]
1457struct BackupResponse {
1458 path: String,
1459 elapsed_ms: u64,
1460}
1461
1462async fn backup_handler(
1463 TenantExtractor(tenant): TenantExtractor,
1464 Json(body): Json<BackupBody>,
1465) -> Result<Json<BackupResponse>, ApiError> {
1466 use std::path::PathBuf;
1467
1468 let dest = PathBuf::from(&body.to);
1469 if dest.as_os_str().is_empty() {
1470 return Err(ApiError::bad_request("`to` must not be empty"));
1471 }
1472 if solo_storage::paths_refer_to_same_file(tenant.db_path(), &dest) {
1475 return Err(ApiError::bad_request(format!(
1476 "destination {} is the same file as the source database; \
1477 refusing to run (would corrupt the live database)",
1478 dest.display()
1479 )));
1480 }
1481 if dest.exists() {
1482 if !body.force {
1483 return Err(ApiError::bad_request(format!(
1484 "destination {} exists; pass force=true to overwrite",
1485 dest.display()
1486 )));
1487 }
1488 std::fs::remove_file(&dest).map_err(|e| {
1489 ApiError::internal(format!(
1490 "remove existing destination {}: {e}",
1491 dest.display()
1492 ))
1493 })?;
1494 }
1495 if let Some(parent) = dest.parent() {
1496 if !parent.as_os_str().is_empty() && !parent.is_dir() {
1497 return Err(ApiError::bad_request(format!(
1498 "destination parent directory {} does not exist",
1499 parent.display()
1500 )));
1501 }
1502 }
1503
1504 let started = std::time::Instant::now();
1505 tenant.write().backup(dest.clone()).await.map_err(ApiError::from)?;
1506 let elapsed_ms = started.elapsed().as_millis() as u64;
1507
1508 Ok(Json(BackupResponse {
1509 path: dest.display().to_string(),
1510 elapsed_ms,
1511 }))
1512}
1513
1514const GRAPH_EXPAND_DEFAULT_LIMIT: u32 = 25;
1553const GRAPH_EXPAND_MAX_LIMIT: u32 = 100;
1554
1555#[derive(Debug, Clone, Copy, Deserialize)]
1558#[serde(rename_all = "snake_case")]
1559enum GraphExpandKind {
1560 ClusterMember,
1561 DocumentChunk,
1562 Triple,
1563 Semantic,
1564}
1565
1566#[derive(Debug, Deserialize)]
1567struct GraphExpandQuery {
1568 node_id: String,
1569 kind: GraphExpandKind,
1570 #[serde(default)]
1571 limit: Option<u32>,
1572}
1573
1574#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1576enum NodeKind {
1577 Episode,
1578 Document,
1579 Chunk,
1580 Cluster,
1581 Entity,
1582}
1583
1584impl NodeKind {
1585 fn as_wire_str(self) -> &'static str {
1586 match self {
1587 Self::Episode => "episode",
1588 Self::Document => "document",
1589 Self::Chunk => "chunk",
1590 Self::Cluster => "cluster",
1591 Self::Entity => "entity",
1592 }
1593 }
1594}
1595
1596fn parse_node_id(raw: &str) -> Result<(NodeKind, &str), ApiError> {
1599 let (prefix, value) = raw.split_once(':').ok_or_else(|| {
1600 ApiError::bad_request(format!(
1601 "node_id must be `<prefix>:<value>` (one of ep:/doc:/chunk:/cl:/ent:); got {raw:?}"
1602 ))
1603 })?;
1604 if value.is_empty() {
1605 return Err(ApiError::bad_request(format!(
1606 "node_id value is empty after prefix: {raw:?}"
1607 )));
1608 }
1609 let kind = match prefix {
1610 "ep" => NodeKind::Episode,
1611 "doc" => NodeKind::Document,
1612 "chunk" => NodeKind::Chunk,
1613 "cl" => NodeKind::Cluster,
1614 "ent" => NodeKind::Entity,
1615 other => {
1616 return Err(ApiError::bad_request(format!(
1617 "unknown node_id prefix {other:?}; expected one of ep:/doc:/chunk:/cl:/ent:"
1618 )));
1619 }
1620 };
1621 Ok((kind, value))
1622}
1623
1624#[derive(Debug, Serialize)]
1627struct GraphNode {
1628 id: String,
1629 kind: &'static str,
1630 label: String,
1631 #[serde(skip_serializing_if = "Option::is_none")]
1632 ts_ms: Option<i64>,
1633 tenant_id: String,
1634 #[serde(skip_serializing_if = "Option::is_none")]
1635 preview: Option<String>,
1636}
1637
1638#[derive(Debug, Serialize)]
1641struct GraphEdge {
1642 id: String,
1643 source: String,
1644 target: String,
1645 kind: &'static str,
1646 #[serde(skip_serializing_if = "Option::is_none")]
1647 predicate: Option<String>,
1648 #[serde(skip_serializing_if = "Option::is_none")]
1649 weight: Option<f32>,
1650}
1651
1652#[derive(Debug, Serialize)]
1653struct GraphExpandResponse {
1654 nodes: Vec<GraphNode>,
1655 edges: Vec<GraphEdge>,
1656}
1657
1658fn edge_id(source: &str, kind: &str, target: &str) -> String {
1659 format!("{source}--{kind}--{target}")
1660}
1661
1662#[derive(Debug)]
1664struct ExpandedEpisode {
1665 memory_id: String,
1666 ts_ms: i64,
1667 content: String,
1668}
1669
1670#[derive(Debug)]
1672struct ExpandedDocument {
1673 doc_id: String,
1674 title: Option<String>,
1675 source: Option<String>,
1676 ingested_at_ms: i64,
1677}
1678
1679#[derive(Debug)]
1681struct ExpandedChunk {
1682 chunk_id: String,
1683 chunk_index: i64,
1684 content: String,
1685}
1686
1687fn truncate_preview(s: &str, max: usize) -> String {
1688 if s.chars().count() <= max {
1689 return s.to_string();
1690 }
1691 let mut out: String = s.chars().take(max - 1).collect();
1692 out.push('…');
1693 out
1694}
1695
1696const GRAPH_LABEL_CHARS: usize = 80;
1699const GRAPH_PREVIEW_CHARS: usize = 200;
1700
1701fn episode_label(content: &str) -> String {
1702 let first_line = content.lines().next().unwrap_or(content);
1703 truncate_preview(first_line, GRAPH_LABEL_CHARS)
1704}
1705
1706fn graph_node_for_episode(tenant_id: &str, ep: &ExpandedEpisode) -> GraphNode {
1707 GraphNode {
1708 id: format!("ep:{}", ep.memory_id),
1709 kind: NodeKind::Episode.as_wire_str(),
1710 label: episode_label(&ep.content),
1711 ts_ms: Some(ep.ts_ms),
1712 tenant_id: tenant_id.to_string(),
1713 preview: Some(truncate_preview(&ep.content, GRAPH_PREVIEW_CHARS)),
1714 }
1715}
1716
1717fn graph_node_for_document(tenant_id: &str, d: &ExpandedDocument) -> GraphNode {
1718 let label = d
1719 .title
1720 .clone()
1721 .or_else(|| d.source.clone())
1722 .unwrap_or_else(|| d.doc_id.clone());
1723 GraphNode {
1724 id: format!("doc:{}", d.doc_id),
1725 kind: NodeKind::Document.as_wire_str(),
1726 label: truncate_preview(&label, GRAPH_LABEL_CHARS),
1727 ts_ms: Some(d.ingested_at_ms),
1728 tenant_id: tenant_id.to_string(),
1729 preview: d.source.clone(),
1730 }
1731}
1732
1733fn graph_node_for_chunk(tenant_id: &str, c: &ExpandedChunk) -> GraphNode {
1734 GraphNode {
1735 id: format!("chunk:{}", c.chunk_id),
1736 kind: NodeKind::Chunk.as_wire_str(),
1737 label: format!("chunk #{}: {}", c.chunk_index, episode_label(&c.content)),
1738 ts_ms: None,
1739 tenant_id: tenant_id.to_string(),
1740 preview: Some(truncate_preview(&c.content, GRAPH_PREVIEW_CHARS)),
1741 }
1742}
1743
1744fn graph_node_for_cluster(
1745 tenant_id: &str,
1746 cluster_id: &str,
1747 abstraction: Option<&str>,
1748 created_at_ms: i64,
1749) -> GraphNode {
1750 let label = abstraction
1751 .map(|a| truncate_preview(a, GRAPH_LABEL_CHARS))
1752 .unwrap_or_else(|| format!("cluster {cluster_id}"));
1753 GraphNode {
1754 id: format!("cl:{cluster_id}"),
1755 kind: NodeKind::Cluster.as_wire_str(),
1756 label,
1757 ts_ms: Some(created_at_ms),
1758 tenant_id: tenant_id.to_string(),
1759 preview: abstraction.map(|a| truncate_preview(a, GRAPH_PREVIEW_CHARS)),
1760 }
1761}
1762
1763fn graph_node_for_entity(tenant_id: &str, value: &str) -> GraphNode {
1764 GraphNode {
1765 id: format!("ent:{value}"),
1766 kind: NodeKind::Entity.as_wire_str(),
1767 label: truncate_preview(value, GRAPH_LABEL_CHARS),
1768 ts_ms: None,
1769 tenant_id: tenant_id.to_string(),
1770 preview: None,
1771 }
1772}
1773
1774async fn graph_expand_handler(
1776 TenantExtractor(tenant): TenantExtractor,
1777 Query(q): Query<GraphExpandQuery>,
1778) -> Result<Json<GraphExpandResponse>, ApiError> {
1779 let limit = q.limit.unwrap_or(GRAPH_EXPAND_DEFAULT_LIMIT);
1783 let limit = limit.clamp(1, GRAPH_EXPAND_MAX_LIMIT) as i64;
1784
1785 let (node_kind, value) = parse_node_id(&q.node_id)?;
1786 let value = value.to_string();
1787 let node_id_full = q.node_id.clone();
1788 let tenant_id_str = tenant.tenant_id().to_string();
1789
1790 match q.kind {
1791 GraphExpandKind::ClusterMember => {
1792 expand_cluster_member(&tenant, &tenant_id_str, node_kind, &value, &node_id_full, limit)
1793 .await
1794 }
1795 GraphExpandKind::DocumentChunk => {
1796 expand_document_chunk(&tenant, &tenant_id_str, node_kind, &value, &node_id_full, limit)
1797 .await
1798 }
1799 GraphExpandKind::Triple => {
1800 expand_triple(&tenant, &tenant_id_str, node_kind, &value, &node_id_full, limit).await
1801 }
1802 GraphExpandKind::Semantic => {
1803 expand_semantic(&tenant, &tenant_id_str, node_kind, &value, &node_id_full, limit).await
1804 }
1805 }
1806 .map(Json)
1807}
1808
1809async fn expand_cluster_member(
1812 tenant: &TenantHandle,
1813 tenant_id: &str,
1814 node_kind: NodeKind,
1815 value: &str,
1816 node_id_full: &str,
1817 limit: i64,
1818) -> Result<GraphExpandResponse, ApiError> {
1819 match node_kind {
1820 NodeKind::Episode => expand_cluster_member_from_episode(
1821 tenant,
1822 tenant_id,
1823 value.to_string(),
1824 node_id_full.to_string(),
1825 limit,
1826 )
1827 .await,
1828 NodeKind::Cluster => expand_cluster_member_from_cluster(
1829 tenant,
1830 tenant_id,
1831 value.to_string(),
1832 node_id_full.to_string(),
1833 limit,
1834 )
1835 .await,
1836 _ => Err(ApiError::bad_request(format!(
1837 "kind=cluster_member only valid for episode or cluster source nodes; got {}",
1838 node_kind.as_wire_str()
1839 ))),
1840 }
1841}
1842
1843async fn expand_cluster_member_from_episode(
1844 tenant: &TenantHandle,
1845 tenant_id: &str,
1846 memory_id: String,
1847 node_id_full: String,
1848 limit: i64,
1849) -> Result<GraphExpandResponse, ApiError> {
1850 let memory_id_for_err = memory_id.clone();
1851 let rows: Vec<(String, Option<String>, i64)> = tenant
1852 .read()
1853 .interact(move |conn| {
1854 let exists: i64 = conn.query_row(
1856 "SELECT COUNT(*) FROM episodes WHERE memory_id = ?1",
1857 rusqlite::params![&memory_id],
1858 |r| r.get(0),
1859 )?;
1860 if exists == 0 {
1861 return Ok(Vec::new());
1862 }
1863 let mut stmt = conn.prepare(
1864 "SELECT c.cluster_id, sa.content, c.created_at_ms
1865 FROM cluster_episodes ce
1866 JOIN clusters c ON c.cluster_id = ce.cluster_id
1867 LEFT JOIN semantic_abstractions sa ON sa.cluster_id = c.cluster_id
1868 WHERE ce.memory_id = ?1
1869 ORDER BY c.created_at_ms DESC
1870 LIMIT ?2",
1871 )?;
1872 let mapped = stmt
1873 .query_map(rusqlite::params![&memory_id, limit], |r| {
1874 Ok((
1875 r.get::<_, String>(0)?,
1876 r.get::<_, Option<String>>(1)?,
1877 r.get::<_, i64>(2)?,
1878 ))
1879 })?
1880 .collect::<rusqlite::Result<Vec<_>>>()?;
1881 Ok::<_, rusqlite::Error>(mapped)
1888 })
1889 .await
1890 .map_err(ApiError::from)?;
1891
1892 if rows.is_empty() {
1899 ensure_episode_exists(tenant, &memory_id_for_err, &node_id_full).await?;
1900 return Ok(GraphExpandResponse {
1901 nodes: Vec::new(),
1902 edges: Vec::new(),
1903 });
1904 }
1905
1906 let mut nodes = Vec::with_capacity(rows.len());
1907 let mut edges = Vec::with_capacity(rows.len());
1908 for (cluster_id, abstraction, created_at_ms) in rows {
1909 let target_id = format!("cl:{cluster_id}");
1910 edges.push(GraphEdge {
1911 id: edge_id(&node_id_full, "cluster_member", &target_id),
1912 source: node_id_full.clone(),
1913 target: target_id,
1914 kind: "cluster_member",
1915 predicate: None,
1916 weight: None,
1917 });
1918 nodes.push(graph_node_for_cluster(
1919 tenant_id,
1920 &cluster_id,
1921 abstraction.as_deref(),
1922 created_at_ms,
1923 ));
1924 }
1925 Ok(GraphExpandResponse { nodes, edges })
1926}
1927
1928async fn expand_cluster_member_from_cluster(
1929 tenant: &TenantHandle,
1930 tenant_id: &str,
1931 cluster_id: String,
1932 node_id_full: String,
1933 limit: i64,
1934) -> Result<GraphExpandResponse, ApiError> {
1935 let cluster_id_for_err = cluster_id.clone();
1936 let rows: Vec<ExpandedEpisode> = tenant
1937 .read()
1938 .interact(move |conn| {
1939 let exists: i64 = conn.query_row(
1940 "SELECT COUNT(*) FROM clusters WHERE cluster_id = ?1",
1941 rusqlite::params![&cluster_id],
1942 |r| r.get(0),
1943 )?;
1944 if exists == 0 {
1945 return Ok(Vec::new());
1946 }
1947 let mut stmt = conn.prepare(
1948 "SELECT e.memory_id, e.ts_ms, e.content
1949 FROM cluster_episodes ce
1950 JOIN episodes e ON e.memory_id = ce.memory_id
1951 WHERE ce.cluster_id = ?1
1952 AND e.status = 'active'
1953 ORDER BY e.ts_ms DESC
1954 LIMIT ?2",
1955 )?;
1956 let mapped = stmt
1957 .query_map(rusqlite::params![&cluster_id, limit], |r| {
1958 Ok(ExpandedEpisode {
1959 memory_id: r.get(0)?,
1960 ts_ms: r.get(1)?,
1961 content: r.get(2)?,
1962 })
1963 })?
1964 .collect::<rusqlite::Result<Vec<_>>>()?;
1965 Ok::<_, rusqlite::Error>(mapped)
1966 })
1967 .await
1968 .map_err(ApiError::from)?;
1969
1970 if rows.is_empty() {
1971 ensure_cluster_exists(tenant, &cluster_id_for_err, &node_id_full).await?;
1972 return Ok(GraphExpandResponse {
1973 nodes: Vec::new(),
1974 edges: Vec::new(),
1975 });
1976 }
1977
1978 let mut nodes = Vec::with_capacity(rows.len());
1979 let mut edges = Vec::with_capacity(rows.len());
1980 for ep in rows {
1981 let target_id = format!("ep:{}", ep.memory_id);
1982 edges.push(GraphEdge {
1983 id: edge_id(&node_id_full, "cluster_member", &target_id),
1984 source: node_id_full.clone(),
1985 target: target_id,
1986 kind: "cluster_member",
1987 predicate: None,
1988 weight: None,
1989 });
1990 nodes.push(graph_node_for_episode(tenant_id, &ep));
1991 }
1992 Ok(GraphExpandResponse { nodes, edges })
1993}
1994
1995async fn expand_document_chunk(
1998 tenant: &TenantHandle,
1999 tenant_id: &str,
2000 node_kind: NodeKind,
2001 value: &str,
2002 node_id_full: &str,
2003 limit: i64,
2004) -> Result<GraphExpandResponse, ApiError> {
2005 match node_kind {
2006 NodeKind::Document => expand_document_chunk_from_document(
2007 tenant,
2008 tenant_id,
2009 value.to_string(),
2010 node_id_full.to_string(),
2011 limit,
2012 )
2013 .await,
2014 NodeKind::Chunk => expand_document_chunk_from_chunk(
2015 tenant,
2016 tenant_id,
2017 value.to_string(),
2018 node_id_full.to_string(),
2019 )
2020 .await,
2021 _ => Err(ApiError::bad_request(format!(
2022 "kind=document_chunk only valid for document or chunk source nodes; got {}",
2023 node_kind.as_wire_str()
2024 ))),
2025 }
2026}
2027
2028async fn expand_document_chunk_from_document(
2029 tenant: &TenantHandle,
2030 tenant_id: &str,
2031 doc_id: String,
2032 node_id_full: String,
2033 limit: i64,
2034) -> Result<GraphExpandResponse, ApiError> {
2035 let doc_id_for_err = doc_id.clone();
2036 let rows: Vec<ExpandedChunk> = tenant
2037 .read()
2038 .interact(move |conn| {
2039 let exists: i64 = conn.query_row(
2040 "SELECT COUNT(*) FROM documents WHERE doc_id = ?1",
2041 rusqlite::params![&doc_id],
2042 |r| r.get(0),
2043 )?;
2044 if exists == 0 {
2045 return Ok(Vec::new());
2046 }
2047 let mut stmt = conn.prepare(
2048 "SELECT chunk_id, chunk_index, content
2049 FROM document_chunks
2050 WHERE doc_id = ?1
2051 ORDER BY chunk_index ASC
2052 LIMIT ?2",
2053 )?;
2054 let mapped = stmt
2055 .query_map(rusqlite::params![&doc_id, limit], |r| {
2056 Ok(ExpandedChunk {
2057 chunk_id: r.get(0)?,
2058 chunk_index: r.get(1)?,
2059 content: r.get(2)?,
2060 })
2061 })?
2062 .collect::<rusqlite::Result<Vec<_>>>()?;
2063 Ok::<_, rusqlite::Error>(mapped)
2064 })
2065 .await
2066 .map_err(ApiError::from)?;
2067
2068 if rows.is_empty() {
2069 ensure_document_exists(tenant, &doc_id_for_err, &node_id_full).await?;
2070 return Ok(GraphExpandResponse {
2071 nodes: Vec::new(),
2072 edges: Vec::new(),
2073 });
2074 }
2075
2076 let mut nodes = Vec::with_capacity(rows.len());
2077 let mut edges = Vec::with_capacity(rows.len());
2078 for c in rows {
2079 let target_id = format!("chunk:{}", c.chunk_id);
2080 edges.push(GraphEdge {
2081 id: edge_id(&node_id_full, "document_chunk", &target_id),
2082 source: node_id_full.clone(),
2083 target: target_id,
2084 kind: "document_chunk",
2085 predicate: None,
2086 weight: None,
2087 });
2088 nodes.push(graph_node_for_chunk(tenant_id, &c));
2089 }
2090 Ok(GraphExpandResponse { nodes, edges })
2091}
2092
2093async fn expand_document_chunk_from_chunk(
2094 tenant: &TenantHandle,
2095 tenant_id: &str,
2096 chunk_id: String,
2097 node_id_full: String,
2098) -> Result<GraphExpandResponse, ApiError> {
2099 let chunk_id_for_err = chunk_id.clone();
2100 let row: Option<ExpandedDocument> = tenant
2101 .read()
2102 .interact(move |conn| {
2103 conn.query_row(
2104 "SELECT d.doc_id, d.title, d.source, d.ingested_at_ms
2105 FROM document_chunks c
2106 JOIN documents d ON d.doc_id = c.doc_id
2107 WHERE c.chunk_id = ?1",
2108 rusqlite::params![&chunk_id],
2109 |r| {
2110 Ok(ExpandedDocument {
2111 doc_id: r.get(0)?,
2112 title: r.get(1)?,
2113 source: r.get(2)?,
2114 ingested_at_ms: r.get(3)?,
2115 })
2116 },
2117 )
2118 .map(Some)
2119 .or_else(|e| match e {
2120 rusqlite::Error::QueryReturnedNoRows => Ok(None),
2121 other => Err(other),
2122 })
2123 })
2124 .await
2125 .map_err(ApiError::from)?;
2126
2127 let d = row.ok_or_else(|| {
2128 ApiError::not_found(format!(
2129 "node_id {node_id_full:?} (chunk_id {chunk_id_for_err}) not found in current tenant"
2130 ))
2131 })?;
2132 let target_id = format!("doc:{}", d.doc_id);
2133 let edge = GraphEdge {
2134 id: edge_id(&node_id_full, "document_chunk", &target_id),
2135 source: node_id_full.clone(),
2136 target: target_id,
2137 kind: "document_chunk",
2138 predicate: None,
2139 weight: None,
2140 };
2141 let node = graph_node_for_document(tenant_id, &d);
2142 Ok(GraphExpandResponse {
2143 nodes: vec![node],
2144 edges: vec![edge],
2145 })
2146}
2147
2148async fn expand_triple(
2151 tenant: &TenantHandle,
2152 tenant_id: &str,
2153 node_kind: NodeKind,
2154 value: &str,
2155 node_id_full: &str,
2156 limit: i64,
2157) -> Result<GraphExpandResponse, ApiError> {
2158 match node_kind {
2159 NodeKind::Episode => expand_triple_from_episode(
2160 tenant,
2161 tenant_id,
2162 value.to_string(),
2163 node_id_full.to_string(),
2164 limit,
2165 )
2166 .await,
2167 NodeKind::Entity => expand_triple_from_entity(
2168 tenant,
2169 tenant_id,
2170 value.to_string(),
2171 node_id_full.to_string(),
2172 limit,
2173 )
2174 .await,
2175 _ => Err(ApiError::bad_request(format!(
2176 "kind=triple only valid for episode or entity source nodes; got {}",
2177 node_kind.as_wire_str()
2178 ))),
2179 }
2180}
2181
2182#[derive(Debug)]
2183struct TripleRow {
2184 subject_id: String,
2185 predicate: String,
2186 object_id: String,
2187 confidence: f32,
2188}
2189
2190async fn expand_triple_from_episode(
2191 tenant: &TenantHandle,
2192 tenant_id: &str,
2193 memory_id: String,
2194 node_id_full: String,
2195 limit: i64,
2196) -> Result<GraphExpandResponse, ApiError> {
2197 let memory_id_for_err = memory_id.clone();
2198 let rows: Vec<TripleRow> = tenant
2199 .read()
2200 .interact(move |conn| {
2201 let rowid_opt: Option<i64> = conn
2203 .query_row(
2204 "SELECT rowid FROM episodes WHERE memory_id = ?1",
2205 rusqlite::params![&memory_id],
2206 |r| r.get(0),
2207 )
2208 .map(Some)
2209 .or_else(|e| match e {
2210 rusqlite::Error::QueryReturnedNoRows => Ok(None),
2211 other => Err(other),
2212 })?;
2213 let Some(rowid) = rowid_opt else {
2214 return Ok(Vec::new());
2215 };
2216 let mut stmt = conn.prepare(
2217 "SELECT subject_id, predicate, object_id, confidence
2218 FROM triples
2219 WHERE source_episode_id = ?1
2220 AND status = 'active'
2221 ORDER BY valid_from_ms DESC
2222 LIMIT ?2",
2223 )?;
2224 let mapped = stmt
2225 .query_map(rusqlite::params![rowid, limit], |r| {
2226 Ok(TripleRow {
2227 subject_id: r.get(0)?,
2228 predicate: r.get(1)?,
2229 object_id: r.get(2)?,
2230 confidence: r.get(3)?,
2231 })
2232 })?
2233 .collect::<rusqlite::Result<Vec<_>>>()?;
2234 Ok::<_, rusqlite::Error>(mapped)
2235 })
2236 .await
2237 .map_err(ApiError::from)?;
2238
2239 if rows.is_empty() {
2240 ensure_episode_exists(tenant, &memory_id_for_err, &node_id_full).await?;
2241 return Ok(GraphExpandResponse {
2242 nodes: Vec::new(),
2243 edges: Vec::new(),
2244 });
2245 }
2246
2247 let mut nodes = Vec::new();
2248 let mut edges = Vec::new();
2249 let mut seen_entities: std::collections::HashSet<String> = Default::default();
2250 for t in rows {
2251 let subj_id = format!("ent:{}", t.subject_id);
2262 let obj_id = format!("ent:{}", t.object_id);
2263 if seen_entities.insert(t.subject_id.clone()) {
2264 nodes.push(graph_node_for_entity(tenant_id, &t.subject_id));
2265 }
2266 if seen_entities.insert(t.object_id.clone()) {
2267 nodes.push(graph_node_for_entity(tenant_id, &t.object_id));
2268 }
2269 edges.push(GraphEdge {
2270 id: edge_id(&subj_id, "triple", &obj_id),
2271 source: subj_id,
2272 target: obj_id,
2273 kind: "triple",
2274 predicate: Some(t.predicate),
2275 weight: Some(t.confidence),
2276 });
2277 }
2278 Ok(GraphExpandResponse { nodes, edges })
2279}
2280
2281async fn expand_triple_from_entity(
2282 tenant: &TenantHandle,
2283 tenant_id: &str,
2284 entity_value: String,
2285 node_id_full: String,
2286 limit: i64,
2287) -> Result<GraphExpandResponse, ApiError> {
2288 let entity_q = entity_value.clone();
2291 let rows: Vec<ExpandedEpisode> = tenant
2292 .read()
2293 .interact(move |conn| {
2294 let mut stmt = conn.prepare(
2297 "SELECT DISTINCT e.memory_id, e.ts_ms, e.content
2298 FROM triples t
2299 JOIN episodes e ON e.rowid = t.source_episode_id
2300 WHERE (t.subject_id = ?1 OR t.object_id = ?1)
2301 AND t.status = 'active'
2302 AND t.source_episode_id IS NOT NULL
2303 AND e.status = 'active'
2304 ORDER BY e.ts_ms DESC
2305 LIMIT ?2",
2306 )?;
2307 let mapped = stmt
2308 .query_map(rusqlite::params![&entity_q, limit], |r| {
2309 Ok(ExpandedEpisode {
2310 memory_id: r.get(0)?,
2311 ts_ms: r.get(1)?,
2312 content: r.get(2)?,
2313 })
2314 })?
2315 .collect::<rusqlite::Result<Vec<_>>>()?;
2316 Ok::<_, rusqlite::Error>(mapped)
2317 })
2318 .await
2319 .map_err(ApiError::from)?;
2320
2321 let mut nodes = Vec::with_capacity(rows.len());
2324 let mut edges = Vec::with_capacity(rows.len());
2325 for ep in rows {
2326 let target_id = format!("ep:{}", ep.memory_id);
2327 edges.push(GraphEdge {
2328 id: edge_id(&node_id_full, "triple", &target_id),
2329 source: node_id_full.clone(),
2330 target: target_id,
2331 kind: "triple",
2332 predicate: None,
2333 weight: None,
2334 });
2335 nodes.push(graph_node_for_episode(tenant_id, &ep));
2336 }
2337 let _ = entity_value;
2339 Ok(GraphExpandResponse { nodes, edges })
2340}
2341
2342async fn expand_semantic(
2345 tenant: &TenantHandle,
2346 tenant_id: &str,
2347 node_kind: NodeKind,
2348 value: &str,
2349 node_id_full: &str,
2350 limit: i64,
2351) -> Result<GraphExpandResponse, ApiError> {
2352 if node_kind != NodeKind::Episode {
2353 return Err(ApiError::bad_request(format!(
2354 "kind=semantic only valid for episode source nodes; got {}",
2355 node_kind.as_wire_str()
2356 )));
2357 }
2358 let memory_id = value.to_string();
2359 let memory_id_q = memory_id.clone();
2360 let content: Option<String> = tenant
2365 .read()
2366 .interact(move |conn| {
2367 conn.query_row(
2368 "SELECT content FROM episodes WHERE memory_id = ?1 AND status = 'active'",
2369 rusqlite::params![&memory_id_q],
2370 |r| r.get::<_, String>(0),
2371 )
2372 .map(Some)
2373 .or_else(|e| match e {
2374 rusqlite::Error::QueryReturnedNoRows => Ok(None),
2375 other => Err(other),
2376 })
2377 })
2378 .await
2379 .map_err(ApiError::from)?;
2380
2381 let content = content.ok_or_else(|| {
2382 ApiError::not_found(format!(
2383 "node_id {node_id_full:?} (memory_id {memory_id}) not found in current tenant"
2384 ))
2385 })?;
2386
2387 let widened = (limit as usize).saturating_add(1).min(100);
2390 let result = solo_query::recall::run_recall_inner(
2391 tenant.embedder(),
2392 tenant.hnsw(),
2393 tenant.read(),
2394 &content,
2395 widened,
2396 )
2397 .await
2398 .map_err(ApiError::from)?;
2399
2400 let mut nodes = Vec::new();
2401 let mut edges = Vec::new();
2402 for hit in result.hits.into_iter() {
2403 if hit.memory_id == memory_id {
2404 continue;
2406 }
2407 if nodes.len() as i64 >= limit {
2408 break;
2409 }
2410 let weight = (1.0 - hit.cos_distance).max(0.0);
2414 let target_id = format!("ep:{}", hit.memory_id);
2415 edges.push(GraphEdge {
2416 id: edge_id(node_id_full, "semantic", &target_id),
2417 source: node_id_full.to_string(),
2418 target: target_id,
2419 kind: "semantic",
2420 predicate: None,
2421 weight: Some(weight),
2422 });
2423 nodes.push(GraphNode {
2424 id: format!("ep:{}", hit.memory_id),
2425 kind: NodeKind::Episode.as_wire_str(),
2426 label: episode_label(&hit.content),
2427 ts_ms: None,
2428 tenant_id: tenant_id.to_string(),
2429 preview: Some(truncate_preview(&hit.content, GRAPH_PREVIEW_CHARS)),
2430 });
2431 }
2432 Ok(GraphExpandResponse { nodes, edges })
2433}
2434
2435async fn ensure_episode_exists(
2439 tenant: &TenantHandle,
2440 memory_id: &str,
2441 node_id_full: &str,
2442) -> Result<(), ApiError> {
2443 let memory_id_q = memory_id.to_string();
2444 let exists: i64 = tenant
2445 .read()
2446 .interact(move |conn| {
2447 conn.query_row(
2448 "SELECT COUNT(*) FROM episodes WHERE memory_id = ?1",
2449 rusqlite::params![&memory_id_q],
2450 |r| r.get(0),
2451 )
2452 })
2453 .await
2454 .map_err(ApiError::from)?;
2455 if exists == 0 {
2456 return Err(ApiError::not_found(format!(
2457 "node_id {node_id_full:?} not found in current tenant"
2458 )));
2459 }
2460 Ok(())
2461}
2462
2463async fn ensure_cluster_exists(
2464 tenant: &TenantHandle,
2465 cluster_id: &str,
2466 node_id_full: &str,
2467) -> Result<(), ApiError> {
2468 let cluster_id_q = cluster_id.to_string();
2469 let exists: i64 = tenant
2470 .read()
2471 .interact(move |conn| {
2472 conn.query_row(
2473 "SELECT COUNT(*) FROM clusters WHERE cluster_id = ?1",
2474 rusqlite::params![&cluster_id_q],
2475 |r| r.get(0),
2476 )
2477 })
2478 .await
2479 .map_err(ApiError::from)?;
2480 if exists == 0 {
2481 return Err(ApiError::not_found(format!(
2482 "node_id {node_id_full:?} not found in current tenant"
2483 )));
2484 }
2485 Ok(())
2486}
2487
2488async fn ensure_document_exists(
2489 tenant: &TenantHandle,
2490 doc_id: &str,
2491 node_id_full: &str,
2492) -> Result<(), ApiError> {
2493 let doc_id_q = doc_id.to_string();
2494 let exists: i64 = tenant
2495 .read()
2496 .interact(move |conn| {
2497 conn.query_row(
2498 "SELECT COUNT(*) FROM documents WHERE doc_id = ?1",
2499 rusqlite::params![&doc_id_q],
2500 |r| r.get(0),
2501 )
2502 })
2503 .await
2504 .map_err(ApiError::from)?;
2505 if exists == 0 {
2506 return Err(ApiError::not_found(format!(
2507 "node_id {node_id_full:?} not found in current tenant"
2508 )));
2509 }
2510 Ok(())
2511}
2512
2513const GRAPH_NODES_DEFAULT_LIMIT: u32 = 100;
2527const GRAPH_NODES_MAX_LIMIT: u32 = 1000;
2528const GRAPH_EDGES_DEFAULT_LIMIT: u32 = 200;
2529const GRAPH_EDGES_MAX_LIMIT: u32 = 2000;
2530const GRAPH_ENTITY_CAP: usize = 200;
2531
2532const ENTITY_CAP_HEADER: &str = "x-solo-entity-cap-reached";
2536
2537#[derive(Debug, Deserialize)]
2538struct GraphNodesQuery {
2539 #[serde(default)]
2544 kind: Option<String>,
2545 #[serde(default)]
2546 since_ms: Option<i64>,
2547 #[serde(default)]
2548 until_ms: Option<i64>,
2549 #[serde(default)]
2550 limit: Option<u32>,
2551 #[serde(default)]
2552 cursor: Option<String>,
2553}
2554
2555#[derive(Debug, Deserialize)]
2556struct GraphEdgesQuery {
2557 #[serde(default)]
2558 node_id: Option<String>,
2559 #[serde(default)]
2562 r#type: Option<String>,
2563 #[serde(default)]
2564 limit: Option<u32>,
2565 #[serde(default)]
2566 cursor: Option<String>,
2567}
2568
2569#[derive(Debug, Serialize)]
2570struct GraphNodesResponse {
2571 nodes: Vec<GraphNode>,
2572 #[serde(skip_serializing_if = "Option::is_none")]
2573 next_cursor: Option<String>,
2574}
2575
2576#[derive(Debug, Serialize)]
2577struct GraphEdgesResponse {
2578 edges: Vec<GraphEdge>,
2579 #[serde(skip_serializing_if = "Option::is_none")]
2580 next_cursor: Option<String>,
2581}
2582
2583fn parse_node_kind_filter(raw: Option<&str>) -> Result<Vec<NodeKind>, ApiError> {
2587 let raw = raw.unwrap_or("").trim();
2588 if raw.is_empty() {
2589 return Ok(vec![
2590 NodeKind::Episode,
2591 NodeKind::Document,
2592 NodeKind::Chunk,
2593 NodeKind::Cluster,
2594 NodeKind::Entity,
2595 ]);
2596 }
2597 let mut out = Vec::new();
2598 for token in raw.split(',') {
2599 let token = token.trim();
2600 if token.is_empty() {
2601 continue;
2602 }
2603 let kind = match token {
2604 "episode" => NodeKind::Episode,
2605 "document" => NodeKind::Document,
2606 "chunk" => NodeKind::Chunk,
2607 "cluster" => NodeKind::Cluster,
2608 "entity" => NodeKind::Entity,
2609 other => {
2610 return Err(ApiError::bad_request(format!(
2611 "unknown node kind {other:?}; expected one of episode/document/chunk/cluster/entity"
2612 )));
2613 }
2614 };
2615 if !out.contains(&kind) {
2616 out.push(kind);
2617 }
2618 }
2619 if out.is_empty() {
2620 return Err(ApiError::bad_request(
2621 "kind filter is empty after parsing; either omit or list at least one kind",
2622 ));
2623 }
2624 Ok(out)
2625}
2626
2627#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2629enum EdgeKind {
2630 Triple,
2631 DocumentChunk,
2632 ClusterMember,
2633}
2634
2635impl EdgeKind {
2636 fn order_idx(self) -> u8 {
2638 match self {
2639 Self::Triple => 0,
2640 Self::DocumentChunk => 1,
2641 Self::ClusterMember => 2,
2642 }
2643 }
2644}
2645
2646fn parse_edge_kind_filter(raw: Option<&str>) -> Result<Vec<EdgeKind>, ApiError> {
2647 let raw = raw.unwrap_or("").trim();
2648 if raw.is_empty() {
2649 return Ok(vec![
2652 EdgeKind::Triple,
2653 EdgeKind::DocumentChunk,
2654 EdgeKind::ClusterMember,
2655 ]);
2656 }
2657 let mut out = Vec::new();
2658 for token in raw.split(',') {
2659 let token = token.trim();
2660 if token.is_empty() {
2661 continue;
2662 }
2663 let kind = match token {
2664 "triple" => EdgeKind::Triple,
2665 "document_chunk" => EdgeKind::DocumentChunk,
2666 "cluster_member" => EdgeKind::ClusterMember,
2667 "semantic" => {
2668 return Err(ApiError::bad_request(
2671 "semantic edges are available via /v1/graph/neighbors/:id?kind=semantic, not /v1/graph/edges (semantic edges aren't precomputed; they're query-time HNSW lookups)",
2672 ));
2673 }
2674 other => {
2675 return Err(ApiError::bad_request(format!(
2676 "unknown edge type {other:?}; expected one of triple/document_chunk/cluster_member"
2677 )));
2678 }
2679 };
2680 if !out.contains(&kind) {
2681 out.push(kind);
2682 }
2683 }
2684 if out.is_empty() {
2685 return Err(ApiError::bad_request(
2686 "type filter is empty after parsing; either omit or list at least one type",
2687 ));
2688 }
2689 Ok(out)
2690}
2691
2692#[derive(Debug, Serialize, Deserialize)]
2696struct NodesCursor {
2697 ts_ms: i64,
2698 id: String,
2699}
2700
2701#[derive(Debug, Serialize, Deserialize)]
2707struct EdgesCursor {
2708 kind_idx: u8,
2709 sub_id: String,
2710}
2711
2712fn encode_cursor<T: Serialize>(value: &T) -> Result<String, ApiError> {
2713 use base64::Engine;
2714 let json = serde_json::to_vec(value).map_err(|e| {
2715 ApiError::internal(format!("cursor serialize: {e}"))
2716 })?;
2717 Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json))
2718}
2719
2720fn decode_cursor<T: for<'de> Deserialize<'de>>(raw: &str) -> Result<T, ApiError> {
2721 use base64::Engine;
2722 let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
2723 .decode(raw.as_bytes())
2724 .map_err(|e| ApiError::bad_request(format!("cursor: bad base64: {e}")))?;
2725 serde_json::from_slice::<T>(&bytes)
2726 .map_err(|e| ApiError::bad_request(format!("cursor: bad JSON payload: {e}")))
2727}
2728
2729#[derive(Debug)]
2733struct StagingNode {
2734 node: GraphNode,
2735 sort_ts_ms: i64,
2736 sort_id: String,
2737}
2738
2739fn cmp_node_sort_keys(a: (i64, &str), b: (i64, &str)) -> std::cmp::Ordering {
2742 match b.0.cmp(&a.0) {
2744 std::cmp::Ordering::Equal => a.1.cmp(b.1), other => other,
2746 }
2747}
2748
2749fn node_passes_cursor(ts_ms: i64, id: &str, cursor: &NodesCursor) -> bool {
2753 cmp_node_sort_keys((ts_ms, id), (cursor.ts_ms, cursor.id.as_str()))
2754 == std::cmp::Ordering::Greater
2755}
2756
2757#[derive(Debug)]
2761struct NodeRowEp {
2762 memory_id: String,
2763 ts_ms: i64,
2764 content: String,
2765}
2766
2767fn fetch_episodes_for_nodes(
2768 conn: &rusqlite::Connection,
2769 since_ms: Option<i64>,
2770 until_ms: Option<i64>,
2771 cursor: Option<&NodesCursor>,
2772 limit: i64,
2773) -> rusqlite::Result<Vec<NodeRowEp>> {
2774 let mut sql = String::from(
2775 "SELECT memory_id, ts_ms, content
2776 FROM episodes
2777 WHERE status = 'active'",
2778 );
2779 let mut params: Vec<rusqlite::types::Value> = Vec::new();
2780 if let Some(s) = since_ms {
2781 sql.push_str(" AND ts_ms >= ?");
2782 params.push(s.into());
2783 }
2784 if let Some(u) = until_ms {
2785 sql.push_str(" AND ts_ms <= ?");
2786 params.push(u.into());
2787 }
2788 if let Some(cur) = cursor {
2795 sql.push_str(" AND ts_ms <= ?");
2796 params.push(cur.ts_ms.into());
2797 }
2798 sql.push_str(" ORDER BY ts_ms DESC, memory_id ASC LIMIT ?");
2799 params.push(limit.into());
2800 let mut stmt = conn.prepare(&sql)?;
2801 let rows: Vec<NodeRowEp> = stmt
2802 .query_map(rusqlite::params_from_iter(params), |r| {
2803 Ok(NodeRowEp {
2804 memory_id: r.get(0)?,
2805 ts_ms: r.get(1)?,
2806 content: r.get(2)?,
2807 })
2808 })?
2809 .collect::<rusqlite::Result<Vec<_>>>()?;
2810 Ok(rows)
2811}
2812
2813#[derive(Debug)]
2814struct NodeRowDoc {
2815 doc_id: String,
2816 title: Option<String>,
2817 source: Option<String>,
2818 ingested_at_ms: i64,
2819}
2820
2821fn fetch_documents_for_nodes(
2822 conn: &rusqlite::Connection,
2823 since_ms: Option<i64>,
2824 until_ms: Option<i64>,
2825 cursor: Option<&NodesCursor>,
2826 limit: i64,
2827) -> rusqlite::Result<Vec<NodeRowDoc>> {
2828 let mut sql = String::from(
2829 "SELECT doc_id, title, source, ingested_at_ms
2830 FROM documents
2831 WHERE status = 'active'",
2832 );
2833 let mut params: Vec<rusqlite::types::Value> = Vec::new();
2834 if let Some(s) = since_ms {
2835 sql.push_str(" AND ingested_at_ms >= ?");
2836 params.push(s.into());
2837 }
2838 if let Some(u) = until_ms {
2839 sql.push_str(" AND ingested_at_ms <= ?");
2840 params.push(u.into());
2841 }
2842 if let Some(cur) = cursor {
2843 sql.push_str(" AND ingested_at_ms <= ?");
2844 params.push(cur.ts_ms.into());
2845 }
2846 sql.push_str(" ORDER BY ingested_at_ms DESC, doc_id ASC LIMIT ?");
2847 params.push(limit.into());
2848 let mut stmt = conn.prepare(&sql)?;
2849 let rows: Vec<NodeRowDoc> = stmt
2850 .query_map(rusqlite::params_from_iter(params), |r| {
2851 Ok(NodeRowDoc {
2852 doc_id: r.get(0)?,
2853 title: r.get(1)?,
2854 source: r.get(2)?,
2855 ingested_at_ms: r.get(3)?,
2856 })
2857 })?
2858 .collect::<rusqlite::Result<Vec<_>>>()?;
2859 Ok(rows)
2860}
2861
2862#[derive(Debug)]
2863struct NodeRowChunk {
2864 chunk_id: String,
2865 chunk_index: i64,
2866 content: String,
2867 created_at_ms: i64,
2868}
2869
2870fn fetch_chunks_for_nodes(
2871 conn: &rusqlite::Connection,
2872 since_ms: Option<i64>,
2873 until_ms: Option<i64>,
2874 cursor: Option<&NodesCursor>,
2875 limit: i64,
2876) -> rusqlite::Result<Vec<NodeRowChunk>> {
2877 let mut sql = String::from(
2880 "SELECT c.chunk_id, c.chunk_index, c.content, c.created_at_ms
2881 FROM document_chunks c
2882 JOIN documents d ON d.doc_id = c.doc_id
2883 WHERE d.status = 'active'",
2884 );
2885 let mut params: Vec<rusqlite::types::Value> = Vec::new();
2886 if let Some(s) = since_ms {
2887 sql.push_str(" AND c.created_at_ms >= ?");
2888 params.push(s.into());
2889 }
2890 if let Some(u) = until_ms {
2891 sql.push_str(" AND c.created_at_ms <= ?");
2892 params.push(u.into());
2893 }
2894 if let Some(cur) = cursor {
2895 sql.push_str(" AND c.created_at_ms <= ?");
2896 params.push(cur.ts_ms.into());
2897 }
2898 sql.push_str(" ORDER BY c.created_at_ms DESC, c.chunk_id ASC LIMIT ?");
2899 params.push(limit.into());
2900 let mut stmt = conn.prepare(&sql)?;
2901 let rows: Vec<NodeRowChunk> = stmt
2902 .query_map(rusqlite::params_from_iter(params), |r| {
2903 Ok(NodeRowChunk {
2904 chunk_id: r.get(0)?,
2905 chunk_index: r.get(1)?,
2906 content: r.get(2)?,
2907 created_at_ms: r.get(3)?,
2908 })
2909 })?
2910 .collect::<rusqlite::Result<Vec<_>>>()?;
2911 Ok(rows)
2912}
2913
2914#[derive(Debug)]
2915struct NodeRowCluster {
2916 cluster_id: String,
2917 abstraction: Option<String>,
2918 created_at_ms: i64,
2919}
2920
2921fn fetch_clusters_for_nodes(
2922 conn: &rusqlite::Connection,
2923 since_ms: Option<i64>,
2924 until_ms: Option<i64>,
2925 cursor: Option<&NodesCursor>,
2926 limit: i64,
2927) -> rusqlite::Result<Vec<NodeRowCluster>> {
2928 let mut sql = String::from(
2931 "SELECT c.cluster_id, sa.content, c.created_at_ms
2932 FROM clusters c
2933 LEFT JOIN semantic_abstractions sa ON sa.cluster_id = c.cluster_id
2934 WHERE 1=1",
2935 );
2936 let mut params: Vec<rusqlite::types::Value> = Vec::new();
2937 if let Some(s) = since_ms {
2938 sql.push_str(" AND c.created_at_ms >= ?");
2939 params.push(s.into());
2940 }
2941 if let Some(u) = until_ms {
2942 sql.push_str(" AND c.created_at_ms <= ?");
2943 params.push(u.into());
2944 }
2945 if let Some(cur) = cursor {
2946 sql.push_str(" AND c.created_at_ms <= ?");
2947 params.push(cur.ts_ms.into());
2948 }
2949 sql.push_str(" ORDER BY c.created_at_ms DESC, c.cluster_id ASC LIMIT ?");
2950 params.push(limit.into());
2951 let mut stmt = conn.prepare(&sql)?;
2952 let rows: Vec<NodeRowCluster> = stmt
2953 .query_map(rusqlite::params_from_iter(params), |r| {
2954 Ok(NodeRowCluster {
2955 cluster_id: r.get(0)?,
2956 abstraction: r.get(1)?,
2957 created_at_ms: r.get(2)?,
2958 })
2959 })?
2960 .collect::<rusqlite::Result<Vec<_>>>()?;
2961 Ok(rows)
2962}
2963
2964#[derive(Debug)]
2965struct NodeRowEntity {
2966 value: String,
2967 ref_count: i64,
2968 first_seen_ms: i64,
2969}
2970
2971fn fetch_entities_for_nodes(
2980 conn: &rusqlite::Connection,
2981 since_ms: Option<i64>,
2982 until_ms: Option<i64>,
2983 cursor: Option<&NodesCursor>,
2984) -> rusqlite::Result<(Vec<NodeRowEntity>, bool)> {
2985 let mut sql = String::from(
2990 "WITH all_refs AS (
2991 SELECT subject_id AS value, valid_from_ms AS ts_ms FROM triples WHERE status = 'active'
2992 UNION ALL
2993 SELECT object_id AS value, valid_from_ms AS ts_ms FROM triples WHERE status = 'active'
2994 )
2995 SELECT value, COUNT(*) AS ref_count, MIN(ts_ms) AS first_seen_ms
2996 FROM all_refs
2997 WHERE 1=1",
2998 );
2999 let mut params: Vec<rusqlite::types::Value> = Vec::new();
3000 if let Some(s) = since_ms {
3001 sql.push_str(" AND ts_ms >= ?");
3002 params.push(s.into());
3003 }
3004 if let Some(u) = until_ms {
3005 sql.push_str(" AND ts_ms <= ?");
3006 params.push(u.into());
3007 }
3008 sql.push_str(" GROUP BY value");
3012 if let Some(ts) = cursor.map(|c| c.ts_ms) {
3013 sql.push_str(" HAVING MIN(ts_ms) <= ?");
3014 params.push(ts.into());
3015 }
3016 let want = GRAPH_ENTITY_CAP as i64 + 1;
3018 sql.push_str(" ORDER BY ref_count DESC, value ASC LIMIT ?");
3019 params.push(want.into());
3020 let mut stmt = conn.prepare(&sql)?;
3021 let rows: Vec<NodeRowEntity> = stmt
3022 .query_map(rusqlite::params_from_iter(params), |r| {
3023 Ok(NodeRowEntity {
3024 value: r.get(0)?,
3025 ref_count: r.get(1)?,
3026 first_seen_ms: r.get(2)?,
3027 })
3028 })?
3029 .collect::<rusqlite::Result<Vec<_>>>()?;
3030 let cap_reached = rows.len() > GRAPH_ENTITY_CAP;
3031 let mut trimmed = rows;
3032 if cap_reached {
3033 trimmed.truncate(GRAPH_ENTITY_CAP);
3034 }
3035 Ok((trimmed, cap_reached))
3036}
3037
3038async fn graph_nodes_handler(
3041 TenantExtractor(tenant): TenantExtractor,
3042 Query(q): Query<GraphNodesQuery>,
3043) -> Result<Response, ApiError> {
3044 let limit = q.limit.unwrap_or(GRAPH_NODES_DEFAULT_LIMIT);
3045 let limit = limit.clamp(1, GRAPH_NODES_MAX_LIMIT);
3046 let kinds = parse_node_kind_filter(q.kind.as_deref())?;
3047 let since_ms = q.since_ms;
3048 let until_ms = q.until_ms;
3049 if let (Some(s), Some(u)) = (since_ms, until_ms) {
3050 if s > u {
3051 return Err(ApiError::bad_request(format!(
3052 "since_ms ({s}) must be <= until_ms ({u})"
3053 )));
3054 }
3055 }
3056 let cursor = match q.cursor.as_deref() {
3057 None => None,
3058 Some("") => None,
3059 Some(raw) => Some(decode_cursor::<NodesCursor>(raw)?),
3060 };
3061 let want_episode = kinds.contains(&NodeKind::Episode);
3062 let want_document = kinds.contains(&NodeKind::Document);
3063 let want_chunk = kinds.contains(&NodeKind::Chunk);
3064 let want_cluster = kinds.contains(&NodeKind::Cluster);
3065 let want_entity = kinds.contains(&NodeKind::Entity);
3066
3067 let per_kind_limit = (limit as i64).saturating_add(2);
3076 let tenant_id_for_blocking = tenant.tenant_id().to_string();
3077 let cursor_clone = cursor.as_ref().map(|c| NodesCursor {
3078 ts_ms: c.ts_ms,
3079 id: c.id.clone(),
3080 });
3081
3082 let (mut staged, cap_reached) = tenant
3083 .read()
3084 .interact(move |conn| {
3085 let mut staged: Vec<StagingNode> = Vec::new();
3086 let mut cap_reached = false;
3087 let cursor_ref = cursor_clone.as_ref();
3088
3089 if want_episode {
3090 let eps = fetch_episodes_for_nodes(conn, since_ms, until_ms, cursor_ref, per_kind_limit)?;
3091 for ep in eps {
3092 let id = format!("ep:{}", ep.memory_id);
3093 let exp = ExpandedEpisode {
3094 memory_id: ep.memory_id,
3095 ts_ms: ep.ts_ms,
3096 content: ep.content,
3097 };
3098 let node = graph_node_for_episode(&tenant_id_for_blocking, &exp);
3099 staged.push(StagingNode {
3100 sort_ts_ms: ep.ts_ms,
3101 sort_id: id.clone(),
3102 node,
3103 });
3104 }
3105 }
3106 if want_document {
3107 let docs = fetch_documents_for_nodes(conn, since_ms, until_ms, cursor_ref, per_kind_limit)?;
3108 for d in docs {
3109 let id = format!("doc:{}", d.doc_id);
3110 let exp = ExpandedDocument {
3111 doc_id: d.doc_id,
3112 title: d.title,
3113 source: d.source,
3114 ingested_at_ms: d.ingested_at_ms,
3115 };
3116 let node = graph_node_for_document(&tenant_id_for_blocking, &exp);
3117 staged.push(StagingNode {
3118 sort_ts_ms: d.ingested_at_ms,
3119 sort_id: id.clone(),
3120 node,
3121 });
3122 }
3123 }
3124 if want_chunk {
3125 let chunks = fetch_chunks_for_nodes(conn, since_ms, until_ms, cursor_ref, per_kind_limit)?;
3126 for c in chunks {
3127 let id = format!("chunk:{}", c.chunk_id);
3128 let exp = ExpandedChunk {
3129 chunk_id: c.chunk_id,
3130 chunk_index: c.chunk_index,
3131 content: c.content,
3132 };
3133 let mut node = graph_node_for_chunk(&tenant_id_for_blocking, &exp);
3138 node.ts_ms = Some(c.created_at_ms);
3139 staged.push(StagingNode {
3140 sort_ts_ms: c.created_at_ms,
3141 sort_id: id.clone(),
3142 node,
3143 });
3144 }
3145 }
3146 if want_cluster {
3147 let cls = fetch_clusters_for_nodes(conn, since_ms, until_ms, cursor_ref, per_kind_limit)?;
3148 for c in cls {
3149 let id = format!("cl:{}", c.cluster_id);
3150 let node = graph_node_for_cluster(
3151 &tenant_id_for_blocking,
3152 &c.cluster_id,
3153 c.abstraction.as_deref(),
3154 c.created_at_ms,
3155 );
3156 staged.push(StagingNode {
3157 sort_ts_ms: c.created_at_ms,
3158 sort_id: id.clone(),
3159 node,
3160 });
3161 }
3162 }
3163 if want_entity {
3164 let (ents, was_cap_reached) =
3165 fetch_entities_for_nodes(conn, since_ms, until_ms, cursor_ref)?;
3166 cap_reached = was_cap_reached;
3167 for e in ents {
3168 let id = format!("ent:{}", e.value);
3169 let mut node = graph_node_for_entity(&tenant_id_for_blocking, &e.value);
3170 node.ts_ms = Some(e.first_seen_ms);
3171 node.preview =
3172 Some(format!("Referenced in {} triples", e.ref_count));
3173 staged.push(StagingNode {
3174 sort_ts_ms: e.first_seen_ms,
3175 sort_id: id.clone(),
3176 node,
3177 });
3178 }
3179 }
3180 Ok::<_, rusqlite::Error>((staged, cap_reached))
3181 })
3182 .await
3183 .map_err(ApiError::from)?;
3184
3185 if let Some(cur) = &cursor {
3187 staged.retain(|s| node_passes_cursor(s.sort_ts_ms, &s.sort_id, cur));
3188 }
3189
3190 staged.sort_by(|a, b| {
3192 cmp_node_sort_keys((a.sort_ts_ms, &a.sort_id), (b.sort_ts_ms, &b.sort_id))
3193 });
3194
3195 let limit_us = limit as usize;
3197 let next_cursor = if staged.len() > limit_us {
3198 let last = &staged[limit_us - 1];
3199 Some(NodesCursor {
3200 ts_ms: last.sort_ts_ms,
3201 id: last.sort_id.clone(),
3202 })
3203 } else {
3204 None
3205 };
3206 staged.truncate(limit_us);
3207
3208 let next_cursor_str = match next_cursor {
3209 Some(c) => Some(encode_cursor(&c)?),
3210 None => None,
3211 };
3212
3213 let nodes: Vec<GraphNode> = staged.into_iter().map(|s| s.node).collect();
3214 let payload = GraphNodesResponse {
3215 nodes,
3216 next_cursor: next_cursor_str,
3217 };
3218
3219 let mut response = Json(payload).into_response();
3222 if cap_reached {
3223 response
3224 .headers_mut()
3225 .insert(ENTITY_CAP_HEADER, HeaderValue::from_static("true"));
3226 }
3227 Ok(response)
3228}
3229
3230#[derive(Debug)]
3233struct StagingEdge {
3234 edge: GraphEdge,
3235 kind_idx: u8,
3236 sub_id: String,
3237}
3238
3239fn cmp_edge_sort_keys(a: (u8, &str), b: (u8, &str)) -> std::cmp::Ordering {
3240 match a.0.cmp(&b.0) {
3241 std::cmp::Ordering::Equal => a.1.cmp(b.1),
3242 other => other,
3243 }
3244}
3245
3246fn edge_passes_cursor(kind_idx: u8, sub_id: &str, cursor: &EdgesCursor) -> bool {
3247 cmp_edge_sort_keys((kind_idx, sub_id), (cursor.kind_idx, cursor.sub_id.as_str()))
3248 == std::cmp::Ordering::Greater
3249}
3250
3251fn edge_touches_focus(
3255 kind: EdgeKind,
3256 focus_kind: NodeKind,
3257 focus_value: &str,
3258 src_value: &str,
3259 tgt_value: &str,
3260 extra_value: Option<&str>,
3261) -> bool {
3262 match kind {
3265 EdgeKind::Triple => match focus_kind {
3266 NodeKind::Episode => src_value == focus_value,
3271 NodeKind::Entity => {
3272 tgt_value == focus_value
3273 || extra_value.map(|x| x == focus_value).unwrap_or(false)
3274 || src_value == focus_value
3275 }
3276 _ => false,
3277 },
3278 EdgeKind::DocumentChunk => match focus_kind {
3279 NodeKind::Document => src_value == focus_value,
3280 NodeKind::Chunk => tgt_value == focus_value,
3281 _ => false,
3282 },
3283 EdgeKind::ClusterMember => match focus_kind {
3284 NodeKind::Cluster => src_value == focus_value,
3285 NodeKind::Episode => tgt_value == focus_value,
3286 _ => false,
3287 },
3288 }
3289}
3290
3291#[derive(Debug)]
3292struct EdgeRowTriple {
3293 triple_id: String,
3294 source_memory_id: Option<String>,
3295 object_id: String,
3296 predicate: String,
3297 confidence: f32,
3298}
3299
3300fn fetch_triple_edges(conn: &rusqlite::Connection) -> rusqlite::Result<Vec<EdgeRowTriple>> {
3301 let safety_cap = (GRAPH_EDGES_MAX_LIMIT as i64) * 4;
3307 let mut stmt = conn.prepare(
3308 "SELECT t.triple_id, e.memory_id, t.object_id, t.predicate, t.confidence
3309 FROM triples t
3310 LEFT JOIN episodes e ON e.rowid = t.source_episode_id
3311 WHERE t.status = 'active'
3312 ORDER BY t.triple_id ASC
3313 LIMIT ?1",
3314 )?;
3315 let rows: Vec<EdgeRowTriple> = stmt
3316 .query_map(rusqlite::params![safety_cap], |r| {
3317 Ok(EdgeRowTriple {
3318 triple_id: r.get(0)?,
3319 source_memory_id: r.get::<_, Option<String>>(1)?,
3320 object_id: r.get(2)?,
3321 predicate: r.get(3)?,
3322 confidence: r.get(4)?,
3323 })
3324 })?
3325 .collect::<rusqlite::Result<Vec<_>>>()?;
3326 Ok(rows)
3327}
3328
3329#[derive(Debug)]
3330struct EdgeRowDocChunk {
3331 chunk_id: String,
3332 doc_id: String,
3333}
3334
3335fn fetch_document_chunk_edges(
3336 conn: &rusqlite::Connection,
3337) -> rusqlite::Result<Vec<EdgeRowDocChunk>> {
3338 let safety_cap = (GRAPH_EDGES_MAX_LIMIT as i64) * 4;
3339 let mut stmt = conn.prepare(
3340 "SELECT c.chunk_id, c.doc_id
3341 FROM document_chunks c
3342 JOIN documents d ON d.doc_id = c.doc_id
3343 WHERE d.status = 'active'
3344 ORDER BY c.chunk_id ASC
3345 LIMIT ?1",
3346 )?;
3347 let rows: Vec<EdgeRowDocChunk> = stmt
3348 .query_map(rusqlite::params![safety_cap], |r| {
3349 Ok(EdgeRowDocChunk {
3350 chunk_id: r.get(0)?,
3351 doc_id: r.get(1)?,
3352 })
3353 })?
3354 .collect::<rusqlite::Result<Vec<_>>>()?;
3355 Ok(rows)
3356}
3357
3358#[derive(Debug)]
3359struct EdgeRowClusterMember {
3360 cluster_id: String,
3361 memory_id: String,
3362}
3363
3364fn fetch_cluster_member_edges(
3365 conn: &rusqlite::Connection,
3366) -> rusqlite::Result<Vec<EdgeRowClusterMember>> {
3367 let safety_cap = (GRAPH_EDGES_MAX_LIMIT as i64) * 4;
3368 let mut stmt = conn.prepare(
3369 "SELECT ce.cluster_id, ce.memory_id
3370 FROM cluster_episodes ce
3371 JOIN episodes e ON e.memory_id = ce.memory_id
3372 WHERE e.status = 'active'
3373 ORDER BY ce.cluster_id ASC, ce.memory_id ASC
3374 LIMIT ?1",
3375 )?;
3376 let rows: Vec<EdgeRowClusterMember> = stmt
3377 .query_map(rusqlite::params![safety_cap], |r| {
3378 Ok(EdgeRowClusterMember {
3379 cluster_id: r.get(0)?,
3380 memory_id: r.get(1)?,
3381 })
3382 })?
3383 .collect::<rusqlite::Result<Vec<_>>>()?;
3384 Ok(rows)
3385}
3386
3387async fn graph_edges_handler(
3390 TenantExtractor(tenant): TenantExtractor,
3391 Query(q): Query<GraphEdgesQuery>,
3392) -> Result<Json<GraphEdgesResponse>, ApiError> {
3393 let limit = q.limit.unwrap_or(GRAPH_EDGES_DEFAULT_LIMIT);
3394 let limit = limit.clamp(1, GRAPH_EDGES_MAX_LIMIT);
3395 let kinds = parse_edge_kind_filter(q.r#type.as_deref())?;
3396 let cursor = match q.cursor.as_deref() {
3397 None => None,
3398 Some("") => None,
3399 Some(raw) => Some(decode_cursor::<EdgesCursor>(raw)?),
3400 };
3401
3402 let focus = match q.node_id.as_deref() {
3403 None => None,
3404 Some(raw) => {
3405 let (kind, value) = parse_node_id(raw)?;
3406 Some((kind, value.to_string()))
3407 }
3408 };
3409
3410 let want_triple = kinds.contains(&EdgeKind::Triple);
3411 let want_doc_chunk = kinds.contains(&EdgeKind::DocumentChunk);
3412 let want_cluster_member = kinds.contains(&EdgeKind::ClusterMember);
3413
3414 let staged: Vec<StagingEdge> = tenant
3415 .read()
3416 .interact(move |conn| {
3417 let mut staged: Vec<StagingEdge> = Vec::new();
3418
3419 if want_triple {
3420 for t in fetch_triple_edges(conn)? {
3421 let src_id = match &t.source_memory_id {
3422 Some(mid) => format!("ep:{mid}"),
3423 None => continue, };
3425 let tgt_id = format!("ent:{}", t.object_id);
3426 if let Some((fk, fv)) = &focus {
3427 if !edge_touches_focus(
3431 EdgeKind::Triple,
3432 *fk,
3433 fv,
3434 t.source_memory_id
3435 .as_deref()
3436 .unwrap_or(""),
3437 &t.object_id,
3438 None,
3444 ) {
3445 continue;
3446 }
3447 }
3448 let edge = GraphEdge {
3449 id: edge_id(&src_id, "triple", &tgt_id),
3450 source: src_id,
3451 target: tgt_id,
3452 kind: "triple",
3453 predicate: Some(t.predicate),
3454 weight: Some(t.confidence),
3455 };
3456 staged.push(StagingEdge {
3457 edge,
3458 kind_idx: EdgeKind::Triple.order_idx(),
3459 sub_id: t.triple_id,
3460 });
3461 }
3462 }
3463 if want_doc_chunk {
3464 for dc in fetch_document_chunk_edges(conn)? {
3465 let src_id = format!("doc:{}", dc.doc_id);
3466 let tgt_id = format!("chunk:{}", dc.chunk_id);
3467 if let Some((fk, fv)) = &focus {
3468 if !edge_touches_focus(
3469 EdgeKind::DocumentChunk,
3470 *fk,
3471 fv,
3472 &dc.doc_id,
3473 &dc.chunk_id,
3474 None,
3475 ) {
3476 continue;
3477 }
3478 }
3479 let edge = GraphEdge {
3480 id: edge_id(&src_id, "document_chunk", &tgt_id),
3481 source: src_id,
3482 target: tgt_id,
3483 kind: "document_chunk",
3484 predicate: None,
3485 weight: None,
3486 };
3487 staged.push(StagingEdge {
3488 edge,
3489 kind_idx: EdgeKind::DocumentChunk.order_idx(),
3490 sub_id: dc.chunk_id,
3491 });
3492 }
3493 }
3494 if want_cluster_member {
3495 for cm in fetch_cluster_member_edges(conn)? {
3496 let src_id = format!("cl:{}", cm.cluster_id);
3497 let tgt_id = format!("ep:{}", cm.memory_id);
3498 if let Some((fk, fv)) = &focus {
3499 if !edge_touches_focus(
3500 EdgeKind::ClusterMember,
3501 *fk,
3502 fv,
3503 &cm.cluster_id,
3504 &cm.memory_id,
3505 None,
3506 ) {
3507 continue;
3508 }
3509 }
3510 let edge = GraphEdge {
3511 id: edge_id(&src_id, "cluster_member", &tgt_id),
3512 source: src_id,
3513 target: tgt_id,
3514 kind: "cluster_member",
3515 predicate: None,
3516 weight: None,
3517 };
3518 let sub_id = format!("{}\u{1f}{}", cm.cluster_id, cm.memory_id);
3519 staged.push(StagingEdge {
3520 edge,
3521 kind_idx: EdgeKind::ClusterMember.order_idx(),
3522 sub_id,
3523 });
3524 }
3525 }
3526 Ok::<_, rusqlite::Error>(staged)
3527 })
3528 .await
3529 .map_err(ApiError::from)?;
3530
3531 let mut staged = staged;
3533 if let Some(cur) = &cursor {
3534 staged.retain(|s| edge_passes_cursor(s.kind_idx, &s.sub_id, cur));
3535 }
3536
3537 staged.sort_by(|a, b| {
3539 cmp_edge_sort_keys((a.kind_idx, &a.sub_id), (b.kind_idx, &b.sub_id))
3540 });
3541
3542 let limit_us = limit as usize;
3543 let next_cursor = if staged.len() > limit_us {
3544 let last = &staged[limit_us - 1];
3545 Some(EdgesCursor {
3546 kind_idx: last.kind_idx,
3547 sub_id: last.sub_id.clone(),
3548 })
3549 } else {
3550 None
3551 };
3552 staged.truncate(limit_us);
3553 let next_cursor_str = match next_cursor {
3554 Some(c) => Some(encode_cursor(&c)?),
3555 None => None,
3556 };
3557
3558 let edges: Vec<GraphEdge> = staged.into_iter().map(|s| s.edge).collect();
3559 Ok(Json(GraphEdgesResponse {
3560 edges,
3561 next_cursor: next_cursor_str,
3562 }))
3563}
3564
3565const GRAPH_INSPECT_ENTITY_TRIPLES_CAP: i64 = 50;
3617
3618#[derive(Debug, Serialize)]
3619struct GraphInspectResponse {
3620 node: GraphNode,
3621 #[serde(skip_serializing_if = "Option::is_none")]
3622 full_text: Option<String>,
3623 triples_in: Vec<GraphEdge>,
3624 triples_out: Vec<GraphEdge>,
3625}
3626
3627async fn graph_inspect_handler(
3629 TenantExtractor(tenant): TenantExtractor,
3630 Path(id): Path<String>,
3631) -> Result<Json<GraphInspectResponse>, ApiError> {
3632 let (kind, value) = parse_node_id(&id)?;
3633 let tenant_id_str = tenant.tenant_id().to_string();
3634 let value = value.to_string();
3635 let node_id_full = id;
3636 match kind {
3637 NodeKind::Episode => {
3638 inspect_episode_node(&tenant, &tenant_id_str, value, node_id_full).await
3639 }
3640 NodeKind::Document => {
3641 inspect_document_node(&tenant, &tenant_id_str, value, node_id_full).await
3642 }
3643 NodeKind::Chunk => {
3644 inspect_chunk_node(&tenant, &tenant_id_str, value, node_id_full).await
3645 }
3646 NodeKind::Cluster => {
3647 inspect_cluster_node(&tenant, &tenant_id_str, value, node_id_full).await
3648 }
3649 NodeKind::Entity => {
3650 inspect_entity_node(&tenant, &tenant_id_str, value, node_id_full).await
3651 }
3652 }
3653 .map(Json)
3654}
3655
3656async fn inspect_episode_node(
3659 tenant: &TenantHandle,
3660 tenant_id: &str,
3661 memory_id: String,
3662 node_id_full: String,
3663) -> Result<GraphInspectResponse, ApiError> {
3664 let memory_id_for_err = memory_id.clone();
3665 let memory_id_q = memory_id.clone();
3666 let fetched: Option<(ExpandedEpisode, Vec<TripleRow>)> = tenant
3669 .read()
3670 .interact(move |conn| {
3671 let ep_row: Option<(i64, i64, String)> = conn
3672 .query_row(
3673 "SELECT rowid, ts_ms, content
3674 FROM episodes
3675 WHERE memory_id = ?1
3676 AND status = 'active'",
3677 rusqlite::params![&memory_id_q],
3678 |r| {
3679 Ok((
3680 r.get::<_, i64>(0)?,
3681 r.get::<_, i64>(1)?,
3682 r.get::<_, String>(2)?,
3683 ))
3684 },
3685 )
3686 .map(Some)
3687 .or_else(|e| match e {
3688 rusqlite::Error::QueryReturnedNoRows => Ok(None),
3689 other => Err(other),
3690 })?;
3691 let Some((rowid, ts_ms, content)) = ep_row else {
3692 return Ok(None);
3693 };
3694 let mut stmt = conn.prepare(
3695 "SELECT subject_id, predicate, object_id, confidence
3696 FROM triples
3697 WHERE source_episode_id = ?1
3698 AND status = 'active'
3699 ORDER BY valid_from_ms DESC",
3700 )?;
3701 let triples = stmt
3702 .query_map(rusqlite::params![rowid], |r| {
3703 Ok(TripleRow {
3704 subject_id: r.get(0)?,
3705 predicate: r.get(1)?,
3706 object_id: r.get(2)?,
3707 confidence: r.get(3)?,
3708 })
3709 })?
3710 .collect::<rusqlite::Result<Vec<_>>>()?;
3711 let ep = ExpandedEpisode {
3712 memory_id: memory_id_q,
3713 ts_ms,
3714 content,
3715 };
3716 Ok::<_, rusqlite::Error>(Some((ep, triples)))
3717 })
3718 .await
3719 .map_err(ApiError::from)?;
3720
3721 let (ep, triples) = fetched.ok_or_else(|| {
3722 ApiError::not_found(format!(
3723 "node_id {node_id_full:?} (memory_id {memory_id_for_err}) not found in current tenant"
3724 ))
3725 })?;
3726
3727 let node = graph_node_for_episode(tenant_id, &ep);
3728 let full_text = Some(ep.content.clone());
3729 let mut triples_out = Vec::with_capacity(triples.len());
3734 for t in triples {
3735 let tgt_id = format!("ent:{}", t.object_id);
3736 triples_out.push(GraphEdge {
3737 id: edge_id(&node_id_full, "triple", &tgt_id),
3738 source: node_id_full.clone(),
3739 target: tgt_id,
3740 kind: "triple",
3741 predicate: Some(t.predicate),
3742 weight: Some(t.confidence),
3743 });
3744 }
3745 Ok(GraphInspectResponse {
3746 node,
3747 full_text,
3748 triples_in: Vec::new(),
3749 triples_out,
3750 })
3751}
3752
3753async fn inspect_document_node(
3754 tenant: &TenantHandle,
3755 tenant_id: &str,
3756 doc_id: String,
3757 node_id_full: String,
3758) -> Result<GraphInspectResponse, ApiError> {
3759 let doc_id_for_err = doc_id.clone();
3760 let doc_id_q = doc_id.clone();
3761 let fetched: Option<(ExpandedDocument, Vec<String>)> = tenant
3767 .read()
3768 .interact(move |conn| {
3769 let doc_row: Option<ExpandedDocument> = conn
3770 .query_row(
3771 "SELECT doc_id, title, source, ingested_at_ms
3772 FROM documents
3773 WHERE doc_id = ?1
3774 AND status = 'active'",
3775 rusqlite::params![&doc_id_q],
3776 |r| {
3777 Ok(ExpandedDocument {
3778 doc_id: r.get(0)?,
3779 title: r.get(1)?,
3780 source: r.get(2)?,
3781 ingested_at_ms: r.get(3)?,
3782 })
3783 },
3784 )
3785 .map(Some)
3786 .or_else(|e| match e {
3787 rusqlite::Error::QueryReturnedNoRows => Ok(None),
3788 other => Err(other),
3789 })?;
3790 let Some(doc) = doc_row else {
3791 return Ok(None);
3792 };
3793 let mut stmt = conn.prepare(
3794 "SELECT content
3795 FROM document_chunks
3796 WHERE doc_id = ?1
3797 ORDER BY chunk_index ASC",
3798 )?;
3799 let chunks = stmt
3800 .query_map(rusqlite::params![&doc_id_q], |r| r.get::<_, String>(0))?
3801 .collect::<rusqlite::Result<Vec<_>>>()?;
3802 Ok::<_, rusqlite::Error>(Some((doc, chunks)))
3803 })
3804 .await
3805 .map_err(ApiError::from)?;
3806
3807 let (doc, chunks) = fetched.ok_or_else(|| {
3808 ApiError::not_found(format!(
3809 "node_id {node_id_full:?} (doc_id {doc_id_for_err}) not found in current tenant"
3810 ))
3811 })?;
3812
3813 let full_text = if chunks.is_empty() {
3814 None
3818 } else {
3819 Some(chunks.join("\n\n"))
3820 };
3821
3822 Ok(GraphInspectResponse {
3823 node: graph_node_for_document(tenant_id, &doc),
3824 full_text,
3825 triples_in: Vec::new(),
3826 triples_out: Vec::new(),
3827 })
3828}
3829
3830async fn inspect_chunk_node(
3831 tenant: &TenantHandle,
3832 tenant_id: &str,
3833 chunk_id: String,
3834 node_id_full: String,
3835) -> Result<GraphInspectResponse, ApiError> {
3836 let chunk_id_for_err = chunk_id.clone();
3837 let chunk_id_q = chunk_id.clone();
3838 let row: Option<(ExpandedChunk, i64)> = tenant
3839 .read()
3840 .interact(move |conn| {
3841 conn.query_row(
3842 "SELECT c.chunk_id, c.chunk_index, c.content, c.created_at_ms
3843 FROM document_chunks c
3844 JOIN documents d ON d.doc_id = c.doc_id
3845 WHERE c.chunk_id = ?1
3846 AND d.status = 'active'",
3847 rusqlite::params![&chunk_id_q],
3848 |r| {
3849 Ok((
3850 ExpandedChunk {
3851 chunk_id: r.get(0)?,
3852 chunk_index: r.get(1)?,
3853 content: r.get(2)?,
3854 },
3855 r.get::<_, i64>(3)?,
3856 ))
3857 },
3858 )
3859 .map(Some)
3860 .or_else(|e| match e {
3861 rusqlite::Error::QueryReturnedNoRows => Ok(None),
3862 other => Err(other),
3863 })
3864 })
3865 .await
3866 .map_err(ApiError::from)?;
3867
3868 let (chunk, created_at_ms) = row.ok_or_else(|| {
3869 ApiError::not_found(format!(
3870 "node_id {node_id_full:?} (chunk_id {chunk_id_for_err}) not found in current tenant"
3871 ))
3872 })?;
3873
3874 let full_text = Some(chunk.content.clone());
3875 let mut node = graph_node_for_chunk(tenant_id, &chunk);
3876 node.ts_ms = Some(created_at_ms);
3879
3880 Ok(GraphInspectResponse {
3881 node,
3882 full_text,
3883 triples_in: Vec::new(),
3884 triples_out: Vec::new(),
3885 })
3886}
3887
3888async fn inspect_cluster_node(
3889 tenant: &TenantHandle,
3890 tenant_id: &str,
3891 cluster_id: String,
3892 node_id_full: String,
3893) -> Result<GraphInspectResponse, ApiError> {
3894 let cluster_id_for_err = cluster_id.clone();
3895 let cluster_id_q = cluster_id.clone();
3896 let row: Option<(Option<String>, i64)> = tenant
3897 .read()
3898 .interact(move |conn| {
3899 conn.query_row(
3900 "SELECT sa.content, c.created_at_ms
3901 FROM clusters c
3902 LEFT JOIN semantic_abstractions sa ON sa.cluster_id = c.cluster_id
3903 WHERE c.cluster_id = ?1",
3904 rusqlite::params![&cluster_id_q],
3905 |r| Ok((r.get::<_, Option<String>>(0)?, r.get::<_, i64>(1)?)),
3906 )
3907 .map(Some)
3908 .or_else(|e| match e {
3909 rusqlite::Error::QueryReturnedNoRows => Ok(None),
3910 other => Err(other),
3911 })
3912 })
3913 .await
3914 .map_err(ApiError::from)?;
3915
3916 let (abstraction, created_at_ms) = row.ok_or_else(|| {
3917 ApiError::not_found(format!(
3918 "node_id {node_id_full:?} (cluster_id {cluster_id_for_err}) not found in current tenant"
3919 ))
3920 })?;
3921
3922 let full_text = match abstraction.as_deref() {
3927 Some(a) => Some(format!("cluster {cluster_id_for_err}\n\n{a}")),
3928 None => Some(format!("cluster {cluster_id_for_err}")),
3929 };
3930
3931 Ok(GraphInspectResponse {
3932 node: graph_node_for_cluster(
3933 tenant_id,
3934 &cluster_id_for_err,
3935 abstraction.as_deref(),
3936 created_at_ms,
3937 ),
3938 full_text,
3939 triples_in: Vec::new(),
3940 triples_out: Vec::new(),
3941 })
3942}
3943
3944async fn inspect_entity_node(
3945 tenant: &TenantHandle,
3946 tenant_id: &str,
3947 entity_value: String,
3948 node_id_full: String,
3949) -> Result<GraphInspectResponse, ApiError> {
3950 let entity_q = entity_value.clone();
3953 let rows: Vec<TripleRow> = tenant
3954 .read()
3955 .interact(move |conn| {
3956 let mut stmt = conn.prepare(
3957 "SELECT subject_id, predicate, object_id, confidence
3958 FROM triples
3959 WHERE (subject_id = ?1 OR object_id = ?1)
3960 AND status = 'active'
3961 ORDER BY valid_from_ms DESC
3962 LIMIT ?2",
3963 )?;
3964 stmt.query_map(
3965 rusqlite::params![&entity_q, GRAPH_INSPECT_ENTITY_TRIPLES_CAP],
3966 |r| {
3967 Ok(TripleRow {
3968 subject_id: r.get(0)?,
3969 predicate: r.get(1)?,
3970 object_id: r.get(2)?,
3971 confidence: r.get(3)?,
3972 })
3973 },
3974 )?
3975 .collect::<rusqlite::Result<Vec<_>>>()
3976 })
3977 .await
3978 .map_err(ApiError::from)?;
3979
3980 if rows.is_empty() {
3981 return Err(ApiError::not_found(format!(
3982 "node_id {node_id_full:?} (entity {entity_value:?}) not found in current tenant -- entities must be referenced by at least one triple to be inspectable"
3983 )));
3984 }
3985
3986 let mut triples_out = Vec::with_capacity(rows.len());
3991 for t in rows {
3992 let other = if t.subject_id == entity_value {
3993 t.object_id
3994 } else {
3995 t.subject_id
3997 };
3998 let tgt_id = format!("ent:{other}");
3999 triples_out.push(GraphEdge {
4000 id: edge_id(&node_id_full, "triple", &tgt_id),
4001 source: node_id_full.clone(),
4002 target: tgt_id,
4003 kind: "triple",
4004 predicate: Some(t.predicate),
4005 weight: Some(t.confidence),
4006 });
4007 }
4008
4009 Ok(GraphInspectResponse {
4010 node: graph_node_for_entity(tenant_id, &entity_value),
4011 full_text: None,
4012 triples_in: Vec::new(),
4013 triples_out,
4014 })
4015}
4016
4017const GRAPH_NEIGHBORS_DEFAULT_LIMIT: u32 = 25;
4084const GRAPH_NEIGHBORS_MAX_LIMIT: u32 = 100;
4086const GRAPH_NEIGHBORS_DEFAULT_THRESHOLD: f32 = 0.75;
4089
4090#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
4093#[serde(rename_all = "snake_case")]
4094enum GraphNeighborsKind {
4095 Explicit,
4096 Semantic,
4097 #[default]
4098 Both,
4099}
4100
4101#[derive(Debug, Deserialize)]
4102struct GraphNeighborsQuery {
4103 #[serde(default)]
4104 kind: Option<GraphNeighborsKind>,
4105 #[serde(default)]
4106 threshold: Option<f32>,
4107 #[serde(default)]
4108 limit: Option<u32>,
4109}
4110
4111async fn graph_neighbors_handler(
4113 TenantExtractor(tenant): TenantExtractor,
4114 Path(id): Path<String>,
4115 Query(q): Query<GraphNeighborsQuery>,
4116) -> Result<Json<GraphExpandResponse>, ApiError> {
4117 let kind = q.kind.unwrap_or_default();
4118 let threshold = q.threshold.unwrap_or(GRAPH_NEIGHBORS_DEFAULT_THRESHOLD);
4119 if !(0.0..=1.0).contains(&threshold) {
4120 return Err(ApiError::bad_request(format!(
4121 "threshold must be in [0.0, 1.0]; got {threshold}"
4122 )));
4123 }
4124 let limit_raw = q.limit.unwrap_or(GRAPH_NEIGHBORS_DEFAULT_LIMIT);
4128 let limit = limit_raw.clamp(1, GRAPH_NEIGHBORS_MAX_LIMIT);
4129
4130 let (node_kind, value) = parse_node_id(&id)?;
4131 let value_owned = value.to_string();
4132 let tenant_id_str = tenant.tenant_id().to_string();
4133 let node_id_full = id;
4134
4135 ensure_neighbors_focal_exists(&tenant, node_kind, &value_owned, &node_id_full).await?;
4142
4143 let (explicit_nodes, explicit_edges) = if matches!(
4145 kind,
4146 GraphNeighborsKind::Explicit | GraphNeighborsKind::Both
4147 ) {
4148 neighbors_explicit(
4149 &tenant,
4150 &tenant_id_str,
4151 node_kind,
4152 &value_owned,
4153 &node_id_full,
4154 limit as i64,
4155 )
4156 .await?
4157 } else {
4158 (Vec::new(), Vec::new())
4159 };
4160
4161 let (semantic_nodes, semantic_edges) = if matches!(
4162 kind,
4163 GraphNeighborsKind::Semantic | GraphNeighborsKind::Both
4164 ) {
4165 match neighbors_semantic(
4166 &tenant,
4167 &tenant_id_str,
4168 node_kind,
4169 &value_owned,
4170 &node_id_full,
4171 limit,
4172 threshold,
4173 )
4174 .await
4175 {
4176 Ok(parts) => parts,
4177 Err(e) => {
4178 if matches!(kind, GraphNeighborsKind::Semantic) {
4189 return Err(e);
4190 }
4191 (Vec::new(), Vec::new())
4192 }
4193 }
4194 } else {
4195 (Vec::new(), Vec::new())
4196 };
4197
4198 let mut explicit_endpoints: std::collections::HashSet<(String, String)> =
4201 std::collections::HashSet::with_capacity(explicit_edges.len());
4202 for e in &explicit_edges {
4203 explicit_endpoints.insert((e.source.clone(), e.target.clone()));
4204 }
4205
4206 let mut nodes: Vec<GraphNode> = Vec::with_capacity(explicit_nodes.len() + semantic_nodes.len());
4207 let mut edges: Vec<GraphEdge> =
4208 Vec::with_capacity(explicit_edges.len() + semantic_edges.len());
4209 let mut seen_node_ids: std::collections::HashSet<String> =
4210 std::collections::HashSet::with_capacity(explicit_nodes.len() + semantic_nodes.len());
4211
4212 for n in explicit_nodes {
4213 if seen_node_ids.insert(n.id.clone()) {
4214 nodes.push(n);
4215 }
4216 }
4217 for e in explicit_edges {
4218 edges.push(e);
4219 }
4220 for n in semantic_nodes {
4221 if seen_node_ids.insert(n.id.clone()) {
4222 nodes.push(n);
4223 }
4224 }
4225 for e in semantic_edges {
4226 if explicit_endpoints.contains(&(e.source.clone(), e.target.clone())) {
4227 continue;
4233 }
4234 edges.push(e);
4235 }
4236
4237 Ok(Json(GraphExpandResponse { nodes, edges }))
4238}
4239
4240async fn ensure_neighbors_focal_exists(
4247 tenant: &TenantHandle,
4248 node_kind: NodeKind,
4249 value: &str,
4250 node_id_full: &str,
4251) -> Result<(), ApiError> {
4252 match node_kind {
4253 NodeKind::Episode => ensure_episode_exists(tenant, value, node_id_full).await,
4254 NodeKind::Cluster => ensure_cluster_exists(tenant, value, node_id_full).await,
4255 NodeKind::Document => ensure_document_exists(tenant, value, node_id_full).await,
4256 NodeKind::Chunk => ensure_chunk_exists(tenant, value, node_id_full).await,
4257 NodeKind::Entity => ensure_entity_referenced(tenant, value, node_id_full).await,
4258 }
4259}
4260
4261async fn ensure_chunk_exists(
4265 tenant: &TenantHandle,
4266 chunk_id: &str,
4267 node_id_full: &str,
4268) -> Result<(), ApiError> {
4269 let chunk_id_q = chunk_id.to_string();
4270 let exists: i64 = tenant
4271 .read()
4272 .interact(move |conn| {
4273 conn.query_row(
4274 "SELECT COUNT(*)
4275 FROM document_chunks c
4276 JOIN documents d ON d.doc_id = c.doc_id
4277 WHERE c.chunk_id = ?1
4278 AND d.status = 'active'",
4279 rusqlite::params![&chunk_id_q],
4280 |r| r.get(0),
4281 )
4282 })
4283 .await
4284 .map_err(ApiError::from)?;
4285 if exists == 0 {
4286 return Err(ApiError::not_found(format!(
4287 "node_id {node_id_full:?} not found in current tenant"
4288 )));
4289 }
4290 Ok(())
4291}
4292
4293async fn ensure_entity_referenced(
4297 tenant: &TenantHandle,
4298 entity_value: &str,
4299 node_id_full: &str,
4300) -> Result<(), ApiError> {
4301 let entity_q = entity_value.to_string();
4302 let exists: i64 = tenant
4303 .read()
4304 .interact(move |conn| {
4305 conn.query_row(
4306 "SELECT COUNT(*)
4307 FROM triples
4308 WHERE (subject_id = ?1 OR object_id = ?1)
4309 AND status = 'active'",
4310 rusqlite::params![&entity_q],
4311 |r| r.get(0),
4312 )
4313 })
4314 .await
4315 .map_err(ApiError::from)?;
4316 if exists == 0 {
4317 return Err(ApiError::not_found(format!(
4318 "node_id {node_id_full:?} (entity {entity_value:?}) not found in current tenant -- entities must be referenced by at least one triple to be neighborable"
4319 )));
4320 }
4321 Ok(())
4322}
4323
4324async fn neighbors_explicit(
4330 tenant: &TenantHandle,
4331 tenant_id: &str,
4332 node_kind: NodeKind,
4333 value: &str,
4334 node_id_full: &str,
4335 limit: i64,
4336) -> Result<(Vec<GraphNode>, Vec<GraphEdge>), ApiError> {
4337 let mut nodes: Vec<GraphNode> = Vec::new();
4338 let mut edges: Vec<GraphEdge> = Vec::new();
4339
4340 match node_kind {
4341 NodeKind::Episode => {
4342 let r1 = expand_cluster_member(tenant, tenant_id, node_kind, value, node_id_full, limit)
4350 .await?;
4351 nodes.extend(r1.nodes);
4352 edges.extend(r1.edges);
4353 let r2 =
4354 expand_triple(tenant, tenant_id, node_kind, value, node_id_full, limit).await?;
4355 nodes.extend(r2.nodes);
4356 edges.extend(r2.edges);
4357 }
4358 NodeKind::Document => {
4359 let r = expand_document_chunk(tenant, tenant_id, node_kind, value, node_id_full, limit)
4362 .await?;
4363 nodes.extend(r.nodes);
4364 edges.extend(r.edges);
4365 }
4366 NodeKind::Chunk => {
4367 let r = expand_document_chunk(tenant, tenant_id, node_kind, value, node_id_full, limit)
4370 .await?;
4371 nodes.extend(r.nodes);
4372 edges.extend(r.edges);
4373 }
4374 NodeKind::Cluster => {
4375 let r = expand_cluster_member(tenant, tenant_id, node_kind, value, node_id_full, limit)
4378 .await?;
4379 nodes.extend(r.nodes);
4380 edges.extend(r.edges);
4381 }
4382 NodeKind::Entity => {
4383 let r =
4386 expand_triple(tenant, tenant_id, node_kind, value, node_id_full, limit).await?;
4387 nodes.extend(r.nodes);
4388 edges.extend(r.edges);
4389 }
4390 }
4391 Ok((nodes, edges))
4392}
4393
4394async fn neighbors_semantic(
4408 tenant: &TenantHandle,
4409 tenant_id: &str,
4410 node_kind: NodeKind,
4411 value: &str,
4412 node_id_full: &str,
4413 limit: u32,
4414 threshold: f32,
4415) -> Result<(Vec<GraphNode>, Vec<GraphEdge>), ApiError> {
4416 match node_kind {
4417 NodeKind::Episode => {
4418 neighbors_semantic_from_episode(
4419 tenant,
4420 tenant_id,
4421 value,
4422 node_id_full,
4423 limit,
4424 threshold,
4425 )
4426 .await
4427 }
4428 NodeKind::Chunk => {
4429 neighbors_semantic_from_chunk(
4430 tenant,
4431 tenant_id,
4432 value,
4433 node_id_full,
4434 limit,
4435 threshold,
4436 )
4437 .await
4438 }
4439 _ => Err(ApiError::bad_request(format!(
4440 "semantic neighbors only valid for episode or chunk source; got {}",
4441 node_kind.as_wire_str()
4442 ))),
4443 }
4444}
4445
4446async fn neighbors_semantic_from_episode(
4447 tenant: &TenantHandle,
4448 tenant_id: &str,
4449 memory_id: &str,
4450 node_id_full: &str,
4451 limit: u32,
4452 threshold: f32,
4453) -> Result<(Vec<GraphNode>, Vec<GraphEdge>), ApiError> {
4454 let memory_id_q = memory_id.to_string();
4455 let memory_id_for_self_excl = memory_id.to_string();
4456 let content: Option<String> = tenant
4457 .read()
4458 .interact(move |conn| {
4459 conn.query_row(
4460 "SELECT content FROM episodes WHERE memory_id = ?1 AND status = 'active'",
4461 rusqlite::params![&memory_id_q],
4462 |r| r.get::<_, String>(0),
4463 )
4464 .map(Some)
4465 .or_else(|e| match e {
4466 rusqlite::Error::QueryReturnedNoRows => Ok(None),
4467 other => Err(other),
4468 })
4469 })
4470 .await
4471 .map_err(ApiError::from)?;
4472
4473 let Some(content) = content else {
4477 return Ok((Vec::new(), Vec::new()));
4478 };
4479
4480 let widened = (limit as usize).saturating_add(1).min(100);
4482 let result = solo_query::recall::run_recall_inner(
4483 tenant.embedder(),
4484 tenant.hnsw(),
4485 tenant.read(),
4486 &content,
4487 widened,
4488 )
4489 .await
4490 .map_err(ApiError::from)?;
4491
4492 let mut nodes = Vec::new();
4493 let mut edges = Vec::new();
4494 for hit in result.hits.into_iter() {
4495 if hit.memory_id == memory_id_for_self_excl {
4496 continue;
4498 }
4499 if nodes.len() as u32 >= limit {
4500 break;
4501 }
4502 let weight = (1.0 - hit.cos_distance).max(0.0);
4503 if weight < threshold {
4504 continue;
4505 }
4506 let target_id = format!("ep:{}", hit.memory_id);
4507 edges.push(GraphEdge {
4508 id: edge_id(node_id_full, "semantic", &target_id),
4509 source: node_id_full.to_string(),
4510 target: target_id,
4511 kind: "semantic",
4512 predicate: None,
4513 weight: Some(weight),
4514 });
4515 nodes.push(GraphNode {
4516 id: format!("ep:{}", hit.memory_id),
4517 kind: NodeKind::Episode.as_wire_str(),
4518 label: episode_label(&hit.content),
4519 ts_ms: None,
4520 tenant_id: tenant_id.to_string(),
4521 preview: Some(truncate_preview(&hit.content, GRAPH_PREVIEW_CHARS)),
4522 });
4523 }
4524 Ok((nodes, edges))
4525}
4526
4527async fn neighbors_semantic_from_chunk(
4528 tenant: &TenantHandle,
4529 tenant_id: &str,
4530 chunk_id: &str,
4531 node_id_full: &str,
4532 limit: u32,
4533 threshold: f32,
4534) -> Result<(Vec<GraphNode>, Vec<GraphEdge>), ApiError> {
4535 let chunk_id_q = chunk_id.to_string();
4536 let chunk_id_for_self_excl = chunk_id.to_string();
4537 let content: Option<String> = tenant
4538 .read()
4539 .interact(move |conn| {
4540 conn.query_row(
4541 "SELECT c.content
4542 FROM document_chunks c
4543 JOIN documents d ON d.doc_id = c.doc_id
4544 WHERE c.chunk_id = ?1
4545 AND d.status = 'active'",
4546 rusqlite::params![&chunk_id_q],
4547 |r| r.get::<_, String>(0),
4548 )
4549 .map(Some)
4550 .or_else(|e| match e {
4551 rusqlite::Error::QueryReturnedNoRows => Ok(None),
4552 other => Err(other),
4553 })
4554 })
4555 .await
4556 .map_err(ApiError::from)?;
4557
4558 let Some(content) = content else {
4559 return Ok((Vec::new(), Vec::new()));
4560 };
4561
4562 let widened = (limit as usize).saturating_add(1).min(100);
4563 let hits = solo_query::doc_search::run_doc_search_inner(
4564 tenant.embedder(),
4565 tenant.hnsw(),
4566 tenant.read(),
4567 &content,
4568 widened,
4569 )
4570 .await
4571 .map_err(ApiError::from)?;
4572
4573 let mut nodes = Vec::new();
4574 let mut edges = Vec::new();
4575 for hit in hits.into_iter() {
4576 if hit.chunk_id == chunk_id_for_self_excl {
4577 continue;
4578 }
4579 if nodes.len() as u32 >= limit {
4580 break;
4581 }
4582 let weight = (1.0 - hit.cos_distance).max(0.0);
4583 if weight < threshold {
4584 continue;
4585 }
4586 let target_id = format!("chunk:{}", hit.chunk_id);
4587 edges.push(GraphEdge {
4588 id: edge_id(node_id_full, "semantic", &target_id),
4589 source: node_id_full.to_string(),
4590 target: target_id,
4591 kind: "semantic",
4592 predicate: None,
4593 weight: Some(weight),
4594 });
4595 let exp = ExpandedChunk {
4596 chunk_id: hit.chunk_id.clone(),
4597 chunk_index: hit.chunk_index as i64,
4598 content: hit.content.clone(),
4599 };
4600 nodes.push(graph_node_for_chunk(tenant_id, &exp));
4601 }
4602 Ok((nodes, edges))
4603}
4604
4605pub const STREAM_HEARTBEAT_SECS: u64 = 30;
4646
4647const STREAM_EVENT_INIT: &str = "init";
4650
4651const STREAM_EVENT_INVALIDATE: &str = "invalidate";
4654
4655const STREAM_EVENT_HEARTBEAT: &str = "heartbeat";
4657
4658async fn graph_stream_handler(
4678 TenantExtractor(tenant): TenantExtractor,
4679) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
4680 let rx = tenant.invalidate_sender().subscribe();
4685 let tenant_id = tenant.tenant_id().to_string();
4686 let stream = build_invalidate_stream(rx, tenant_id, STREAM_HEARTBEAT_SECS);
4687 Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(3600)))
4694}
4695
4696struct StreamState {
4700 rx: broadcast::Receiver<InvalidateEvent>,
4701 heartbeat: tokio::time::Interval,
4702 tenant_id: String,
4703 needs_init: bool,
4706}
4707
4708fn build_invalidate_stream(
4716 rx: broadcast::Receiver<InvalidateEvent>,
4717 tenant_id: String,
4718 heartbeat_secs: u64,
4719) -> impl Stream<Item = Result<Event, Infallible>> {
4720 let start_at = tokio::time::Instant::now() + Duration::from_secs(heartbeat_secs);
4726 let heartbeat =
4727 tokio::time::interval_at(start_at, Duration::from_secs(heartbeat_secs));
4728
4729 let state = StreamState {
4730 rx,
4731 heartbeat,
4732 tenant_id,
4733 needs_init: true,
4734 };
4735 futures::stream::unfold(state, move |mut state| async move {
4736 if state.needs_init {
4740 state.needs_init = false;
4741 let init_payload = serde_json::json!({
4742 "connected": true,
4743 "tenant_id": state.tenant_id,
4744 "ts_ms": chrono::Utc::now().timestamp_millis(),
4745 });
4746 let ev = Event::default()
4747 .event(STREAM_EVENT_INIT)
4748 .json_data(init_payload)
4749 .unwrap_or_else(|_| Event::default().event(STREAM_EVENT_INIT));
4750 return Some((Ok::<Event, Infallible>(ev), state));
4751 }
4752 loop {
4753 tokio::select! {
4754 event = state.rx.recv() => {
4755 match event {
4756 Ok(ev) => {
4757 let sse_event = Event::default()
4758 .event(STREAM_EVENT_INVALIDATE)
4759 .json_data(&ev)
4760 .unwrap_or_else(|_| Event::default()
4761 .event(STREAM_EVENT_INVALIDATE));
4762 return Some((Ok::<Event, Infallible>(sse_event), state));
4763 }
4764 Err(broadcast::error::RecvError::Lagged(n)) => {
4765 tracing::warn!(
4766 lagged = n,
4767 "graph stream subscriber lagged; client will \
4768 resync on the next real invalidate"
4769 );
4770 }
4773 Err(broadcast::error::RecvError::Closed) => {
4774 tracing::debug!(
4775 "graph stream broadcast closed; ending SSE stream"
4776 );
4777 return None;
4778 }
4779 }
4780 }
4781 _ = state.heartbeat.tick() => {
4782 let hb_payload = serde_json::json!({
4783 "ts_ms": chrono::Utc::now().timestamp_millis(),
4784 });
4785 let sse_event = Event::default()
4786 .event(STREAM_EVENT_HEARTBEAT)
4787 .json_data(hb_payload)
4788 .unwrap_or_else(|_| Event::default()
4789 .event(STREAM_EVENT_HEARTBEAT));
4790 return Some((Ok::<Event, Infallible>(sse_event), state));
4791 }
4792 }
4793 }
4794 })
4795}
4796
4797#[derive(Debug, Clone, Serialize)]
4891struct TenantListItem {
4892 id: String,
4895 #[serde(skip_serializing_if = "Option::is_none")]
4898 display_name: Option<String>,
4899 created_at_ms: i64,
4901 #[serde(skip_serializing_if = "Option::is_none")]
4905 last_accessed_ms: Option<i64>,
4906 status: TenantStatusJson,
4911 #[serde(skip_serializing_if = "Option::is_none")]
4914 quota_bytes: Option<u64>,
4915 episode_count: Option<i64>,
4922 size_bytes: Option<u64>,
4927 pct_used: Option<f64>,
4932}
4933
4934#[derive(Debug, Clone, Copy, Serialize)]
4941#[serde(rename_all = "snake_case")]
4942enum TenantStatusJson {
4943 Active,
4944}
4945
4946impl From<&solo_storage::TenantStatus> for TenantStatusJson {
4947 fn from(s: &solo_storage::TenantStatus) -> Self {
4948 match s {
4952 solo_storage::TenantStatus::Active => TenantStatusJson::Active,
4953 solo_storage::TenantStatus::PendingMigration
4957 | solo_storage::TenantStatus::PendingDelete => TenantStatusJson::Active,
4958 }
4959 }
4960}
4961
4962#[derive(Debug, Serialize)]
4964struct TenantsListResponse {
4965 tenants: Vec<TenantListItem>,
4966}
4967
4968const TENANTS_COUNT_HYDRATION_CAP: usize = 50;
4978
4979const X_SOLO_TENANTS_COUNT_CAP_HEADER: &str = "x-solo-tenants-count-cap-reached";
4986
4987async fn tenants_list_handler(
5000 State(state): State<SoloHttpState>,
5001 MaybePrincipal(maybe_principal): MaybePrincipal,
5002) -> Result<Response, ApiError> {
5003 let mut records = state.registry.list_active().await.map_err(ApiError::from)?;
5009
5010 records.retain(|r| matches!(r.status, solo_storage::TenantStatus::Active));
5015
5016 let filtered = filter_tenants_for_principal(records, maybe_principal.as_ref());
5021
5022 let cap = TENANTS_COUNT_HYDRATION_CAP;
5027 let costs = state
5028 .registry
5029 .hydrate_tenant_cost_numbers(&filtered, cap)
5030 .await;
5031 let cap_reached = filtered.len() > cap;
5032
5033 let tenants: Vec<TenantListItem> = filtered
5034 .iter()
5035 .zip(costs.iter())
5036 .map(|(r, cost)| {
5037 let pct_used = match (cost.size_bytes, r.quota_bytes) {
5038 (Some(size), Some(quota)) if quota > 0 => {
5039 let raw = (size as f64) * 100.0 / (quota as f64);
5040 Some(raw.min(100.0))
5041 }
5042 _ => None,
5043 };
5044 TenantListItem {
5045 id: r.tenant_id.to_string(),
5046 display_name: r.display_name.clone(),
5047 created_at_ms: r.created_at_ms,
5048 last_accessed_ms: r.last_accessed_ms,
5049 status: TenantStatusJson::from(&r.status),
5050 quota_bytes: r.quota_bytes,
5051 episode_count: cost.episode_count,
5052 size_bytes: cost.size_bytes,
5053 pct_used,
5054 }
5055 })
5056 .collect();
5057
5058 let body = Json(TenantsListResponse { tenants });
5059 if cap_reached {
5060 let mut resp = body.into_response();
5061 resp.headers_mut().insert(
5062 axum::http::HeaderName::from_static(X_SOLO_TENANTS_COUNT_CAP_HEADER),
5063 axum::http::HeaderValue::from_static("true"),
5064 );
5065 Ok(resp)
5066 } else {
5067 Ok(body.into_response())
5068 }
5069}
5070
5071fn filter_tenants_for_principal(
5084 records: Vec<solo_storage::TenantRecord>,
5085 principal: Option<&AuthenticatedPrincipal>,
5086) -> Vec<solo_storage::TenantRecord> {
5087 let Some(p) = principal else {
5088 return records;
5091 };
5092 if is_single_principal_bearer(p) {
5093 return records;
5096 }
5097 let Some(claim) = p.tenant_claim.as_ref() else {
5101 return Vec::new();
5102 };
5103 records
5104 .into_iter()
5105 .filter(|r| r.tenant_id == *claim)
5106 .collect()
5107}
5108
5109fn is_single_principal_bearer(principal: &AuthenticatedPrincipal) -> bool {
5121 principal.subject == "bearer"
5122 && principal.claims.is_null()
5123 && principal.scopes.is_empty()
5124}
5125
5126pub const MCP_STREAM_EVENT_INIT: &str = "init";
5137
5138async fn mcp_http_post_handler(
5158 TenantExtractor(tenant): TenantExtractor,
5159 State(state): State<SoloHttpState>,
5160 AuditPrincipal(principal): AuditPrincipal,
5161 body: axum::body::Bytes,
5162) -> Response {
5163 let request: crate::mcp_dispatch::JsonRpcRequest = match serde_json::from_slice(&body) {
5169 Ok(r) => r,
5170 Err(e) => {
5171 return (
5172 StatusCode::BAD_REQUEST,
5173 Json(serde_json::json!({
5174 "error": format!("invalid JSON-RPC request: {e}"),
5175 "status": 400,
5176 })),
5177 )
5178 .into_response();
5179 }
5180 };
5181 if request.jsonrpc != "2.0" {
5182 return (
5183 StatusCode::BAD_REQUEST,
5184 Json(serde_json::json!({
5185 "error": format!(
5186 "invalid JSON-RPC request: expected jsonrpc=\"2.0\", got {:?}",
5187 request.jsonrpc
5188 ),
5189 "status": 400,
5190 })),
5191 )
5192 .into_response();
5193 }
5194
5195 let dispatcher = crate::mcp_dispatch::McpDispatcher::new(
5197 state.registry.clone(),
5198 tenant,
5199 (*state.user_aliases).clone(),
5200 principal,
5201 );
5202
5203 match dispatcher.dispatch(request).await {
5204 Some(response) => {
5205 (StatusCode::OK, Json(response)).into_response()
5210 }
5211 None => {
5212 StatusCode::ACCEPTED.into_response()
5217 }
5218 }
5219}
5220
5221async fn mcp_http_get_handler(
5233 TenantExtractor(tenant): TenantExtractor,
5234) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
5235 let tenant_id = tenant.tenant_id().to_string();
5236 let stream = build_mcp_init_stream(tenant_id);
5237 Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(3600)))
5243}
5244
5245fn build_mcp_init_stream(
5247 tenant_id: String,
5248) -> impl Stream<Item = Result<Event, Infallible>> {
5249 futures::stream::unfold(Some(tenant_id), move |state| async move {
5250 let Some(tenant_id) = state else {
5251 std::future::pending::<()>().await;
5255 return None;
5256 };
5257 let init_payload = serde_json::json!({
5258 "connected": true,
5259 "tenant_id": tenant_id,
5260 "ts_ms": chrono::Utc::now().timestamp_millis(),
5261 });
5262 let ev = Event::default()
5263 .event(MCP_STREAM_EVENT_INIT)
5264 .json_data(init_payload)
5265 .unwrap_or_else(|_| Event::default().event(MCP_STREAM_EVENT_INIT));
5266 Some((Ok::<Event, Infallible>(ev), None))
5267 })
5268}
5269
5270#[derive(Debug)]
5275pub struct ApiError {
5276 status: StatusCode,
5277 message: String,
5278}
5279
5280impl ApiError {
5281 fn bad_request(msg: impl Into<String>) -> Self {
5282 Self {
5283 status: StatusCode::BAD_REQUEST,
5284 message: msg.into(),
5285 }
5286 }
5287 fn not_found(msg: impl Into<String>) -> Self {
5288 Self {
5289 status: StatusCode::NOT_FOUND,
5290 message: msg.into(),
5291 }
5292 }
5293 fn internal(msg: impl Into<String>) -> Self {
5294 Self {
5295 status: StatusCode::INTERNAL_SERVER_ERROR,
5296 message: msg.into(),
5297 }
5298 }
5299}
5300
5301impl From<solo_core::Error> for ApiError {
5302 fn from(e: solo_core::Error) -> Self {
5303 use solo_core::Error;
5304 match e {
5305 Error::NotFound(msg) => ApiError::not_found(msg),
5306 Error::InvalidInput(msg) => ApiError::bad_request(msg),
5307 Error::Conflict(msg) => Self {
5308 status: StatusCode::CONFLICT,
5309 message: msg,
5310 },
5311 other => ApiError::internal(other.to_string()),
5312 }
5313 }
5314}
5315
5316impl IntoResponse for ApiError {
5317 fn into_response(self) -> Response {
5318 let body = serde_json::json!({
5319 "error": self.message,
5320 "status": self.status.as_u16(),
5321 });
5322 (self.status, Json(body)).into_response()
5323 }
5324}
5325
5326#[cfg(test)]
5330mod handler_tests {
5331 use super::*;
5340 use axum::body::Body;
5341 use axum::http::{Request, StatusCode};
5342 use http_body_util::BodyExt;
5343 use serde_json::{Value, json};
5344 use solo_storage::test_support::StubVectorIndex;
5345 use solo_storage::{
5346 EmbedderConfig, IdentityConfig, KeyMaterial, ReaderPool, SoloConfig,
5347 StubEmbedder, TenantHandle, TenantRegistry, WriterActor, WriterSpawn,
5348 };
5349 use solo_core::VectorIndex;
5350 use std::sync::Arc as StdArc;
5351 use tower::ServiceExt;
5352
5353 fn fake_config(dim: u32) -> SoloConfig {
5354 SoloConfig {
5355 schema_version: 1,
5356 salt_hex: "00000000000000000000000000000000".to_string(),
5357 embedder: EmbedderConfig {
5358 name: "stub".to_string(),
5359 version: "v1".to_string(),
5360 dim,
5361 dtype: "f32".to_string(),
5362 },
5363 identity: IdentityConfig::default(),
5364 documents: solo_storage::DocumentConfig::default(),
5365 auth: None,
5366 audit: solo_storage::AuditSettings::default(),
5367 redaction: solo_storage::RedactionConfig::default(),
5368 llm: None,
5369 triples: solo_storage::TriplesConfig::default(),
5370 sampling: solo_storage::SamplingConfig::default(),
5371 }
5372 }
5373
5374 struct Harness {
5375 router: axum::Router,
5376 _tmp: tempfile::TempDir,
5377 db_path: std::path::PathBuf,
5378 write_handle_extra: Option<solo_storage::WriteHandle>,
5379 join: Option<std::thread::JoinHandle<()>>,
5380 tenant_handle: StdArc<TenantHandle>,
5385 registry: StdArc<TenantRegistry>,
5389 }
5390
5391 impl Harness {
5392 fn invalidate_sender(&self) -> tokio::sync::broadcast::Sender<InvalidateEvent> {
5399 self.tenant_handle.invalidate_sender().clone()
5400 }
5401 }
5402
5403 impl Harness {
5404 fn new(runtime: &tokio::runtime::Runtime) -> Self {
5405 Self::new_with_auth(runtime, None)
5406 }
5407
5408 fn open_db(&self) -> rusqlite::Connection {
5412 solo_storage::test_support::open_test_db_at(&self.db_path)
5413 }
5414
5415 fn new_with_auth(
5416 runtime: &tokio::runtime::Runtime,
5417 bearer_token: Option<String>,
5418 ) -> Self {
5419 Self::new_with_auth_config(
5420 runtime,
5421 bearer_token.map(|token| crate::auth::AuthConfig::Bearer { token }),
5422 )
5423 }
5424
5425 fn new_with_auth_config(
5426 runtime: &tokio::runtime::Runtime,
5427 auth: Option<crate::auth::AuthConfig>,
5428 ) -> Self {
5429 use solo_storage::embedder_registry::{EmbedderIdentity, get_or_insert_embedder_id};
5430
5431 let tmp = tempfile::TempDir::new().unwrap();
5432 let dim = 16usize;
5433 let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
5434 let embedder: StdArc<dyn solo_core::Embedder> =
5435 StdArc::new(StubEmbedder::new("stub", "v1", dim));
5436 let path = tmp.path().join("test.db");
5437
5438 let embedder_id = {
5439 let conn = solo_storage::test_support::open_test_db_at(&path);
5440 get_or_insert_embedder_id(
5441 &conn,
5442 &EmbedderIdentity {
5443 name: "stub".into(),
5444 version: "v1".into(),
5445 dim: dim as u32,
5446 dtype: "f32".into(),
5447 },
5448 )
5449 .unwrap()
5450 };
5451
5452 let conn = solo_storage::test_support::open_test_db_at(&path);
5453 let WriterSpawn { handle, join } = WriterActor::spawn_full(
5454 conn,
5455 hnsw.clone(),
5456 tmp.path().to_path_buf(),
5457 embedder_id,
5458 );
5459 let pool: ReaderPool =
5460 runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
5461
5462 let tenant_id = solo_core::TenantId::default_tenant();
5465 let tenant_handle = StdArc::new(
5466 TenantHandle::from_parts_for_tests(
5467 tenant_id.clone(),
5468 fake_config(dim as u32),
5469 path.clone(),
5470 tmp.path().to_path_buf(),
5471 embedder_id,
5472 hnsw,
5473 embedder.clone(),
5474 handle.clone(),
5475 std::thread::spawn(|| {}),
5481 pool,
5482 ),
5483 );
5484 let tenant_handle_clone = tenant_handle.clone();
5485
5486 let key = KeyMaterial::from_bytes_for_tests([0u8; 32]);
5490 let registry = StdArc::new(TenantRegistry::for_tests_with_single_tenant(
5491 tmp.path().to_path_buf(),
5492 key,
5493 embedder,
5494 tenant_handle,
5495 ));
5496 let registry_clone = registry.clone();
5497
5498 let state = SoloHttpState {
5499 registry,
5500 default_tenant: tenant_id,
5501 user_aliases: Arc::new(Vec::new()),
5502 };
5503 let router = router_with_auth_config(state, auth);
5504 Harness {
5505 router,
5506 _tmp: tmp,
5507 db_path: path,
5508 write_handle_extra: Some(handle),
5509 join: Some(join),
5510 tenant_handle: tenant_handle_clone,
5511 registry: registry_clone,
5512 }
5513 }
5514
5515 fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
5516 let join = self.join.take();
5517 let extra = self.write_handle_extra.take();
5518 let tenant_handle = self.tenant_handle;
5525 let registry = self.registry;
5531 runtime.block_on(async move {
5532 drop(extra);
5533 drop(tenant_handle); drop(registry); drop(self.router); drop(self._tmp);
5537 if let Some(join) = join {
5538 let (tx, rx) = std::sync::mpsc::channel();
5539 std::thread::spawn(move || {
5540 let _ = tx.send(join.join());
5541 });
5542 tokio::task::spawn_blocking(move || {
5543 rx.recv_timeout(std::time::Duration::from_secs(5))
5544 })
5545 .await
5546 .expect("blocking task")
5547 .expect("writer thread did not exit within 5s")
5548 .expect("writer thread panicked");
5549 }
5550 });
5551 }
5552 }
5553
5554 fn rt() -> tokio::runtime::Runtime {
5555 tokio::runtime::Builder::new_multi_thread()
5556 .worker_threads(2)
5557 .enable_all()
5558 .build()
5559 .unwrap()
5560 }
5561
5562 async fn call(
5566 router: axum::Router,
5567 method: &str,
5568 uri: &str,
5569 body: Option<Value>,
5570 ) -> (StatusCode, Value) {
5571 call_with_auth(router, method, uri, body, None).await
5572 }
5573
5574 async fn call_with_auth(
5575 router: axum::Router,
5576 method: &str,
5577 uri: &str,
5578 body: Option<Value>,
5579 auth: Option<&str>,
5580 ) -> (StatusCode, Value) {
5581 let mut req_builder = Request::builder()
5582 .method(method)
5583 .uri(uri)
5584 .header("content-type", "application/json");
5585 if let Some(a) = auth {
5586 req_builder = req_builder.header("authorization", a);
5587 }
5588 let req = if let Some(b) = body {
5589 let bytes = serde_json::to_vec(&b).unwrap();
5590 req_builder.body(Body::from(bytes)).unwrap()
5591 } else {
5592 req_builder = req_builder.header("content-length", "0");
5593 req_builder.body(Body::empty()).unwrap()
5594 };
5595 let resp = router.oneshot(req).await.expect("oneshot");
5596 let status = resp.status();
5597 let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
5598 let v: Value = if body_bytes.is_empty() {
5599 Value::Null
5600 } else {
5601 serde_json::from_slice(&body_bytes).unwrap_or(Value::Null)
5602 };
5603 (status, v)
5604 }
5605
5606 #[test]
5607 fn health_returns_ok() {
5608 let runtime = rt();
5609 let h = Harness::new(&runtime);
5610 let r = h.router.clone();
5611 let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
5612 assert_eq!(status, StatusCode::OK);
5613 h.shutdown(&runtime);
5614 }
5615
5616 #[test]
5621 fn openapi_json_describes_all_endpoints() {
5622 let runtime = rt();
5623 let h = Harness::new(&runtime);
5624 let r = h.router.clone();
5625 let (status, spec) = runtime.block_on(call(r, "GET", "/openapi.json", None));
5626 assert_eq!(status, StatusCode::OK);
5627 assert!(spec.is_object(), "openapi.json must be a JSON object");
5628
5629 assert!(
5631 spec.get("openapi")
5632 .and_then(|v| v.as_str())
5633 .is_some_and(|s| s.starts_with("3.")),
5634 "missing or wrong openapi version: {spec}"
5635 );
5636 assert!(spec.pointer("/info/title").is_some());
5637 assert!(spec.pointer("/info/version").is_some());
5638
5639 let paths = spec
5641 .get("paths")
5642 .and_then(|v| v.as_object())
5643 .expect("paths must be an object");
5644 for expected in [
5645 "/health",
5646 "/openapi.json",
5647 "/memory",
5648 "/memory/search",
5649 "/memory/consolidate",
5650 "/memory/{id}",
5651 "/memory/themes",
5653 "/memory/facts_about",
5654 "/memory/contradictions",
5655 "/memory/clusters/{cluster_id}",
5657 "/memory/documents",
5659 "/memory/documents/search",
5660 "/memory/documents/{id}",
5661 ] {
5662 assert!(
5663 paths.contains_key(expected),
5664 "openapi paths missing {expected}: {paths:?}"
5665 );
5666 }
5667
5668 let docs = paths.get("/memory/documents").expect("/memory/documents");
5671 assert!(docs.get("post").is_some(), "POST /memory/documents undocumented");
5672 assert!(docs.get("get").is_some(), "GET /memory/documents undocumented");
5673
5674 let docid = paths
5677 .get("/memory/documents/{id}")
5678 .expect("/memory/documents/{id}");
5679 assert!(
5680 docid.get("get").is_some(),
5681 "GET /memory/documents/{{id}} undocumented"
5682 );
5683 assert!(
5684 docid.get("delete").is_some(),
5685 "DELETE /memory/documents/{{id}} undocumented"
5686 );
5687
5688 let memid = paths.get("/memory/{id}").expect("memory/{id}");
5691 assert!(memid.get("get").is_some(), "GET /memory/{{id}} undocumented");
5692 assert!(
5693 memid.get("delete").is_some(),
5694 "DELETE /memory/{{id}} undocumented"
5695 );
5696
5697 for schema_name in [
5699 "RememberRequest",
5700 "RememberResponse",
5701 "RecallRequest",
5702 "RecallResult",
5703 "EpisodeRecord",
5704 "ApiError",
5705 "ConsolidationScope",
5706 "ConsolidationReport",
5707 "ThemeHit",
5709 "FactHit",
5710 "ContradictionHit",
5711 "ClusterRecord",
5713 "IngestDocumentRequest",
5715 "IngestReport",
5716 "ForgetDocumentReport",
5717 "SearchDocsRequest",
5718 "DocSearchHit",
5719 "DocumentInspectResult",
5720 "DocumentSummary",
5721 ] {
5722 let ptr = format!("/components/schemas/{schema_name}");
5723 assert!(
5724 spec.pointer(&ptr).is_some(),
5725 "component schema {schema_name} missing"
5726 );
5727 }
5728
5729 assert!(
5731 spec.pointer("/components/securitySchemes/bearerAuth")
5732 .is_some(),
5733 "bearerAuth security scheme missing"
5734 );
5735
5736 h.shutdown(&runtime);
5737 }
5738
5739 #[test]
5743 fn openapi_json_is_exempt_from_bearer_auth() {
5744 let runtime = rt();
5745 let h = Harness::new_with_auth(&runtime, Some("super-secret".into()));
5746 let r = h.router.clone();
5747 let (status, _body) = runtime.block_on(call(r, "GET", "/openapi.json", None));
5749 assert_eq!(status, StatusCode::OK);
5750 h.shutdown(&runtime);
5751 }
5752
5753 #[test]
5754 fn remember_returns_memory_id() {
5755 let runtime = rt();
5756 let h = Harness::new(&runtime);
5757 let r = h.router.clone();
5758 let (status, body) = runtime.block_on(call(
5759 r,
5760 "POST",
5761 "/memory",
5762 Some(json!({ "content": "http harness test" })),
5763 ));
5764 assert_eq!(status, StatusCode::OK);
5765 let mid = body.get("memory_id").and_then(|v| v.as_str()).unwrap();
5766 assert_eq!(mid.len(), 36, "uuid length");
5767 h.shutdown(&runtime);
5768 }
5769
5770 #[test]
5771 fn empty_content_returns_400() {
5772 let runtime = rt();
5773 let h = Harness::new(&runtime);
5774 let r = h.router.clone();
5775 let (status, body) =
5776 runtime.block_on(call(r, "POST", "/memory", Some(json!({ "content": "" }))));
5777 assert_eq!(status, StatusCode::BAD_REQUEST);
5778 assert!(
5779 body.get("error")
5780 .and_then(|e| e.as_str())
5781 .map(|s| s.contains("must not be empty"))
5782 .unwrap_or(false),
5783 "got: {body}"
5784 );
5785 h.shutdown(&runtime);
5786 }
5787
5788 #[test]
5789 fn empty_query_returns_400() {
5790 let runtime = rt();
5791 let h = Harness::new(&runtime);
5792 let r = h.router.clone();
5793 let (status, body) = runtime.block_on(call(
5794 r,
5795 "POST",
5796 "/memory/search",
5797 Some(json!({ "query": "" })),
5798 ));
5799 assert_eq!(status, StatusCode::BAD_REQUEST);
5800 assert!(
5801 body.get("error")
5802 .and_then(|e| e.as_str())
5803 .map(|s| s.contains("must not be empty"))
5804 .unwrap_or(false),
5805 "got: {body}"
5806 );
5807 h.shutdown(&runtime);
5808 }
5809
5810 #[test]
5811 fn inspect_unknown_returns_404() {
5812 let runtime = rt();
5813 let h = Harness::new(&runtime);
5814 let r = h.router.clone();
5815 let (status, body) = runtime.block_on(call(
5816 r,
5817 "GET",
5818 "/memory/00000000-0000-7000-8000-000000000000",
5819 None,
5820 ));
5821 assert_eq!(status, StatusCode::NOT_FOUND);
5822 assert!(body.get("error").is_some(), "got: {body}");
5823 h.shutdown(&runtime);
5824 }
5825
5826 #[test]
5827 fn inspect_invalid_id_returns_400() {
5828 let runtime = rt();
5829 let h = Harness::new(&runtime);
5830 let r = h.router.clone();
5831 let (status, _body) = runtime.block_on(call(r, "GET", "/memory/not-a-uuid", None));
5832 assert_eq!(status, StatusCode::BAD_REQUEST);
5833 h.shutdown(&runtime);
5834 }
5835
5836 #[test]
5837 fn forget_unknown_returns_404() {
5838 let runtime = rt();
5839 let h = Harness::new(&runtime);
5840 let r = h.router.clone();
5841 let (status, _body) = runtime.block_on(call(
5842 r,
5843 "DELETE",
5844 "/memory/00000000-0000-7000-8000-000000000000",
5845 None,
5846 ));
5847 assert_eq!(status, StatusCode::NOT_FOUND);
5848 h.shutdown(&runtime);
5849 }
5850
5851 #[test]
5859 fn consolidate_endpoint_returns_report() {
5860 let runtime = rt();
5861 let h = Harness::new(&runtime);
5862 let r = h.router.clone();
5863 runtime.block_on(async move {
5864 let (status, body) = call(r.clone(), "POST", "/memory/consolidate", None).await;
5866 assert_eq!(status, StatusCode::OK);
5867 for field in [
5868 "episodes_seen",
5869 "clusters_built",
5870 "episodes_clustered",
5871 "abstractions_built",
5872 "triples_built",
5873 "contradictions_found",
5874 ] {
5875 assert!(
5876 body.get(field).and_then(|v| v.as_u64()).is_some(),
5877 "missing field {field}: {body}"
5878 );
5879 }
5880 assert_eq!(body["episodes_seen"], 0);
5881 assert_eq!(body["clusters_built"], 0);
5882
5883 let (status2, _body2) = call(
5886 r,
5887 "POST",
5888 "/memory/consolidate",
5889 Some(json!({ "window_days": 7 })),
5890 )
5891 .await;
5892 assert_eq!(status2, StatusCode::OK);
5893 });
5894 h.shutdown(&runtime);
5895 }
5896
5897 #[test]
5898 fn auth_required_routes_reject_missing_token() {
5899 let runtime = rt();
5900 let h = Harness::new_with_auth(&runtime, Some("secret-xyz".into()));
5901 let r = h.router.clone();
5902 runtime.block_on(async move {
5903 let (status, _body) = call(
5905 r.clone(),
5906 "POST",
5907 "/memory",
5908 Some(json!({ "content": "x" })),
5909 )
5910 .await;
5911 assert_eq!(status, StatusCode::UNAUTHORIZED);
5912
5913 let (status, _body) = call_with_auth(
5915 r.clone(),
5916 "POST",
5917 "/memory",
5918 Some(json!({ "content": "x" })),
5919 Some("Bearer wrong-token"),
5920 )
5921 .await;
5922 assert_eq!(status, StatusCode::UNAUTHORIZED);
5923
5924 let (status, body) = call_with_auth(
5926 r.clone(),
5927 "POST",
5928 "/memory",
5929 Some(json!({ "content": "authed" })),
5930 Some("Bearer secret-xyz"),
5931 )
5932 .await;
5933 assert_eq!(status, StatusCode::OK);
5934 assert!(body.get("memory_id").is_some());
5935 });
5936 h.shutdown(&runtime);
5937 }
5938
5939 #[test]
5940 fn health_endpoint_does_not_require_auth() {
5941 let runtime = rt();
5942 let h = Harness::new_with_auth(&runtime, Some("secret".into()));
5943 let r = h.router.clone();
5944 let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
5945 assert_eq!(status, StatusCode::OK);
5947 h.shutdown(&runtime);
5948 }
5949
5950 #[test]
5951 fn auth_response_includes_www_authenticate_header() {
5952 let runtime = rt();
5957 let h = Harness::new_with_auth(&runtime, Some("secret".into()));
5958 let r = h.router.clone();
5959 runtime.block_on(async move {
5960 let req = Request::builder()
5961 .method("POST")
5962 .uri("/memory")
5963 .header("content-type", "application/json")
5964 .body(Body::from(serde_json::to_vec(&json!({ "content": "x" })).unwrap()))
5965 .unwrap();
5966 let resp = r.oneshot(req).await.unwrap();
5967 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
5968 let www = resp
5969 .headers()
5970 .get("www-authenticate")
5971 .and_then(|v| v.to_str().ok())
5972 .unwrap_or("");
5973 assert!(
5974 www.starts_with("Bearer"),
5975 "expected WWW-Authenticate: Bearer..., got: {www}"
5976 );
5977 });
5978 h.shutdown(&runtime);
5979 }
5980
5981 fn base64_url_for_test(bytes: &[u8]) -> String {
5989 use base64::Engine;
5990 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
5991 }
5992
5993 async fn spin_fake_idp() -> (wiremock::MockServer, String, Vec<u8>, &'static str) {
5996 use wiremock::matchers::{method, path};
5997 use wiremock::{Mock, MockServer, ResponseTemplate};
5998 let server = MockServer::start().await;
5999 let secret = b"http-test-secret-for-hmac-fixture".to_vec();
6000 let kid = "http-test-kid";
6001 let discovery = serde_json::json!({
6002 "issuer": server.uri(),
6003 "jwks_uri": format!("{}/jwks", server.uri()),
6004 });
6005 Mock::given(method("GET"))
6006 .and(path("/.well-known/openid-configuration"))
6007 .respond_with(ResponseTemplate::new(200).set_body_json(discovery))
6008 .mount(&server)
6009 .await;
6010 let jwks = serde_json::json!({
6011 "keys": [
6012 {
6013 "kty": "oct",
6014 "kid": kid,
6015 "alg": "HS256",
6016 "k": base64_url_for_test(&secret),
6017 }
6018 ]
6019 });
6020 Mock::given(method("GET"))
6021 .and(path("/jwks"))
6022 .respond_with(ResponseTemplate::new(200).set_body_json(jwks))
6023 .mount(&server)
6024 .await;
6025 let discovery_url = format!("{}/.well-known/openid-configuration", server.uri());
6026 (server, discovery_url, secret, kid)
6027 }
6028
6029 fn mint_idp_token(
6030 server_uri: &str,
6031 kid: &str,
6032 secret: &[u8],
6033 tenant_claim: &str,
6034 audience: &str,
6035 ) -> String {
6036 use jsonwebtoken::{Algorithm, EncodingKey, Header};
6037 let mut header = Header::new(Algorithm::HS256);
6038 header.kid = Some(kid.to_string());
6039 let now = std::time::SystemTime::now()
6040 .duration_since(std::time::UNIX_EPOCH)
6041 .unwrap()
6042 .as_secs();
6043 let claims = serde_json::json!({
6044 "iss": server_uri,
6045 "sub": "test-user-1",
6046 "aud": audience,
6047 "exp": now + 600,
6048 "iat": now,
6049 "solo_tenant": tenant_claim,
6050 });
6051 jsonwebtoken::encode(&header, &claims, &EncodingKey::from_secret(secret))
6052 .expect("mint token")
6053 }
6054
6055 #[test]
6056 fn http_oidc_accept_resolves_to_tenant_from_claim() {
6057 let runtime = rt();
6058 let (fake_server, discovery_url, secret, kid) =
6059 runtime.block_on(async { spin_fake_idp().await });
6060 let server_uri = fake_server.uri();
6061 let _server_guard = fake_server;
6063
6064 let auth = crate::auth::AuthConfig::Oidc {
6065 discovery_url,
6066 audience: "test-audience".to_string(),
6067 tenant_claim_name: "solo_tenant".to_string(),
6068 };
6069 let h = Harness::new_with_auth_config(&runtime, Some(auth));
6070 let r = h.router.clone();
6071
6072 let token = mint_idp_token(
6074 &server_uri,
6075 kid,
6076 &secret,
6077 "default",
6078 "test-audience",
6079 );
6080
6081 runtime.block_on(async move {
6082 let (status, body) = call_with_auth(
6084 r.clone(),
6085 "POST",
6086 "/memory",
6087 Some(json!({ "content": "oidc-routed content" })),
6088 Some(&format!("Bearer {token}")),
6089 )
6090 .await;
6091 assert_eq!(status, StatusCode::OK, "got body: {body}");
6092 assert!(body.get("memory_id").is_some(), "no memory_id in {body}");
6093 });
6094 h.shutdown(&runtime);
6095 }
6096
6097 #[test]
6098 fn http_oidc_reject_missing_token_returns_401() {
6099 let runtime = rt();
6100 let (fake_server, discovery_url, _secret, _kid) =
6101 runtime.block_on(async { spin_fake_idp().await });
6102 let _server_guard = fake_server;
6103 let auth = crate::auth::AuthConfig::Oidc {
6104 discovery_url,
6105 audience: "test-audience".to_string(),
6106 tenant_claim_name: "solo_tenant".to_string(),
6107 };
6108 let h = Harness::new_with_auth_config(&runtime, Some(auth));
6109 let r = h.router.clone();
6110 runtime.block_on(async move {
6111 let (status, _body) =
6113 call(r.clone(), "POST", "/memory", Some(json!({ "content": "x" }))).await;
6114 assert_eq!(status, StatusCode::UNAUTHORIZED);
6115
6116 let (status, _body) = call_with_auth(
6118 r.clone(),
6119 "POST",
6120 "/memory",
6121 Some(json!({ "content": "x" })),
6122 Some("Bearer not-a-real-jwt"),
6123 )
6124 .await;
6125 assert_eq!(status, StatusCode::UNAUTHORIZED);
6126 });
6127 h.shutdown(&runtime);
6128 }
6129
6130 #[test]
6131 fn full_remember_recall_inspect_forget_round_trip() {
6132 let runtime = rt();
6133 let h = Harness::new(&runtime);
6134 let r = h.router.clone();
6135 runtime.block_on(async move {
6136 let (status, body) = call(
6138 r.clone(),
6139 "POST",
6140 "/memory",
6141 Some(json!({ "content": "round-trip content" })),
6142 )
6143 .await;
6144 assert_eq!(status, StatusCode::OK);
6145 let mid = body
6146 .get("memory_id")
6147 .and_then(|v| v.as_str())
6148 .unwrap()
6149 .to_string();
6150
6151 let (status, body) = call(
6153 r.clone(),
6154 "POST",
6155 "/memory/search",
6156 Some(json!({ "query": "round-trip content", "limit": 5 })),
6157 )
6158 .await;
6159 assert_eq!(status, StatusCode::OK);
6160 let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
6161 assert!(
6162 hits.iter()
6163 .any(|h| h.get("content").and_then(|c| c.as_str())
6164 == Some("round-trip content")),
6165 "expected hit with content; got: {body}"
6166 );
6167
6168 let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
6170 assert_eq!(status, StatusCode::OK);
6171 assert_eq!(body.get("status").and_then(|v| v.as_str()), Some("active"));
6172
6173 let (status, _body) =
6175 call(r.clone(), "DELETE", &format!("/memory/{mid}"), None).await;
6176 assert_eq!(status, StatusCode::NO_CONTENT);
6177
6178 let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
6180 assert_eq!(status, StatusCode::OK);
6181 assert_eq!(
6182 body.get("status").and_then(|v| v.as_str()),
6183 Some("forgotten")
6184 );
6185
6186 let (status, body) = call(
6188 r.clone(),
6189 "POST",
6190 "/memory/search",
6191 Some(json!({ "query": "round-trip content", "limit": 5 })),
6192 )
6193 .await;
6194 assert_eq!(status, StatusCode::OK);
6195 let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
6196 assert!(
6197 hits.iter().all(|h| h.get("memory_id").and_then(|m| m.as_str())
6198 != Some(mid.as_str())),
6199 "forgotten row should be excluded from recall: {body}"
6200 );
6201 });
6202 h.shutdown(&runtime);
6203 }
6204
6205 #[test]
6212 fn themes_endpoint_returns_empty_array_on_empty_db() {
6213 let runtime = rt();
6214 let h = Harness::new(&runtime);
6215 let r = h.router.clone();
6216 let (status, body) =
6217 runtime.block_on(call(r, "GET", "/memory/themes", None));
6218 assert_eq!(status, StatusCode::OK);
6219 assert!(body.is_array(), "expected array, got {body}");
6220 assert_eq!(body.as_array().unwrap().len(), 0);
6221 h.shutdown(&runtime);
6222 }
6223
6224 #[test]
6225 fn themes_endpoint_passes_through_query_params() {
6226 let runtime = rt();
6227 let h = Harness::new(&runtime);
6228 let r = h.router.clone();
6229 let (status, body) = runtime.block_on(call(
6230 r,
6231 "GET",
6232 "/memory/themes?window_days=7&limit=20",
6233 None,
6234 ));
6235 assert_eq!(status, StatusCode::OK);
6236 assert!(body.is_array(), "expected array, got {body}");
6237 h.shutdown(&runtime);
6238 }
6239
6240 #[test]
6241 fn facts_about_endpoint_requires_subject() {
6242 let runtime = rt();
6243 let h = Harness::new(&runtime);
6244 let r = h.router.clone();
6245 let (status, _body) =
6249 runtime.block_on(call(r, "GET", "/memory/facts_about", None));
6250 assert!(
6251 status == StatusCode::BAD_REQUEST
6252 || status == StatusCode::UNPROCESSABLE_ENTITY,
6253 "expected 400 or 422 for missing subject, got {status}"
6254 );
6255 h.shutdown(&runtime);
6256 }
6257
6258 #[test]
6259 fn facts_about_endpoint_rejects_blank_subject() {
6260 let runtime = rt();
6261 let h = Harness::new(&runtime);
6262 let r = h.router.clone();
6263 let (status, body) = runtime.block_on(call(
6266 r,
6267 "GET",
6268 "/memory/facts_about?subject=%20%20",
6269 None,
6270 ));
6271 assert_eq!(status, StatusCode::BAD_REQUEST);
6272 assert!(
6273 body.get("error")
6274 .and_then(|v| v.as_str())
6275 .is_some_and(|s| s.contains("subject")),
6276 "expected error mentioning subject, got {body}"
6277 );
6278 h.shutdown(&runtime);
6279 }
6280
6281 #[test]
6282 fn facts_about_endpoint_returns_empty_array_for_unknown_subject() {
6283 let runtime = rt();
6284 let h = Harness::new(&runtime);
6285 let r = h.router.clone();
6286 let (status, body) = runtime.block_on(call(
6287 r,
6288 "GET",
6289 "/memory/facts_about?subject=NobodyKnows",
6290 None,
6291 ));
6292 assert_eq!(status, StatusCode::OK);
6293 assert_eq!(body.as_array().unwrap().len(), 0);
6294 h.shutdown(&runtime);
6295 }
6296
6297 #[test]
6298 fn facts_about_endpoint_parses_include_as_object_query_param() {
6299 let runtime = rt();
6307 let h = Harness::new(&runtime);
6308 let r = h.router.clone();
6309 let (status, body) = runtime.block_on(call(
6310 r,
6311 "GET",
6312 "/memory/facts_about?subject=Maya&include_as_object=true",
6313 None,
6314 ));
6315 assert_eq!(
6316 status,
6317 StatusCode::OK,
6318 "expected 200 with include_as_object query param, got {status}"
6319 );
6320 assert!(body.is_array());
6321 h.shutdown(&runtime);
6322 }
6323
6324 #[test]
6325 fn inspect_cluster_endpoint_unknown_id_returns_404() {
6326 let runtime = rt();
6330 let h = Harness::new(&runtime);
6331 let r = h.router.clone();
6332 let (status, body) = runtime.block_on(call(
6333 r,
6334 "GET",
6335 "/memory/clusters/no-such-cluster",
6336 None,
6337 ));
6338 assert_eq!(status, StatusCode::NOT_FOUND);
6339 assert!(
6340 body.get("error")
6341 .and_then(|v| v.as_str())
6342 .is_some_and(|s| s.contains("no-such-cluster")),
6343 "expected error mentioning cluster id, got {body}"
6344 );
6345 h.shutdown(&runtime);
6346 }
6347
6348 #[test]
6349 fn inspect_cluster_endpoint_passes_full_content_query_param() {
6350 let runtime = rt();
6356 let h = Harness::new(&runtime);
6357 let r = h.router.clone();
6358 let (status, _body) = runtime.block_on(call(
6359 r,
6360 "GET",
6361 "/memory/clusters/missing?full_content=true",
6362 None,
6363 ));
6364 assert_eq!(status, StatusCode::NOT_FOUND);
6365 h.shutdown(&runtime);
6366 }
6367
6368 #[test]
6369 fn contradictions_endpoint_returns_empty_array_on_empty_db() {
6370 let runtime = rt();
6371 let h = Harness::new(&runtime);
6372 let r = h.router.clone();
6373 let (status, body) = runtime.block_on(call(
6374 r,
6375 "GET",
6376 "/memory/contradictions",
6377 None,
6378 ));
6379 assert_eq!(status, StatusCode::OK);
6380 assert!(body.is_array());
6381 assert_eq!(body.as_array().unwrap().len(), 0);
6382 h.shutdown(&runtime);
6383 }
6384
6385 #[test]
6386 fn derived_endpoints_require_bearer_when_auth_enabled() {
6387 let runtime = rt();
6388 let h = Harness::new_with_auth(&runtime, Some("secret-token".to_string()));
6389 for path in [
6396 "/memory/themes",
6397 "/memory/facts_about?subject=Sam",
6398 "/memory/contradictions",
6399 "/memory/clusters/any-id",
6400 ] {
6401 let (status, _) = runtime.block_on(call(h.router.clone(), "GET", path, None));
6402 assert_eq!(
6403 status,
6404 StatusCode::UNAUTHORIZED,
6405 "{path} should 401 without token"
6406 );
6407 }
6408 h.shutdown(&runtime);
6409 }
6410
6411 #[test]
6423 fn list_documents_endpoint_returns_empty_array_on_empty_db() {
6424 let runtime = rt();
6425 let h = Harness::new(&runtime);
6426 let r = h.router.clone();
6427 let (status, body) = runtime.block_on(call(r, "GET", "/memory/documents", None));
6428 assert_eq!(status, StatusCode::OK);
6429 assert!(body.is_array(), "expected array, got {body}");
6430 assert_eq!(body.as_array().unwrap().len(), 0);
6431 h.shutdown(&runtime);
6432 }
6433
6434 #[test]
6435 fn list_documents_endpoint_parses_query_params() {
6436 let runtime = rt();
6437 let h = Harness::new(&runtime);
6438 let r = h.router.clone();
6439 let (status, body) = runtime.block_on(call(
6440 r,
6441 "GET",
6442 "/memory/documents?limit=5&offset=0&include_forgotten=true",
6443 None,
6444 ));
6445 assert_eq!(status, StatusCode::OK);
6446 assert!(body.is_array());
6447 h.shutdown(&runtime);
6448 }
6449
6450 #[test]
6451 fn ingest_document_endpoint_rejects_empty_path() {
6452 let runtime = rt();
6453 let h = Harness::new(&runtime);
6454 let r = h.router.clone();
6455 let (status, body) = runtime.block_on(call(
6456 r,
6457 "POST",
6458 "/memory/documents",
6459 Some(json!({ "path": "" })),
6460 ));
6461 assert_eq!(status, StatusCode::BAD_REQUEST);
6462 assert!(
6463 body.get("error")
6464 .and_then(|v| v.as_str())
6465 .is_some_and(|s| s.contains("path")),
6466 "expected error mentioning path, got {body}"
6467 );
6468 h.shutdown(&runtime);
6469 }
6470
6471 #[test]
6472 fn search_docs_endpoint_rejects_empty_query() {
6473 let runtime = rt();
6474 let h = Harness::new(&runtime);
6475 let r = h.router.clone();
6476 let (status, body) = runtime.block_on(call(
6477 r,
6478 "POST",
6479 "/memory/documents/search",
6480 Some(json!({ "query": " " })),
6481 ));
6482 assert_eq!(status, StatusCode::BAD_REQUEST);
6483 assert!(
6484 body.get("error")
6485 .and_then(|v| v.as_str())
6486 .is_some_and(|s| s.contains("must not be empty")
6487 || s.contains("doc_search")),
6488 "expected error mentioning empty query, got {body}"
6489 );
6490 h.shutdown(&runtime);
6491 }
6492
6493 #[test]
6494 fn inspect_document_endpoint_unknown_id_returns_404() {
6495 let runtime = rt();
6496 let h = Harness::new(&runtime);
6497 let r = h.router.clone();
6498 let (status, body) = runtime.block_on(call(
6499 r,
6500 "GET",
6501 "/memory/documents/00000000-0000-7000-8000-000000000000",
6502 None,
6503 ));
6504 assert_eq!(status, StatusCode::NOT_FOUND);
6505 assert!(body.get("error").is_some(), "got: {body}");
6506 h.shutdown(&runtime);
6507 }
6508
6509 #[test]
6510 fn inspect_document_endpoint_rejects_malformed_id() {
6511 let runtime = rt();
6512 let h = Harness::new(&runtime);
6513 let r = h.router.clone();
6514 let (status, _body) =
6515 runtime.block_on(call(r, "GET", "/memory/documents/not-a-uuid", None));
6516 assert_eq!(status, StatusCode::BAD_REQUEST);
6517 h.shutdown(&runtime);
6518 }
6519
6520 #[test]
6521 fn forget_document_endpoint_unknown_id_returns_404() {
6522 let runtime = rt();
6525 let h = Harness::new(&runtime);
6526 let r = h.router.clone();
6527 let (status, _body) = runtime.block_on(call(
6528 r,
6529 "DELETE",
6530 "/memory/documents/00000000-0000-7000-8000-000000000000",
6531 None,
6532 ));
6533 assert_eq!(status, StatusCode::NOT_FOUND);
6534 h.shutdown(&runtime);
6535 }
6536
6537 #[test]
6538 fn forget_document_endpoint_rejects_malformed_id() {
6539 let runtime = rt();
6540 let h = Harness::new(&runtime);
6541 let r = h.router.clone();
6542 let (status, _body) =
6543 runtime.block_on(call(r, "DELETE", "/memory/documents/not-a-uuid", None));
6544 assert_eq!(status, StatusCode::BAD_REQUEST);
6545 h.shutdown(&runtime);
6546 }
6547
6548 #[test]
6549 fn document_endpoints_require_bearer_when_auth_enabled() {
6550 let runtime = rt();
6554 let h = Harness::new_with_auth(&runtime, Some("doc-secret".to_string()));
6555 let cases: &[(&str, &str, Option<Value>)] = &[
6556 ("POST", "/memory/documents", Some(json!({ "path": "/x" }))),
6557 ("GET", "/memory/documents", None),
6558 (
6559 "POST",
6560 "/memory/documents/search",
6561 Some(json!({ "query": "x" })),
6562 ),
6563 (
6564 "GET",
6565 "/memory/documents/00000000-0000-7000-8000-000000000000",
6566 None,
6567 ),
6568 (
6569 "DELETE",
6570 "/memory/documents/00000000-0000-7000-8000-000000000000",
6571 None,
6572 ),
6573 ];
6574 for (method, path, body) in cases {
6575 let (status, _) =
6576 runtime.block_on(call(h.router.clone(), method, path, body.clone()));
6577 assert_eq!(
6578 status,
6579 StatusCode::UNAUTHORIZED,
6580 "{method} {path} should 401 without token"
6581 );
6582 }
6583 h.shutdown(&runtime);
6584 }
6585
6586 #[test]
6587 fn document_endpoints_accept_correct_bearer_token() {
6588 let runtime = rt();
6594 let h = Harness::new_with_auth(&runtime, Some("doc-secret".to_string()));
6595 runtime.block_on(async {
6596 let (status, _) = call_with_auth(
6598 h.router.clone(),
6599 "GET",
6600 "/memory/documents",
6601 None,
6602 Some("Bearer doc-secret"),
6603 )
6604 .await;
6605 assert_eq!(status, StatusCode::OK);
6606
6607 let (status, _) = call_with_auth(
6609 h.router.clone(),
6610 "GET",
6611 "/memory/documents/00000000-0000-7000-8000-000000000000",
6612 None,
6613 Some("Bearer doc-secret"),
6614 )
6615 .await;
6616 assert_eq!(status, StatusCode::NOT_FOUND);
6617 });
6618 h.shutdown(&runtime);
6619 }
6620
6621 #[test]
6628 fn tenant_header_default_resolves() {
6629 let runtime = rt();
6630 let h = Harness::new(&runtime);
6631 let r = h.router.clone();
6632 let (status, _body) = runtime.block_on(async {
6633 let req = Request::builder()
6634 .method("GET")
6635 .uri("/memory/00000000-0000-7000-8000-000000000000")
6636 .header("x-solo-tenant", "default")
6637 .body(Body::empty())
6638 .unwrap();
6639 let resp = r.oneshot(req).await.expect("oneshot");
6640 let s = resp.status();
6641 let _b = resp.into_body().collect().await.unwrap().to_bytes();
6642 (s, _b)
6643 });
6644 assert_eq!(status, StatusCode::NOT_FOUND);
6648 h.shutdown(&runtime);
6649 }
6650
6651 #[test]
6653 fn tenant_header_invalid_returns_400() {
6654 let runtime = rt();
6655 let h = Harness::new(&runtime);
6656 let r = h.router.clone();
6657 let (status, body) = runtime.block_on(async {
6658 let req = Request::builder()
6659 .method("GET")
6660 .uri("/memory/00000000-0000-7000-8000-000000000000")
6661 .header("x-solo-tenant", "UPPER")
6662 .body(Body::empty())
6663 .unwrap();
6664 let resp = r.oneshot(req).await.expect("oneshot");
6665 let s = resp.status();
6666 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
6667 let v: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
6668 (s, v)
6669 });
6670 assert_eq!(status, StatusCode::BAD_REQUEST);
6671 let msg = body.get("error").and_then(|e| e.as_str()).unwrap_or("");
6672 assert!(
6673 msg.to_lowercase().contains("tenant") || msg.to_lowercase().contains("invalid"),
6674 "error must mention tenant/invalid: {msg}"
6675 );
6676 h.shutdown(&runtime);
6677 }
6678
6679 #[test]
6681 fn tenant_header_unknown_returns_404() {
6682 let runtime = rt();
6683 let h = Harness::new(&runtime);
6684 let r = h.router.clone();
6685 let (status, _body) = runtime.block_on(async {
6686 let req = Request::builder()
6687 .method("GET")
6688 .uri("/memory/00000000-0000-7000-8000-000000000000")
6689 .header("x-solo-tenant", "never-registered")
6690 .body(Body::empty())
6691 .unwrap();
6692 let resp = r.oneshot(req).await.expect("oneshot");
6693 let s = resp.status();
6694 let _b = resp.into_body().collect().await.unwrap().to_bytes();
6695 (s, _b)
6696 });
6697 assert_eq!(status, StatusCode::NOT_FOUND);
6698 h.shutdown(&runtime);
6699 }
6700
6701 #[test]
6705 fn tenant_header_missing_defaults_to_state_default_tenant() {
6706 let runtime = rt();
6707 let h = Harness::new(&runtime);
6708 let r = h.router.clone();
6709 let (status, _body) = runtime.block_on(async {
6710 let req = Request::builder()
6711 .method("GET")
6712 .uri("/memory/00000000-0000-7000-8000-000000000000")
6713 .body(Body::empty())
6714 .unwrap();
6715 let resp = r.oneshot(req).await.expect("oneshot");
6716 let s = resp.status();
6717 let _b = resp.into_body().collect().await.unwrap().to_bytes();
6718 (s, _b)
6719 });
6720 assert_eq!(status, StatusCode::NOT_FOUND);
6721 h.shutdown(&runtime);
6722 }
6723
6724 fn seed_episode(
6738 conn: &rusqlite::Connection,
6739 memory_id: &str,
6740 ts_ms: i64,
6741 content: &str,
6742 ) -> i64 {
6743 conn.execute(
6744 "INSERT INTO episodes
6745 (memory_id, ts_ms, source_type, content,
6746 encoding_context_json, tier, status,
6747 confidence, strength, salience,
6748 created_at_ms, updated_at_ms)
6749 VALUES (?1, ?2, 'user_message', ?3,
6750 '{}', 'hot', 'active',
6751 1.0, 0.5, 0.5, ?2, ?2)",
6752 rusqlite::params![memory_id, ts_ms, content],
6753 )
6754 .expect("seed episode");
6755 conn.last_insert_rowid()
6756 }
6757
6758 fn seed_cluster_row(conn: &rusqlite::Connection, cluster_id: &str, created_at_ms: i64) {
6759 conn.execute(
6760 "INSERT INTO clusters (cluster_id, coherence, created_at_ms)
6761 VALUES (?1, 0.5, ?2)",
6762 rusqlite::params![cluster_id, created_at_ms],
6763 )
6764 .expect("seed cluster");
6765 }
6766
6767 fn seed_cluster_member(conn: &rusqlite::Connection, cluster_id: &str, memory_id: &str) {
6768 conn.execute(
6769 "INSERT INTO cluster_episodes (cluster_id, memory_id) VALUES (?1, ?2)",
6770 rusqlite::params![cluster_id, memory_id],
6771 )
6772 .expect("seed cluster_episodes");
6773 }
6774
6775 fn seed_document_row(conn: &rusqlite::Connection, doc_id: &str, title: &str) {
6776 conn.execute(
6777 "INSERT INTO documents
6778 (doc_id, source, title, mime_type, ingested_at_ms,
6779 modified_at_ms, status, chunk_count, content_hash, byte_size)
6780 VALUES (?1, ?2, ?3, 'text/plain', 0, NULL,
6781 'active', 0, ?1, NULL)",
6782 rusqlite::params![doc_id, format!("/tmp/{title}.txt"), title],
6783 )
6784 .expect("seed doc");
6785 }
6786
6787 fn seed_chunk_row(
6788 conn: &rusqlite::Connection,
6789 chunk_id: &str,
6790 doc_id: &str,
6791 chunk_index: i64,
6792 content: &str,
6793 ) {
6794 conn.execute(
6795 "INSERT INTO document_chunks
6796 (chunk_id, doc_id, chunk_index, content,
6797 token_count, start_offset, end_offset, created_at_ms)
6798 VALUES (?1, ?2, ?3, ?4, 1, 0, ?5, 0)",
6799 rusqlite::params![chunk_id, doc_id, chunk_index, content, content.len() as i64],
6800 )
6801 .expect("seed chunk");
6802 }
6803
6804 fn seed_triple_row(
6805 conn: &rusqlite::Connection,
6806 triple_id: &str,
6807 subject: &str,
6808 predicate: &str,
6809 object: &str,
6810 source_episode_rowid: Option<i64>,
6811 ) {
6812 conn.execute(
6813 "INSERT INTO triples
6814 (triple_id, subject_id, predicate, object_id, object_kind,
6815 valid_from_ms, valid_to_ms, confidence, provenance_json,
6816 status, created_at_ms, updated_at_ms, source_episode_id)
6817 VALUES (?1, ?2, ?3, ?4, 'literal', 0, NULL, 0.9, '{}',
6818 'active', 0, 0, ?5)",
6819 rusqlite::params![triple_id, subject, predicate, object, source_episode_rowid],
6820 )
6821 .expect("seed triple");
6822 }
6823
6824 fn seed_abstraction_row(
6827 conn: &rusqlite::Connection,
6828 abstraction_id: &str,
6829 cluster_id: &str,
6830 content: &str,
6831 ) {
6832 conn.execute(
6833 "INSERT INTO semantic_abstractions
6834 (abstraction_id, cluster_id, content, provenance_json,
6835 confidence, created_at_ms)
6836 VALUES (?1, ?2, ?3, '{}', 0.9, 0)",
6837 rusqlite::params![abstraction_id, cluster_id, content],
6838 )
6839 .expect("seed abstraction");
6840 }
6841
6842 fn percent_encode_node_id(node_id: &str) -> String {
6845 let mut out = String::with_capacity(node_id.len());
6846 for c in node_id.chars() {
6847 match c {
6848 ':' => out.push_str("%3A"),
6849 ' ' => out.push_str("%20"),
6850 '&' => out.push_str("%26"),
6851 '+' => out.push_str("%2B"),
6852 '?' => out.push_str("%3F"),
6853 '#' => out.push_str("%23"),
6854 _ => out.push(c),
6855 }
6856 }
6857 out
6858 }
6859
6860 fn graph_uri(node_id: &str, kind: &str) -> String {
6861 let encoded = percent_encode_node_id(node_id);
6862 format!("/v1/graph/expand?node_id={encoded}&kind={kind}")
6863 }
6864
6865 fn graph_uri_with_limit(node_id: &str, kind: &str, limit: u32) -> String {
6866 let encoded = percent_encode_node_id(node_id);
6867 format!("/v1/graph/expand?node_id={encoded}&kind={kind}&limit={limit}")
6868 }
6869
6870 #[test]
6871 fn expand_cluster_member_from_episode_returns_clusters() {
6872 let runtime = rt();
6873 let h = Harness::new(&runtime);
6874 let memory_id = "11111111-1111-7000-8000-000000000001";
6875 {
6876 let conn = h.open_db();
6877 seed_episode(&conn, memory_id, 100, "ep content");
6878 seed_cluster_row(&conn, "cl-a", 200);
6879 seed_cluster_member(&conn, "cl-a", memory_id);
6880 }
6881 let node_id = format!("ep:{memory_id}");
6882 let (status, body) = runtime.block_on(call(
6883 h.router.clone(),
6884 "GET",
6885 &graph_uri(&node_id, "cluster_member"),
6886 None,
6887 ));
6888 assert_eq!(status, StatusCode::OK, "body: {body}");
6889 let nodes = body.get("nodes").and_then(|v| v.as_array()).expect("nodes array");
6890 let edges = body.get("edges").and_then(|v| v.as_array()).expect("edges array");
6891 assert_eq!(nodes.len(), 1, "{body}");
6892 assert_eq!(nodes[0]["id"], "cl:cl-a");
6893 assert_eq!(nodes[0]["kind"], "cluster");
6894 assert_eq!(edges.len(), 1);
6895 assert_eq!(edges[0]["source"], node_id);
6896 assert_eq!(edges[0]["target"], "cl:cl-a");
6897 assert_eq!(edges[0]["kind"], "cluster_member");
6898 h.shutdown(&runtime);
6899 }
6900
6901 #[test]
6902 fn expand_cluster_member_from_cluster_returns_episodes() {
6903 let runtime = rt();
6904 let h = Harness::new(&runtime);
6905 {
6906 let conn = h.open_db();
6907 seed_cluster_row(&conn, "cl-multi", 500);
6908 for i in 0..5 {
6909 let mid = format!("2222{i}222-2222-7000-8000-000000000001");
6910 seed_episode(&conn, &mid, 100 + i as i64, &format!("content {i}"));
6911 seed_cluster_member(&conn, "cl-multi", &mid);
6912 }
6913 }
6914 let (status, body) = runtime.block_on(call(
6915 h.router.clone(),
6916 "GET",
6917 &graph_uri_with_limit("cl:cl-multi", "cluster_member", 3),
6918 None,
6919 ));
6920 assert_eq!(status, StatusCode::OK, "body: {body}");
6921 let nodes = body["nodes"].as_array().unwrap();
6922 let edges = body["edges"].as_array().unwrap();
6923 assert_eq!(nodes.len(), 3, "limit honored: {body}");
6924 assert_eq!(edges.len(), 3);
6925 for n in nodes {
6926 assert_eq!(n["kind"], "episode");
6927 }
6928 h.shutdown(&runtime);
6929 }
6930
6931 #[test]
6932 fn expand_document_chunk_from_document_returns_chunks() {
6933 let runtime = rt();
6934 let h = Harness::new(&runtime);
6935 let doc_id = "33333333-3333-7000-8000-000000000001";
6936 {
6937 let conn = h.open_db();
6938 seed_document_row(&conn, doc_id, "doc A");
6939 seed_chunk_row(&conn, "c2", doc_id, 2, "chunk 2 text");
6942 seed_chunk_row(&conn, "c0", doc_id, 0, "chunk 0 text");
6943 seed_chunk_row(&conn, "c1", doc_id, 1, "chunk 1 text");
6944 seed_chunk_row(&conn, "c3", doc_id, 3, "chunk 3 text");
6945 }
6946 let node_id = format!("doc:{doc_id}");
6947 let (status, body) = runtime.block_on(call(
6948 h.router.clone(),
6949 "GET",
6950 &graph_uri(&node_id, "document_chunk"),
6951 None,
6952 ));
6953 assert_eq!(status, StatusCode::OK, "body: {body}");
6954 let nodes = body["nodes"].as_array().unwrap();
6955 let edges = body["edges"].as_array().unwrap();
6956 assert_eq!(nodes.len(), 4);
6957 assert_eq!(edges.len(), 4);
6958 assert_eq!(nodes[0]["id"], "chunk:c0");
6960 assert_eq!(nodes[1]["id"], "chunk:c1");
6961 assert_eq!(nodes[2]["id"], "chunk:c2");
6962 assert_eq!(nodes[3]["id"], "chunk:c3");
6963 for e in edges {
6964 assert_eq!(e["kind"], "document_chunk");
6965 }
6966 h.shutdown(&runtime);
6967 }
6968
6969 #[test]
6970 fn expand_document_chunk_from_chunk_returns_parent_document() {
6971 let runtime = rt();
6972 let h = Harness::new(&runtime);
6973 let doc_id = "44444444-4444-7000-8000-000000000001";
6974 {
6975 let conn = h.open_db();
6976 seed_document_row(&conn, doc_id, "parent doc");
6977 seed_chunk_row(&conn, "c-orphan", doc_id, 0, "chunk content");
6978 }
6979 let (status, body) = runtime.block_on(call(
6980 h.router.clone(),
6981 "GET",
6982 &graph_uri("chunk:c-orphan", "document_chunk"),
6983 None,
6984 ));
6985 assert_eq!(status, StatusCode::OK, "body: {body}");
6986 let nodes = body["nodes"].as_array().unwrap();
6987 let edges = body["edges"].as_array().unwrap();
6988 assert_eq!(nodes.len(), 1);
6989 assert_eq!(edges.len(), 1);
6990 assert_eq!(nodes[0]["id"], format!("doc:{doc_id}"));
6991 assert_eq!(edges[0]["source"], "chunk:c-orphan");
6992 assert_eq!(edges[0]["target"], format!("doc:{doc_id}"));
6993 h.shutdown(&runtime);
6994 }
6995
6996 #[test]
6997 fn expand_triple_from_episode_returns_entities() {
6998 let runtime = rt();
6999 let h = Harness::new(&runtime);
7000 let memory_id = "55555555-5555-7000-8000-000000000001";
7001 let rowid;
7002 {
7003 let conn = h.open_db();
7004 rowid = seed_episode(&conn, memory_id, 100, "alice works at anthropic");
7005 seed_triple_row(&conn, "t1", "Alice", "works_at", "Anthropic", Some(rowid));
7007 seed_triple_row(&conn, "t2", "Bob", "lives_in", "NYC", Some(rowid));
7008 }
7009 let node_id = format!("ep:{memory_id}");
7010 let (status, body) = runtime.block_on(call(
7011 h.router.clone(),
7012 "GET",
7013 &graph_uri(&node_id, "triple"),
7014 None,
7015 ));
7016 assert_eq!(status, StatusCode::OK, "body: {body}");
7017 let nodes = body["nodes"].as_array().unwrap();
7018 let edges = body["edges"].as_array().unwrap();
7019 assert_eq!(nodes.len(), 4, "expected 4 unique entity nodes: {body}");
7020 assert_eq!(edges.len(), 2);
7021 let ids: std::collections::HashSet<String> = nodes
7022 .iter()
7023 .map(|n| n["id"].as_str().unwrap().to_string())
7024 .collect();
7025 for expected in ["ent:Alice", "ent:Anthropic", "ent:Bob", "ent:NYC"] {
7026 assert!(ids.contains(expected), "missing {expected} in {body}");
7027 }
7028 for e in edges {
7029 assert_eq!(e["kind"], "triple");
7030 assert!(e["predicate"].is_string(), "predicate set: {body}");
7031 }
7032 h.shutdown(&runtime);
7033 }
7034
7035 #[test]
7036 fn expand_triple_from_entity_returns_episodes() {
7037 let runtime = rt();
7038 let h = Harness::new(&runtime);
7039 {
7040 let conn = h.open_db();
7041 let r1 = seed_episode(
7042 &conn,
7043 "66666666-6666-7000-8000-000000000001",
7044 100,
7045 "alice ep one",
7046 );
7047 let r2 = seed_episode(
7048 &conn,
7049 "66666666-6666-7000-8000-000000000002",
7050 200,
7051 "alice ep two",
7052 );
7053 let r3 = seed_episode(
7054 &conn,
7055 "66666666-6666-7000-8000-000000000003",
7056 300,
7057 "alice ep three",
7058 );
7059 seed_triple_row(&conn, "t1", "Alice", "p", "Bob", Some(r1));
7061 seed_triple_row(&conn, "t2", "Carol", "p", "Alice", Some(r2));
7062 seed_triple_row(&conn, "t3", "Alice", "q", "Dave", Some(r3));
7063 seed_triple_row(&conn, "t-orphan", "Alice", "p", "Eve", None);
7065 }
7066 let (status, body) = runtime.block_on(call(
7067 h.router.clone(),
7068 "GET",
7069 &graph_uri("ent:Alice", "triple"),
7070 None,
7071 ));
7072 assert_eq!(status, StatusCode::OK, "body: {body}");
7073 let nodes = body["nodes"].as_array().unwrap();
7074 let edges = body["edges"].as_array().unwrap();
7075 assert_eq!(nodes.len(), 3, "expected 3 episodes: {body}");
7076 assert_eq!(edges.len(), 3);
7077 for n in nodes {
7078 assert_eq!(n["kind"], "episode");
7079 }
7080 for e in edges {
7081 assert_eq!(e["source"], "ent:Alice");
7082 assert_eq!(e["kind"], "triple");
7083 }
7084 h.shutdown(&runtime);
7085 }
7086
7087 #[test]
7088 fn expand_semantic_from_episode_returns_similar() {
7089 let runtime = rt();
7090 let h = Harness::new(&runtime);
7091 runtime.block_on(async {
7097 let mid1 = post_remember(h.router.clone(), "alpha alpha alpha").await;
7098 let _mid2 = post_remember(h.router.clone(), "beta beta beta").await;
7099 let _mid3 = post_remember(h.router.clone(), "gamma gamma gamma").await;
7100 let (status, body) = call(
7102 h.router.clone(),
7103 "GET",
7104 &graph_uri_with_limit(&format!("ep:{mid1}"), "semantic", 5),
7105 None,
7106 )
7107 .await;
7108 assert_eq!(status, StatusCode::OK, "body: {body}");
7109 let nodes = body["nodes"].as_array().unwrap();
7110 let edges = body["edges"].as_array().unwrap();
7111 for n in nodes {
7113 assert_ne!(
7114 n["id"].as_str().unwrap(),
7115 format!("ep:{mid1}"),
7116 "self must be excluded: {body}"
7117 );
7118 }
7119 for e in edges {
7121 assert_eq!(e["kind"], "semantic");
7122 assert!(e["weight"].is_number(), "weight set: {body}");
7123 }
7124 });
7125 h.shutdown(&runtime);
7126 }
7127
7128 async fn post_remember(router: axum::Router, content: &str) -> String {
7130 let (status, body) = call(
7131 router,
7132 "POST",
7133 "/memory",
7134 Some(json!({ "content": content })),
7135 )
7136 .await;
7137 assert_eq!(status, StatusCode::OK, "post failed: {body}");
7138 body["memory_id"].as_str().unwrap().to_string()
7139 }
7140
7141 #[test]
7142 fn expand_400_on_invalid_kind() {
7143 let runtime = rt();
7144 let h = Harness::new(&runtime);
7145 let (status, _body) = runtime.block_on(call(
7146 h.router.clone(),
7147 "GET",
7148 "/v1/graph/expand?node_id=ep:any&kind=banana",
7149 None,
7150 ));
7151 assert!(
7153 status == StatusCode::BAD_REQUEST || status == StatusCode::UNPROCESSABLE_ENTITY,
7154 "expected 400/422 for bad kind, got {status}"
7155 );
7156 h.shutdown(&runtime);
7157 }
7158
7159 #[test]
7160 fn expand_400_on_invalid_node_for_kind() {
7161 let runtime = rt();
7162 let h = Harness::new(&runtime);
7163 let (status, body) = runtime.block_on(call(
7165 h.router.clone(),
7166 "GET",
7167 &graph_uri("cl:doesnt-matter", "semantic"),
7168 None,
7169 ));
7170 assert_eq!(status, StatusCode::BAD_REQUEST);
7171 assert!(
7172 body["error"]
7173 .as_str()
7174 .is_some_and(|s| s.contains("semantic only valid for episode")),
7175 "got: {body}"
7176 );
7177 h.shutdown(&runtime);
7178 }
7179
7180 #[test]
7181 fn expand_404_on_missing_node_id() {
7182 let runtime = rt();
7183 let h = Harness::new(&runtime);
7184 let (status, body) = runtime.block_on(call(
7185 h.router.clone(),
7186 "GET",
7187 &graph_uri("ep:99999999-9999-7000-8000-000000000999", "cluster_member"),
7188 None,
7189 ));
7190 assert_eq!(status, StatusCode::NOT_FOUND, "{body}");
7191 h.shutdown(&runtime);
7192 }
7193
7194 #[test]
7195 fn expand_limit_clamped_at_100() {
7196 let runtime = rt();
7197 let h = Harness::new(&runtime);
7198 {
7200 let conn = h.open_db();
7201 seed_cluster_row(&conn, "cl-huge", 1_000);
7202 for i in 0..150 {
7203 let mid = format!("77777777-7777-7000-8000-{:012}", i);
7204 seed_episode(&conn, &mid, 100 + i as i64, &format!("content {i}"));
7205 seed_cluster_member(&conn, "cl-huge", &mid);
7206 }
7207 }
7208 let (status, body) = runtime.block_on(call(
7209 h.router.clone(),
7210 "GET",
7211 &graph_uri_with_limit("cl:cl-huge", "cluster_member", 999),
7212 None,
7213 ));
7214 assert_eq!(status, StatusCode::OK, "body: {body}");
7215 let nodes = body["nodes"].as_array().unwrap();
7216 assert_eq!(
7217 nodes.len(),
7218 100,
7219 "limit must be silently clamped to 100, got {}",
7220 nodes.len()
7221 );
7222 h.shutdown(&runtime);
7223 }
7224
7225 #[test]
7226 fn expand_bad_node_id_prefix_returns_400() {
7227 let runtime = rt();
7228 let h = Harness::new(&runtime);
7229 let (status, body) = runtime.block_on(call(
7230 h.router.clone(),
7231 "GET",
7232 "/v1/graph/expand?node_id=garbage&kind=cluster_member",
7233 None,
7234 ));
7235 assert_eq!(status, StatusCode::BAD_REQUEST);
7236 assert!(
7237 body["error"]
7238 .as_str()
7239 .is_some_and(|s| s.contains("node_id must be")),
7240 "got: {body}"
7241 );
7242 h.shutdown(&runtime);
7243 }
7244
7245 #[test]
7246 fn expand_respects_tenant_scoping_via_unknown_tenant_header() {
7247 let runtime = rt();
7252 let h = Harness::new(&runtime);
7253 let memory_id = "88888888-8888-7000-8000-000000000001";
7257 {
7258 let conn = h.open_db();
7259 seed_episode(&conn, memory_id, 100, "scoped");
7260 seed_cluster_row(&conn, "cl-scoped", 200);
7261 seed_cluster_member(&conn, "cl-scoped", memory_id);
7262 }
7263 let node_id = format!("ep:{memory_id}");
7264 let r = h.router.clone();
7265 let (status, _body) = runtime.block_on(async {
7266 let req = Request::builder()
7267 .method("GET")
7268 .uri(graph_uri(&node_id, "cluster_member"))
7269 .header("x-solo-tenant", "never-registered-tenant")
7270 .body(Body::empty())
7271 .unwrap();
7272 let resp = r.oneshot(req).await.expect("oneshot");
7273 let s = resp.status();
7274 let _b = resp.into_body().collect().await.unwrap().to_bytes();
7275 (s, _b)
7276 });
7277 assert_eq!(status, StatusCode::NOT_FOUND);
7280 h.shutdown(&runtime);
7281 }
7282
7283 #[test]
7284 fn expand_respects_auth_when_enabled() {
7285 let runtime = rt();
7286 let h = Harness::new_with_auth(&runtime, Some("graph-secret".into()));
7287 let (status, _) = runtime.block_on(call(
7289 h.router.clone(),
7290 "GET",
7291 &graph_uri("ep:any", "cluster_member"),
7292 None,
7293 ));
7294 assert_eq!(status, StatusCode::UNAUTHORIZED);
7295 let (status, _) = runtime.block_on(call_with_auth(
7297 h.router.clone(),
7298 "GET",
7299 &graph_uri("ep:99999999-9999-7000-8000-000000000999", "cluster_member"),
7300 None,
7301 Some("Bearer graph-secret"),
7302 ));
7303 assert_eq!(status, StatusCode::NOT_FOUND);
7304 h.shutdown(&runtime);
7305 }
7306
7307 #[test]
7308 fn expand_works_when_auth_none() {
7309 let runtime = rt();
7310 let h = Harness::new(&runtime);
7311 let (status, _) = runtime.block_on(call(
7314 h.router.clone(),
7315 "GET",
7316 &graph_uri("ep:99999999-9999-7000-8000-000000000999", "cluster_member"),
7317 None,
7318 ));
7319 assert_eq!(status, StatusCode::NOT_FOUND);
7320 h.shutdown(&runtime);
7321 }
7322
7323 async fn call_with_headers(
7336 router: axum::Router,
7337 method: &str,
7338 uri: &str,
7339 ) -> (StatusCode, axum::http::HeaderMap, Value) {
7340 let req = Request::builder()
7341 .method(method)
7342 .uri(uri)
7343 .header("content-length", "0")
7344 .body(Body::empty())
7345 .unwrap();
7346 let resp = router.oneshot(req).await.expect("oneshot");
7347 let status = resp.status();
7348 let headers = resp.headers().clone();
7349 let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
7350 let v: Value = if body_bytes.is_empty() {
7351 Value::Null
7352 } else {
7353 serde_json::from_slice(&body_bytes).unwrap_or(Value::Null)
7354 };
7355 (status, headers, v)
7356 }
7357
7358 #[test]
7359 fn nodes_returns_all_kinds_when_no_filter() {
7360 let runtime = rt();
7361 let h = Harness::new(&runtime);
7362 {
7363 let conn = h.open_db();
7364 let rowid = seed_episode(
7365 &conn,
7366 "aaaaaaaa-0000-7000-8000-000000000001",
7367 100,
7368 "episode one",
7369 );
7370 seed_document_row(&conn, "doc-1", "doc one");
7371 seed_chunk_row(&conn, "chunk-1", "doc-1", 0, "chunk one body");
7372 seed_cluster_row(&conn, "cl-one", 200);
7373 seed_triple_row(
7374 &conn,
7375 "t-one",
7376 "Alice",
7377 "knows",
7378 "Bob",
7379 Some(rowid),
7380 );
7381 }
7382 let (status, body) = runtime.block_on(call(
7383 h.router.clone(),
7384 "GET",
7385 "/v1/graph/nodes",
7386 None,
7387 ));
7388 assert_eq!(status, StatusCode::OK, "body: {body}");
7389 let nodes = body["nodes"].as_array().unwrap();
7390 let kinds: std::collections::HashSet<&str> = nodes
7391 .iter()
7392 .map(|n| n["kind"].as_str().unwrap())
7393 .collect();
7394 for expected in ["episode", "document", "chunk", "cluster", "entity"] {
7395 assert!(
7396 kinds.contains(expected),
7397 "expected {expected} kind in response: {body}"
7398 );
7399 }
7400 h.shutdown(&runtime);
7401 }
7402
7403 #[test]
7404 fn nodes_filter_by_single_kind() {
7405 let runtime = rt();
7406 let h = Harness::new(&runtime);
7407 {
7408 let conn = h.open_db();
7409 seed_episode(&conn, "bbbbbbbb-0000-7000-8000-000000000001", 100, "ep");
7410 seed_document_row(&conn, "doc-only", "d");
7411 seed_cluster_row(&conn, "cl-only", 300);
7412 }
7413 let (status, body) = runtime.block_on(call(
7414 h.router.clone(),
7415 "GET",
7416 "/v1/graph/nodes?kind=episode",
7417 None,
7418 ));
7419 assert_eq!(status, StatusCode::OK, "body: {body}");
7420 let nodes = body["nodes"].as_array().unwrap();
7421 assert!(!nodes.is_empty(), "{body}");
7422 for n in nodes {
7423 assert_eq!(n["kind"], "episode", "kind filter must be exclusive: {body}");
7424 }
7425 h.shutdown(&runtime);
7426 }
7427
7428 #[test]
7429 fn nodes_filter_by_multiple_kinds() {
7430 let runtime = rt();
7431 let h = Harness::new(&runtime);
7432 {
7433 let conn = h.open_db();
7434 seed_episode(&conn, "cccccccc-0000-7000-8000-000000000001", 100, "ep");
7435 seed_document_row(&conn, "doc-multi", "d");
7436 seed_cluster_row(&conn, "cl-multi", 300);
7437 }
7438 let (status, body) = runtime.block_on(call(
7439 h.router.clone(),
7440 "GET",
7441 "/v1/graph/nodes?kind=episode,document",
7442 None,
7443 ));
7444 assert_eq!(status, StatusCode::OK, "body: {body}");
7445 let nodes = body["nodes"].as_array().unwrap();
7446 let kinds: std::collections::HashSet<&str> = nodes
7447 .iter()
7448 .map(|n| n["kind"].as_str().unwrap())
7449 .collect();
7450 assert!(kinds.contains("episode"), "{body}");
7451 assert!(kinds.contains("document"), "{body}");
7452 assert!(
7453 !kinds.contains("cluster"),
7454 "cluster must be filtered out: {body}"
7455 );
7456 h.shutdown(&runtime);
7457 }
7458
7459 #[test]
7460 fn nodes_entity_synthesis_caps_at_200() {
7461 let runtime = rt();
7462 let h = Harness::new(&runtime);
7463 {
7464 let conn = h.open_db();
7465 let rowid = seed_episode(
7470 &conn,
7471 "dddddddd-0000-7000-8000-000000000001",
7472 100,
7473 "ep",
7474 );
7475 for i in 0..250 {
7476 let triple_id = format!("t-cap-{i:03}");
7477 let obj = format!("Entity{i:03}");
7478 seed_triple_row(&conn, &triple_id, "Alice", "knows", &obj, Some(rowid));
7479 }
7480 }
7481 let (status, headers, body) = runtime.block_on(call_with_headers(
7482 h.router.clone(),
7483 "GET",
7484 "/v1/graph/nodes?kind=entity&limit=500",
7485 ));
7486 assert_eq!(status, StatusCode::OK, "body: {body}");
7487 let nodes = body["nodes"].as_array().unwrap();
7488 assert_eq!(
7489 nodes.len(),
7490 200,
7491 "entity cap must be enforced at 200, got {}",
7492 nodes.len()
7493 );
7494 assert_eq!(
7495 headers
7496 .get("x-solo-entity-cap-reached")
7497 .and_then(|v| v.to_str().ok()),
7498 Some("true"),
7499 "cap-reached header missing: headers={headers:?}"
7500 );
7501 for n in nodes {
7502 assert_eq!(n["kind"], "entity");
7503 }
7504 h.shutdown(&runtime);
7505 }
7506
7507 #[test]
7508 fn nodes_since_until_filter_works() {
7509 let runtime = rt();
7510 let h = Harness::new(&runtime);
7511 {
7512 let conn = h.open_db();
7513 seed_episode(
7514 &conn,
7515 "eeeeeeee-0000-7000-8000-000000000001",
7516 100,
7517 "early",
7518 );
7519 seed_episode(
7520 &conn,
7521 "eeeeeeee-0000-7000-8000-000000000002",
7522 500,
7523 "middle",
7524 );
7525 seed_episode(
7526 &conn,
7527 "eeeeeeee-0000-7000-8000-000000000003",
7528 1000,
7529 "late",
7530 );
7531 }
7532 let (status, body) = runtime.block_on(call(
7533 h.router.clone(),
7534 "GET",
7535 "/v1/graph/nodes?kind=episode&since_ms=400&until_ms=600",
7536 None,
7537 ));
7538 assert_eq!(status, StatusCode::OK, "body: {body}");
7539 let nodes = body["nodes"].as_array().unwrap();
7540 assert_eq!(nodes.len(), 1, "{body}");
7541 assert_eq!(
7542 nodes[0]["id"],
7543 "ep:eeeeeeee-0000-7000-8000-000000000002"
7544 );
7545 h.shutdown(&runtime);
7546 }
7547
7548 #[test]
7549 fn nodes_pagination_round_trip() {
7550 let runtime = rt();
7551 let h = Harness::new(&runtime);
7552 {
7553 let conn = h.open_db();
7554 for i in 0..150 {
7555 let mid = format!("f0000000-0000-7000-8000-{i:012}");
7556 seed_episode(&conn, &mid, 1_000 + i as i64, "page");
7559 }
7560 }
7561 let limit = 50u32;
7562 let mut seen: std::collections::HashSet<String> = Default::default();
7563 let mut next_cursor: Option<String> = None;
7564 for page_idx in 0..4 {
7565 let cursor_param = next_cursor
7566 .as_deref()
7567 .map(|c| format!("&cursor={c}"))
7568 .unwrap_or_default();
7569 let uri = format!(
7570 "/v1/graph/nodes?kind=episode&limit={limit}{cursor_param}"
7571 );
7572 let (status, body) =
7573 runtime.block_on(call(h.router.clone(), "GET", &uri, None));
7574 assert_eq!(status, StatusCode::OK, "page {page_idx}: {body}");
7575 let nodes = body["nodes"].as_array().unwrap();
7576 assert!(
7577 nodes.len() <= limit as usize,
7578 "page {page_idx} over-fetched: {body}"
7579 );
7580 for n in nodes {
7581 let id = n["id"].as_str().unwrap().to_string();
7582 assert!(seen.insert(id.clone()), "duplicate id across pages: {id}");
7583 }
7584 next_cursor = body
7585 .get("next_cursor")
7586 .and_then(|v| v.as_str())
7587 .map(|s| s.to_string());
7588 if next_cursor.is_none() {
7589 break;
7590 }
7591 }
7592 assert_eq!(
7593 seen.len(),
7594 150,
7595 "expected 150 distinct ids across pages, got {}",
7596 seen.len()
7597 );
7598 assert!(
7599 next_cursor.is_none(),
7600 "cursor should be null after last page; got {next_cursor:?}"
7601 );
7602 h.shutdown(&runtime);
7603 }
7604
7605 #[test]
7606 fn nodes_respects_tenant_scoping() {
7607 let runtime = rt();
7608 let h = Harness::new(&runtime);
7609 {
7610 let conn = h.open_db();
7611 seed_episode(
7612 &conn,
7613 "11110000-0000-7000-8000-000000000001",
7614 100,
7615 "tenant scope",
7616 );
7617 }
7618 let r = h.router.clone();
7621 let (status, _body) = runtime.block_on(async {
7622 let req = Request::builder()
7623 .method("GET")
7624 .uri("/v1/graph/nodes")
7625 .header("x-solo-tenant", "never-registered-tenant")
7626 .body(Body::empty())
7627 .unwrap();
7628 let resp = r.oneshot(req).await.expect("oneshot");
7629 let s = resp.status();
7630 let _b = resp.into_body().collect().await.unwrap().to_bytes();
7631 (s, _b)
7632 });
7633 assert_eq!(status, StatusCode::NOT_FOUND);
7634 h.shutdown(&runtime);
7635 }
7636
7637 #[test]
7638 fn nodes_respects_auth_when_enabled() {
7639 let runtime = rt();
7640 let h = Harness::new_with_auth(&runtime, Some("nodes-secret".into()));
7641 let (status, _) = runtime.block_on(call(
7642 h.router.clone(),
7643 "GET",
7644 "/v1/graph/nodes",
7645 None,
7646 ));
7647 assert_eq!(
7648 status,
7649 StatusCode::UNAUTHORIZED,
7650 "must reject unauthenticated request"
7651 );
7652 let (status, _) = runtime.block_on(call_with_auth(
7653 h.router.clone(),
7654 "GET",
7655 "/v1/graph/nodes",
7656 None,
7657 Some("Bearer nodes-secret"),
7658 ));
7659 assert_eq!(status, StatusCode::OK, "must pass through with bearer");
7660 h.shutdown(&runtime);
7661 }
7662
7663 #[test]
7664 fn nodes_works_with_auth_none() {
7665 let runtime = rt();
7666 let h = Harness::new(&runtime);
7667 let (status, body) = runtime.block_on(call(
7668 h.router.clone(),
7669 "GET",
7670 "/v1/graph/nodes",
7671 None,
7672 ));
7673 assert_eq!(status, StatusCode::OK, "{body}");
7674 assert!(body.get("nodes").is_some());
7675 h.shutdown(&runtime);
7676 }
7677
7678 #[test]
7681 fn edges_returns_all_default_kinds() {
7682 let runtime = rt();
7683 let h = Harness::new(&runtime);
7684 {
7685 let conn = h.open_db();
7686 let rowid = seed_episode(
7687 &conn,
7688 "22220000-0000-7000-8000-000000000001",
7689 100,
7690 "ep src",
7691 );
7692 seed_triple_row(&conn, "t-def", "Alice", "knows", "Bob", Some(rowid));
7693 seed_document_row(&conn, "doc-e", "doc");
7694 seed_chunk_row(&conn, "c-e", "doc-e", 0, "chunk");
7695 seed_cluster_row(&conn, "cl-e", 200);
7696 seed_cluster_member(
7697 &conn,
7698 "cl-e",
7699 "22220000-0000-7000-8000-000000000001",
7700 );
7701 }
7702 let (status, body) = runtime.block_on(call(
7703 h.router.clone(),
7704 "GET",
7705 "/v1/graph/edges",
7706 None,
7707 ));
7708 assert_eq!(status, StatusCode::OK, "body: {body}");
7709 let edges = body["edges"].as_array().unwrap();
7710 let kinds: std::collections::HashSet<&str> = edges
7711 .iter()
7712 .map(|e| e["kind"].as_str().unwrap())
7713 .collect();
7714 assert!(kinds.contains("triple"), "{body}");
7715 assert!(kinds.contains("document_chunk"), "{body}");
7716 assert!(kinds.contains("cluster_member"), "{body}");
7717 assert!(
7718 !kinds.contains("semantic"),
7719 "semantic is NOT in default response: {body}"
7720 );
7721 h.shutdown(&runtime);
7722 }
7723
7724 #[test]
7725 fn edges_filter_by_node_id_finds_incident_edges() {
7726 let runtime = rt();
7727 let h = Harness::new(&runtime);
7728 let memory_id = "33330000-0000-7000-8000-000000000001";
7729 {
7730 let conn = h.open_db();
7731 let rowid = seed_episode(&conn, memory_id, 100, "ep multi-triple");
7732 seed_triple_row(&conn, "t-a", "Alice", "p", "Bob", Some(rowid));
7733 seed_triple_row(&conn, "t-b", "Alice", "p", "Carol", Some(rowid));
7734 seed_triple_row(&conn, "t-c", "Alice", "p", "Dave", Some(rowid));
7735 let decoy_rowid = seed_episode(
7737 &conn,
7738 "33330000-0000-7000-8000-000000000999",
7739 200,
7740 "decoy",
7741 );
7742 seed_triple_row(
7743 &conn,
7744 "t-decoy",
7745 "Alice",
7746 "p",
7747 "Eve",
7748 Some(decoy_rowid),
7749 );
7750 }
7751 let uri = format!(
7752 "/v1/graph/edges?type=triple&node_id={}",
7753 percent_encode_node_id(&format!("ep:{memory_id}"))
7754 );
7755 let (status, body) =
7756 runtime.block_on(call(h.router.clone(), "GET", &uri, None));
7757 assert_eq!(status, StatusCode::OK, "body: {body}");
7758 let edges = body["edges"].as_array().unwrap();
7759 assert_eq!(edges.len(), 3, "expected 3 incident edges: {body}");
7760 for e in edges {
7761 assert_eq!(e["source"], format!("ep:{memory_id}"));
7762 assert_eq!(e["kind"], "triple");
7763 }
7764 h.shutdown(&runtime);
7765 }
7766
7767 #[test]
7768 fn edges_filter_by_type_works() {
7769 let runtime = rt();
7770 let h = Harness::new(&runtime);
7771 {
7772 let conn = h.open_db();
7773 let rowid = seed_episode(
7774 &conn,
7775 "44440000-0000-7000-8000-000000000001",
7776 100,
7777 "ep",
7778 );
7779 seed_triple_row(&conn, "t-only", "Alice", "p", "Bob", Some(rowid));
7780 seed_document_row(&conn, "doc-skip", "doc");
7781 seed_chunk_row(&conn, "c-skip", "doc-skip", 0, "chunk");
7782 }
7783 let (status, body) = runtime.block_on(call(
7784 h.router.clone(),
7785 "GET",
7786 "/v1/graph/edges?type=triple",
7787 None,
7788 ));
7789 assert_eq!(status, StatusCode::OK, "{body}");
7790 let edges = body["edges"].as_array().unwrap();
7791 assert!(!edges.is_empty(), "{body}");
7792 for e in edges {
7793 assert_eq!(e["kind"], "triple", "{body}");
7794 }
7795 h.shutdown(&runtime);
7796 }
7797
7798 #[test]
7799 fn edges_rejects_semantic_type_with_400() {
7800 let runtime = rt();
7801 let h = Harness::new(&runtime);
7802 let (status, body) = runtime.block_on(call(
7803 h.router.clone(),
7804 "GET",
7805 "/v1/graph/edges?type=semantic",
7806 None,
7807 ));
7808 assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}");
7809 let err = body["error"].as_str().unwrap_or_default();
7810 assert!(
7811 err.contains("/v1/graph/neighbors"),
7812 "error must point to /v1/graph/neighbors: {body}"
7813 );
7814 h.shutdown(&runtime);
7815 }
7816
7817 #[test]
7818 fn edges_pagination_round_trip() {
7819 let runtime = rt();
7820 let h = Harness::new(&runtime);
7821 {
7822 let conn = h.open_db();
7823 let rowid = seed_episode(
7824 &conn,
7825 "55550000-0000-7000-8000-000000000001",
7826 100,
7827 "ep big",
7828 );
7829 for i in 0..60 {
7831 let tid = format!("t-page-{i:03}");
7832 let obj = format!("Obj{i:03}");
7833 seed_triple_row(&conn, &tid, "Alice", "p", &obj, Some(rowid));
7834 }
7835 }
7836 let limit = 25u32;
7837 let mut seen: std::collections::HashSet<String> = Default::default();
7838 let mut next_cursor: Option<String> = None;
7839 for page_idx in 0..5 {
7840 let cursor_param = next_cursor
7841 .as_deref()
7842 .map(|c| format!("&cursor={c}"))
7843 .unwrap_or_default();
7844 let uri = format!(
7845 "/v1/graph/edges?type=triple&limit={limit}{cursor_param}"
7846 );
7847 let (status, body) =
7848 runtime.block_on(call(h.router.clone(), "GET", &uri, None));
7849 assert_eq!(status, StatusCode::OK, "page {page_idx}: {body}");
7850 let edges = body["edges"].as_array().unwrap();
7851 for e in edges {
7852 let id = e["id"].as_str().unwrap().to_string();
7853 assert!(seen.insert(id.clone()), "duplicate edge id: {id}");
7854 }
7855 next_cursor = body
7856 .get("next_cursor")
7857 .and_then(|v| v.as_str())
7858 .map(|s| s.to_string());
7859 if next_cursor.is_none() {
7860 break;
7861 }
7862 }
7863 assert_eq!(
7864 seen.len(),
7865 60,
7866 "expected 60 distinct edges, got {}",
7867 seen.len()
7868 );
7869 assert!(next_cursor.is_none(), "expected exhausted cursor");
7870 h.shutdown(&runtime);
7871 }
7872
7873 #[test]
7874 fn edges_respects_tenant_scoping() {
7875 let runtime = rt();
7876 let h = Harness::new(&runtime);
7877 {
7878 let conn = h.open_db();
7879 let rowid = seed_episode(
7880 &conn,
7881 "66660000-0000-7000-8000-000000000001",
7882 100,
7883 "ep",
7884 );
7885 seed_triple_row(&conn, "t-tenant", "Alice", "p", "Bob", Some(rowid));
7886 }
7887 let r = h.router.clone();
7888 let (status, _) = runtime.block_on(async {
7889 let req = Request::builder()
7890 .method("GET")
7891 .uri("/v1/graph/edges")
7892 .header("x-solo-tenant", "never-registered-tenant")
7893 .body(Body::empty())
7894 .unwrap();
7895 let resp = r.oneshot(req).await.expect("oneshot");
7896 let s = resp.status();
7897 let _b = resp.into_body().collect().await.unwrap().to_bytes();
7898 (s, _b)
7899 });
7900 assert_eq!(status, StatusCode::NOT_FOUND);
7901 h.shutdown(&runtime);
7902 }
7903
7904 #[test]
7905 fn edges_respects_auth_when_enabled() {
7906 let runtime = rt();
7907 let h = Harness::new_with_auth(&runtime, Some("edges-secret".into()));
7908 let (status, _) = runtime.block_on(call(
7909 h.router.clone(),
7910 "GET",
7911 "/v1/graph/edges",
7912 None,
7913 ));
7914 assert_eq!(status, StatusCode::UNAUTHORIZED);
7915 let (status, _) = runtime.block_on(call_with_auth(
7916 h.router.clone(),
7917 "GET",
7918 "/v1/graph/edges",
7919 None,
7920 Some("Bearer edges-secret"),
7921 ));
7922 assert_eq!(status, StatusCode::OK);
7923 h.shutdown(&runtime);
7924 }
7925
7926 fn inspect_uri(node_id: &str) -> String {
7937 format!("/v1/graph/inspect/{}", percent_encode_node_id(node_id))
7941 }
7942
7943 #[test]
7944 fn inspect_episode_returns_full_text_plus_triples_out() {
7945 let runtime = rt();
7946 let h = Harness::new(&runtime);
7947 let memory_id = "a1110000-0000-7000-8000-000000000001";
7948 let full_text = "Met Alice for coffee at the new place. She mentioned the project is on track but they're hitting issues with the deploy pipeline.";
7949 {
7950 let conn = h.open_db();
7951 let rowid = seed_episode(&conn, memory_id, 1_715_625_600_000, full_text);
7952 seed_triple_row(&conn, "t-ep-1", "user", "met_with", "Alice", Some(rowid));
7953 seed_triple_row(&conn, "t-ep-2", "user", "discussed", "deploy_pipeline", Some(rowid));
7954 seed_triple_row(&conn, "t-ep-3", "Alice", "works_on", "project", Some(rowid));
7955 }
7956 let (status, body) = runtime.block_on(call(
7957 h.router.clone(),
7958 "GET",
7959 &inspect_uri(&format!("ep:{memory_id}")),
7960 None,
7961 ));
7962 assert_eq!(status, StatusCode::OK, "body: {body}");
7963 assert_eq!(body["node"]["kind"], "episode");
7964 assert_eq!(body["node"]["id"], format!("ep:{memory_id}"));
7965 assert_eq!(
7966 body["full_text"].as_str().unwrap(),
7967 full_text,
7968 "full_text must match episodes.content verbatim, untruncated"
7969 );
7970 let triples_out = body["triples_out"].as_array().unwrap();
7971 assert_eq!(triples_out.len(), 3, "{body}");
7972 let triples_in = body["triples_in"].as_array().unwrap();
7973 assert!(triples_in.is_empty(), "episodes have no triples_in: {body}");
7974 for e in triples_out {
7975 assert_eq!(e["kind"], "triple");
7976 assert_eq!(e["source"], format!("ep:{memory_id}"));
7977 assert!(e["target"].as_str().unwrap().starts_with("ent:"));
7978 assert!(e["predicate"].as_str().is_some());
7979 assert!(e["weight"].as_f64().is_some());
7980 }
7981 h.shutdown(&runtime);
7982 }
7983
7984 #[test]
7985 fn inspect_episode_triples_in_is_empty_for_v10p1() {
7986 let runtime = rt();
7991 let h = Harness::new(&runtime);
7992 let focal = "a2220000-0000-7000-8000-000000000001";
7993 let other = "a2220000-0000-7000-8000-000000000002";
7994 {
7995 let conn = h.open_db();
7996 seed_episode(&conn, focal, 100, "focal episode body");
7997 let other_rowid = seed_episode(&conn, other, 200, "another episode");
7998 for i in 0..5 {
8001 let tid = format!("t-other-{i}");
8002 seed_triple_row(&conn, &tid, "user", "did", "thing", Some(other_rowid));
8003 }
8004 }
8005 let (status, body) = runtime.block_on(call(
8006 h.router.clone(),
8007 "GET",
8008 &inspect_uri(&format!("ep:{focal}")),
8009 None,
8010 ));
8011 assert_eq!(status, StatusCode::OK, "body: {body}");
8012 let triples_in = body["triples_in"].as_array().unwrap();
8013 assert!(
8014 triples_in.is_empty(),
8015 "episode triples_in must be empty regardless of cross-episode entity references: {body}"
8016 );
8017 h.shutdown(&runtime);
8018 }
8019
8020 #[test]
8021 fn inspect_document_returns_full_text_concatenated_from_chunks() {
8022 let runtime = rt();
8023 let h = Harness::new(&runtime);
8024 let doc_id = "d3330000-0000-7000-8000-000000000001";
8025 {
8026 let conn = h.open_db();
8027 seed_document_row(&conn, doc_id, "doc-title");
8028 seed_chunk_row(&conn, "ch-doc-1", doc_id, 0, "First chunk body.");
8029 seed_chunk_row(&conn, "ch-doc-2", doc_id, 1, "Second chunk body.");
8030 seed_chunk_row(&conn, "ch-doc-3", doc_id, 2, "Third chunk body.");
8031 }
8032 let (status, body) = runtime.block_on(call(
8033 h.router.clone(),
8034 "GET",
8035 &inspect_uri(&format!("doc:{doc_id}")),
8036 None,
8037 ));
8038 assert_eq!(status, StatusCode::OK, "body: {body}");
8039 assert_eq!(body["node"]["kind"], "document");
8040 let full_text = body["full_text"].as_str().unwrap();
8041 assert_eq!(
8043 full_text,
8044 "First chunk body.\n\nSecond chunk body.\n\nThird chunk body."
8045 );
8046 assert!(body["triples_in"].as_array().unwrap().is_empty());
8047 assert!(body["triples_out"].as_array().unwrap().is_empty());
8048 h.shutdown(&runtime);
8049 }
8050
8051 #[test]
8052 fn inspect_chunk_returns_text() {
8053 let runtime = rt();
8054 let h = Harness::new(&runtime);
8055 let chunk_body = "This is the body of the chunk being inspected.";
8056 {
8057 let conn = h.open_db();
8058 seed_document_row(&conn, "doc-chunk-host", "host");
8059 seed_chunk_row(&conn, "chunk-inspect-target", "doc-chunk-host", 0, chunk_body);
8060 }
8061 let (status, body) = runtime.block_on(call(
8062 h.router.clone(),
8063 "GET",
8064 &inspect_uri("chunk:chunk-inspect-target"),
8065 None,
8066 ));
8067 assert_eq!(status, StatusCode::OK, "body: {body}");
8068 assert_eq!(body["node"]["kind"], "chunk");
8069 assert_eq!(body["full_text"].as_str().unwrap(), chunk_body);
8070 assert!(body["triples_in"].as_array().unwrap().is_empty());
8071 assert!(body["triples_out"].as_array().unwrap().is_empty());
8072 h.shutdown(&runtime);
8073 }
8074
8075 #[test]
8076 fn inspect_cluster_returns_label_and_abstraction() {
8077 let runtime = rt();
8078 let h = Harness::new(&runtime);
8079 let cluster_id = "cl-inspect-target";
8080 let abstraction_text = "Discussions about the deploy pipeline and on-call rotation.";
8081 {
8082 let conn = h.open_db();
8083 seed_cluster_row(&conn, cluster_id, 12345);
8084 seed_abstraction_row(&conn, "abs-1", cluster_id, abstraction_text);
8085 }
8086 let (status, body) = runtime.block_on(call(
8087 h.router.clone(),
8088 "GET",
8089 &inspect_uri(&format!("cl:{cluster_id}")),
8090 None,
8091 ));
8092 assert_eq!(status, StatusCode::OK, "body: {body}");
8093 assert_eq!(body["node"]["kind"], "cluster");
8094 let full_text = body["full_text"].as_str().unwrap();
8095 assert!(
8096 full_text.contains(cluster_id),
8097 "full_text must include cluster label: {full_text}"
8098 );
8099 assert!(
8100 full_text.contains(abstraction_text),
8101 "full_text must include abstraction text: {full_text}"
8102 );
8103 assert!(full_text.contains("\n\n"), "label and abstraction must be separated: {full_text}");
8106 h.shutdown(&runtime);
8107 }
8108
8109 #[test]
8110 fn inspect_entity_returns_triples_only() {
8111 let runtime = rt();
8112 let h = Harness::new(&runtime);
8113 {
8114 let conn = h.open_db();
8115 let rowid = seed_episode(
8116 &conn,
8117 "e5550000-0000-7000-8000-000000000001",
8118 100,
8119 "host episode",
8120 );
8121 seed_triple_row(&conn, "t-ent-1", "Alice", "knows", "Bob", Some(rowid));
8123 seed_triple_row(&conn, "t-ent-2", "Alice", "works_at", "Anthropic", Some(rowid));
8124 seed_triple_row(&conn, "t-ent-3", "user", "met", "Alice", Some(rowid));
8125 seed_triple_row(&conn, "t-ent-4", "Alice", "owns", "laptop", Some(rowid));
8126 seed_triple_row(&conn, "t-ent-5", "Carol", "mentors", "Alice", Some(rowid));
8127 }
8128 let (status, body) = runtime.block_on(call(
8129 h.router.clone(),
8130 "GET",
8131 &inspect_uri("ent:Alice"),
8132 None,
8133 ));
8134 assert_eq!(status, StatusCode::OK, "body: {body}");
8135 assert_eq!(body["node"]["kind"], "entity");
8136 assert_eq!(body["node"]["id"], "ent:Alice");
8137 assert!(
8138 body["full_text"].is_null(),
8139 "entity full_text must be null (entities have no body): {body}"
8140 );
8141 let triples_out = body["triples_out"].as_array().unwrap();
8142 assert_eq!(triples_out.len(), 5, "{body}");
8143 assert!(body["triples_in"].as_array().unwrap().is_empty());
8144 for e in triples_out {
8145 assert_eq!(e["kind"], "triple");
8146 assert_eq!(e["source"], "ent:Alice");
8147 assert!(e["target"].as_str().unwrap().starts_with("ent:"));
8150 assert_ne!(e["target"], "ent:Alice");
8151 }
8152 h.shutdown(&runtime);
8153 }
8154
8155 #[test]
8156 fn inspect_entity_with_zero_triples_returns_404() {
8157 let runtime = rt();
8158 let h = Harness::new(&runtime);
8159 {
8162 let conn = h.open_db();
8163 let rowid = seed_episode(
8164 &conn,
8165 "e6660000-0000-7000-8000-000000000001",
8166 100,
8167 "ep",
8168 );
8169 seed_triple_row(&conn, "t-other", "Bob", "knows", "Carol", Some(rowid));
8170 }
8171 let (status, body) = runtime.block_on(call(
8172 h.router.clone(),
8173 "GET",
8174 &inspect_uri("ent:Nonexistent"),
8175 None,
8176 ));
8177 assert_eq!(status, StatusCode::NOT_FOUND, "body: {body}");
8178 let err = body["error"].as_str().unwrap_or_default();
8179 assert!(
8180 err.contains("Nonexistent") || err.contains("entity"),
8181 "error must mention entity: {body}"
8182 );
8183 h.shutdown(&runtime);
8184 }
8185
8186 #[test]
8187 fn inspect_404_on_missing_node() {
8188 let runtime = rt();
8190 let h = Harness::new(&runtime);
8191 let (status, body) = runtime.block_on(call(
8192 h.router.clone(),
8193 "GET",
8194 &inspect_uri("ep:99999999-9999-7000-8000-000000000999"),
8195 None,
8196 ));
8197 assert_eq!(status, StatusCode::NOT_FOUND, "body: {body}");
8198 h.shutdown(&runtime);
8199 }
8200
8201 #[test]
8202 fn inspect_400_on_invalid_prefix() {
8203 let runtime = rt();
8204 let h = Harness::new(&runtime);
8205 let (status, body) = runtime.block_on(call(
8206 h.router.clone(),
8207 "GET",
8208 &inspect_uri("xyz:foo"),
8209 None,
8210 ));
8211 assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}");
8212 let err = body["error"].as_str().unwrap_or_default();
8213 assert!(
8214 err.contains("xyz") || err.contains("prefix"),
8215 "error must mention bad prefix: {body}"
8216 );
8217 h.shutdown(&runtime);
8218 }
8219
8220 #[test]
8221 fn inspect_respects_tenant_scoping() {
8222 let runtime = rt();
8223 let h = Harness::new(&runtime);
8224 let memory_id = "a7770000-0000-7000-8000-000000000001";
8225 {
8226 let conn = h.open_db();
8227 seed_episode(&conn, memory_id, 100, "tenant scope");
8228 }
8229 let r = h.router.clone();
8233 let (status, _) = runtime.block_on(async {
8234 let req = Request::builder()
8235 .method("GET")
8236 .uri(inspect_uri(&format!("ep:{memory_id}")))
8237 .header("x-solo-tenant", "never-registered-tenant")
8238 .body(Body::empty())
8239 .unwrap();
8240 let resp = r.oneshot(req).await.expect("oneshot");
8241 let s = resp.status();
8242 let _b = resp.into_body().collect().await.unwrap().to_bytes();
8243 (s, _b)
8244 });
8245 assert_eq!(status, StatusCode::NOT_FOUND);
8246 let (status, body) = runtime.block_on(call(
8248 h.router.clone(),
8249 "GET",
8250 &inspect_uri(&format!("ep:{memory_id}")),
8251 None,
8252 ));
8253 assert_eq!(status, StatusCode::OK, "default tenant must resolve: {body}");
8254 h.shutdown(&runtime);
8255 }
8256
8257 #[test]
8258 fn inspect_respects_auth_when_enabled() {
8259 let runtime = rt();
8260 let h = Harness::new_with_auth(&runtime, Some("inspect-secret".into()));
8261 let (status, _) = runtime.block_on(call(
8263 h.router.clone(),
8264 "GET",
8265 &inspect_uri("ep:99999999-9999-7000-8000-000000000999"),
8266 None,
8267 ));
8268 assert_eq!(status, StatusCode::UNAUTHORIZED);
8269 let (status, _) = runtime.block_on(call_with_auth(
8272 h.router.clone(),
8273 "GET",
8274 &inspect_uri("ep:99999999-9999-7000-8000-000000000999"),
8275 None,
8276 Some("Bearer inspect-secret"),
8277 ));
8278 assert_eq!(status, StatusCode::NOT_FOUND);
8279 h.shutdown(&runtime);
8280 }
8281
8282 fn neighbors_uri(
8296 node_id: &str,
8297 kind: Option<&str>,
8298 threshold: Option<f32>,
8299 limit: Option<u32>,
8300 ) -> String {
8301 let mut qs: Vec<String> = Vec::new();
8302 if let Some(k) = kind {
8303 qs.push(format!("kind={k}"));
8304 }
8305 if let Some(t) = threshold {
8306 qs.push(format!("threshold={t}"));
8307 }
8308 if let Some(l) = limit {
8309 qs.push(format!("limit={l}"));
8310 }
8311 let encoded = percent_encode_node_id(node_id);
8312 if qs.is_empty() {
8313 format!("/v1/graph/neighbors/{encoded}")
8314 } else {
8315 format!("/v1/graph/neighbors/{encoded}?{}", qs.join("&"))
8316 }
8317 }
8318
8319 #[test]
8324 fn neighbors_explicit_only_returns_no_semantic_edges() {
8325 let runtime = rt();
8326 let h = Harness::new(&runtime);
8327 runtime.block_on(async {
8328 let focal = post_remember(h.router.clone(), "alpha alpha alpha").await;
8332 let _other1 = post_remember(h.router.clone(), "beta beta beta").await;
8333 let _other2 = post_remember(h.router.clone(), "gamma gamma gamma").await;
8334 {
8337 let conn = h.open_db();
8338 let rowid: i64 = conn
8339 .query_row(
8340 "SELECT rowid FROM episodes WHERE memory_id = ?1",
8341 rusqlite::params![&focal],
8342 |r| r.get(0),
8343 )
8344 .unwrap();
8345 seed_triple_row(&conn, "t-exp-1", "Alice", "knows", "Bob", Some(rowid));
8346 seed_triple_row(&conn, "t-exp-2", "Alice", "owns", "laptop", Some(rowid));
8347 }
8348 let (status, body) = call(
8349 h.router.clone(),
8350 "GET",
8351 &neighbors_uri(&format!("ep:{focal}"), Some("explicit"), None, None),
8352 None,
8353 )
8354 .await;
8355 assert_eq!(status, StatusCode::OK, "body: {body}");
8356 let edges = body["edges"].as_array().unwrap();
8357 assert!(!edges.is_empty(), "expected explicit edges: {body}");
8358 for e in edges {
8359 assert_ne!(
8360 e["kind"], "semantic",
8361 "kind=explicit must drop semantic edges: {body}"
8362 );
8363 }
8364 });
8365 h.shutdown(&runtime);
8366 }
8367
8368 #[test]
8371 fn neighbors_semantic_only_returns_no_explicit_edges() {
8372 let runtime = rt();
8373 let h = Harness::new(&runtime);
8374 runtime.block_on(async {
8375 let focal = post_remember(h.router.clone(), "alpha alpha alpha").await;
8376 let _other1 = post_remember(h.router.clone(), "beta beta beta").await;
8377 let _other2 = post_remember(h.router.clone(), "gamma gamma gamma").await;
8378 {
8379 let conn = h.open_db();
8380 let rowid: i64 = conn
8381 .query_row(
8382 "SELECT rowid FROM episodes WHERE memory_id = ?1",
8383 rusqlite::params![&focal],
8384 |r| r.get(0),
8385 )
8386 .unwrap();
8387 seed_triple_row(&conn, "t-exp-1", "Alice", "knows", "Bob", Some(rowid));
8388 }
8389 let (status, body) = call(
8391 h.router.clone(),
8392 "GET",
8393 &neighbors_uri(&format!("ep:{focal}"), Some("semantic"), Some(0.0), None),
8394 None,
8395 )
8396 .await;
8397 assert_eq!(status, StatusCode::OK, "body: {body}");
8398 let edges = body["edges"].as_array().unwrap();
8399 for e in edges {
8400 assert_eq!(
8401 e["kind"], "semantic",
8402 "kind=semantic must drop explicit edges: {body}"
8403 );
8404 assert!(e["weight"].is_number(), "semantic edges carry weight: {body}");
8405 }
8406 });
8407 h.shutdown(&runtime);
8408 }
8409
8410 #[test]
8412 fn neighbors_both_default_returns_combined() {
8413 let runtime = rt();
8414 let h = Harness::new(&runtime);
8415 runtime.block_on(async {
8416 let focal = post_remember(h.router.clone(), "alpha alpha alpha").await;
8417 let _other1 = post_remember(h.router.clone(), "beta beta beta").await;
8418 {
8419 let conn = h.open_db();
8420 let rowid: i64 = conn
8421 .query_row(
8422 "SELECT rowid FROM episodes WHERE memory_id = ?1",
8423 rusqlite::params![&focal],
8424 |r| r.get(0),
8425 )
8426 .unwrap();
8427 seed_triple_row(&conn, "t-both-1", "Alice", "met", "Bob", Some(rowid));
8428 }
8429 let (status, body) = call(
8430 h.router.clone(),
8431 "GET",
8432 &neighbors_uri(&format!("ep:{focal}"), None, Some(0.0), None),
8435 None,
8436 )
8437 .await;
8438 assert_eq!(status, StatusCode::OK, "body: {body}");
8439 let edges = body["edges"].as_array().unwrap();
8440 let kinds: std::collections::HashSet<&str> = edges
8441 .iter()
8442 .map(|e| e["kind"].as_str().unwrap())
8443 .collect();
8444 assert!(
8445 kinds.contains("triple"),
8446 "expected at least one triple edge: {body}"
8447 );
8448 assert!(
8449 kinds.contains("semantic"),
8450 "expected at least one semantic edge: {body}"
8451 );
8452 });
8453 h.shutdown(&runtime);
8454 }
8455
8456 #[test]
8461 fn neighbors_dedupes_semantic_when_explicit_exists() {
8462 let runtime = rt();
8463 let h = Harness::new(&runtime);
8464 runtime.block_on(async {
8465 let focal = post_remember(h.router.clone(), "alpha alpha alpha").await;
8466 let _other = post_remember(h.router.clone(), "beta beta beta").await;
8502 {
8503 let conn = h.open_db();
8504 let rowid: i64 = conn
8505 .query_row(
8506 "SELECT rowid FROM episodes WHERE memory_id = ?1",
8507 rusqlite::params![&focal],
8508 |r| r.get(0),
8509 )
8510 .unwrap();
8511 seed_triple_row(
8512 &conn,
8513 "t-dedupe-1",
8514 "Alice",
8515 "knows",
8516 "Bob",
8517 Some(rowid),
8518 );
8519 }
8520 let (status, body) = call(
8521 h.router.clone(),
8522 "GET",
8523 &neighbors_uri(&format!("ep:{focal}"), Some("both"), Some(0.0), None),
8524 None,
8525 )
8526 .await;
8527 assert_eq!(status, StatusCode::OK, "body: {body}");
8528 let edges = body["edges"].as_array().unwrap();
8532 let mut seen: std::collections::HashMap<(String, String), i32> =
8533 std::collections::HashMap::new();
8534 for e in edges {
8535 let key = (
8536 e["source"].as_str().unwrap().to_string(),
8537 e["target"].as_str().unwrap().to_string(),
8538 );
8539 *seen.entry(key).or_insert(0) += 1;
8540 }
8541 for (pair, count) in &seen {
8542 assert_eq!(
8543 *count, 1,
8544 "edge pair {pair:?} appears {count} times -- dedupe rule violated: {body}"
8545 );
8546 }
8547 });
8548 h.shutdown(&runtime);
8549 }
8550
8551 #[test]
8554 fn neighbors_threshold_filters_low_similarity() {
8555 let runtime = rt();
8556 let h = Harness::new(&runtime);
8557 runtime.block_on(async {
8558 let focal = post_remember(h.router.clone(), "alpha alpha alpha").await;
8559 let _o1 = post_remember(h.router.clone(), "beta one").await;
8560 let _o2 = post_remember(h.router.clone(), "beta two").await;
8561 let _o3 = post_remember(h.router.clone(), "beta three").await;
8562 let (status, low_body) = call(
8564 h.router.clone(),
8565 "GET",
8566 &neighbors_uri(&format!("ep:{focal}"), Some("semantic"), Some(0.0), None),
8567 None,
8568 )
8569 .await;
8570 assert_eq!(status, StatusCode::OK, "body: {low_body}");
8571 let low_edge_count = low_body["edges"].as_array().unwrap().len();
8572 let (status, high_body) = call(
8574 h.router.clone(),
8575 "GET",
8576 &neighbors_uri(&format!("ep:{focal}"), Some("semantic"), Some(0.99), None),
8577 None,
8578 )
8579 .await;
8580 assert_eq!(status, StatusCode::OK, "body: {high_body}");
8581 let high_edge_count = high_body["edges"].as_array().unwrap().len();
8582 assert!(
8583 high_edge_count <= low_edge_count,
8584 "high-threshold ({high_edge_count}) must not exceed low-threshold ({low_edge_count}): low={low_body}, high={high_body}"
8585 );
8586 for e in high_body["edges"].as_array().unwrap() {
8589 if let Some(w) = e["weight"].as_f64() {
8590 assert!(
8591 w >= 0.99,
8592 "edge with weight {w} survived threshold=0.99: {e}"
8593 );
8594 }
8595 }
8596 });
8597 h.shutdown(&runtime);
8598 }
8599
8600 #[test]
8603 fn neighbors_limit_clamped_at_100() {
8604 let runtime = rt();
8605 let h = Harness::new(&runtime);
8606 {
8609 let conn = h.open_db();
8610 seed_cluster_row(&conn, "cl-huge-n", 1000);
8611 for i in 0..150 {
8612 let mid = format!("99119911-1111-7000-8000-{:012}", i);
8613 seed_episode(&conn, &mid, 100 + i as i64, &format!("content {i}"));
8614 seed_cluster_member(&conn, "cl-huge-n", &mid);
8615 }
8616 }
8617 let (status, body) = runtime.block_on(call(
8618 h.router.clone(),
8619 "GET",
8620 &neighbors_uri("cl:cl-huge-n", Some("explicit"), None, Some(999)),
8621 None,
8622 ));
8623 assert_eq!(status, StatusCode::OK, "body: {body}");
8624 let edges = body["edges"].as_array().unwrap();
8625 assert_eq!(
8626 edges.len(),
8627 100,
8628 "limit must be silently clamped to 100, got {}",
8629 edges.len()
8630 );
8631 h.shutdown(&runtime);
8632 }
8633
8634 #[test]
8636 fn neighbors_semantic_rejects_document_source() {
8637 let runtime = rt();
8638 let h = Harness::new(&runtime);
8639 let doc_id = "d-semrej-0000-7000-8000-000000000001";
8640 {
8641 let conn = h.open_db();
8642 seed_document_row(&conn, doc_id, "host");
8643 }
8644 let (status, body) = runtime.block_on(call(
8645 h.router.clone(),
8646 "GET",
8647 &neighbors_uri(
8648 &format!("doc:{doc_id}"),
8649 Some("semantic"),
8650 None,
8651 None,
8652 ),
8653 None,
8654 ));
8655 assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}");
8656 let err = body["error"].as_str().unwrap_or_default();
8657 assert!(
8658 err.contains("episode") && err.contains("chunk"),
8659 "error must list supported kinds: {body}"
8660 );
8661 h.shutdown(&runtime);
8662 }
8663
8664 #[test]
8666 fn neighbors_semantic_rejects_cluster_source() {
8667 let runtime = rt();
8668 let h = Harness::new(&runtime);
8669 let cluster_id = "cl-semrej-target";
8670 {
8671 let conn = h.open_db();
8672 seed_cluster_row(&conn, cluster_id, 12345);
8673 }
8674 let (status, body) = runtime.block_on(call(
8675 h.router.clone(),
8676 "GET",
8677 &neighbors_uri(
8678 &format!("cl:{cluster_id}"),
8679 Some("semantic"),
8680 None,
8681 None,
8682 ),
8683 None,
8684 ));
8685 assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}");
8686 h.shutdown(&runtime);
8687 }
8688
8689 #[test]
8693 fn neighbors_entity_returns_triples_only() {
8694 let runtime = rt();
8695 let h = Harness::new(&runtime);
8696 runtime.block_on(async {
8697 let host_mid = post_remember(h.router.clone(), "Alice and Bob talked").await;
8702 {
8703 let conn = h.open_db();
8704 let rowid: i64 = conn
8705 .query_row(
8706 "SELECT rowid FROM episodes WHERE memory_id = ?1",
8707 rusqlite::params![&host_mid],
8708 |r| r.get(0),
8709 )
8710 .unwrap();
8711 seed_triple_row(&conn, "t-ent-n-1", "Alice", "knows", "Bob", Some(rowid));
8712 seed_triple_row(&conn, "t-ent-n-2", "Alice", "works_at", "Acme", Some(rowid));
8713 }
8714 let (status, body) = call(
8715 h.router.clone(),
8716 "GET",
8717 &neighbors_uri("ent:Alice", None, Some(0.0), None),
8718 None,
8719 )
8720 .await;
8721 assert_eq!(status, StatusCode::OK, "body: {body}");
8722 let edges = body["edges"].as_array().unwrap();
8723 assert!(!edges.is_empty(), "expected explicit triples: {body}");
8724 for e in edges {
8725 assert_eq!(
8726 e["kind"], "triple",
8727 "entity focal must produce only triple edges: {body}"
8728 );
8729 }
8730 });
8731 h.shutdown(&runtime);
8732 }
8733
8734 #[test]
8737 fn neighbors_respects_tenant_scoping() {
8738 let runtime = rt();
8739 let h = Harness::new(&runtime);
8740 let memory_id = "a8880000-0000-7000-8000-000000000001";
8741 {
8742 let conn = h.open_db();
8743 seed_episode(&conn, memory_id, 100, "tenant scope");
8744 }
8745 let r = h.router.clone();
8747 let (status, _) = runtime.block_on(async {
8748 let req = Request::builder()
8749 .method("GET")
8750 .uri(neighbors_uri(
8751 &format!("ep:{memory_id}"),
8752 Some("explicit"),
8753 None,
8754 None,
8755 ))
8756 .header("x-solo-tenant", "never-registered-tenant-n")
8757 .body(Body::empty())
8758 .unwrap();
8759 let resp = r.oneshot(req).await.expect("oneshot");
8760 let s = resp.status();
8761 let _b = resp.into_body().collect().await.unwrap().to_bytes();
8762 (s, _b)
8763 });
8764 assert_eq!(status, StatusCode::NOT_FOUND);
8765 let (status, body) = runtime.block_on(call(
8767 h.router.clone(),
8768 "GET",
8769 &neighbors_uri(&format!("ep:{memory_id}"), Some("explicit"), None, None),
8770 None,
8771 ));
8772 assert_eq!(status, StatusCode::OK, "default tenant must resolve: {body}");
8773 h.shutdown(&runtime);
8774 }
8775
8776 #[test]
8779 fn neighbors_respects_auth_when_enabled() {
8780 let runtime = rt();
8781 let h = Harness::new_with_auth(&runtime, Some("neighbors-secret".into()));
8782 let (status, _) = runtime.block_on(call(
8784 h.router.clone(),
8785 "GET",
8786 &neighbors_uri(
8787 "ep:99999999-9999-7000-8000-000000000999",
8788 Some("explicit"),
8789 None,
8790 None,
8791 ),
8792 None,
8793 ));
8794 assert_eq!(status, StatusCode::UNAUTHORIZED);
8795 let (status, _) = runtime.block_on(call_with_auth(
8797 h.router.clone(),
8798 "GET",
8799 &neighbors_uri(
8800 "ep:99999999-9999-7000-8000-000000000999",
8801 Some("explicit"),
8802 None,
8803 None,
8804 ),
8805 None,
8806 Some("Bearer neighbors-secret"),
8807 ));
8808 assert_eq!(status, StatusCode::NOT_FOUND);
8809 h.shutdown(&runtime);
8810 }
8811
8812 #[derive(Debug, Clone)]
8827 struct ParsedSseEvent {
8828 event: String,
8829 data: Value,
8830 }
8831
8832 async fn read_one_sse_event(
8836 body: &mut axum::body::Body,
8837 timeout: std::time::Duration,
8838 ) -> Option<ParsedSseEvent> {
8839 use http_body_util::BodyExt;
8840 let mut buf = String::new();
8841 let start = std::time::Instant::now();
8842 loop {
8843 if start.elapsed() >= timeout {
8844 return None;
8845 }
8846 let remaining = timeout.saturating_sub(start.elapsed());
8847 let frame_res =
8848 tokio::time::timeout(remaining, body.frame()).await;
8849 let frame = match frame_res {
8850 Ok(Some(Ok(f))) => f,
8851 Ok(Some(Err(_))) | Ok(None) => return None,
8852 Err(_) => return None,
8853 };
8854 if let Ok(data) = frame.into_data() {
8855 buf.push_str(&String::from_utf8_lossy(&data));
8856 while let Some(idx) = buf.find("\n\n") {
8858 let block: String = buf.drain(..idx + 2).collect();
8859 if let Some(parsed) = parse_sse_block(&block) {
8860 return Some(parsed);
8861 }
8862 }
8863 }
8864 }
8865 }
8866
8867 fn parse_sse_block(block: &str) -> Option<ParsedSseEvent> {
8871 let mut event: Option<String> = None;
8872 let mut data: Option<String> = None;
8873 for line in block.lines() {
8874 if let Some(rest) = line.strip_prefix("event:") {
8875 event = Some(rest.trim().to_string());
8876 } else if let Some(rest) = line.strip_prefix("data:") {
8877 data = Some(rest.trim().to_string());
8878 }
8879 }
8880 let event = event?;
8881 let data_str = data?;
8882 let data_json = serde_json::from_str(&data_str).ok()?;
8883 Some(ParsedSseEvent {
8884 event,
8885 data: data_json,
8886 })
8887 }
8888
8889 async fn open_sse_stream_inner(
8893 router: axum::Router,
8894 auth: Option<&str>,
8895 tenant: Option<&str>,
8896 ) -> (StatusCode, axum::body::Body) {
8897 let mut builder = Request::builder()
8898 .method("GET")
8899 .uri("/v1/graph/stream");
8900 if let Some(a) = auth {
8901 builder = builder.header("authorization", a);
8902 }
8903 if let Some(t) = tenant {
8904 builder = builder.header("x-solo-tenant", t);
8905 }
8906 let req = builder
8907 .header("content-length", "0")
8908 .body(Body::empty())
8909 .unwrap();
8910 let resp = router.oneshot(req).await.expect("oneshot");
8911 let status = resp.status();
8912 let body = resp.into_body();
8913 (status, body)
8914 }
8915
8916 #[test]
8918 fn stream_emits_init_event_on_connect() {
8919 let runtime = rt();
8920 let h = Harness::new(&runtime);
8921 let r = h.router.clone();
8922 runtime.block_on(async {
8923 let (status, mut body) = open_sse_stream_inner(r, None, None).await;
8924 assert_eq!(status, StatusCode::OK);
8925 let ev = read_one_sse_event(&mut body, std::time::Duration::from_secs(2))
8926 .await
8927 .expect("must receive init event within 2s");
8928 assert_eq!(ev.event, "init");
8929 assert_eq!(ev.data["connected"].as_bool(), Some(true));
8930 assert_eq!(ev.data["tenant_id"].as_str(), Some("default"));
8931 assert!(ev.data["ts_ms"].is_number());
8932 });
8933 h.shutdown(&runtime);
8934 }
8935
8936 #[test]
8939 fn stream_emits_invalidate_after_writer_event() {
8940 let runtime = rt();
8941 let h = Harness::new(&runtime);
8942 let r = h.router.clone();
8943 let sender = h.invalidate_sender();
8944 runtime.block_on(async {
8945 let (status, mut body) = open_sse_stream_inner(r, None, None).await;
8946 assert_eq!(status, StatusCode::OK);
8947 let init = read_one_sse_event(&mut body, std::time::Duration::from_secs(2))
8949 .await
8950 .unwrap();
8951 assert_eq!(init.event, "init");
8952 sender
8954 .send(InvalidateEvent {
8955 reason: "memory.remember".to_string(),
8956 tenant_id: "default".to_string(),
8957 ts_ms: 1_715_625_600_000,
8958 kind: "episode".to_string(),
8959 })
8960 .expect("must have at least one subscriber");
8961 let ev = read_one_sse_event(&mut body, std::time::Duration::from_secs(2))
8963 .await
8964 .expect("invalidate event must arrive within 2s");
8965 assert_eq!(ev.event, "invalidate");
8966 assert_eq!(ev.data["reason"].as_str(), Some("memory.remember"));
8967 assert_eq!(ev.data["tenant_id"].as_str(), Some("default"));
8968 assert_eq!(ev.data["kind"].as_str(), Some("episode"));
8969 });
8970 h.shutdown(&runtime);
8971 }
8972
8973 #[test]
8976 fn stream_emits_invalidate_for_each_writer_command() {
8977 let runtime = rt();
8978 let h = Harness::new(&runtime);
8979 let r = h.router.clone();
8980 let sender = h.invalidate_sender();
8981 let cases = [
8982 ("memory.remember", "episode"),
8983 ("memory.forget", "episode"),
8984 ("memory.consolidate", "cluster"),
8985 ("memory.ingest_document", "document"),
8986 ("memory.forget_document", "document"),
8987 ("memory.triples_extract", "cluster"),
8988 ("memory.reembed", "episode"),
8989 ("gdpr.forget_user", "tenant"),
8990 ];
8991 runtime.block_on(async {
8992 let (status, mut body) = open_sse_stream_inner(r, None, None).await;
8993 assert_eq!(status, StatusCode::OK);
8994 let _ = read_one_sse_event(&mut body, std::time::Duration::from_secs(2))
8996 .await
8997 .unwrap();
8998 for (reason, kind) in cases {
8999 sender
9000 .send(InvalidateEvent {
9001 reason: reason.to_string(),
9002 tenant_id: "default".to_string(),
9003 ts_ms: 1_715_625_600_000,
9004 kind: kind.to_string(),
9005 })
9006 .unwrap();
9007 let ev = read_one_sse_event(&mut body, std::time::Duration::from_secs(2))
9008 .await
9009 .unwrap_or_else(|| panic!("must receive event for {reason}"));
9010 assert_eq!(ev.event, "invalidate");
9011 assert_eq!(
9012 ev.data["reason"].as_str(),
9013 Some(reason),
9014 "reason mismatch"
9015 );
9016 assert_eq!(ev.data["kind"].as_str(), Some(kind), "kind mismatch");
9017 }
9018 });
9019 h.shutdown(&runtime);
9020 }
9021
9022 #[test]
9030 fn stream_emits_heartbeat_when_no_events() {
9031 let runtime = rt();
9032 let h = Harness::new(&runtime);
9033 let sender = h.invalidate_sender();
9034 runtime.block_on(async {
9035 let rx = sender.subscribe();
9038 let stream = build_invalidate_stream(rx, "default".to_string(), 1);
9041 let sse: Sse<_> = Sse::new(stream);
9045 let resp = sse.into_response();
9046 let mut body = resp.into_body();
9047 let first =
9049 read_one_sse_event(&mut body, std::time::Duration::from_secs(2))
9050 .await
9051 .expect("init event must arrive");
9052 assert_eq!(first.event, "init");
9053 let second =
9056 read_one_sse_event(&mut body, std::time::Duration::from_secs(3))
9057 .await
9058 .expect("heartbeat event must arrive within 3s");
9059 assert_eq!(second.event, "heartbeat");
9060 assert!(second.data["ts_ms"].is_number());
9061 });
9062 h.shutdown(&runtime);
9063 }
9064
9065 #[test]
9068 fn stream_concurrent_subscribers_same_tenant() {
9069 let runtime = rt();
9070 let h = Harness::new(&runtime);
9071 let r1 = h.router.clone();
9072 let r2 = h.router.clone();
9073 let r3 = h.router.clone();
9074 let sender = h.invalidate_sender();
9075 runtime.block_on(async {
9076 let (s1, mut body1) = open_sse_stream_inner(r1, None, None).await;
9078 let (s2, mut body2) = open_sse_stream_inner(r2, None, None).await;
9079 let (s3, mut body3) = open_sse_stream_inner(r3, None, None).await;
9080 assert_eq!(s1, StatusCode::OK);
9081 assert_eq!(s2, StatusCode::OK);
9082 assert_eq!(s3, StatusCode::OK);
9083 for body in [&mut body1, &mut body2, &mut body3] {
9085 let ev = read_one_sse_event(body, std::time::Duration::from_secs(2))
9086 .await
9087 .unwrap();
9088 assert_eq!(ev.event, "init");
9089 }
9090 assert!(
9092 sender.receiver_count() >= 3,
9093 "expected ≥3 subscribers, got {}",
9094 sender.receiver_count()
9095 );
9096 sender
9098 .send(InvalidateEvent {
9099 reason: "memory.remember".to_string(),
9100 tenant_id: "default".to_string(),
9101 ts_ms: 1_715_625_600_000,
9102 kind: "episode".to_string(),
9103 })
9104 .expect("send must succeed");
9105 for body in [&mut body1, &mut body2, &mut body3] {
9107 let ev = read_one_sse_event(body, std::time::Duration::from_secs(2))
9108 .await
9109 .unwrap();
9110 assert_eq!(ev.event, "invalidate");
9111 assert_eq!(ev.data["reason"].as_str(), Some("memory.remember"));
9112 }
9113 });
9114 h.shutdown(&runtime);
9115 }
9116
9117 #[test]
9120 fn stream_handles_client_disconnect_gracefully() {
9121 let runtime = rt();
9122 let h = Harness::new(&runtime);
9123 let r = h.router.clone();
9124 let sender = h.invalidate_sender();
9125 let before = sender.receiver_count();
9126 runtime.block_on(async {
9127 let (status, mut body) = open_sse_stream_inner(r, None, None).await;
9128 assert_eq!(status, StatusCode::OK);
9129 let _ = read_one_sse_event(&mut body, std::time::Duration::from_secs(2))
9131 .await
9132 .unwrap();
9133 let during = sender.receiver_count();
9134 assert!(
9135 during > before,
9136 "subscriber count must increase while stream is live (before={before}, during={during})"
9137 );
9138 drop(body);
9142 });
9143 runtime.block_on(async {
9145 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
9146 });
9147 let after = sender.receiver_count();
9148 assert!(
9149 after <= before,
9150 "subscriber count must drop back after disconnect (before={before}, after={after})"
9151 );
9152 h.shutdown(&runtime);
9153 }
9154
9155 #[test]
9157 fn stream_respects_auth_when_enabled() {
9158 let runtime = rt();
9159 let h = Harness::new_with_auth(&runtime, Some("stream-secret".into()));
9160 let r = h.router.clone();
9161 runtime.block_on(async {
9162 let (status, _body) = open_sse_stream_inner(r, None, None).await;
9163 assert_eq!(status, StatusCode::UNAUTHORIZED);
9164 });
9165 h.shutdown(&runtime);
9166 }
9167
9168 #[test]
9170 fn stream_works_with_auth_none() {
9171 let runtime = rt();
9172 let h = Harness::new(&runtime);
9173 let r = h.router.clone();
9174 runtime.block_on(async {
9175 let (status, mut body) = open_sse_stream_inner(r, None, None).await;
9176 assert_eq!(status, StatusCode::OK);
9177 let ev = read_one_sse_event(&mut body, std::time::Duration::from_secs(2))
9178 .await
9179 .expect("must receive init event");
9180 assert_eq!(ev.event, "init");
9181 });
9182 h.shutdown(&runtime);
9183 }
9184
9185 #[test]
9187 fn stream_respects_auth_accepts_valid_token() {
9188 let runtime = rt();
9189 let h = Harness::new_with_auth(&runtime, Some("stream-secret".into()));
9190 let r = h.router.clone();
9191 runtime.block_on(async {
9192 let (status, mut body) =
9193 open_sse_stream_inner(r, Some("Bearer stream-secret"), None).await;
9194 assert_eq!(status, StatusCode::OK);
9195 let ev = read_one_sse_event(&mut body, std::time::Duration::from_secs(2))
9196 .await
9197 .expect("must receive init event with valid bearer");
9198 assert_eq!(ev.event, "init");
9199 assert_eq!(ev.data["tenant_id"].as_str(), Some("default"));
9200 });
9201 h.shutdown(&runtime);
9202 }
9203
9204 #[test]
9207 fn stream_respects_tenant_scoping() {
9208 let runtime = rt();
9209 let h = Harness::new(&runtime);
9210 let r = h.router.clone();
9211 runtime.block_on(async {
9212 let (status, _body) =
9213 open_sse_stream_inner(r, None, Some("never-registered-tenant-x")).await;
9214 assert_eq!(status, StatusCode::NOT_FOUND);
9218 });
9219 h.shutdown(&runtime);
9220 }
9221
9222 async fn seed_three_tenants(registry: &TenantRegistry) -> Vec<String> {
9240 use solo_core::TenantId as TenantIdT;
9241 let ids = ["alice", "bob", "default"];
9242 for id in ids {
9243 let tid = TenantIdT::new(id).unwrap();
9244 registry
9245 .with_index(|idx| {
9246 idx.register(&tid, &format!("{id}.db"), Some(&format!("{id} tenant")))
9247 .unwrap();
9248 })
9253 .await;
9254 tokio::time::sleep(std::time::Duration::from_millis(2)).await;
9255 }
9256 vec!["alice".into(), "bob".into(), "default".into()]
9260 }
9261
9262 #[test]
9266 fn tenants_returns_all_when_auth_none() {
9267 let runtime = rt();
9268 let h = Harness::new(&runtime);
9269 let r = h.router.clone();
9270 runtime.block_on(async {
9271 let _expected = seed_three_tenants(&h.registry).await;
9272 let (status, body) = call(r, "GET", "/v1/tenants", None).await;
9273 assert_eq!(status, StatusCode::OK);
9274 let arr = body
9275 .get("tenants")
9276 .and_then(|v| v.as_array())
9277 .expect("tenants array");
9278 assert_eq!(arr.len(), 3, "got body: {body}");
9279 let ids: Vec<&str> =
9280 arr.iter().filter_map(|t| t["id"].as_str()).collect();
9281 assert_eq!(ids, vec!["alice", "bob", "default"]);
9282 });
9283 h.shutdown(&runtime);
9284 }
9285
9286 #[test]
9291 fn tenants_returns_all_when_bearer_auth() {
9292 let runtime = rt();
9293 let h = Harness::new_with_auth(&runtime, Some("tlist-secret".into()));
9294 let r = h.router.clone();
9295 runtime.block_on(async {
9296 seed_three_tenants(&h.registry).await;
9297 let (status, body) = call_with_auth(
9298 r,
9299 "GET",
9300 "/v1/tenants",
9301 None,
9302 Some("Bearer tlist-secret"),
9303 )
9304 .await;
9305 assert_eq!(status, StatusCode::OK, "got body: {body}");
9306 let arr = body["tenants"].as_array().expect("tenants array");
9307 assert_eq!(arr.len(), 3, "bearer must see all tenants");
9308 });
9309 h.shutdown(&runtime);
9310 }
9311
9312 #[test]
9316 fn tenants_filters_to_principal_claim_when_oidc() {
9317 let runtime = rt();
9318 let (fake_server, discovery_url, secret, kid) =
9319 runtime.block_on(async { spin_fake_idp().await });
9320 let server_uri = fake_server.uri();
9321 let _server_guard = fake_server;
9322
9323 let auth = crate::auth::AuthConfig::Oidc {
9324 discovery_url,
9325 audience: "tlist-audience".to_string(),
9326 tenant_claim_name: "solo_tenant".to_string(),
9327 };
9328 let h = Harness::new_with_auth_config(&runtime, Some(auth));
9329 let r = h.router.clone();
9330
9331 runtime.block_on(async {
9332 seed_three_tenants(&h.registry).await;
9333 let token = mint_idp_token(
9334 &server_uri,
9335 kid,
9336 &secret,
9337 "alice",
9338 "tlist-audience",
9339 );
9340 let (status, body) = call_with_auth(
9341 r,
9342 "GET",
9343 "/v1/tenants",
9344 None,
9345 Some(&format!("Bearer {token}")),
9346 )
9347 .await;
9348 assert_eq!(status, StatusCode::OK, "got body: {body}");
9349 let arr = body["tenants"].as_array().expect("tenants array");
9350 assert_eq!(arr.len(), 1, "OIDC alice must see exactly one tenant");
9351 assert_eq!(arr[0]["id"].as_str(), Some("alice"));
9352 });
9353 h.shutdown(&runtime);
9354 }
9355
9356 #[test]
9362 fn tenants_returns_empty_when_oidc_claim_unmatched() {
9363 let runtime = rt();
9364 let (fake_server, discovery_url, secret, kid) =
9365 runtime.block_on(async { spin_fake_idp().await });
9366 let server_uri = fake_server.uri();
9367 let _server_guard = fake_server;
9368
9369 let auth = crate::auth::AuthConfig::Oidc {
9370 discovery_url,
9371 audience: "tlist-audience".to_string(),
9372 tenant_claim_name: "solo_tenant".to_string(),
9373 };
9374 let h = Harness::new_with_auth_config(&runtime, Some(auth));
9375 let r = h.router.clone();
9376
9377 runtime.block_on(async {
9378 seed_three_tenants(&h.registry).await;
9379 let token = mint_idp_token(
9382 &server_uri,
9383 kid,
9384 &secret,
9385 "nonexistent",
9386 "tlist-audience",
9387 );
9388 let (status, body) = call_with_auth(
9389 r,
9390 "GET",
9391 "/v1/tenants",
9392 None,
9393 Some(&format!("Bearer {token}")),
9394 )
9395 .await;
9396 assert_eq!(
9397 status,
9398 StatusCode::OK,
9399 "must be 200 OK, not 404 — don't leak tenant existence: {body}"
9400 );
9401 let arr = body["tenants"].as_array().expect("tenants array");
9402 assert_eq!(
9403 arr.len(),
9404 0,
9405 "unmatched OIDC claim must produce empty list, got: {body}"
9406 );
9407 });
9408 h.shutdown(&runtime);
9409 }
9410
9411 #[test]
9426 fn tenants_response_shape_matches_solo_web_types() {
9427 let runtime = rt();
9428 let h = Harness::new(&runtime);
9429 let r = h.router.clone();
9430 runtime.block_on(async {
9431 let tid = solo_core::TenantId::new("shaped").unwrap();
9434 h.registry
9435 .with_index(|idx| {
9436 idx.register_with_quota(
9437 &tid,
9438 "shaped.db",
9439 Some("Shaped tenant"),
9440 Some(1_048_576),
9441 )
9442 .unwrap();
9443 })
9444 .await;
9445 let (status, body) = call(r, "GET", "/v1/tenants", None).await;
9446 assert_eq!(status, StatusCode::OK);
9447 let item = &body["tenants"][0];
9448 assert_eq!(item["id"].as_str(), Some("shaped"));
9450 assert_eq!(item["display_name"].as_str(), Some("Shaped tenant"));
9451 assert!(
9452 item["created_at_ms"].is_i64(),
9453 "created_at_ms must be an i64, got {item}"
9454 );
9455 assert_eq!(item["status"].as_str(), Some("active"));
9456 assert_eq!(item["quota_bytes"].as_u64(), Some(1_048_576));
9458 assert!(
9464 item["episode_count"].is_null(),
9465 "episode_count must be JSON null when tenant DB is missing, got {item}"
9466 );
9467 assert!(
9468 item["size_bytes"].is_null(),
9469 "size_bytes must be JSON null when tenant DB is missing, got {item}"
9470 );
9471 assert!(
9472 item["pct_used"].is_null(),
9473 "pct_used must be JSON null when size_bytes is null, got {item}"
9474 );
9475 });
9476 h.shutdown(&runtime);
9477 }
9478
9479 #[test]
9484 fn tenants_respects_auth_when_enabled() {
9485 let runtime = rt();
9486 let h = Harness::new_with_auth(&runtime, Some("must-auth".into()));
9487 let r = h.router.clone();
9488 runtime.block_on(async {
9489 seed_three_tenants(&h.registry).await;
9490 let (status, _body) = call(r, "GET", "/v1/tenants", None).await;
9492 assert_eq!(status, StatusCode::UNAUTHORIZED);
9493 });
9494 h.shutdown(&runtime);
9495 }
9496
9497 #[test]
9502 fn tenants_status_filter_excludes_non_active() {
9503 let runtime = rt();
9504 let h = Harness::new(&runtime);
9505 let r = h.router.clone();
9506 runtime.block_on(async {
9507 let keeper = solo_core::TenantId::new("keeper").unwrap();
9510 let migrating = solo_core::TenantId::new("migrating").unwrap();
9511 let deleting = solo_core::TenantId::new("deleting").unwrap();
9512 h.registry
9513 .with_index(|idx| {
9514 idx.register(&keeper, "keeper.db", None).unwrap();
9515 idx.register_with_status(
9516 &migrating,
9517 "migrating.db",
9518 None,
9519 solo_storage::TenantStatus::PendingMigration,
9520 )
9521 .unwrap();
9522 idx.register_with_status(
9523 &deleting,
9524 "deleting.db",
9525 None,
9526 solo_storage::TenantStatus::PendingDelete,
9527 )
9528 .unwrap();
9529 })
9530 .await;
9531 let (status, body) = call(r, "GET", "/v1/tenants", None).await;
9532 assert_eq!(status, StatusCode::OK);
9533 let arr = body["tenants"].as_array().expect("tenants array");
9534 let ids: Vec<&str> =
9535 arr.iter().filter_map(|t| t["id"].as_str()).collect();
9536 assert_eq!(
9537 ids,
9538 vec!["keeper"],
9539 "only Active tenants visible; got: {body}"
9540 );
9541 });
9542 h.shutdown(&runtime);
9543 }
9544
9545 #[test]
9550 fn tenants_returns_empty_array_when_no_tenants_registered() {
9551 let runtime = rt();
9552 let h = Harness::new(&runtime);
9553 let r = h.router.clone();
9554 runtime.block_on(async {
9555 let (status, body) = call(r, "GET", "/v1/tenants", None).await;
9559 assert_eq!(status, StatusCode::OK);
9560 let arr = body["tenants"].as_array().expect("tenants array");
9561 assert_eq!(arr.len(), 0, "expected empty array, got: {body}");
9562 });
9563 h.shutdown(&runtime);
9564 }
9565
9566 fn seed_per_tenant_db_with_episodes(
9592 data_dir: &std::path::Path,
9593 db_filename: &str,
9594 n_active: i64,
9595 n_forgotten: i64,
9596 ) -> std::path::PathBuf {
9597 let tenants_dir = data_dir.join(solo_storage::TENANTS_SUBDIR);
9598 std::fs::create_dir_all(&tenants_dir).unwrap();
9599 let db_path = tenants_dir.join(db_filename);
9600 let mut conn = rusqlite::Connection::open(&db_path).unwrap();
9604 solo_storage::run_migrations(&mut conn).unwrap();
9607 for i in 0..n_active {
9608 conn.execute(
9609 "INSERT INTO episodes (memory_id, ts_ms, source_type, content, confidence, strength, salience, tier, status, created_at_ms, updated_at_ms)
9610 VALUES (?, 0, 'user_message', 'x', 0.5, 0.5, 0.5, 'hot', 'active', 0, 0)",
9611 rusqlite::params![format!("a-{i}")],
9612 )
9613 .unwrap();
9614 }
9615 for i in 0..n_forgotten {
9616 conn.execute(
9617 "INSERT INTO episodes (memory_id, ts_ms, source_type, content, confidence, strength, salience, tier, status, created_at_ms, updated_at_ms)
9618 VALUES (?, 0, 'user_message', 'x', 0.5, 0.5, 0.5, 'hot', 'forgotten', 0, 0)",
9619 rusqlite::params![format!("f-{i}")],
9620 )
9621 .unwrap();
9622 }
9623 drop(conn);
9624 db_path
9625 }
9626
9627 #[test]
9632 fn tenants_response_hydrates_episode_count_when_tenant_has_data() {
9633 let runtime = rt();
9634 let h = Harness::new(&runtime);
9635 let r = h.router.clone();
9636 let data_dir = h._tmp.path().to_path_buf();
9637 runtime.block_on(async {
9638 let tid = solo_core::TenantId::new("counted").unwrap();
9639 seed_per_tenant_db_with_episodes(&data_dir, "counted.db", 3, 2);
9640 h.registry
9641 .with_index(|idx| {
9642 idx.register(&tid, "counted.db", Some("Counted tenant"))
9643 .unwrap();
9644 })
9645 .await;
9646 let (status, body) = call(r, "GET", "/v1/tenants", None).await;
9647 assert_eq!(status, StatusCode::OK);
9648 let item = &body["tenants"][0];
9649 assert_eq!(item["id"].as_str(), Some("counted"));
9650 assert_eq!(
9651 item["episode_count"].as_i64(),
9652 Some(3),
9653 "episode_count must be 3 (active rows only, 2 forgotten excluded); got {item}"
9654 );
9655 });
9656 h.shutdown(&runtime);
9657 }
9658
9659 #[test]
9664 fn tenants_response_hydrates_size_bytes_from_db_file() {
9665 let runtime = rt();
9666 let h = Harness::new(&runtime);
9667 let r = h.router.clone();
9668 let data_dir = h._tmp.path().to_path_buf();
9669 runtime.block_on(async {
9670 let tid = solo_core::TenantId::new("sized").unwrap();
9671 let db_path =
9672 seed_per_tenant_db_with_episodes(&data_dir, "sized.db", 1, 0);
9673 h.registry
9674 .with_index(|idx| {
9675 idx.register(&tid, "sized.db", None).unwrap();
9676 })
9677 .await;
9678 let on_disk = std::fs::metadata(&db_path).unwrap().len();
9679 assert!(on_disk > 0, "test setup: db file should be non-empty");
9680 let (status, body) = call(r, "GET", "/v1/tenants", None).await;
9681 assert_eq!(status, StatusCode::OK);
9682 let item = &body["tenants"][0];
9683 assert_eq!(item["id"].as_str(), Some("sized"));
9684 assert_eq!(
9685 item["size_bytes"].as_u64(),
9686 Some(on_disk),
9687 "size_bytes must match fs::metadata; got {item}"
9688 );
9689 });
9690 h.shutdown(&runtime);
9691 }
9692
9693 #[test]
9698 fn tenants_response_computes_pct_used_when_quota_set() {
9699 let runtime = rt();
9700 let h = Harness::new(&runtime);
9701 let r = h.router.clone();
9702 let data_dir = h._tmp.path().to_path_buf();
9703 runtime.block_on(async {
9704 let tid = solo_core::TenantId::new("quoted").unwrap();
9705 let db_path =
9706 seed_per_tenant_db_with_episodes(&data_dir, "quoted.db", 1, 0);
9707 let on_disk = std::fs::metadata(&db_path).unwrap().len();
9711 let quota = on_disk * 4; h.registry
9713 .with_index(|idx| {
9714 idx.register_with_quota(&tid, "quoted.db", None, Some(quota))
9715 .unwrap();
9716 })
9717 .await;
9718 let (status, body) = call(r, "GET", "/v1/tenants", None).await;
9719 assert_eq!(status, StatusCode::OK);
9720 let item = &body["tenants"][0];
9721 let pct = item["pct_used"].as_f64().expect("pct_used must be a number");
9722 assert!(
9723 (0.0..=100.0).contains(&pct),
9724 "pct_used must be in [0, 100], got {pct}"
9725 );
9726 assert!(
9730 (20.0..=30.0).contains(&pct),
9731 "pct_used must be ~25% for size=quota/4, got {pct}"
9732 );
9733 });
9734 h.shutdown(&runtime);
9735 }
9736
9737 #[test]
9741 fn tenants_response_pct_used_null_when_quota_null() {
9742 let runtime = rt();
9743 let h = Harness::new(&runtime);
9744 let r = h.router.clone();
9745 let data_dir = h._tmp.path().to_path_buf();
9746 runtime.block_on(async {
9747 let tid = solo_core::TenantId::new("unlimited").unwrap();
9748 seed_per_tenant_db_with_episodes(&data_dir, "unlimited.db", 1, 0);
9749 h.registry
9750 .with_index(|idx| {
9751 idx.register(&tid, "unlimited.db", None).unwrap();
9752 })
9753 .await;
9754 let (status, body) = call(r, "GET", "/v1/tenants", None).await;
9755 assert_eq!(status, StatusCode::OK);
9756 let item = &body["tenants"][0];
9757 assert_eq!(item["id"].as_str(), Some("unlimited"));
9758 assert!(
9759 item["quota_bytes"].is_null(),
9760 "test setup: quota_bytes must be null, got {item}"
9761 );
9762 assert!(
9763 item["pct_used"].is_null(),
9764 "pct_used must be JSON null when quota_bytes is null, got {item}"
9765 );
9766 assert!(
9769 item["size_bytes"].is_u64(),
9770 "size_bytes must still be present when quota_bytes is null, got {item}"
9771 );
9772 });
9773 h.shutdown(&runtime);
9774 }
9775
9776 #[test]
9789 fn tenants_response_sets_cap_reached_header_when_over_cap() {
9790 let runtime = rt();
9791 let h = Harness::new(&runtime);
9792 let r = h.router.clone();
9793 runtime.block_on(async {
9794 h.registry
9796 .with_index(|idx| {
9797 for i in 0..51 {
9798 let id = format!("t{i:02}");
9799 let tid = solo_core::TenantId::new(&id).unwrap();
9800 idx.register(&tid, &format!("{id}.db"), None).unwrap();
9801 }
9802 })
9803 .await;
9804 use axum::body::Body;
9806 use axum::http::Request;
9807 use http_body_util::BodyExt;
9808 let req = Request::builder()
9809 .method("GET")
9810 .uri("/v1/tenants")
9811 .body(Body::empty())
9812 .unwrap();
9813 let resp = r.oneshot(req).await.unwrap();
9814 assert_eq!(resp.status(), StatusCode::OK);
9815 let cap_header = resp
9816 .headers()
9817 .get(X_SOLO_TENANTS_COUNT_CAP_HEADER)
9818 .expect("cap-reached header must be present");
9819 assert_eq!(
9820 cap_header.to_str().unwrap(),
9821 "true",
9822 "cap-reached header value must be 'true' when over cap"
9823 );
9824 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
9827 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9828 let arr = body["tenants"].as_array().expect("tenants array");
9829 assert_eq!(arr.len(), 51, "got {} tenants", arr.len());
9830 assert!(
9835 arr[50]["episode_count"].is_null(),
9836 "the 51st tenant (beyond cap) must have null episode_count, got {}",
9837 arr[50]
9838 );
9839 });
9840 h.shutdown(&runtime);
9841 }
9842
9843 #[test]
9848 fn tenants_response_omits_cap_header_when_under_cap() {
9849 let runtime = rt();
9850 let h = Harness::new(&runtime);
9851 let r = h.router.clone();
9852 runtime.block_on(async {
9853 seed_three_tenants(&h.registry).await;
9854 use axum::body::Body;
9855 use axum::http::Request;
9856 let req = Request::builder()
9857 .method("GET")
9858 .uri("/v1/tenants")
9859 .body(Body::empty())
9860 .unwrap();
9861 let resp = r.oneshot(req).await.unwrap();
9862 assert_eq!(resp.status(), StatusCode::OK);
9863 assert!(
9864 resp.headers().get(X_SOLO_TENANTS_COUNT_CAP_HEADER).is_none(),
9865 "cap-reached header must be absent under the cap"
9866 );
9867 });
9868 h.shutdown(&runtime);
9869 }
9870
9871 fn make_record(id: &str) -> solo_storage::TenantRecord {
9881 solo_storage::TenantRecord {
9882 tenant_id: solo_core::TenantId::new(id).unwrap(),
9883 db_filename: format!("{id}.db"),
9884 display_name: None,
9885 created_at_ms: 0,
9886 status: solo_storage::TenantStatus::Active,
9887 quota_bytes: None,
9888 last_accessed_ms: None,
9889 }
9890 }
9891
9892 #[test]
9893 fn filter_no_principal_returns_all() {
9894 let records = vec![make_record("a"), make_record("b")];
9895 let out = filter_tenants_for_principal(records.clone(), None);
9896 assert_eq!(out.len(), 2);
9897 assert_eq!(out[0].tenant_id.as_str(), "a");
9898 assert_eq!(out[1].tenant_id.as_str(), "b");
9899 }
9900
9901 #[test]
9902 fn filter_bearer_principal_returns_all() {
9903 let records = vec![make_record("a"), make_record("b")];
9904 let p = AuthenticatedPrincipal::bearer(
9905 solo_core::TenantId::new("a").unwrap(),
9906 );
9907 let out = filter_tenants_for_principal(records, Some(&p));
9908 assert_eq!(out.len(), 2);
9909 }
9910
9911 #[test]
9912 fn filter_oidc_principal_keeps_only_claim() {
9913 let records = vec![make_record("a"), make_record("b"), make_record("c")];
9914 let p = AuthenticatedPrincipal {
9916 subject: "alice@example.com".to_string(),
9917 tenant_claim: Some(solo_core::TenantId::new("b").unwrap()),
9918 scopes: vec!["read".to_string()],
9919 claims: serde_json::json!({ "sub": "alice@example.com" }),
9920 };
9921 let out = filter_tenants_for_principal(records, Some(&p));
9922 assert_eq!(out.len(), 1);
9923 assert_eq!(out[0].tenant_id.as_str(), "b");
9924 }
9925
9926 #[test]
9927 fn filter_oidc_principal_with_no_claim_returns_empty() {
9928 let records = vec![make_record("a")];
9931 let p = AuthenticatedPrincipal {
9932 subject: "alice@example.com".to_string(),
9933 tenant_claim: None,
9934 scopes: vec![],
9935 claims: serde_json::json!({ "sub": "alice@example.com" }),
9936 };
9937 let out = filter_tenants_for_principal(records, Some(&p));
9938 assert!(out.is_empty());
9939 }
9940
9941 #[test]
9942 fn is_single_principal_bearer_discriminator() {
9943 let bearer = AuthenticatedPrincipal::bearer(
9944 solo_core::TenantId::new("default").unwrap(),
9945 );
9946 assert!(is_single_principal_bearer(&bearer));
9947
9948 let oidc = AuthenticatedPrincipal {
9949 subject: "alice".to_string(),
9950 tenant_claim: Some(solo_core::TenantId::new("alice").unwrap()),
9951 scopes: vec![],
9952 claims: serde_json::json!({ "x": 1 }),
9953 };
9954 assert!(!is_single_principal_bearer(&oidc));
9955
9956 let weird = AuthenticatedPrincipal {
9960 subject: "bearer".to_string(),
9961 tenant_claim: Some(solo_core::TenantId::default_tenant()),
9962 scopes: vec![],
9963 claims: serde_json::json!({ "leak": 1 }),
9964 };
9965 assert!(!is_single_principal_bearer(&weird));
9966 }
9967
9968 #[test]
9988 fn mcp_http_tools_list_returns_fourteen_canonical_tools() {
9989 let runtime = rt();
9990 let h = Harness::new(&runtime);
9991 let r = h.router.clone();
9992 runtime.block_on(async move {
9993 let req = json!({
9994 "jsonrpc": "2.0",
9995 "id": 1,
9996 "method": "tools/list",
9997 });
9998 let (status, body) = call(r, "POST", "/mcp", Some(req)).await;
9999 assert_eq!(status, StatusCode::OK);
10000 assert_eq!(body.get("jsonrpc").and_then(|v| v.as_str()), Some("2.0"));
10001 assert_eq!(body.get("id").and_then(|v| v.as_i64()), Some(1));
10002 let tools = body
10003 .pointer("/result/tools")
10004 .and_then(|v| v.as_array())
10005 .unwrap_or_else(|| panic!("missing /result/tools: {body}"));
10006 let mut names: Vec<String> = tools
10007 .iter()
10008 .filter_map(|t| t.get("name").and_then(|n| n.as_str()).map(String::from))
10009 .collect();
10010 names.sort();
10011 assert_eq!(
10012 names,
10013 vec![
10014 "memory_contradictions".to_string(),
10015 "memory_facts_about".to_string(),
10016 "memory_forget".to_string(),
10017 "memory_forget_document".to_string(),
10018 "memory_ingest_document".to_string(),
10019 "memory_inspect".to_string(),
10020 "memory_inspect_cluster".to_string(),
10021 "memory_inspect_document".to_string(),
10022 "memory_list_documents".to_string(),
10023 "memory_recall".to_string(),
10024 "memory_remember".to_string(),
10025 "memory_remember_batch".to_string(),
10026 "memory_search_docs".to_string(),
10027 "memory_themes".to_string(),
10028 ],
10029 "mcp_http: tools/list returned unexpected name set"
10030 );
10031 });
10032 h.shutdown(&runtime);
10033 }
10034
10035 #[test]
10041 fn mcp_http_remember_writes_episode_visible_via_graph_nodes() {
10042 let runtime = rt();
10043 let h = Harness::new(&runtime);
10044 let r = h.router.clone();
10045 runtime.block_on(async move {
10046 let req = json!({
10048 "jsonrpc": "2.0",
10049 "id": 2,
10050 "method": "tools/call",
10051 "params": {
10052 "name": "memory_remember",
10053 "arguments": { "content": "mcp-http-cross-surface-smoke" },
10054 },
10055 });
10056 let (status, body) = call(r.clone(), "POST", "/mcp", Some(req)).await;
10057 assert_eq!(status, StatusCode::OK);
10058 let result_text = body
10059 .pointer("/result/content/0/text")
10060 .and_then(|v| v.as_str())
10061 .unwrap_or_else(|| panic!("missing /result/content/0/text: {body}"));
10062 assert!(
10063 result_text.starts_with("remembered "),
10064 "expected `remembered <id>`, got: {result_text}"
10065 );
10066
10067 let (status2, nodes_body) =
10072 call(r, "GET", "/v1/graph/nodes?kind=episode&limit=10", None).await;
10073 assert_eq!(status2, StatusCode::OK);
10074 let nodes = nodes_body
10075 .get("nodes")
10076 .and_then(|v| v.as_array())
10077 .unwrap_or_else(|| panic!("missing nodes: {nodes_body}"));
10078 assert!(
10079 nodes.iter().any(|n| {
10080 let label_hit = n
10081 .get("label")
10082 .and_then(|c| c.as_str())
10083 .is_some_and(|s| s.contains("mcp-http-cross-surface-smoke"));
10084 let preview_hit = n
10085 .get("preview")
10086 .and_then(|c| c.as_str())
10087 .is_some_and(|s| s.contains("mcp-http-cross-surface-smoke"));
10088 label_hit || preview_hit
10089 }),
10090 "graph/nodes didn't surface the MCP-written episode: {nodes_body}"
10091 );
10092 });
10093 h.shutdown(&runtime);
10094 }
10095
10096 #[test]
10100 fn mcp_http_recall_returns_just_remembered_episode() {
10101 let runtime = rt();
10102 let h = Harness::new(&runtime);
10103 let r = h.router.clone();
10104 runtime.block_on(async move {
10105 let needle = "mcp-http-recall-needle-deadbeef";
10107 let req = json!({
10108 "jsonrpc": "2.0",
10109 "id": 3,
10110 "method": "tools/call",
10111 "params": {
10112 "name": "memory_remember",
10113 "arguments": { "content": needle },
10114 },
10115 });
10116 let (status, _body) = call(r.clone(), "POST", "/mcp", Some(req)).await;
10117 assert_eq!(status, StatusCode::OK);
10118
10119 let req = json!({
10121 "jsonrpc": "2.0",
10122 "id": 4,
10123 "method": "tools/call",
10124 "params": {
10125 "name": "memory_recall",
10126 "arguments": { "query": needle, "limit": 5 },
10127 },
10128 });
10129 let (status, body) = call(r, "POST", "/mcp", Some(req)).await;
10130 assert_eq!(status, StatusCode::OK);
10131 let recall_text = body
10132 .pointer("/result/content/0/text")
10133 .and_then(|v| v.as_str())
10134 .unwrap_or_else(|| panic!("missing /result/content/0/text: {body}"));
10135 assert!(
10136 recall_text.contains(needle),
10137 "recall didn't surface needle `{needle}`: {recall_text}"
10138 );
10139 });
10140 h.shutdown(&runtime);
10141 }
10142
10143 #[test]
10148 fn mcp_http_malformed_body_returns_400() {
10149 let runtime = rt();
10150 let h = Harness::new(&runtime);
10151 let r = h.router.clone();
10152 runtime.block_on(async move {
10153 let req = Request::builder()
10154 .method("POST")
10155 .uri("/mcp")
10156 .header("content-type", "application/json")
10157 .body(Body::from("not-json-at-all".as_bytes()))
10158 .unwrap();
10159 let resp = r.oneshot(req).await.unwrap();
10160 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
10161 let body_bytes =
10162 resp.into_body().collect().await.unwrap().to_bytes();
10163 let v: Value = serde_json::from_slice(&body_bytes).unwrap();
10164 assert!(
10165 v.get("error")
10166 .and_then(|e| e.as_str())
10167 .map(|s| s.contains("invalid JSON-RPC request"))
10168 .unwrap_or(false),
10169 "got: {v}"
10170 );
10171 });
10172 h.shutdown(&runtime);
10173 }
10174
10175 #[test]
10178 fn mcp_http_wrong_jsonrpc_version_returns_400() {
10179 let runtime = rt();
10180 let h = Harness::new(&runtime);
10181 let r = h.router.clone();
10182 runtime.block_on(async move {
10183 let req = json!({
10184 "jsonrpc": "1.0",
10185 "id": 1,
10186 "method": "tools/list",
10187 });
10188 let (status, _body) = call(r, "POST", "/mcp", Some(req)).await;
10189 assert_eq!(status, StatusCode::BAD_REQUEST);
10190 });
10191 h.shutdown(&runtime);
10192 }
10193
10194 #[test]
10198 fn mcp_http_unknown_method_returns_in_body_method_not_found() {
10199 let runtime = rt();
10200 let h = Harness::new(&runtime);
10201 let r = h.router.clone();
10202 runtime.block_on(async move {
10203 let req = json!({
10204 "jsonrpc": "2.0",
10205 "id": 5,
10206 "method": "definitely/not/a/method",
10207 });
10208 let (status, body) = call(r, "POST", "/mcp", Some(req)).await;
10209 assert_eq!(status, StatusCode::OK);
10210 assert_eq!(
10211 body.pointer("/error/code").and_then(|v| v.as_i64()),
10212 Some(-32601),
10213 "expected JSON-RPC METHOD_NOT_FOUND (-32601), got: {body}"
10214 );
10215 });
10216 h.shutdown(&runtime);
10217 }
10218
10219 #[test]
10222 fn mcp_http_post_respects_bearer_auth() {
10223 let runtime = rt();
10224 let h = Harness::new_with_auth(&runtime, Some("secret-mcp-token".into()));
10225 let r = h.router.clone();
10226 runtime.block_on(async move {
10227 let req = json!({
10229 "jsonrpc": "2.0",
10230 "id": 6,
10231 "method": "tools/list",
10232 });
10233 let (status, _body) = call(r.clone(), "POST", "/mcp", Some(req.clone())).await;
10234 assert_eq!(status, StatusCode::UNAUTHORIZED);
10235
10236 let (status, body) = call_with_auth(
10238 r,
10239 "POST",
10240 "/mcp",
10241 Some(req),
10242 Some("Bearer secret-mcp-token"),
10243 )
10244 .await;
10245 assert_eq!(status, StatusCode::OK);
10246 assert_eq!(
10247 body.pointer("/result/tools").and_then(|v| v.as_array()).map(|a| a.len()),
10248 Some(14),
10249 "authed tools/list should still return 14 tools: {body}"
10250 );
10251 });
10252 h.shutdown(&runtime);
10253 }
10254
10255 #[test]
10261 fn mcp_http_cors_preflight_allows_mcp_session_id_header() {
10262 let runtime = rt();
10263 let h = Harness::new(&runtime);
10264 let r = h.router.clone();
10265 runtime.block_on(async move {
10266 let req = Request::builder()
10267 .method("OPTIONS")
10268 .uri("/mcp")
10269 .header("origin", "http://localhost:5173")
10270 .header("access-control-request-method", "POST")
10271 .header(
10272 "access-control-request-headers",
10273 "content-type, mcp-session-id, x-solo-tenant, authorization",
10274 )
10275 .body(Body::empty())
10276 .unwrap();
10277 let resp = r.oneshot(req).await.unwrap();
10278 assert_eq!(resp.status(), StatusCode::OK);
10280 let allow_headers = resp
10281 .headers()
10282 .get("access-control-allow-headers")
10283 .and_then(|h| h.to_str().ok())
10284 .unwrap_or("")
10285 .to_lowercase();
10286 assert!(
10287 allow_headers.contains("mcp-session-id"),
10288 "preflight allow-headers must include mcp-session-id; got: {allow_headers}"
10289 );
10290 assert!(
10291 allow_headers.contains("x-solo-tenant"),
10292 "preflight allow-headers must still include x-solo-tenant; got: {allow_headers}"
10293 );
10294 let allow_origin = resp
10297 .headers()
10298 .get("access-control-allow-origin")
10299 .and_then(|h| h.to_str().ok())
10300 .unwrap_or("");
10301 assert_eq!(allow_origin, "http://localhost:5173");
10302 });
10303 h.shutdown(&runtime);
10304 }
10305
10306 #[test]
10309 fn mcp_http_notification_returns_202_accepted() {
10310 let runtime = rt();
10311 let h = Harness::new(&runtime);
10312 let r = h.router.clone();
10313 runtime.block_on(async move {
10314 let req = json!({
10315 "jsonrpc": "2.0",
10316 "method": "notifications/initialized",
10317 "params": {},
10318 });
10319 let (status, body) = call(r, "POST", "/mcp", Some(req)).await;
10320 assert_eq!(status, StatusCode::ACCEPTED);
10321 assert_eq!(body, Value::Null);
10324 });
10325 h.shutdown(&runtime);
10326 }
10327
10328 #[test]
10334 fn mcp_http_initialize_returns_solo_server_info() {
10335 let runtime = rt();
10336 let h = Harness::new(&runtime);
10337 let r = h.router.clone();
10338 runtime.block_on(async move {
10339 let req = json!({
10340 "jsonrpc": "2.0",
10341 "id": 7,
10342 "method": "initialize",
10343 "params": {
10344 "protocolVersion": "2024-11-05",
10345 "capabilities": {},
10346 "clientInfo": { "name": "solo-http-test", "version": "0.0.0" },
10347 },
10348 });
10349 let (status, body) = call(r, "POST", "/mcp", Some(req)).await;
10350 assert_eq!(status, StatusCode::OK);
10351 assert_eq!(
10352 body.pointer("/result/serverInfo/name").and_then(|v| v.as_str()),
10353 Some("solo"),
10354 "serverInfo.name must be `solo`, not `solo-api` or `rmcp`; got: {body}"
10355 );
10356 assert_eq!(
10362 body.pointer("/result/protocolVersion").and_then(|v| v.as_str()),
10363 Some("2024-11-05"),
10364 );
10365 });
10366 h.shutdown(&runtime);
10367 }
10368}
10369
10370#[cfg(test)]
10371mod cors_tests {
10372 use super::is_localhost_origin;
10373
10374 #[test]
10375 fn accepts_canonical_localhost_origins() {
10376 assert!(is_localhost_origin("http://localhost"));
10377 assert!(is_localhost_origin("http://localhost:3000"));
10378 assert!(is_localhost_origin("https://localhost:8443"));
10379 assert!(is_localhost_origin("http://127.0.0.1"));
10380 assert!(is_localhost_origin("http://127.0.0.1:5173"));
10381 assert!(is_localhost_origin("http://[::1]"));
10382 assert!(is_localhost_origin("http://[::1]:8080"));
10383 }
10384
10385 #[test]
10386 fn rejects_remote_origins() {
10387 assert!(!is_localhost_origin("http://example.com"));
10388 assert!(!is_localhost_origin("https://malicious.example"));
10389 assert!(!is_localhost_origin("http://192.168.1.5"));
10390 assert!(!is_localhost_origin("http://10.0.0.1"));
10391 }
10392
10393 #[test]
10394 fn rejects_dns_rebinding_tricks() {
10395 assert!(!is_localhost_origin("http://127.0.0.1.nip.io"));
10399 assert!(!is_localhost_origin("http://localhost.evil.com"));
10400 assert!(!is_localhost_origin("http://evil.localhost"));
10401 }
10402
10403 #[test]
10404 fn rejects_non_http_schemes() {
10405 assert!(!is_localhost_origin("file:///"));
10406 assert!(!is_localhost_origin("ws://localhost:3000"));
10407 assert!(!is_localhost_origin("javascript:alert(1)"));
10408 }
10409
10410 #[test]
10411 fn rejects_malformed() {
10412 assert!(!is_localhost_origin(""));
10413 assert!(!is_localhost_origin("localhost"));
10414 assert!(!is_localhost_origin("//localhost"));
10415 }
10416}
10417