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 .with_state(state.clone());
323
324 let authed = if let Some(cfg) = auth {
325 let validator = Arc::new(AuthValidator::from_config(
329 &cfg,
330 state.default_tenant.clone(),
331 ));
332 authed.layer(axum::middleware::from_fn_with_state(
333 validator,
334 crate::auth::middleware::auth_middleware,
335 ))
336 } else {
337 authed
338 };
339
340 public
341 .merge(authed)
342 .layer(cors)
343 .layer(TraceLayer::new_for_http())
344}
345
346pub fn router(state: SoloHttpState) -> Router {
348 router_with_auth_config(state, None)
349}
350
351fn build_cors_layer() -> CorsLayer {
352 CorsLayer::new()
366 .allow_origin(AllowOrigin::predicate(|origin: &HeaderValue, _req| {
367 origin
368 .to_str()
369 .map(is_localhost_origin)
370 .unwrap_or(false)
371 }))
372 .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
373 .allow_headers([
374 axum::http::header::CONTENT_TYPE,
375 axum::http::header::AUTHORIZATION,
376 axum::http::HeaderName::from_static("x-solo-tenant"),
381 ])
382}
383
384fn is_localhost_origin(origin: &str) -> bool {
388 let rest = origin
389 .strip_prefix("http://")
390 .or_else(|| origin.strip_prefix("https://"));
391 let host = match rest {
392 Some(r) => r,
393 None => return false,
394 };
395 let host = host.split('/').next().unwrap_or(host);
397 let host = if let Some(idx) = host.rfind(':') {
399 if host.starts_with('[') {
401 host.find(']')
403 .map(|i| &host[..=i])
404 .unwrap_or(host)
405 } else {
406 &host[..idx]
407 }
408 } else {
409 host
410 };
411 matches!(host, "localhost" | "127.0.0.1" | "[::1]")
412}
413
414pub async fn serve_http(
420 addr: SocketAddr,
421 state: SoloHttpState,
422 bearer_token: Option<String>,
423 shutdown: impl std::future::Future<Output = ()> + Send + 'static,
424) -> std::io::Result<()> {
425 let auth = bearer_token.map(|token| AuthConfig::Bearer { token });
426 serve_http_with_auth_config(addr, state, auth, shutdown).await
427}
428
429pub async fn serve_http_with_auth_config(
433 addr: SocketAddr,
434 state: SoloHttpState,
435 auth: Option<AuthConfig>,
436 shutdown: impl std::future::Future<Output = ()> + Send + 'static,
437) -> std::io::Result<()> {
438 let auth_kind = match &auth {
439 Some(AuthConfig::Bearer { .. }) => "bearer",
440 Some(AuthConfig::Oidc { .. }) => "oidc",
441 None => "none",
442 };
443 let app = router_with_auth_config(state, auth);
444 let listener = tokio::net::TcpListener::bind(addr).await?;
445 tracing::info!(%addr, auth = auth_kind, "solo http: listening");
446 axum::serve(listener, app)
447 .with_graceful_shutdown(shutdown)
448 .await
449}
450
451async fn openapi_handler() -> Json<serde_json::Value> {
465 Json(openapi_spec())
466}
467
468pub fn openapi_spec() -> serde_json::Value {
472 serde_json::json!({
473 "openapi": "3.1.0",
474 "info": {
475 "title": "Solo HTTP API",
476 "description":
477 "Local-first personal memory daemon. The HTTP transport \
478 mirrors the four MCP tools (memory_remember / recall / \
479 inspect / forget). Default deployment is loopback-only \
480 (127.0.0.1); LAN-bound deployments require a bearer \
481 token via `solo http-serve --bind <ip> --bearer-token-file <path>`.",
482 "version": env!("CARGO_PKG_VERSION"),
483 "license": { "name": "Apache-2.0" }
484 },
485 "servers": [
486 { "url": "http://127.0.0.1:7437", "description": "Default loopback (replace port with your --http-port)" }
487 ],
488 "components": {
489 "securitySchemes": {
490 "bearerAuth": {
491 "type": "http",
492 "scheme": "bearer",
493 "description":
494 "Bearer-token auth. Required only on LAN-bound deployments \
495 (`solo http-serve --bind <non-loopback> --bearer-token-file <path>`); \
496 the default `127.0.0.1` deployment is unauthenticated. \
497 `GET /health` and `GET /openapi.json` are exempt from auth even \
498 on bearer-protected instances."
499 }
500 },
501 "schemas": {
502 "RememberRequest": {
503 "type": "object",
504 "required": ["content"],
505 "properties": {
506 "content": { "type": "string", "minLength": 1, "description": "Episode content to embed + store." },
507 "source_type": { "type": "string", "description": "Free-form source tag (e.g. `user_message`, `tool_output`). Defaults to `user_message`." },
508 "source_id": { "type": "string", "description": "Optional upstream ID for traceability." }
509 },
510 "additionalProperties": false
511 },
512 "RememberResponse": {
513 "type": "object",
514 "required": ["memory_id"],
515 "properties": {
516 "memory_id": { "type": "string", "format": "uuid", "description": "UUID v7 assigned to the new episode." }
517 }
518 },
519 "RecallRequest": {
520 "type": "object",
521 "required": ["query"],
522 "properties": {
523 "query": { "type": "string", "minLength": 1, "description": "Natural-language query; embedded by the same model as stored episodes." },
524 "limit": { "type": "integer", "minimum": 1, "maximum": 50, "default": 5, "description": "Max number of hits to return." }
525 },
526 "additionalProperties": false
527 },
528 "RecallResult": {
529 "type": "object",
530 "description":
531 "Recall response. Fields are stable across v0.1 but not exhaustively documented here — \
532 see `solo_query::RecallResult` in the source for the canonical shape. \
533 Treat as a forward-compatible JSON object.",
534 "additionalProperties": true
535 },
536 "ConsolidationScope": {
537 "type": "object",
538 "description": "Filter + flags for consolidation. All fields optional; empty body = unbounded defaults.",
539 "properties": {
540 "window_days": { "type": "integer", "nullable": true, "description": "Restrict to memories with ts_ms >= now - window_days * 86400000. Null/omitted = unbounded." },
541 "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." }
542 },
543 "additionalProperties": false
544 },
545 "ConsolidationReport": {
546 "type": "object",
547 "required": [
548 "episodes_seen", "clusters_built", "clusters_merged",
549 "clusters_absorbed", "existing_clusters_merged",
550 "episodes_clustered", "abstractions_built",
551 "abstractions_regenerated", "triples_built",
552 "contradictions_found"
553 ],
554 "properties": {
555 "episodes_seen": { "type": "integer", "minimum": 0 },
556 "clusters_built": { "type": "integer", "minimum": 0, "description": "Brand-new clusters that survived to be persisted (post in-run-merge, post cross-run-absorb)." },
557 "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." },
558 "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." },
559 "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." },
560 "episodes_clustered": { "type": "integer", "minimum": 0 },
561 "abstractions_built": { "type": "integer", "minimum": 0, "description": "Fresh abstractions persisted for newly-built clusters. 0 when no LlmClient is wired." },
562 "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." },
563 "triples_built": { "type": "integer", "minimum": 0 },
564 "contradictions_found": { "type": "integer", "minimum": 0 }
565 }
566 },
567 "EpisodeRecord": {
568 "type": "object",
569 "description":
570 "Inspect response: full episode record. Fields are stable across v0.1 but not \
571 exhaustively documented here — see `solo_query::EpisodeRecord` in the source. \
572 Treat as a forward-compatible JSON object.",
573 "additionalProperties": true
574 },
575 "ThemeHit": {
576 "type": "object",
577 "description":
578 "One cluster + its (optional) abstraction. Returned by GET /memory/themes. \
579 See `solo_query::ThemeHit` for the canonical shape: cluster_id, \
580 abstraction_id?, abstraction_text?, episode_count, coherence, created_at_ms.",
581 "additionalProperties": true
582 },
583 "FactHit": {
584 "type": "object",
585 "description":
586 "One Steward-extracted SPO triple. Returned by GET /memory/facts_about. \
587 See `solo_query::FactHit` for fields: triple_id, subject_id, predicate, \
588 object_id, object_kind, valid_from_ms, valid_to_ms?, confidence, cluster_id?.",
589 "additionalProperties": true
590 },
591 "ContradictionHit": {
592 "type": "object",
593 "description":
594 "One Steward-flagged contradiction with each side's triple LEFT JOIN'd in. \
595 Returned by GET /memory/contradictions. See `solo_query::ContradictionHit`: \
596 a_id, b_id, kind, explanation, detected_at_ms, a_triple?, b_triple?.",
597 "additionalProperties": true
598 },
599 "ClusterRecord": {
600 "type": "object",
601 "description":
602 "Snapshot of one cluster — its row, optional abstraction, and source episodes \
603 (content truncated to 200 chars unless ?full_content=true). Returned by \
604 GET /memory/clusters/{cluster_id}. See `solo_query::ClusterRecord`.",
605 "additionalProperties": true
606 },
607 "IngestDocumentRequest": {
608 "type": "object",
609 "required": ["path"],
610 "properties": {
611 "path": {
612 "type": "string",
613 "minLength": 1,
614 "description":
615 "Server-side absolute path to the file to ingest. The file must be \
616 readable by the Solo process. Supported formats: plaintext / \
617 markdown / code, HTML, PDF."
618 }
619 },
620 "additionalProperties": false
621 },
622 "IngestReport": {
623 "type": "object",
624 "description":
625 "Returned by POST /memory/documents. Reports the document id assigned, \
626 the number of chunks persisted + embedded, the total byte size, and a \
627 `deduped` flag (true when the same content_hash was already present and \
628 the existing doc_id was returned unchanged). See `solo_storage::IngestReport`.",
629 "required": ["doc_id", "chunks_persisted", "bytes_ingested", "deduped"],
630 "properties": {
631 "doc_id": { "type": "string", "format": "uuid" },
632 "chunks_persisted": { "type": "integer", "minimum": 0 },
633 "bytes_ingested": { "type": "integer", "minimum": 0, "format": "int64" },
634 "deduped": { "type": "boolean" }
635 },
636 "additionalProperties": false
637 },
638 "ForgetDocumentReport": {
639 "type": "object",
640 "description":
641 "Returned by DELETE /memory/documents/{id}. Reports the doc_id soft-deleted \
642 and how many chunk rowids were tombstoned in the HNSW index. The chunk rows \
643 themselves survive in SQL for forensic value. See `solo_storage::ForgetDocumentReport`.",
644 "required": ["doc_id", "chunks_tombstoned"],
645 "properties": {
646 "doc_id": { "type": "string", "format": "uuid" },
647 "chunks_tombstoned": { "type": "integer", "minimum": 0 }
648 },
649 "additionalProperties": false
650 },
651 "SearchDocsRequest": {
652 "type": "object",
653 "required": ["query"],
654 "properties": {
655 "query": { "type": "string", "minLength": 1 },
656 "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 }
657 },
658 "additionalProperties": false
659 },
660 "DocSearchHit": {
661 "type": "object",
662 "description":
663 "One chunk hit + parent-doc context. Fields per `solo_query::DocSearchHit`: \
664 chunk_id, doc_id, doc_title?, doc_source?, doc_mime_type?, chunk_index, \
665 content, cos_distance, start_offset, end_offset.",
666 "additionalProperties": true
667 },
668 "DocumentInspectResult": {
669 "type": "object",
670 "description":
671 "Returned by GET /memory/documents/{id}. A `document` record (full metadata) \
672 plus an ordered list of chunk summaries (each preview truncated to 200 \
673 chars). See `solo_query::DocumentInspectResult`.",
674 "additionalProperties": true
675 },
676 "DocumentSummary": {
677 "type": "object",
678 "description":
679 "One row from GET /memory/documents. Fields per `solo_query::DocumentSummary`: \
680 doc_id, title?, source?, mime_type?, ingested_at_ms, chunk_count, status.",
681 "additionalProperties": true
682 },
683 "ApiError": {
684 "type": "object",
685 "required": ["error", "status"],
686 "properties": {
687 "error": { "type": "string" },
688 "status": { "type": "integer", "minimum": 400, "maximum": 599 }
689 }
690 }
691 }
692 },
693 "paths": {
694 "/health": {
695 "get": {
696 "summary": "Liveness probe",
697 "description": "Returns plain text `ok`. Always unauthenticated.",
698 "responses": {
699 "200": {
700 "description": "Server is up.",
701 "content": { "text/plain": { "schema": { "type": "string", "example": "ok" } } }
702 }
703 }
704 }
705 },
706 "/openapi.json": {
707 "get": {
708 "summary": "Self-describing OpenAPI 3.1 spec",
709 "description": "Returns this document. Always unauthenticated.",
710 "responses": {
711 "200": {
712 "description": "OpenAPI 3.1 document.",
713 "content": { "application/json": { "schema": { "type": "object" } } }
714 }
715 }
716 }
717 },
718 "/memory": {
719 "post": {
720 "summary": "Remember (store an episode)",
721 "description": "Equivalent to MCP tool `memory_remember`.",
722 "security": [{ "bearerAuth": [] }, {}],
723 "requestBody": {
724 "required": true,
725 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RememberRequest" } } }
726 },
727 "responses": {
728 "200": {
729 "description": "Memory stored; returns the new MemoryId.",
730 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RememberResponse" } } }
731 },
732 "400": { "description": "Bad request (e.g. empty content).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
733 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
734 }
735 }
736 },
737 "/memory/search": {
738 "post": {
739 "summary": "Recall (vector search)",
740 "description": "Equivalent to MCP tool `memory_recall`. Embeds the query, runs HNSW search, returns the top-K hits in cosine-distance order.",
741 "security": [{ "bearerAuth": [] }, {}],
742 "requestBody": {
743 "required": true,
744 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecallRequest" } } }
745 },
746 "responses": {
747 "200": {
748 "description": "Search results.",
749 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecallResult" } } }
750 },
751 "400": { "description": "Bad request (e.g. empty query).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
752 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
753 }
754 }
755 },
756 "/memory/consolidate": {
757 "post": {
758 "summary": "Run a consolidation pass (clustering + abstraction)",
759 "description":
760 "Idempotent. Triggers the SWS-equivalent clustering pass; if a `Steward` LLM is wired \
761 on the server, also runs the REM-equivalent abstraction pass that populates \
762 `semantic_abstractions` and `triples`. Empty request body = default scope (unbounded \
763 window). Equivalent to the `solo consolidate` CLI.",
764 "security": [{ "bearerAuth": [] }, {}],
765 "requestBody": {
766 "required": false,
767 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConsolidationScope" } } }
768 },
769 "responses": {
770 "200": {
771 "description": "Consolidation complete; report counts the work done.",
772 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConsolidationReport" } } }
773 },
774 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
775 }
776 }
777 },
778 "/backup": {
779 "post": {
780 "summary": "Online encrypted backup",
781 "description":
782 "Run an online SQLCipher backup of the live data dir to a server-side path. \
783 The destination file is encrypted with the same Argon2id-derived raw key as \
784 the source, so it restores under the same passphrase + a copy of the source's \
785 `solo.config.toml`. Hot — the backup runs against the writer's existing \
786 connection without taking the lockfile, so the daemon keeps serving reads + \
787 writes during the operation. v0.3.2+.",
788 "security": [{ "bearerAuth": [] }, {}],
789 "requestBody": {
790 "required": true,
791 "content": { "application/json": { "schema": {
792 "type": "object",
793 "properties": {
794 "to": { "type": "string", "description": "Server-side absolute path for the backup file." },
795 "force": { "type": "boolean", "description": "Overwrite an existing destination file. Default false.", "default": false }
796 },
797 "required": ["to"]
798 } } }
799 },
800 "responses": {
801 "200": {
802 "description": "Backup complete; reports the destination path + elapsed milliseconds.",
803 "content": { "application/json": { "schema": {
804 "type": "object",
805 "properties": {
806 "path": { "type": "string" },
807 "elapsed_ms": { "type": "integer", "format": "int64" }
808 }
809 } } }
810 },
811 "400": { "description": "Destination invalid, exists without force, or its parent doesn't exist." },
812 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." },
813 "500": { "description": "Backup failed (disk full, permission denied, etc.)." }
814 }
815 }
816 },
817 "/memory/{id}": {
818 "get": {
819 "summary": "Inspect a memory by ID",
820 "description": "Equivalent to MCP tool `memory_inspect`.",
821 "security": [{ "bearerAuth": [] }, {}],
822 "parameters": [{
823 "name": "id",
824 "in": "path",
825 "required": true,
826 "schema": { "type": "string", "format": "uuid" },
827 "description": "MemoryId (UUID v7)."
828 }],
829 "responses": {
830 "200": {
831 "description": "Episode record.",
832 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EpisodeRecord" } } }
833 },
834 "400": { "description": "Malformed ID.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
835 "404": { "description": "No such memory.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
836 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
837 }
838 },
839 "delete": {
840 "summary": "Forget (soft-delete) a memory by ID",
841 "description":
842 "Equivalent to MCP tool `memory_forget`. Soft-delete: flips `episodes.status = 'forgotten'` \
843 and tombstones the HNSW vector. The row + embedding are preserved for forensics; \
844 re-running `solo reembed` after this does NOT restore visibility.",
845 "security": [{ "bearerAuth": [] }, {}],
846 "parameters": [
847 { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } },
848 { "name": "reason", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Free-form reason logged via tracing (not yet persisted to the DB)." }
849 ],
850 "responses": {
851 "204": { "description": "Forgotten (or already forgotten — idempotent)." },
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 },
858 "/memory/themes": {
859 "get": {
860 "summary": "List recent cluster themes",
861 "description":
862 "Equivalent to MCP tool `memory_themes`. List cluster abstractions ordered by \
863 most-recent first. Use to surface 'what has the user been thinking about lately' \
864 without paging through individual episodes. v0.4.0+.",
865 "security": [{ "bearerAuth": [] }, {}],
866 "parameters": [
867 { "name": "window_days", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1 }, "description": "Optional time window. Omit for unfiltered (all-time, most-recent first)." },
868 { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
869 ],
870 "responses": {
871 "200": {
872 "description": "Array of ThemeHits (possibly empty).",
873 "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ThemeHit" } } } }
874 },
875 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
876 }
877 }
878 },
879 "/memory/facts_about": {
880 "get": {
881 "summary": "Query the SPO knowledge graph by subject",
882 "description":
883 "Equivalent to MCP tool `memory_facts_about`. Query Steward-extracted triples by \
884 subject + optional predicate + optional time window. Subject is required \
885 (predicate-only scans not supported). Pass `include_as_object=true` (v0.5.1+) \
886 to also surface rows where `subject` appears as the object. v0.4.0+.",
887 "security": [{ "bearerAuth": [] }, {}],
888 "parameters": [
889 { "name": "subject", "in": "query", "required": true, "schema": { "type": "string", "minLength": 1 }, "description": "Subject id to query (e.g. `Sam`)." },
890 { "name": "predicate", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Optional predicate filter (e.g. `works_at`)." },
891 { "name": "since_ms", "in": "query", "required": false, "schema": { "type": "integer" }, "description": "Optional valid_from_ms lower bound (epoch ms)." },
892 { "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." },
893 { "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+." },
894 { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
895 ],
896 "responses": {
897 "200": {
898 "description": "Array of FactHits (possibly empty).",
899 "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/FactHit" } } } }
900 },
901 "400": { "description": "Bad request (e.g. empty subject).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
902 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
903 }
904 }
905 },
906 "/memory/contradictions": {
907 "get": {
908 "summary": "List Steward-flagged contradictions",
909 "description":
910 "Equivalent to MCP tool `memory_contradictions`. Each result includes both \
911 sides' triple SPO via LEFT JOIN for context. v0.4.0+.",
912 "security": [{ "bearerAuth": [] }, {}],
913 "parameters": [
914 { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
915 ],
916 "responses": {
917 "200": {
918 "description": "Array of ContradictionHits (possibly empty).",
919 "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ContradictionHit" } } } }
920 },
921 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
922 }
923 }
924 },
925 "/memory/clusters/{cluster_id}": {
926 "get": {
927 "summary": "Inspect a single cluster",
928 "description":
929 "Equivalent to MCP tool `memory_inspect_cluster`. Returns the cluster row, \
930 its (optional) abstraction, and its source episodes. By default each \
931 episode's `content` is truncated to 200 chars with a trailing `…`. Pass \
932 `?full_content=true` to get verbatim episode content. v0.5.0+.",
933 "security": [{ "bearerAuth": [] }, {}],
934 "parameters": [
935 { "name": "cluster_id", "in": "path", "required": true, "schema": { "type": "string", "minLength": 1 }, "description": "Cluster id (from a previous GET /memory/themes response)." },
936 { "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)." }
937 ],
938 "responses": {
939 "200": {
940 "description": "Cluster snapshot.",
941 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ClusterRecord" } } }
942 },
943 "400": { "description": "Bad request (e.g. empty cluster_id).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
944 "404": { "description": "No such cluster.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
945 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
946 }
947 }
948 },
949 "/memory/documents": {
950 "post": {
951 "summary": "Ingest a document",
952 "description":
953 "Equivalent to MCP tool `memory_ingest_document`. Reads the file at the \
954 supplied server-side path, parses + chunks + embeds, and persists under \
955 `documents` + `document_chunks`. Returns the new doc_id, chunk count, and \
956 a `deduped` flag (true when an existing document with the same content_hash \
957 was returned without re-embedding). v0.7.0+.",
958 "security": [{ "bearerAuth": [] }, {}],
959 "requestBody": {
960 "required": true,
961 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IngestDocumentRequest" } } }
962 },
963 "responses": {
964 "200": {
965 "description": "Document ingested (or deduplicated).",
966 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IngestReport" } } }
967 },
968 "400": { "description": "Bad request (e.g. empty path, file unreadable, parse error).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
969 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
970 }
971 },
972 "get": {
973 "summary": "List ingested documents (paginated)",
974 "description":
975 "Equivalent to MCP tool `memory_list_documents`. Returns a paginated index, \
976 newest first. Forgotten documents are hidden by default; pass \
977 `?include_forgotten=true` to see them too. v0.7.0+.",
978 "security": [{ "bearerAuth": [] }, {}],
979 "parameters": [
980 { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 } },
981 { "name": "offset", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 0, "default": 0 } },
982 { "name": "include_forgotten", "in": "query", "required": false, "schema": { "type": "boolean", "default": false } }
983 ],
984 "responses": {
985 "200": {
986 "description": "Array of DocumentSummary (possibly empty).",
987 "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/DocumentSummary" } } } }
988 },
989 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
990 }
991 }
992 },
993 "/memory/documents/search": {
994 "post": {
995 "summary": "Vector search across document chunks",
996 "description":
997 "Equivalent to MCP tool `memory_search_docs`. Embeds the query and returns \
998 up to `limit` matching chunks, best match first, each annotated with the \
999 parent document's title + source path. Forgotten documents are excluded. \
1000 v0.7.0+.",
1001 "security": [{ "bearerAuth": [] }, {}],
1002 "requestBody": {
1003 "required": true,
1004 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SearchDocsRequest" } } }
1005 },
1006 "responses": {
1007 "200": {
1008 "description": "Array of DocSearchHits (possibly empty).",
1009 "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/DocSearchHit" } } } }
1010 },
1011 "400": { "description": "Bad request (e.g. empty query).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
1012 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
1013 }
1014 }
1015 },
1016 "/memory/documents/{id}": {
1017 "get": {
1018 "summary": "Inspect one document",
1019 "description":
1020 "Equivalent to MCP tool `memory_inspect_document`. Returns the document's \
1021 metadata plus a preview of every chunk (truncated to 200 chars). v0.7.0+.",
1022 "security": [{ "bearerAuth": [] }, {}],
1023 "parameters": [
1024 { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "DocumentId (UUID v7)." }
1025 ],
1026 "responses": {
1027 "200": {
1028 "description": "Document inspection result.",
1029 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DocumentInspectResult" } } }
1030 },
1031 "400": { "description": "Malformed id.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
1032 "404": { "description": "No such document.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
1033 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
1034 }
1035 },
1036 "delete": {
1037 "summary": "Forget (soft-delete) one document",
1038 "description":
1039 "Equivalent to MCP tool `memory_forget_document`. Flips `documents.status` \
1040 to `forgotten` and tombstones every chunk's HNSW rowid. The chunk rows \
1041 survive in SQL for forensic value. v0.7.0+.",
1042 "security": [{ "bearerAuth": [] }, {}],
1043 "parameters": [
1044 { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
1045 ],
1046 "responses": {
1047 "200": {
1048 "description": "Document soft-deleted; report counts chunks tombstoned.",
1049 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ForgetDocumentReport" } } }
1050 },
1051 "400": { "description": "Malformed id.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
1052 "404": { "description": "No such document.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
1053 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
1054 }
1055 }
1056 }
1057 }
1058 })
1059}
1060
1061#[derive(Debug, Deserialize)]
1066struct RememberBody {
1067 content: String,
1068 #[serde(default)]
1069 source_type: Option<String>,
1070 #[serde(default)]
1071 source_id: Option<String>,
1072}
1073
1074#[derive(Debug, Serialize)]
1075struct RememberResponse {
1076 memory_id: String,
1077}
1078
1079async fn remember_handler(
1080 TenantExtractor(tenant): TenantExtractor,
1081 AuditPrincipal(principal): AuditPrincipal,
1082 Json(body): Json<RememberBody>,
1083) -> Result<Json<RememberResponse>, ApiError> {
1084 let content = body.content.trim_end().to_string();
1085 if content.is_empty() {
1086 return Err(ApiError::bad_request("content must not be empty"));
1087 }
1088 let embedding = tenant.embedder().embed(&content).await.map_err(ApiError::from)?;
1089 let episode = Episode {
1090 memory_id: MemoryId::new(),
1091 ts_ms: chrono::Utc::now().timestamp_millis(),
1092 source_type: body.source_type.unwrap_or_else(|| "user_message".into()),
1093 source_id: body.source_id,
1094 content,
1095 encoding_context: EncodingContext::default(),
1096 provenance: None,
1097 confidence: Confidence::new(0.9).unwrap(),
1098 strength: 0.5,
1099 salience: 0.5,
1100 tier: Tier::Hot,
1101 };
1102 let mid = tenant
1103 .write()
1104 .remember_as(principal, episode, embedding)
1105 .await
1106 .map_err(ApiError::from)?;
1107 Ok(Json(RememberResponse {
1108 memory_id: mid.to_string(),
1109 }))
1110}
1111
1112#[derive(Debug, Deserialize)]
1113struct RecallBody {
1114 query: String,
1115 #[serde(default = "default_limit")]
1116 limit: usize,
1117}
1118
1119fn default_limit() -> usize {
1120 5
1121}
1122
1123async fn recall_handler(
1124 TenantExtractor(tenant): TenantExtractor,
1125 AuditPrincipal(principal): AuditPrincipal,
1126 Json(body): Json<RecallBody>,
1127) -> Result<Json<solo_query::RecallResult>, ApiError> {
1128 let result = solo_query::run_recall(tenant.as_ref(), principal, &body.query, body.limit)
1132 .await
1133 .map_err(ApiError::from)?;
1134 Ok(Json(result))
1135}
1136
1137async fn inspect_handler(
1138 TenantExtractor(tenant): TenantExtractor,
1139 AuditPrincipal(principal): AuditPrincipal,
1140 Path(id): Path<String>,
1141) -> Result<Json<solo_query::EpisodeRecord>, ApiError> {
1142 let mid = MemoryId::from_str(&id)
1143 .map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
1144 let row = solo_query::inspect_one(tenant.read(), tenant.audit(), principal, mid)
1145 .await
1146 .map_err(ApiError::from)?;
1147 Ok(Json(row))
1148}
1149
1150#[derive(Debug, Deserialize)]
1157struct ThemesQuery {
1158 #[serde(default)]
1159 window_days: Option<i64>,
1160 #[serde(default = "default_limit")]
1161 limit: usize,
1162}
1163
1164async fn themes_handler(
1165 TenantExtractor(tenant): TenantExtractor,
1166 AuditPrincipal(principal): AuditPrincipal,
1167 Query(q): Query<ThemesQuery>,
1168) -> Result<Json<Vec<solo_query::ThemeHit>>, ApiError> {
1169 let hits = solo_query::themes(
1170 tenant.read(),
1171 tenant.audit(),
1172 principal,
1173 q.window_days,
1174 q.limit,
1175 )
1176 .await
1177 .map_err(ApiError::from)?;
1178 Ok(Json(hits))
1179}
1180
1181#[derive(Debug, Deserialize)]
1182struct FactsAboutQuery {
1183 subject: String,
1184 #[serde(default)]
1185 predicate: Option<String>,
1186 #[serde(default)]
1187 since_ms: Option<i64>,
1188 #[serde(default)]
1189 until_ms: Option<i64>,
1190 #[serde(default)]
1193 include_as_object: bool,
1194 #[serde(default = "default_limit")]
1195 limit: usize,
1196}
1197
1198async fn facts_about_handler(
1199 State(s): State<SoloHttpState>,
1200 TenantExtractor(tenant): TenantExtractor,
1201 AuditPrincipal(principal): AuditPrincipal,
1202 Query(q): Query<FactsAboutQuery>,
1203) -> Result<Json<Vec<solo_query::FactHit>>, ApiError> {
1204 if q.subject.trim().is_empty() {
1205 return Err(ApiError::bad_request("subject must not be empty"));
1206 }
1207 let hits = solo_query::facts_about(
1208 tenant.read(),
1209 tenant.audit(),
1210 principal,
1211 &q.subject,
1212 &s.user_aliases,
1213 q.include_as_object,
1214 q.predicate.as_deref(),
1215 q.since_ms,
1216 q.until_ms,
1217 q.limit,
1218 )
1219 .await
1220 .map_err(ApiError::from)?;
1221 Ok(Json(hits))
1222}
1223
1224#[derive(Debug, Deserialize)]
1225struct ContradictionsQuery {
1226 #[serde(default = "default_limit")]
1227 limit: usize,
1228}
1229
1230async fn contradictions_handler(
1231 TenantExtractor(tenant): TenantExtractor,
1232 AuditPrincipal(principal): AuditPrincipal,
1233 Query(q): Query<ContradictionsQuery>,
1234) -> Result<Json<Vec<solo_query::ContradictionHit>>, ApiError> {
1235 let hits = solo_query::contradictions(tenant.read(), tenant.audit(), principal, q.limit)
1236 .await
1237 .map_err(ApiError::from)?;
1238 Ok(Json(hits))
1239}
1240
1241#[derive(Debug, Deserialize, Default)]
1242struct InspectClusterQuery {
1243 #[serde(default)]
1247 full_content: bool,
1248}
1249
1250async fn inspect_cluster_handler(
1251 TenantExtractor(tenant): TenantExtractor,
1252 AuditPrincipal(principal): AuditPrincipal,
1253 Path(cluster_id): Path<String>,
1254 Query(q): Query<InspectClusterQuery>,
1255) -> Result<Json<solo_query::ClusterRecord>, ApiError> {
1256 if cluster_id.trim().is_empty() {
1257 return Err(ApiError::bad_request("cluster_id must not be empty"));
1258 }
1259 let record = solo_query::inspect_cluster(
1260 tenant.read(),
1261 tenant.audit(),
1262 principal,
1263 &cluster_id,
1264 q.full_content,
1265 )
1266 .await
1267 .map_err(ApiError::from)?;
1268 Ok(Json(record))
1269}
1270
1271#[derive(Debug, Deserialize)]
1276struct IngestDocumentBody {
1277 path: String,
1280}
1281
1282async fn ingest_document_handler(
1283 TenantExtractor(tenant): TenantExtractor,
1284 AuditPrincipal(principal): AuditPrincipal,
1285 Json(body): Json<IngestDocumentBody>,
1286) -> Result<Json<solo_storage::IngestReport>, ApiError> {
1287 if body.path.trim().is_empty() {
1288 return Err(ApiError::bad_request("path must not be empty"));
1289 }
1290 let path = std::path::PathBuf::from(body.path);
1291 let chunk_config = solo_storage::document::ChunkConfig::default();
1292 let report = tenant
1293 .write()
1294 .ingest_document_as(principal, path, chunk_config)
1295 .await
1296 .map_err(ApiError::from)?;
1297 Ok(Json(report))
1298}
1299
1300#[derive(Debug, Deserialize)]
1301struct SearchDocsBody {
1302 query: String,
1303 #[serde(default = "default_limit")]
1304 limit: usize,
1305}
1306
1307async fn search_docs_handler(
1308 TenantExtractor(tenant): TenantExtractor,
1309 AuditPrincipal(principal): AuditPrincipal,
1310 Json(body): Json<SearchDocsBody>,
1311) -> Result<Json<Vec<solo_query::DocSearchHit>>, ApiError> {
1312 let hits = solo_query::run_doc_search(tenant.as_ref(), principal, &body.query, body.limit)
1313 .await
1314 .map_err(ApiError::from)?;
1315 Ok(Json(hits))
1316}
1317
1318async fn inspect_document_handler(
1319 TenantExtractor(tenant): TenantExtractor,
1320 AuditPrincipal(principal): AuditPrincipal,
1321 Path(id): Path<String>,
1322) -> Result<Json<solo_query::DocumentInspectResult>, ApiError> {
1323 let doc_id = DocumentId::from_str(&id)
1324 .map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
1325 let result_opt =
1326 solo_query::inspect_document(tenant.read(), tenant.audit(), principal, &doc_id)
1327 .await
1328 .map_err(ApiError::from)?;
1329 match result_opt {
1330 Some(record) => Ok(Json(record)),
1331 None => Err(ApiError::not_found(format!("document {doc_id} not found"))),
1332 }
1333}
1334
1335#[derive(Debug, Deserialize)]
1336struct ListDocumentsQuery {
1337 #[serde(default = "default_list_documents_limit")]
1338 limit: usize,
1339 #[serde(default)]
1340 offset: usize,
1341 #[serde(default)]
1342 include_forgotten: bool,
1343}
1344
1345fn default_list_documents_limit() -> usize {
1346 20
1347}
1348
1349async fn list_documents_handler(
1350 TenantExtractor(tenant): TenantExtractor,
1351 AuditPrincipal(principal): AuditPrincipal,
1352 Query(q): Query<ListDocumentsQuery>,
1353) -> Result<Json<Vec<solo_query::DocumentSummary>>, ApiError> {
1354 let rows = solo_query::list_documents(
1355 tenant.read(),
1356 tenant.audit(),
1357 principal,
1358 q.limit,
1359 q.offset,
1360 q.include_forgotten,
1361 )
1362 .await
1363 .map_err(ApiError::from)?;
1364 Ok(Json(rows))
1365}
1366
1367async fn forget_document_handler(
1368 TenantExtractor(tenant): TenantExtractor,
1369 AuditPrincipal(principal): AuditPrincipal,
1370 Path(id): Path<String>,
1371) -> Result<Json<solo_storage::ForgetDocumentReport>, ApiError> {
1372 let doc_id = DocumentId::from_str(&id)
1373 .map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
1374 let report = tenant
1375 .write()
1376 .forget_document_as(principal, doc_id)
1377 .await
1378 .map_err(ApiError::from)?;
1379 Ok(Json(report))
1380}
1381
1382#[derive(Debug, Deserialize)]
1383struct ForgetQuery {
1384 #[serde(default)]
1385 reason: Option<String>,
1386}
1387
1388async fn forget_handler(
1389 TenantExtractor(tenant): TenantExtractor,
1390 AuditPrincipal(principal): AuditPrincipal,
1391 Path(id): Path<String>,
1392 Query(q): Query<ForgetQuery>,
1393) -> Result<StatusCode, ApiError> {
1394 let mid = MemoryId::from_str(&id).map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
1395 let reason = q.reason.unwrap_or_else(|| "http".into());
1396 tenant
1397 .write()
1398 .forget_as(principal, mid, reason)
1399 .await
1400 .map_err(ApiError::from)?;
1401 Ok(StatusCode::NO_CONTENT)
1402}
1403
1404async fn consolidate_handler(
1405 TenantExtractor(tenant): TenantExtractor,
1406 AuditPrincipal(principal): AuditPrincipal,
1407 body: axum::body::Bytes,
1408) -> Result<Json<solo_storage::ConsolidationReport>, ApiError> {
1409 let scope = if body.is_empty() {
1415 solo_storage::ConsolidationScope::default()
1416 } else {
1417 serde_json::from_slice(&body)
1418 .map_err(|e| ApiError::bad_request(format!("invalid JSON: {e}")))?
1419 };
1420 let report = tenant
1421 .write()
1422 .consolidate_as(principal, scope)
1423 .await
1424 .map_err(ApiError::from)?;
1425 Ok(Json(report))
1426}
1427
1428#[derive(Debug, Deserialize)]
1429struct BackupBody {
1430 to: String,
1434 #[serde(default)]
1435 force: bool,
1436}
1437
1438#[derive(Debug, Serialize)]
1439struct BackupResponse {
1440 path: String,
1441 elapsed_ms: u64,
1442}
1443
1444async fn backup_handler(
1445 TenantExtractor(tenant): TenantExtractor,
1446 Json(body): Json<BackupBody>,
1447) -> Result<Json<BackupResponse>, ApiError> {
1448 use std::path::PathBuf;
1449
1450 let dest = PathBuf::from(&body.to);
1451 if dest.as_os_str().is_empty() {
1452 return Err(ApiError::bad_request("`to` must not be empty"));
1453 }
1454 if solo_storage::paths_refer_to_same_file(tenant.db_path(), &dest) {
1457 return Err(ApiError::bad_request(format!(
1458 "destination {} is the same file as the source database; \
1459 refusing to run (would corrupt the live database)",
1460 dest.display()
1461 )));
1462 }
1463 if dest.exists() {
1464 if !body.force {
1465 return Err(ApiError::bad_request(format!(
1466 "destination {} exists; pass force=true to overwrite",
1467 dest.display()
1468 )));
1469 }
1470 std::fs::remove_file(&dest).map_err(|e| {
1471 ApiError::internal(format!(
1472 "remove existing destination {}: {e}",
1473 dest.display()
1474 ))
1475 })?;
1476 }
1477 if let Some(parent) = dest.parent() {
1478 if !parent.as_os_str().is_empty() && !parent.is_dir() {
1479 return Err(ApiError::bad_request(format!(
1480 "destination parent directory {} does not exist",
1481 parent.display()
1482 )));
1483 }
1484 }
1485
1486 let started = std::time::Instant::now();
1487 tenant.write().backup(dest.clone()).await.map_err(ApiError::from)?;
1488 let elapsed_ms = started.elapsed().as_millis() as u64;
1489
1490 Ok(Json(BackupResponse {
1491 path: dest.display().to_string(),
1492 elapsed_ms,
1493 }))
1494}
1495
1496const GRAPH_EXPAND_DEFAULT_LIMIT: u32 = 25;
1535const GRAPH_EXPAND_MAX_LIMIT: u32 = 100;
1536
1537#[derive(Debug, Clone, Copy, Deserialize)]
1540#[serde(rename_all = "snake_case")]
1541enum GraphExpandKind {
1542 ClusterMember,
1543 DocumentChunk,
1544 Triple,
1545 Semantic,
1546}
1547
1548#[derive(Debug, Deserialize)]
1549struct GraphExpandQuery {
1550 node_id: String,
1551 kind: GraphExpandKind,
1552 #[serde(default)]
1553 limit: Option<u32>,
1554}
1555
1556#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1558enum NodeKind {
1559 Episode,
1560 Document,
1561 Chunk,
1562 Cluster,
1563 Entity,
1564}
1565
1566impl NodeKind {
1567 fn as_wire_str(self) -> &'static str {
1568 match self {
1569 Self::Episode => "episode",
1570 Self::Document => "document",
1571 Self::Chunk => "chunk",
1572 Self::Cluster => "cluster",
1573 Self::Entity => "entity",
1574 }
1575 }
1576}
1577
1578fn parse_node_id(raw: &str) -> Result<(NodeKind, &str), ApiError> {
1581 let (prefix, value) = raw.split_once(':').ok_or_else(|| {
1582 ApiError::bad_request(format!(
1583 "node_id must be `<prefix>:<value>` (one of ep:/doc:/chunk:/cl:/ent:); got {raw:?}"
1584 ))
1585 })?;
1586 if value.is_empty() {
1587 return Err(ApiError::bad_request(format!(
1588 "node_id value is empty after prefix: {raw:?}"
1589 )));
1590 }
1591 let kind = match prefix {
1592 "ep" => NodeKind::Episode,
1593 "doc" => NodeKind::Document,
1594 "chunk" => NodeKind::Chunk,
1595 "cl" => NodeKind::Cluster,
1596 "ent" => NodeKind::Entity,
1597 other => {
1598 return Err(ApiError::bad_request(format!(
1599 "unknown node_id prefix {other:?}; expected one of ep:/doc:/chunk:/cl:/ent:"
1600 )));
1601 }
1602 };
1603 Ok((kind, value))
1604}
1605
1606#[derive(Debug, Serialize)]
1609struct GraphNode {
1610 id: String,
1611 kind: &'static str,
1612 label: String,
1613 #[serde(skip_serializing_if = "Option::is_none")]
1614 ts_ms: Option<i64>,
1615 tenant_id: String,
1616 #[serde(skip_serializing_if = "Option::is_none")]
1617 preview: Option<String>,
1618}
1619
1620#[derive(Debug, Serialize)]
1623struct GraphEdge {
1624 id: String,
1625 source: String,
1626 target: String,
1627 kind: &'static str,
1628 #[serde(skip_serializing_if = "Option::is_none")]
1629 predicate: Option<String>,
1630 #[serde(skip_serializing_if = "Option::is_none")]
1631 weight: Option<f32>,
1632}
1633
1634#[derive(Debug, Serialize)]
1635struct GraphExpandResponse {
1636 nodes: Vec<GraphNode>,
1637 edges: Vec<GraphEdge>,
1638}
1639
1640fn edge_id(source: &str, kind: &str, target: &str) -> String {
1641 format!("{source}--{kind}--{target}")
1642}
1643
1644#[derive(Debug)]
1646struct ExpandedEpisode {
1647 memory_id: String,
1648 ts_ms: i64,
1649 content: String,
1650}
1651
1652#[derive(Debug)]
1654struct ExpandedDocument {
1655 doc_id: String,
1656 title: Option<String>,
1657 source: Option<String>,
1658 ingested_at_ms: i64,
1659}
1660
1661#[derive(Debug)]
1663struct ExpandedChunk {
1664 chunk_id: String,
1665 chunk_index: i64,
1666 content: String,
1667}
1668
1669fn truncate_preview(s: &str, max: usize) -> String {
1670 if s.chars().count() <= max {
1671 return s.to_string();
1672 }
1673 let mut out: String = s.chars().take(max - 1).collect();
1674 out.push('…');
1675 out
1676}
1677
1678const GRAPH_LABEL_CHARS: usize = 80;
1681const GRAPH_PREVIEW_CHARS: usize = 200;
1682
1683fn episode_label(content: &str) -> String {
1684 let first_line = content.lines().next().unwrap_or(content);
1685 truncate_preview(first_line, GRAPH_LABEL_CHARS)
1686}
1687
1688fn graph_node_for_episode(tenant_id: &str, ep: &ExpandedEpisode) -> GraphNode {
1689 GraphNode {
1690 id: format!("ep:{}", ep.memory_id),
1691 kind: NodeKind::Episode.as_wire_str(),
1692 label: episode_label(&ep.content),
1693 ts_ms: Some(ep.ts_ms),
1694 tenant_id: tenant_id.to_string(),
1695 preview: Some(truncate_preview(&ep.content, GRAPH_PREVIEW_CHARS)),
1696 }
1697}
1698
1699fn graph_node_for_document(tenant_id: &str, d: &ExpandedDocument) -> GraphNode {
1700 let label = d
1701 .title
1702 .clone()
1703 .or_else(|| d.source.clone())
1704 .unwrap_or_else(|| d.doc_id.clone());
1705 GraphNode {
1706 id: format!("doc:{}", d.doc_id),
1707 kind: NodeKind::Document.as_wire_str(),
1708 label: truncate_preview(&label, GRAPH_LABEL_CHARS),
1709 ts_ms: Some(d.ingested_at_ms),
1710 tenant_id: tenant_id.to_string(),
1711 preview: d.source.clone(),
1712 }
1713}
1714
1715fn graph_node_for_chunk(tenant_id: &str, c: &ExpandedChunk) -> GraphNode {
1716 GraphNode {
1717 id: format!("chunk:{}", c.chunk_id),
1718 kind: NodeKind::Chunk.as_wire_str(),
1719 label: format!("chunk #{}: {}", c.chunk_index, episode_label(&c.content)),
1720 ts_ms: None,
1721 tenant_id: tenant_id.to_string(),
1722 preview: Some(truncate_preview(&c.content, GRAPH_PREVIEW_CHARS)),
1723 }
1724}
1725
1726fn graph_node_for_cluster(
1727 tenant_id: &str,
1728 cluster_id: &str,
1729 abstraction: Option<&str>,
1730 created_at_ms: i64,
1731) -> GraphNode {
1732 let label = abstraction
1733 .map(|a| truncate_preview(a, GRAPH_LABEL_CHARS))
1734 .unwrap_or_else(|| format!("cluster {cluster_id}"));
1735 GraphNode {
1736 id: format!("cl:{cluster_id}"),
1737 kind: NodeKind::Cluster.as_wire_str(),
1738 label,
1739 ts_ms: Some(created_at_ms),
1740 tenant_id: tenant_id.to_string(),
1741 preview: abstraction.map(|a| truncate_preview(a, GRAPH_PREVIEW_CHARS)),
1742 }
1743}
1744
1745fn graph_node_for_entity(tenant_id: &str, value: &str) -> GraphNode {
1746 GraphNode {
1747 id: format!("ent:{value}"),
1748 kind: NodeKind::Entity.as_wire_str(),
1749 label: truncate_preview(value, GRAPH_LABEL_CHARS),
1750 ts_ms: None,
1751 tenant_id: tenant_id.to_string(),
1752 preview: None,
1753 }
1754}
1755
1756async fn graph_expand_handler(
1758 TenantExtractor(tenant): TenantExtractor,
1759 Query(q): Query<GraphExpandQuery>,
1760) -> Result<Json<GraphExpandResponse>, ApiError> {
1761 let limit = q.limit.unwrap_or(GRAPH_EXPAND_DEFAULT_LIMIT);
1765 let limit = limit.clamp(1, GRAPH_EXPAND_MAX_LIMIT) as i64;
1766
1767 let (node_kind, value) = parse_node_id(&q.node_id)?;
1768 let value = value.to_string();
1769 let node_id_full = q.node_id.clone();
1770 let tenant_id_str = tenant.tenant_id().to_string();
1771
1772 match q.kind {
1773 GraphExpandKind::ClusterMember => {
1774 expand_cluster_member(&tenant, &tenant_id_str, node_kind, &value, &node_id_full, limit)
1775 .await
1776 }
1777 GraphExpandKind::DocumentChunk => {
1778 expand_document_chunk(&tenant, &tenant_id_str, node_kind, &value, &node_id_full, limit)
1779 .await
1780 }
1781 GraphExpandKind::Triple => {
1782 expand_triple(&tenant, &tenant_id_str, node_kind, &value, &node_id_full, limit).await
1783 }
1784 GraphExpandKind::Semantic => {
1785 expand_semantic(&tenant, &tenant_id_str, node_kind, &value, &node_id_full, limit).await
1786 }
1787 }
1788 .map(Json)
1789}
1790
1791async fn expand_cluster_member(
1794 tenant: &TenantHandle,
1795 tenant_id: &str,
1796 node_kind: NodeKind,
1797 value: &str,
1798 node_id_full: &str,
1799 limit: i64,
1800) -> Result<GraphExpandResponse, ApiError> {
1801 match node_kind {
1802 NodeKind::Episode => expand_cluster_member_from_episode(
1803 tenant,
1804 tenant_id,
1805 value.to_string(),
1806 node_id_full.to_string(),
1807 limit,
1808 )
1809 .await,
1810 NodeKind::Cluster => expand_cluster_member_from_cluster(
1811 tenant,
1812 tenant_id,
1813 value.to_string(),
1814 node_id_full.to_string(),
1815 limit,
1816 )
1817 .await,
1818 _ => Err(ApiError::bad_request(format!(
1819 "kind=cluster_member only valid for episode or cluster source nodes; got {}",
1820 node_kind.as_wire_str()
1821 ))),
1822 }
1823}
1824
1825async fn expand_cluster_member_from_episode(
1826 tenant: &TenantHandle,
1827 tenant_id: &str,
1828 memory_id: String,
1829 node_id_full: String,
1830 limit: i64,
1831) -> Result<GraphExpandResponse, ApiError> {
1832 let memory_id_for_err = memory_id.clone();
1833 let rows: Vec<(String, Option<String>, i64)> = tenant
1834 .read()
1835 .interact(move |conn| {
1836 let exists: i64 = conn.query_row(
1838 "SELECT COUNT(*) FROM episodes WHERE memory_id = ?1",
1839 rusqlite::params![&memory_id],
1840 |r| r.get(0),
1841 )?;
1842 if exists == 0 {
1843 return Ok(Vec::new());
1844 }
1845 let mut stmt = conn.prepare(
1846 "SELECT c.cluster_id, sa.content, c.created_at_ms
1847 FROM cluster_episodes ce
1848 JOIN clusters c ON c.cluster_id = ce.cluster_id
1849 LEFT JOIN semantic_abstractions sa ON sa.cluster_id = c.cluster_id
1850 WHERE ce.memory_id = ?1
1851 ORDER BY c.created_at_ms DESC
1852 LIMIT ?2",
1853 )?;
1854 let mapped = stmt
1855 .query_map(rusqlite::params![&memory_id, limit], |r| {
1856 Ok((
1857 r.get::<_, String>(0)?,
1858 r.get::<_, Option<String>>(1)?,
1859 r.get::<_, i64>(2)?,
1860 ))
1861 })?
1862 .collect::<rusqlite::Result<Vec<_>>>()?;
1863 Ok::<_, rusqlite::Error>(mapped)
1870 })
1871 .await
1872 .map_err(ApiError::from)?;
1873
1874 if rows.is_empty() {
1881 ensure_episode_exists(tenant, &memory_id_for_err, &node_id_full).await?;
1882 return Ok(GraphExpandResponse {
1883 nodes: Vec::new(),
1884 edges: Vec::new(),
1885 });
1886 }
1887
1888 let mut nodes = Vec::with_capacity(rows.len());
1889 let mut edges = Vec::with_capacity(rows.len());
1890 for (cluster_id, abstraction, created_at_ms) in rows {
1891 let target_id = format!("cl:{cluster_id}");
1892 edges.push(GraphEdge {
1893 id: edge_id(&node_id_full, "cluster_member", &target_id),
1894 source: node_id_full.clone(),
1895 target: target_id,
1896 kind: "cluster_member",
1897 predicate: None,
1898 weight: None,
1899 });
1900 nodes.push(graph_node_for_cluster(
1901 tenant_id,
1902 &cluster_id,
1903 abstraction.as_deref(),
1904 created_at_ms,
1905 ));
1906 }
1907 Ok(GraphExpandResponse { nodes, edges })
1908}
1909
1910async fn expand_cluster_member_from_cluster(
1911 tenant: &TenantHandle,
1912 tenant_id: &str,
1913 cluster_id: String,
1914 node_id_full: String,
1915 limit: i64,
1916) -> Result<GraphExpandResponse, ApiError> {
1917 let cluster_id_for_err = cluster_id.clone();
1918 let rows: Vec<ExpandedEpisode> = tenant
1919 .read()
1920 .interact(move |conn| {
1921 let exists: i64 = conn.query_row(
1922 "SELECT COUNT(*) FROM clusters WHERE cluster_id = ?1",
1923 rusqlite::params![&cluster_id],
1924 |r| r.get(0),
1925 )?;
1926 if exists == 0 {
1927 return Ok(Vec::new());
1928 }
1929 let mut stmt = conn.prepare(
1930 "SELECT e.memory_id, e.ts_ms, e.content
1931 FROM cluster_episodes ce
1932 JOIN episodes e ON e.memory_id = ce.memory_id
1933 WHERE ce.cluster_id = ?1
1934 AND e.status = 'active'
1935 ORDER BY e.ts_ms DESC
1936 LIMIT ?2",
1937 )?;
1938 let mapped = stmt
1939 .query_map(rusqlite::params![&cluster_id, limit], |r| {
1940 Ok(ExpandedEpisode {
1941 memory_id: r.get(0)?,
1942 ts_ms: r.get(1)?,
1943 content: r.get(2)?,
1944 })
1945 })?
1946 .collect::<rusqlite::Result<Vec<_>>>()?;
1947 Ok::<_, rusqlite::Error>(mapped)
1948 })
1949 .await
1950 .map_err(ApiError::from)?;
1951
1952 if rows.is_empty() {
1953 ensure_cluster_exists(tenant, &cluster_id_for_err, &node_id_full).await?;
1954 return Ok(GraphExpandResponse {
1955 nodes: Vec::new(),
1956 edges: Vec::new(),
1957 });
1958 }
1959
1960 let mut nodes = Vec::with_capacity(rows.len());
1961 let mut edges = Vec::with_capacity(rows.len());
1962 for ep in rows {
1963 let target_id = format!("ep:{}", ep.memory_id);
1964 edges.push(GraphEdge {
1965 id: edge_id(&node_id_full, "cluster_member", &target_id),
1966 source: node_id_full.clone(),
1967 target: target_id,
1968 kind: "cluster_member",
1969 predicate: None,
1970 weight: None,
1971 });
1972 nodes.push(graph_node_for_episode(tenant_id, &ep));
1973 }
1974 Ok(GraphExpandResponse { nodes, edges })
1975}
1976
1977async fn expand_document_chunk(
1980 tenant: &TenantHandle,
1981 tenant_id: &str,
1982 node_kind: NodeKind,
1983 value: &str,
1984 node_id_full: &str,
1985 limit: i64,
1986) -> Result<GraphExpandResponse, ApiError> {
1987 match node_kind {
1988 NodeKind::Document => expand_document_chunk_from_document(
1989 tenant,
1990 tenant_id,
1991 value.to_string(),
1992 node_id_full.to_string(),
1993 limit,
1994 )
1995 .await,
1996 NodeKind::Chunk => expand_document_chunk_from_chunk(
1997 tenant,
1998 tenant_id,
1999 value.to_string(),
2000 node_id_full.to_string(),
2001 )
2002 .await,
2003 _ => Err(ApiError::bad_request(format!(
2004 "kind=document_chunk only valid for document or chunk source nodes; got {}",
2005 node_kind.as_wire_str()
2006 ))),
2007 }
2008}
2009
2010async fn expand_document_chunk_from_document(
2011 tenant: &TenantHandle,
2012 tenant_id: &str,
2013 doc_id: String,
2014 node_id_full: String,
2015 limit: i64,
2016) -> Result<GraphExpandResponse, ApiError> {
2017 let doc_id_for_err = doc_id.clone();
2018 let rows: Vec<ExpandedChunk> = tenant
2019 .read()
2020 .interact(move |conn| {
2021 let exists: i64 = conn.query_row(
2022 "SELECT COUNT(*) FROM documents WHERE doc_id = ?1",
2023 rusqlite::params![&doc_id],
2024 |r| r.get(0),
2025 )?;
2026 if exists == 0 {
2027 return Ok(Vec::new());
2028 }
2029 let mut stmt = conn.prepare(
2030 "SELECT chunk_id, chunk_index, content
2031 FROM document_chunks
2032 WHERE doc_id = ?1
2033 ORDER BY chunk_index ASC
2034 LIMIT ?2",
2035 )?;
2036 let mapped = stmt
2037 .query_map(rusqlite::params![&doc_id, limit], |r| {
2038 Ok(ExpandedChunk {
2039 chunk_id: r.get(0)?,
2040 chunk_index: r.get(1)?,
2041 content: r.get(2)?,
2042 })
2043 })?
2044 .collect::<rusqlite::Result<Vec<_>>>()?;
2045 Ok::<_, rusqlite::Error>(mapped)
2046 })
2047 .await
2048 .map_err(ApiError::from)?;
2049
2050 if rows.is_empty() {
2051 ensure_document_exists(tenant, &doc_id_for_err, &node_id_full).await?;
2052 return Ok(GraphExpandResponse {
2053 nodes: Vec::new(),
2054 edges: Vec::new(),
2055 });
2056 }
2057
2058 let mut nodes = Vec::with_capacity(rows.len());
2059 let mut edges = Vec::with_capacity(rows.len());
2060 for c in rows {
2061 let target_id = format!("chunk:{}", c.chunk_id);
2062 edges.push(GraphEdge {
2063 id: edge_id(&node_id_full, "document_chunk", &target_id),
2064 source: node_id_full.clone(),
2065 target: target_id,
2066 kind: "document_chunk",
2067 predicate: None,
2068 weight: None,
2069 });
2070 nodes.push(graph_node_for_chunk(tenant_id, &c));
2071 }
2072 Ok(GraphExpandResponse { nodes, edges })
2073}
2074
2075async fn expand_document_chunk_from_chunk(
2076 tenant: &TenantHandle,
2077 tenant_id: &str,
2078 chunk_id: String,
2079 node_id_full: String,
2080) -> Result<GraphExpandResponse, ApiError> {
2081 let chunk_id_for_err = chunk_id.clone();
2082 let row: Option<ExpandedDocument> = tenant
2083 .read()
2084 .interact(move |conn| {
2085 conn.query_row(
2086 "SELECT d.doc_id, d.title, d.source, d.ingested_at_ms
2087 FROM document_chunks c
2088 JOIN documents d ON d.doc_id = c.doc_id
2089 WHERE c.chunk_id = ?1",
2090 rusqlite::params![&chunk_id],
2091 |r| {
2092 Ok(ExpandedDocument {
2093 doc_id: r.get(0)?,
2094 title: r.get(1)?,
2095 source: r.get(2)?,
2096 ingested_at_ms: r.get(3)?,
2097 })
2098 },
2099 )
2100 .map(Some)
2101 .or_else(|e| match e {
2102 rusqlite::Error::QueryReturnedNoRows => Ok(None),
2103 other => Err(other),
2104 })
2105 })
2106 .await
2107 .map_err(ApiError::from)?;
2108
2109 let d = row.ok_or_else(|| {
2110 ApiError::not_found(format!(
2111 "node_id {node_id_full:?} (chunk_id {chunk_id_for_err}) not found in current tenant"
2112 ))
2113 })?;
2114 let target_id = format!("doc:{}", d.doc_id);
2115 let edge = GraphEdge {
2116 id: edge_id(&node_id_full, "document_chunk", &target_id),
2117 source: node_id_full.clone(),
2118 target: target_id,
2119 kind: "document_chunk",
2120 predicate: None,
2121 weight: None,
2122 };
2123 let node = graph_node_for_document(tenant_id, &d);
2124 Ok(GraphExpandResponse {
2125 nodes: vec![node],
2126 edges: vec![edge],
2127 })
2128}
2129
2130async fn expand_triple(
2133 tenant: &TenantHandle,
2134 tenant_id: &str,
2135 node_kind: NodeKind,
2136 value: &str,
2137 node_id_full: &str,
2138 limit: i64,
2139) -> Result<GraphExpandResponse, ApiError> {
2140 match node_kind {
2141 NodeKind::Episode => expand_triple_from_episode(
2142 tenant,
2143 tenant_id,
2144 value.to_string(),
2145 node_id_full.to_string(),
2146 limit,
2147 )
2148 .await,
2149 NodeKind::Entity => expand_triple_from_entity(
2150 tenant,
2151 tenant_id,
2152 value.to_string(),
2153 node_id_full.to_string(),
2154 limit,
2155 )
2156 .await,
2157 _ => Err(ApiError::bad_request(format!(
2158 "kind=triple only valid for episode or entity source nodes; got {}",
2159 node_kind.as_wire_str()
2160 ))),
2161 }
2162}
2163
2164#[derive(Debug)]
2165struct TripleRow {
2166 subject_id: String,
2167 predicate: String,
2168 object_id: String,
2169 confidence: f32,
2170}
2171
2172async fn expand_triple_from_episode(
2173 tenant: &TenantHandle,
2174 tenant_id: &str,
2175 memory_id: String,
2176 node_id_full: String,
2177 limit: i64,
2178) -> Result<GraphExpandResponse, ApiError> {
2179 let memory_id_for_err = memory_id.clone();
2180 let rows: Vec<TripleRow> = tenant
2181 .read()
2182 .interact(move |conn| {
2183 let rowid_opt: Option<i64> = conn
2185 .query_row(
2186 "SELECT rowid FROM episodes WHERE memory_id = ?1",
2187 rusqlite::params![&memory_id],
2188 |r| r.get(0),
2189 )
2190 .map(Some)
2191 .or_else(|e| match e {
2192 rusqlite::Error::QueryReturnedNoRows => Ok(None),
2193 other => Err(other),
2194 })?;
2195 let Some(rowid) = rowid_opt else {
2196 return Ok(Vec::new());
2197 };
2198 let mut stmt = conn.prepare(
2199 "SELECT subject_id, predicate, object_id, confidence
2200 FROM triples
2201 WHERE source_episode_id = ?1
2202 AND status = 'active'
2203 ORDER BY valid_from_ms DESC
2204 LIMIT ?2",
2205 )?;
2206 let mapped = stmt
2207 .query_map(rusqlite::params![rowid, limit], |r| {
2208 Ok(TripleRow {
2209 subject_id: r.get(0)?,
2210 predicate: r.get(1)?,
2211 object_id: r.get(2)?,
2212 confidence: r.get(3)?,
2213 })
2214 })?
2215 .collect::<rusqlite::Result<Vec<_>>>()?;
2216 Ok::<_, rusqlite::Error>(mapped)
2217 })
2218 .await
2219 .map_err(ApiError::from)?;
2220
2221 if rows.is_empty() {
2222 ensure_episode_exists(tenant, &memory_id_for_err, &node_id_full).await?;
2223 return Ok(GraphExpandResponse {
2224 nodes: Vec::new(),
2225 edges: Vec::new(),
2226 });
2227 }
2228
2229 let mut nodes = Vec::new();
2230 let mut edges = Vec::new();
2231 let mut seen_entities: std::collections::HashSet<String> = Default::default();
2232 for t in rows {
2233 let subj_id = format!("ent:{}", t.subject_id);
2244 let obj_id = format!("ent:{}", t.object_id);
2245 if seen_entities.insert(t.subject_id.clone()) {
2246 nodes.push(graph_node_for_entity(tenant_id, &t.subject_id));
2247 }
2248 if seen_entities.insert(t.object_id.clone()) {
2249 nodes.push(graph_node_for_entity(tenant_id, &t.object_id));
2250 }
2251 edges.push(GraphEdge {
2252 id: edge_id(&subj_id, "triple", &obj_id),
2253 source: subj_id,
2254 target: obj_id,
2255 kind: "triple",
2256 predicate: Some(t.predicate),
2257 weight: Some(t.confidence),
2258 });
2259 }
2260 Ok(GraphExpandResponse { nodes, edges })
2261}
2262
2263async fn expand_triple_from_entity(
2264 tenant: &TenantHandle,
2265 tenant_id: &str,
2266 entity_value: String,
2267 node_id_full: String,
2268 limit: i64,
2269) -> Result<GraphExpandResponse, ApiError> {
2270 let entity_q = entity_value.clone();
2273 let rows: Vec<ExpandedEpisode> = tenant
2274 .read()
2275 .interact(move |conn| {
2276 let mut stmt = conn.prepare(
2279 "SELECT DISTINCT e.memory_id, e.ts_ms, e.content
2280 FROM triples t
2281 JOIN episodes e ON e.rowid = t.source_episode_id
2282 WHERE (t.subject_id = ?1 OR t.object_id = ?1)
2283 AND t.status = 'active'
2284 AND t.source_episode_id IS NOT NULL
2285 AND e.status = 'active'
2286 ORDER BY e.ts_ms DESC
2287 LIMIT ?2",
2288 )?;
2289 let mapped = stmt
2290 .query_map(rusqlite::params![&entity_q, limit], |r| {
2291 Ok(ExpandedEpisode {
2292 memory_id: r.get(0)?,
2293 ts_ms: r.get(1)?,
2294 content: r.get(2)?,
2295 })
2296 })?
2297 .collect::<rusqlite::Result<Vec<_>>>()?;
2298 Ok::<_, rusqlite::Error>(mapped)
2299 })
2300 .await
2301 .map_err(ApiError::from)?;
2302
2303 let mut nodes = Vec::with_capacity(rows.len());
2306 let mut edges = Vec::with_capacity(rows.len());
2307 for ep in rows {
2308 let target_id = format!("ep:{}", ep.memory_id);
2309 edges.push(GraphEdge {
2310 id: edge_id(&node_id_full, "triple", &target_id),
2311 source: node_id_full.clone(),
2312 target: target_id,
2313 kind: "triple",
2314 predicate: None,
2315 weight: None,
2316 });
2317 nodes.push(graph_node_for_episode(tenant_id, &ep));
2318 }
2319 let _ = entity_value;
2321 Ok(GraphExpandResponse { nodes, edges })
2322}
2323
2324async fn expand_semantic(
2327 tenant: &TenantHandle,
2328 tenant_id: &str,
2329 node_kind: NodeKind,
2330 value: &str,
2331 node_id_full: &str,
2332 limit: i64,
2333) -> Result<GraphExpandResponse, ApiError> {
2334 if node_kind != NodeKind::Episode {
2335 return Err(ApiError::bad_request(format!(
2336 "kind=semantic only valid for episode source nodes; got {}",
2337 node_kind.as_wire_str()
2338 )));
2339 }
2340 let memory_id = value.to_string();
2341 let memory_id_q = memory_id.clone();
2342 let content: Option<String> = tenant
2347 .read()
2348 .interact(move |conn| {
2349 conn.query_row(
2350 "SELECT content FROM episodes WHERE memory_id = ?1 AND status = 'active'",
2351 rusqlite::params![&memory_id_q],
2352 |r| r.get::<_, String>(0),
2353 )
2354 .map(Some)
2355 .or_else(|e| match e {
2356 rusqlite::Error::QueryReturnedNoRows => Ok(None),
2357 other => Err(other),
2358 })
2359 })
2360 .await
2361 .map_err(ApiError::from)?;
2362
2363 let content = content.ok_or_else(|| {
2364 ApiError::not_found(format!(
2365 "node_id {node_id_full:?} (memory_id {memory_id}) not found in current tenant"
2366 ))
2367 })?;
2368
2369 let widened = (limit as usize).saturating_add(1).min(100);
2372 let result = solo_query::recall::run_recall_inner(
2373 tenant.embedder(),
2374 tenant.hnsw(),
2375 tenant.read(),
2376 &content,
2377 widened,
2378 )
2379 .await
2380 .map_err(ApiError::from)?;
2381
2382 let mut nodes = Vec::new();
2383 let mut edges = Vec::new();
2384 for hit in result.hits.into_iter() {
2385 if hit.memory_id == memory_id {
2386 continue;
2388 }
2389 if nodes.len() as i64 >= limit {
2390 break;
2391 }
2392 let weight = (1.0 - hit.cos_distance).max(0.0);
2396 let target_id = format!("ep:{}", hit.memory_id);
2397 edges.push(GraphEdge {
2398 id: edge_id(node_id_full, "semantic", &target_id),
2399 source: node_id_full.to_string(),
2400 target: target_id,
2401 kind: "semantic",
2402 predicate: None,
2403 weight: Some(weight),
2404 });
2405 nodes.push(GraphNode {
2406 id: format!("ep:{}", hit.memory_id),
2407 kind: NodeKind::Episode.as_wire_str(),
2408 label: episode_label(&hit.content),
2409 ts_ms: None,
2410 tenant_id: tenant_id.to_string(),
2411 preview: Some(truncate_preview(&hit.content, GRAPH_PREVIEW_CHARS)),
2412 });
2413 }
2414 Ok(GraphExpandResponse { nodes, edges })
2415}
2416
2417async fn ensure_episode_exists(
2421 tenant: &TenantHandle,
2422 memory_id: &str,
2423 node_id_full: &str,
2424) -> Result<(), ApiError> {
2425 let memory_id_q = memory_id.to_string();
2426 let exists: i64 = tenant
2427 .read()
2428 .interact(move |conn| {
2429 conn.query_row(
2430 "SELECT COUNT(*) FROM episodes WHERE memory_id = ?1",
2431 rusqlite::params![&memory_id_q],
2432 |r| r.get(0),
2433 )
2434 })
2435 .await
2436 .map_err(ApiError::from)?;
2437 if exists == 0 {
2438 return Err(ApiError::not_found(format!(
2439 "node_id {node_id_full:?} not found in current tenant"
2440 )));
2441 }
2442 Ok(())
2443}
2444
2445async fn ensure_cluster_exists(
2446 tenant: &TenantHandle,
2447 cluster_id: &str,
2448 node_id_full: &str,
2449) -> Result<(), ApiError> {
2450 let cluster_id_q = cluster_id.to_string();
2451 let exists: i64 = tenant
2452 .read()
2453 .interact(move |conn| {
2454 conn.query_row(
2455 "SELECT COUNT(*) FROM clusters WHERE cluster_id = ?1",
2456 rusqlite::params![&cluster_id_q],
2457 |r| r.get(0),
2458 )
2459 })
2460 .await
2461 .map_err(ApiError::from)?;
2462 if exists == 0 {
2463 return Err(ApiError::not_found(format!(
2464 "node_id {node_id_full:?} not found in current tenant"
2465 )));
2466 }
2467 Ok(())
2468}
2469
2470async fn ensure_document_exists(
2471 tenant: &TenantHandle,
2472 doc_id: &str,
2473 node_id_full: &str,
2474) -> Result<(), ApiError> {
2475 let doc_id_q = doc_id.to_string();
2476 let exists: i64 = tenant
2477 .read()
2478 .interact(move |conn| {
2479 conn.query_row(
2480 "SELECT COUNT(*) FROM documents WHERE doc_id = ?1",
2481 rusqlite::params![&doc_id_q],
2482 |r| r.get(0),
2483 )
2484 })
2485 .await
2486 .map_err(ApiError::from)?;
2487 if exists == 0 {
2488 return Err(ApiError::not_found(format!(
2489 "node_id {node_id_full:?} not found in current tenant"
2490 )));
2491 }
2492 Ok(())
2493}
2494
2495const GRAPH_NODES_DEFAULT_LIMIT: u32 = 100;
2509const GRAPH_NODES_MAX_LIMIT: u32 = 1000;
2510const GRAPH_EDGES_DEFAULT_LIMIT: u32 = 200;
2511const GRAPH_EDGES_MAX_LIMIT: u32 = 2000;
2512const GRAPH_ENTITY_CAP: usize = 200;
2513
2514const ENTITY_CAP_HEADER: &str = "x-solo-entity-cap-reached";
2518
2519#[derive(Debug, Deserialize)]
2520struct GraphNodesQuery {
2521 #[serde(default)]
2526 kind: Option<String>,
2527 #[serde(default)]
2528 since_ms: Option<i64>,
2529 #[serde(default)]
2530 until_ms: Option<i64>,
2531 #[serde(default)]
2532 limit: Option<u32>,
2533 #[serde(default)]
2534 cursor: Option<String>,
2535}
2536
2537#[derive(Debug, Deserialize)]
2538struct GraphEdgesQuery {
2539 #[serde(default)]
2540 node_id: Option<String>,
2541 #[serde(default)]
2544 r#type: Option<String>,
2545 #[serde(default)]
2546 limit: Option<u32>,
2547 #[serde(default)]
2548 cursor: Option<String>,
2549}
2550
2551#[derive(Debug, Serialize)]
2552struct GraphNodesResponse {
2553 nodes: Vec<GraphNode>,
2554 #[serde(skip_serializing_if = "Option::is_none")]
2555 next_cursor: Option<String>,
2556}
2557
2558#[derive(Debug, Serialize)]
2559struct GraphEdgesResponse {
2560 edges: Vec<GraphEdge>,
2561 #[serde(skip_serializing_if = "Option::is_none")]
2562 next_cursor: Option<String>,
2563}
2564
2565fn parse_node_kind_filter(raw: Option<&str>) -> Result<Vec<NodeKind>, ApiError> {
2569 let raw = raw.unwrap_or("").trim();
2570 if raw.is_empty() {
2571 return Ok(vec![
2572 NodeKind::Episode,
2573 NodeKind::Document,
2574 NodeKind::Chunk,
2575 NodeKind::Cluster,
2576 NodeKind::Entity,
2577 ]);
2578 }
2579 let mut out = Vec::new();
2580 for token in raw.split(',') {
2581 let token = token.trim();
2582 if token.is_empty() {
2583 continue;
2584 }
2585 let kind = match token {
2586 "episode" => NodeKind::Episode,
2587 "document" => NodeKind::Document,
2588 "chunk" => NodeKind::Chunk,
2589 "cluster" => NodeKind::Cluster,
2590 "entity" => NodeKind::Entity,
2591 other => {
2592 return Err(ApiError::bad_request(format!(
2593 "unknown node kind {other:?}; expected one of episode/document/chunk/cluster/entity"
2594 )));
2595 }
2596 };
2597 if !out.contains(&kind) {
2598 out.push(kind);
2599 }
2600 }
2601 if out.is_empty() {
2602 return Err(ApiError::bad_request(
2603 "kind filter is empty after parsing; either omit or list at least one kind",
2604 ));
2605 }
2606 Ok(out)
2607}
2608
2609#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2611enum EdgeKind {
2612 Triple,
2613 DocumentChunk,
2614 ClusterMember,
2615}
2616
2617impl EdgeKind {
2618 fn order_idx(self) -> u8 {
2620 match self {
2621 Self::Triple => 0,
2622 Self::DocumentChunk => 1,
2623 Self::ClusterMember => 2,
2624 }
2625 }
2626}
2627
2628fn parse_edge_kind_filter(raw: Option<&str>) -> Result<Vec<EdgeKind>, ApiError> {
2629 let raw = raw.unwrap_or("").trim();
2630 if raw.is_empty() {
2631 return Ok(vec![
2634 EdgeKind::Triple,
2635 EdgeKind::DocumentChunk,
2636 EdgeKind::ClusterMember,
2637 ]);
2638 }
2639 let mut out = Vec::new();
2640 for token in raw.split(',') {
2641 let token = token.trim();
2642 if token.is_empty() {
2643 continue;
2644 }
2645 let kind = match token {
2646 "triple" => EdgeKind::Triple,
2647 "document_chunk" => EdgeKind::DocumentChunk,
2648 "cluster_member" => EdgeKind::ClusterMember,
2649 "semantic" => {
2650 return Err(ApiError::bad_request(
2653 "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)",
2654 ));
2655 }
2656 other => {
2657 return Err(ApiError::bad_request(format!(
2658 "unknown edge type {other:?}; expected one of triple/document_chunk/cluster_member"
2659 )));
2660 }
2661 };
2662 if !out.contains(&kind) {
2663 out.push(kind);
2664 }
2665 }
2666 if out.is_empty() {
2667 return Err(ApiError::bad_request(
2668 "type filter is empty after parsing; either omit or list at least one type",
2669 ));
2670 }
2671 Ok(out)
2672}
2673
2674#[derive(Debug, Serialize, Deserialize)]
2678struct NodesCursor {
2679 ts_ms: i64,
2680 id: String,
2681}
2682
2683#[derive(Debug, Serialize, Deserialize)]
2689struct EdgesCursor {
2690 kind_idx: u8,
2691 sub_id: String,
2692}
2693
2694fn encode_cursor<T: Serialize>(value: &T) -> Result<String, ApiError> {
2695 use base64::Engine;
2696 let json = serde_json::to_vec(value).map_err(|e| {
2697 ApiError::internal(format!("cursor serialize: {e}"))
2698 })?;
2699 Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json))
2700}
2701
2702fn decode_cursor<T: for<'de> Deserialize<'de>>(raw: &str) -> Result<T, ApiError> {
2703 use base64::Engine;
2704 let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
2705 .decode(raw.as_bytes())
2706 .map_err(|e| ApiError::bad_request(format!("cursor: bad base64: {e}")))?;
2707 serde_json::from_slice::<T>(&bytes)
2708 .map_err(|e| ApiError::bad_request(format!("cursor: bad JSON payload: {e}")))
2709}
2710
2711#[derive(Debug)]
2715struct StagingNode {
2716 node: GraphNode,
2717 sort_ts_ms: i64,
2718 sort_id: String,
2719}
2720
2721fn cmp_node_sort_keys(a: (i64, &str), b: (i64, &str)) -> std::cmp::Ordering {
2724 match b.0.cmp(&a.0) {
2726 std::cmp::Ordering::Equal => a.1.cmp(b.1), other => other,
2728 }
2729}
2730
2731fn node_passes_cursor(ts_ms: i64, id: &str, cursor: &NodesCursor) -> bool {
2735 cmp_node_sort_keys((ts_ms, id), (cursor.ts_ms, cursor.id.as_str()))
2736 == std::cmp::Ordering::Greater
2737}
2738
2739#[derive(Debug)]
2743struct NodeRowEp {
2744 memory_id: String,
2745 ts_ms: i64,
2746 content: String,
2747}
2748
2749fn fetch_episodes_for_nodes(
2750 conn: &rusqlite::Connection,
2751 since_ms: Option<i64>,
2752 until_ms: Option<i64>,
2753 cursor: Option<&NodesCursor>,
2754 limit: i64,
2755) -> rusqlite::Result<Vec<NodeRowEp>> {
2756 let mut sql = String::from(
2757 "SELECT memory_id, ts_ms, content
2758 FROM episodes
2759 WHERE status = 'active'",
2760 );
2761 let mut params: Vec<rusqlite::types::Value> = Vec::new();
2762 if let Some(s) = since_ms {
2763 sql.push_str(" AND ts_ms >= ?");
2764 params.push(s.into());
2765 }
2766 if let Some(u) = until_ms {
2767 sql.push_str(" AND ts_ms <= ?");
2768 params.push(u.into());
2769 }
2770 if let Some(cur) = cursor {
2777 sql.push_str(" AND ts_ms <= ?");
2778 params.push(cur.ts_ms.into());
2779 }
2780 sql.push_str(" ORDER BY ts_ms DESC, memory_id ASC LIMIT ?");
2781 params.push(limit.into());
2782 let mut stmt = conn.prepare(&sql)?;
2783 let rows: Vec<NodeRowEp> = stmt
2784 .query_map(rusqlite::params_from_iter(params), |r| {
2785 Ok(NodeRowEp {
2786 memory_id: r.get(0)?,
2787 ts_ms: r.get(1)?,
2788 content: r.get(2)?,
2789 })
2790 })?
2791 .collect::<rusqlite::Result<Vec<_>>>()?;
2792 Ok(rows)
2793}
2794
2795#[derive(Debug)]
2796struct NodeRowDoc {
2797 doc_id: String,
2798 title: Option<String>,
2799 source: Option<String>,
2800 ingested_at_ms: i64,
2801}
2802
2803fn fetch_documents_for_nodes(
2804 conn: &rusqlite::Connection,
2805 since_ms: Option<i64>,
2806 until_ms: Option<i64>,
2807 cursor: Option<&NodesCursor>,
2808 limit: i64,
2809) -> rusqlite::Result<Vec<NodeRowDoc>> {
2810 let mut sql = String::from(
2811 "SELECT doc_id, title, source, ingested_at_ms
2812 FROM documents
2813 WHERE status = 'active'",
2814 );
2815 let mut params: Vec<rusqlite::types::Value> = Vec::new();
2816 if let Some(s) = since_ms {
2817 sql.push_str(" AND ingested_at_ms >= ?");
2818 params.push(s.into());
2819 }
2820 if let Some(u) = until_ms {
2821 sql.push_str(" AND ingested_at_ms <= ?");
2822 params.push(u.into());
2823 }
2824 if let Some(cur) = cursor {
2825 sql.push_str(" AND ingested_at_ms <= ?");
2826 params.push(cur.ts_ms.into());
2827 }
2828 sql.push_str(" ORDER BY ingested_at_ms DESC, doc_id ASC LIMIT ?");
2829 params.push(limit.into());
2830 let mut stmt = conn.prepare(&sql)?;
2831 let rows: Vec<NodeRowDoc> = stmt
2832 .query_map(rusqlite::params_from_iter(params), |r| {
2833 Ok(NodeRowDoc {
2834 doc_id: r.get(0)?,
2835 title: r.get(1)?,
2836 source: r.get(2)?,
2837 ingested_at_ms: r.get(3)?,
2838 })
2839 })?
2840 .collect::<rusqlite::Result<Vec<_>>>()?;
2841 Ok(rows)
2842}
2843
2844#[derive(Debug)]
2845struct NodeRowChunk {
2846 chunk_id: String,
2847 chunk_index: i64,
2848 content: String,
2849 created_at_ms: i64,
2850}
2851
2852fn fetch_chunks_for_nodes(
2853 conn: &rusqlite::Connection,
2854 since_ms: Option<i64>,
2855 until_ms: Option<i64>,
2856 cursor: Option<&NodesCursor>,
2857 limit: i64,
2858) -> rusqlite::Result<Vec<NodeRowChunk>> {
2859 let mut sql = String::from(
2862 "SELECT c.chunk_id, c.chunk_index, c.content, c.created_at_ms
2863 FROM document_chunks c
2864 JOIN documents d ON d.doc_id = c.doc_id
2865 WHERE d.status = 'active'",
2866 );
2867 let mut params: Vec<rusqlite::types::Value> = Vec::new();
2868 if let Some(s) = since_ms {
2869 sql.push_str(" AND c.created_at_ms >= ?");
2870 params.push(s.into());
2871 }
2872 if let Some(u) = until_ms {
2873 sql.push_str(" AND c.created_at_ms <= ?");
2874 params.push(u.into());
2875 }
2876 if let Some(cur) = cursor {
2877 sql.push_str(" AND c.created_at_ms <= ?");
2878 params.push(cur.ts_ms.into());
2879 }
2880 sql.push_str(" ORDER BY c.created_at_ms DESC, c.chunk_id ASC LIMIT ?");
2881 params.push(limit.into());
2882 let mut stmt = conn.prepare(&sql)?;
2883 let rows: Vec<NodeRowChunk> = stmt
2884 .query_map(rusqlite::params_from_iter(params), |r| {
2885 Ok(NodeRowChunk {
2886 chunk_id: r.get(0)?,
2887 chunk_index: r.get(1)?,
2888 content: r.get(2)?,
2889 created_at_ms: r.get(3)?,
2890 })
2891 })?
2892 .collect::<rusqlite::Result<Vec<_>>>()?;
2893 Ok(rows)
2894}
2895
2896#[derive(Debug)]
2897struct NodeRowCluster {
2898 cluster_id: String,
2899 abstraction: Option<String>,
2900 created_at_ms: i64,
2901}
2902
2903fn fetch_clusters_for_nodes(
2904 conn: &rusqlite::Connection,
2905 since_ms: Option<i64>,
2906 until_ms: Option<i64>,
2907 cursor: Option<&NodesCursor>,
2908 limit: i64,
2909) -> rusqlite::Result<Vec<NodeRowCluster>> {
2910 let mut sql = String::from(
2913 "SELECT c.cluster_id, sa.content, c.created_at_ms
2914 FROM clusters c
2915 LEFT JOIN semantic_abstractions sa ON sa.cluster_id = c.cluster_id
2916 WHERE 1=1",
2917 );
2918 let mut params: Vec<rusqlite::types::Value> = Vec::new();
2919 if let Some(s) = since_ms {
2920 sql.push_str(" AND c.created_at_ms >= ?");
2921 params.push(s.into());
2922 }
2923 if let Some(u) = until_ms {
2924 sql.push_str(" AND c.created_at_ms <= ?");
2925 params.push(u.into());
2926 }
2927 if let Some(cur) = cursor {
2928 sql.push_str(" AND c.created_at_ms <= ?");
2929 params.push(cur.ts_ms.into());
2930 }
2931 sql.push_str(" ORDER BY c.created_at_ms DESC, c.cluster_id ASC LIMIT ?");
2932 params.push(limit.into());
2933 let mut stmt = conn.prepare(&sql)?;
2934 let rows: Vec<NodeRowCluster> = stmt
2935 .query_map(rusqlite::params_from_iter(params), |r| {
2936 Ok(NodeRowCluster {
2937 cluster_id: r.get(0)?,
2938 abstraction: r.get(1)?,
2939 created_at_ms: r.get(2)?,
2940 })
2941 })?
2942 .collect::<rusqlite::Result<Vec<_>>>()?;
2943 Ok(rows)
2944}
2945
2946#[derive(Debug)]
2947struct NodeRowEntity {
2948 value: String,
2949 ref_count: i64,
2950 first_seen_ms: i64,
2951}
2952
2953fn fetch_entities_for_nodes(
2962 conn: &rusqlite::Connection,
2963 since_ms: Option<i64>,
2964 until_ms: Option<i64>,
2965 cursor: Option<&NodesCursor>,
2966) -> rusqlite::Result<(Vec<NodeRowEntity>, bool)> {
2967 let mut sql = String::from(
2972 "WITH all_refs AS (
2973 SELECT subject_id AS value, valid_from_ms AS ts_ms FROM triples WHERE status = 'active'
2974 UNION ALL
2975 SELECT object_id AS value, valid_from_ms AS ts_ms FROM triples WHERE status = 'active'
2976 )
2977 SELECT value, COUNT(*) AS ref_count, MIN(ts_ms) AS first_seen_ms
2978 FROM all_refs
2979 WHERE 1=1",
2980 );
2981 let mut params: Vec<rusqlite::types::Value> = Vec::new();
2982 if let Some(s) = since_ms {
2983 sql.push_str(" AND ts_ms >= ?");
2984 params.push(s.into());
2985 }
2986 if let Some(u) = until_ms {
2987 sql.push_str(" AND ts_ms <= ?");
2988 params.push(u.into());
2989 }
2990 sql.push_str(" GROUP BY value");
2994 if let Some(ts) = cursor.map(|c| c.ts_ms) {
2995 sql.push_str(" HAVING MIN(ts_ms) <= ?");
2996 params.push(ts.into());
2997 }
2998 let want = GRAPH_ENTITY_CAP as i64 + 1;
3000 sql.push_str(" ORDER BY ref_count DESC, value ASC LIMIT ?");
3001 params.push(want.into());
3002 let mut stmt = conn.prepare(&sql)?;
3003 let rows: Vec<NodeRowEntity> = stmt
3004 .query_map(rusqlite::params_from_iter(params), |r| {
3005 Ok(NodeRowEntity {
3006 value: r.get(0)?,
3007 ref_count: r.get(1)?,
3008 first_seen_ms: r.get(2)?,
3009 })
3010 })?
3011 .collect::<rusqlite::Result<Vec<_>>>()?;
3012 let cap_reached = rows.len() > GRAPH_ENTITY_CAP;
3013 let mut trimmed = rows;
3014 if cap_reached {
3015 trimmed.truncate(GRAPH_ENTITY_CAP);
3016 }
3017 Ok((trimmed, cap_reached))
3018}
3019
3020async fn graph_nodes_handler(
3023 TenantExtractor(tenant): TenantExtractor,
3024 Query(q): Query<GraphNodesQuery>,
3025) -> Result<Response, ApiError> {
3026 let limit = q.limit.unwrap_or(GRAPH_NODES_DEFAULT_LIMIT);
3027 let limit = limit.clamp(1, GRAPH_NODES_MAX_LIMIT);
3028 let kinds = parse_node_kind_filter(q.kind.as_deref())?;
3029 let since_ms = q.since_ms;
3030 let until_ms = q.until_ms;
3031 if let (Some(s), Some(u)) = (since_ms, until_ms) {
3032 if s > u {
3033 return Err(ApiError::bad_request(format!(
3034 "since_ms ({s}) must be <= until_ms ({u})"
3035 )));
3036 }
3037 }
3038 let cursor = match q.cursor.as_deref() {
3039 None => None,
3040 Some("") => None,
3041 Some(raw) => Some(decode_cursor::<NodesCursor>(raw)?),
3042 };
3043 let want_episode = kinds.contains(&NodeKind::Episode);
3044 let want_document = kinds.contains(&NodeKind::Document);
3045 let want_chunk = kinds.contains(&NodeKind::Chunk);
3046 let want_cluster = kinds.contains(&NodeKind::Cluster);
3047 let want_entity = kinds.contains(&NodeKind::Entity);
3048
3049 let per_kind_limit = (limit as i64).saturating_add(2);
3058 let tenant_id_for_blocking = tenant.tenant_id().to_string();
3059 let cursor_clone = cursor.as_ref().map(|c| NodesCursor {
3060 ts_ms: c.ts_ms,
3061 id: c.id.clone(),
3062 });
3063
3064 let (mut staged, cap_reached) = tenant
3065 .read()
3066 .interact(move |conn| {
3067 let mut staged: Vec<StagingNode> = Vec::new();
3068 let mut cap_reached = false;
3069 let cursor_ref = cursor_clone.as_ref();
3070
3071 if want_episode {
3072 let eps = fetch_episodes_for_nodes(conn, since_ms, until_ms, cursor_ref, per_kind_limit)?;
3073 for ep in eps {
3074 let id = format!("ep:{}", ep.memory_id);
3075 let exp = ExpandedEpisode {
3076 memory_id: ep.memory_id,
3077 ts_ms: ep.ts_ms,
3078 content: ep.content,
3079 };
3080 let node = graph_node_for_episode(&tenant_id_for_blocking, &exp);
3081 staged.push(StagingNode {
3082 sort_ts_ms: ep.ts_ms,
3083 sort_id: id.clone(),
3084 node,
3085 });
3086 }
3087 }
3088 if want_document {
3089 let docs = fetch_documents_for_nodes(conn, since_ms, until_ms, cursor_ref, per_kind_limit)?;
3090 for d in docs {
3091 let id = format!("doc:{}", d.doc_id);
3092 let exp = ExpandedDocument {
3093 doc_id: d.doc_id,
3094 title: d.title,
3095 source: d.source,
3096 ingested_at_ms: d.ingested_at_ms,
3097 };
3098 let node = graph_node_for_document(&tenant_id_for_blocking, &exp);
3099 staged.push(StagingNode {
3100 sort_ts_ms: d.ingested_at_ms,
3101 sort_id: id.clone(),
3102 node,
3103 });
3104 }
3105 }
3106 if want_chunk {
3107 let chunks = fetch_chunks_for_nodes(conn, since_ms, until_ms, cursor_ref, per_kind_limit)?;
3108 for c in chunks {
3109 let id = format!("chunk:{}", c.chunk_id);
3110 let exp = ExpandedChunk {
3111 chunk_id: c.chunk_id,
3112 chunk_index: c.chunk_index,
3113 content: c.content,
3114 };
3115 let mut node = graph_node_for_chunk(&tenant_id_for_blocking, &exp);
3120 node.ts_ms = Some(c.created_at_ms);
3121 staged.push(StagingNode {
3122 sort_ts_ms: c.created_at_ms,
3123 sort_id: id.clone(),
3124 node,
3125 });
3126 }
3127 }
3128 if want_cluster {
3129 let cls = fetch_clusters_for_nodes(conn, since_ms, until_ms, cursor_ref, per_kind_limit)?;
3130 for c in cls {
3131 let id = format!("cl:{}", c.cluster_id);
3132 let node = graph_node_for_cluster(
3133 &tenant_id_for_blocking,
3134 &c.cluster_id,
3135 c.abstraction.as_deref(),
3136 c.created_at_ms,
3137 );
3138 staged.push(StagingNode {
3139 sort_ts_ms: c.created_at_ms,
3140 sort_id: id.clone(),
3141 node,
3142 });
3143 }
3144 }
3145 if want_entity {
3146 let (ents, was_cap_reached) =
3147 fetch_entities_for_nodes(conn, since_ms, until_ms, cursor_ref)?;
3148 cap_reached = was_cap_reached;
3149 for e in ents {
3150 let id = format!("ent:{}", e.value);
3151 let mut node = graph_node_for_entity(&tenant_id_for_blocking, &e.value);
3152 node.ts_ms = Some(e.first_seen_ms);
3153 node.preview =
3154 Some(format!("Referenced in {} triples", e.ref_count));
3155 staged.push(StagingNode {
3156 sort_ts_ms: e.first_seen_ms,
3157 sort_id: id.clone(),
3158 node,
3159 });
3160 }
3161 }
3162 Ok::<_, rusqlite::Error>((staged, cap_reached))
3163 })
3164 .await
3165 .map_err(ApiError::from)?;
3166
3167 if let Some(cur) = &cursor {
3169 staged.retain(|s| node_passes_cursor(s.sort_ts_ms, &s.sort_id, cur));
3170 }
3171
3172 staged.sort_by(|a, b| {
3174 cmp_node_sort_keys((a.sort_ts_ms, &a.sort_id), (b.sort_ts_ms, &b.sort_id))
3175 });
3176
3177 let limit_us = limit as usize;
3179 let next_cursor = if staged.len() > limit_us {
3180 let last = &staged[limit_us - 1];
3181 Some(NodesCursor {
3182 ts_ms: last.sort_ts_ms,
3183 id: last.sort_id.clone(),
3184 })
3185 } else {
3186 None
3187 };
3188 staged.truncate(limit_us);
3189
3190 let next_cursor_str = match next_cursor {
3191 Some(c) => Some(encode_cursor(&c)?),
3192 None => None,
3193 };
3194
3195 let nodes: Vec<GraphNode> = staged.into_iter().map(|s| s.node).collect();
3196 let payload = GraphNodesResponse {
3197 nodes,
3198 next_cursor: next_cursor_str,
3199 };
3200
3201 let mut response = Json(payload).into_response();
3204 if cap_reached {
3205 response
3206 .headers_mut()
3207 .insert(ENTITY_CAP_HEADER, HeaderValue::from_static("true"));
3208 }
3209 Ok(response)
3210}
3211
3212#[derive(Debug)]
3215struct StagingEdge {
3216 edge: GraphEdge,
3217 kind_idx: u8,
3218 sub_id: String,
3219}
3220
3221fn cmp_edge_sort_keys(a: (u8, &str), b: (u8, &str)) -> std::cmp::Ordering {
3222 match a.0.cmp(&b.0) {
3223 std::cmp::Ordering::Equal => a.1.cmp(b.1),
3224 other => other,
3225 }
3226}
3227
3228fn edge_passes_cursor(kind_idx: u8, sub_id: &str, cursor: &EdgesCursor) -> bool {
3229 cmp_edge_sort_keys((kind_idx, sub_id), (cursor.kind_idx, cursor.sub_id.as_str()))
3230 == std::cmp::Ordering::Greater
3231}
3232
3233fn edge_touches_focus(
3237 kind: EdgeKind,
3238 focus_kind: NodeKind,
3239 focus_value: &str,
3240 src_value: &str,
3241 tgt_value: &str,
3242 extra_value: Option<&str>,
3243) -> bool {
3244 match kind {
3247 EdgeKind::Triple => match focus_kind {
3248 NodeKind::Episode => src_value == focus_value,
3253 NodeKind::Entity => {
3254 tgt_value == focus_value
3255 || extra_value.map(|x| x == focus_value).unwrap_or(false)
3256 || src_value == focus_value
3257 }
3258 _ => false,
3259 },
3260 EdgeKind::DocumentChunk => match focus_kind {
3261 NodeKind::Document => src_value == focus_value,
3262 NodeKind::Chunk => tgt_value == focus_value,
3263 _ => false,
3264 },
3265 EdgeKind::ClusterMember => match focus_kind {
3266 NodeKind::Cluster => src_value == focus_value,
3267 NodeKind::Episode => tgt_value == focus_value,
3268 _ => false,
3269 },
3270 }
3271}
3272
3273#[derive(Debug)]
3274struct EdgeRowTriple {
3275 triple_id: String,
3276 source_memory_id: Option<String>,
3277 object_id: String,
3278 predicate: String,
3279 confidence: f32,
3280}
3281
3282fn fetch_triple_edges(conn: &rusqlite::Connection) -> rusqlite::Result<Vec<EdgeRowTriple>> {
3283 let safety_cap = (GRAPH_EDGES_MAX_LIMIT as i64) * 4;
3289 let mut stmt = conn.prepare(
3290 "SELECT t.triple_id, e.memory_id, t.object_id, t.predicate, t.confidence
3291 FROM triples t
3292 LEFT JOIN episodes e ON e.rowid = t.source_episode_id
3293 WHERE t.status = 'active'
3294 ORDER BY t.triple_id ASC
3295 LIMIT ?1",
3296 )?;
3297 let rows: Vec<EdgeRowTriple> = stmt
3298 .query_map(rusqlite::params![safety_cap], |r| {
3299 Ok(EdgeRowTriple {
3300 triple_id: r.get(0)?,
3301 source_memory_id: r.get::<_, Option<String>>(1)?,
3302 object_id: r.get(2)?,
3303 predicate: r.get(3)?,
3304 confidence: r.get(4)?,
3305 })
3306 })?
3307 .collect::<rusqlite::Result<Vec<_>>>()?;
3308 Ok(rows)
3309}
3310
3311#[derive(Debug)]
3312struct EdgeRowDocChunk {
3313 chunk_id: String,
3314 doc_id: String,
3315}
3316
3317fn fetch_document_chunk_edges(
3318 conn: &rusqlite::Connection,
3319) -> rusqlite::Result<Vec<EdgeRowDocChunk>> {
3320 let safety_cap = (GRAPH_EDGES_MAX_LIMIT as i64) * 4;
3321 let mut stmt = conn.prepare(
3322 "SELECT c.chunk_id, c.doc_id
3323 FROM document_chunks c
3324 JOIN documents d ON d.doc_id = c.doc_id
3325 WHERE d.status = 'active'
3326 ORDER BY c.chunk_id ASC
3327 LIMIT ?1",
3328 )?;
3329 let rows: Vec<EdgeRowDocChunk> = stmt
3330 .query_map(rusqlite::params![safety_cap], |r| {
3331 Ok(EdgeRowDocChunk {
3332 chunk_id: r.get(0)?,
3333 doc_id: r.get(1)?,
3334 })
3335 })?
3336 .collect::<rusqlite::Result<Vec<_>>>()?;
3337 Ok(rows)
3338}
3339
3340#[derive(Debug)]
3341struct EdgeRowClusterMember {
3342 cluster_id: String,
3343 memory_id: String,
3344}
3345
3346fn fetch_cluster_member_edges(
3347 conn: &rusqlite::Connection,
3348) -> rusqlite::Result<Vec<EdgeRowClusterMember>> {
3349 let safety_cap = (GRAPH_EDGES_MAX_LIMIT as i64) * 4;
3350 let mut stmt = conn.prepare(
3351 "SELECT ce.cluster_id, ce.memory_id
3352 FROM cluster_episodes ce
3353 JOIN episodes e ON e.memory_id = ce.memory_id
3354 WHERE e.status = 'active'
3355 ORDER BY ce.cluster_id ASC, ce.memory_id ASC
3356 LIMIT ?1",
3357 )?;
3358 let rows: Vec<EdgeRowClusterMember> = stmt
3359 .query_map(rusqlite::params![safety_cap], |r| {
3360 Ok(EdgeRowClusterMember {
3361 cluster_id: r.get(0)?,
3362 memory_id: r.get(1)?,
3363 })
3364 })?
3365 .collect::<rusqlite::Result<Vec<_>>>()?;
3366 Ok(rows)
3367}
3368
3369async fn graph_edges_handler(
3372 TenantExtractor(tenant): TenantExtractor,
3373 Query(q): Query<GraphEdgesQuery>,
3374) -> Result<Json<GraphEdgesResponse>, ApiError> {
3375 let limit = q.limit.unwrap_or(GRAPH_EDGES_DEFAULT_LIMIT);
3376 let limit = limit.clamp(1, GRAPH_EDGES_MAX_LIMIT);
3377 let kinds = parse_edge_kind_filter(q.r#type.as_deref())?;
3378 let cursor = match q.cursor.as_deref() {
3379 None => None,
3380 Some("") => None,
3381 Some(raw) => Some(decode_cursor::<EdgesCursor>(raw)?),
3382 };
3383
3384 let focus = match q.node_id.as_deref() {
3385 None => None,
3386 Some(raw) => {
3387 let (kind, value) = parse_node_id(raw)?;
3388 Some((kind, value.to_string()))
3389 }
3390 };
3391
3392 let want_triple = kinds.contains(&EdgeKind::Triple);
3393 let want_doc_chunk = kinds.contains(&EdgeKind::DocumentChunk);
3394 let want_cluster_member = kinds.contains(&EdgeKind::ClusterMember);
3395
3396 let staged: Vec<StagingEdge> = tenant
3397 .read()
3398 .interact(move |conn| {
3399 let mut staged: Vec<StagingEdge> = Vec::new();
3400
3401 if want_triple {
3402 for t in fetch_triple_edges(conn)? {
3403 let src_id = match &t.source_memory_id {
3404 Some(mid) => format!("ep:{mid}"),
3405 None => continue, };
3407 let tgt_id = format!("ent:{}", t.object_id);
3408 if let Some((fk, fv)) = &focus {
3409 if !edge_touches_focus(
3413 EdgeKind::Triple,
3414 *fk,
3415 fv,
3416 t.source_memory_id
3417 .as_deref()
3418 .unwrap_or(""),
3419 &t.object_id,
3420 None,
3426 ) {
3427 continue;
3428 }
3429 }
3430 let edge = GraphEdge {
3431 id: edge_id(&src_id, "triple", &tgt_id),
3432 source: src_id,
3433 target: tgt_id,
3434 kind: "triple",
3435 predicate: Some(t.predicate),
3436 weight: Some(t.confidence),
3437 };
3438 staged.push(StagingEdge {
3439 edge,
3440 kind_idx: EdgeKind::Triple.order_idx(),
3441 sub_id: t.triple_id,
3442 });
3443 }
3444 }
3445 if want_doc_chunk {
3446 for dc in fetch_document_chunk_edges(conn)? {
3447 let src_id = format!("doc:{}", dc.doc_id);
3448 let tgt_id = format!("chunk:{}", dc.chunk_id);
3449 if let Some((fk, fv)) = &focus {
3450 if !edge_touches_focus(
3451 EdgeKind::DocumentChunk,
3452 *fk,
3453 fv,
3454 &dc.doc_id,
3455 &dc.chunk_id,
3456 None,
3457 ) {
3458 continue;
3459 }
3460 }
3461 let edge = GraphEdge {
3462 id: edge_id(&src_id, "document_chunk", &tgt_id),
3463 source: src_id,
3464 target: tgt_id,
3465 kind: "document_chunk",
3466 predicate: None,
3467 weight: None,
3468 };
3469 staged.push(StagingEdge {
3470 edge,
3471 kind_idx: EdgeKind::DocumentChunk.order_idx(),
3472 sub_id: dc.chunk_id,
3473 });
3474 }
3475 }
3476 if want_cluster_member {
3477 for cm in fetch_cluster_member_edges(conn)? {
3478 let src_id = format!("cl:{}", cm.cluster_id);
3479 let tgt_id = format!("ep:{}", cm.memory_id);
3480 if let Some((fk, fv)) = &focus {
3481 if !edge_touches_focus(
3482 EdgeKind::ClusterMember,
3483 *fk,
3484 fv,
3485 &cm.cluster_id,
3486 &cm.memory_id,
3487 None,
3488 ) {
3489 continue;
3490 }
3491 }
3492 let edge = GraphEdge {
3493 id: edge_id(&src_id, "cluster_member", &tgt_id),
3494 source: src_id,
3495 target: tgt_id,
3496 kind: "cluster_member",
3497 predicate: None,
3498 weight: None,
3499 };
3500 let sub_id = format!("{}\u{1f}{}", cm.cluster_id, cm.memory_id);
3501 staged.push(StagingEdge {
3502 edge,
3503 kind_idx: EdgeKind::ClusterMember.order_idx(),
3504 sub_id,
3505 });
3506 }
3507 }
3508 Ok::<_, rusqlite::Error>(staged)
3509 })
3510 .await
3511 .map_err(ApiError::from)?;
3512
3513 let mut staged = staged;
3515 if let Some(cur) = &cursor {
3516 staged.retain(|s| edge_passes_cursor(s.kind_idx, &s.sub_id, cur));
3517 }
3518
3519 staged.sort_by(|a, b| {
3521 cmp_edge_sort_keys((a.kind_idx, &a.sub_id), (b.kind_idx, &b.sub_id))
3522 });
3523
3524 let limit_us = limit as usize;
3525 let next_cursor = if staged.len() > limit_us {
3526 let last = &staged[limit_us - 1];
3527 Some(EdgesCursor {
3528 kind_idx: last.kind_idx,
3529 sub_id: last.sub_id.clone(),
3530 })
3531 } else {
3532 None
3533 };
3534 staged.truncate(limit_us);
3535 let next_cursor_str = match next_cursor {
3536 Some(c) => Some(encode_cursor(&c)?),
3537 None => None,
3538 };
3539
3540 let edges: Vec<GraphEdge> = staged.into_iter().map(|s| s.edge).collect();
3541 Ok(Json(GraphEdgesResponse {
3542 edges,
3543 next_cursor: next_cursor_str,
3544 }))
3545}
3546
3547const GRAPH_INSPECT_ENTITY_TRIPLES_CAP: i64 = 50;
3599
3600#[derive(Debug, Serialize)]
3601struct GraphInspectResponse {
3602 node: GraphNode,
3603 #[serde(skip_serializing_if = "Option::is_none")]
3604 full_text: Option<String>,
3605 triples_in: Vec<GraphEdge>,
3606 triples_out: Vec<GraphEdge>,
3607}
3608
3609async fn graph_inspect_handler(
3611 TenantExtractor(tenant): TenantExtractor,
3612 Path(id): Path<String>,
3613) -> Result<Json<GraphInspectResponse>, ApiError> {
3614 let (kind, value) = parse_node_id(&id)?;
3615 let tenant_id_str = tenant.tenant_id().to_string();
3616 let value = value.to_string();
3617 let node_id_full = id;
3618 match kind {
3619 NodeKind::Episode => {
3620 inspect_episode_node(&tenant, &tenant_id_str, value, node_id_full).await
3621 }
3622 NodeKind::Document => {
3623 inspect_document_node(&tenant, &tenant_id_str, value, node_id_full).await
3624 }
3625 NodeKind::Chunk => {
3626 inspect_chunk_node(&tenant, &tenant_id_str, value, node_id_full).await
3627 }
3628 NodeKind::Cluster => {
3629 inspect_cluster_node(&tenant, &tenant_id_str, value, node_id_full).await
3630 }
3631 NodeKind::Entity => {
3632 inspect_entity_node(&tenant, &tenant_id_str, value, node_id_full).await
3633 }
3634 }
3635 .map(Json)
3636}
3637
3638async fn inspect_episode_node(
3641 tenant: &TenantHandle,
3642 tenant_id: &str,
3643 memory_id: String,
3644 node_id_full: String,
3645) -> Result<GraphInspectResponse, ApiError> {
3646 let memory_id_for_err = memory_id.clone();
3647 let memory_id_q = memory_id.clone();
3648 let fetched: Option<(ExpandedEpisode, Vec<TripleRow>)> = tenant
3651 .read()
3652 .interact(move |conn| {
3653 let ep_row: Option<(i64, i64, String)> = conn
3654 .query_row(
3655 "SELECT rowid, ts_ms, content
3656 FROM episodes
3657 WHERE memory_id = ?1
3658 AND status = 'active'",
3659 rusqlite::params![&memory_id_q],
3660 |r| {
3661 Ok((
3662 r.get::<_, i64>(0)?,
3663 r.get::<_, i64>(1)?,
3664 r.get::<_, String>(2)?,
3665 ))
3666 },
3667 )
3668 .map(Some)
3669 .or_else(|e| match e {
3670 rusqlite::Error::QueryReturnedNoRows => Ok(None),
3671 other => Err(other),
3672 })?;
3673 let Some((rowid, ts_ms, content)) = ep_row else {
3674 return Ok(None);
3675 };
3676 let mut stmt = conn.prepare(
3677 "SELECT subject_id, predicate, object_id, confidence
3678 FROM triples
3679 WHERE source_episode_id = ?1
3680 AND status = 'active'
3681 ORDER BY valid_from_ms DESC",
3682 )?;
3683 let triples = stmt
3684 .query_map(rusqlite::params![rowid], |r| {
3685 Ok(TripleRow {
3686 subject_id: r.get(0)?,
3687 predicate: r.get(1)?,
3688 object_id: r.get(2)?,
3689 confidence: r.get(3)?,
3690 })
3691 })?
3692 .collect::<rusqlite::Result<Vec<_>>>()?;
3693 let ep = ExpandedEpisode {
3694 memory_id: memory_id_q,
3695 ts_ms,
3696 content,
3697 };
3698 Ok::<_, rusqlite::Error>(Some((ep, triples)))
3699 })
3700 .await
3701 .map_err(ApiError::from)?;
3702
3703 let (ep, triples) = fetched.ok_or_else(|| {
3704 ApiError::not_found(format!(
3705 "node_id {node_id_full:?} (memory_id {memory_id_for_err}) not found in current tenant"
3706 ))
3707 })?;
3708
3709 let node = graph_node_for_episode(tenant_id, &ep);
3710 let full_text = Some(ep.content.clone());
3711 let mut triples_out = Vec::with_capacity(triples.len());
3716 for t in triples {
3717 let tgt_id = format!("ent:{}", t.object_id);
3718 triples_out.push(GraphEdge {
3719 id: edge_id(&node_id_full, "triple", &tgt_id),
3720 source: node_id_full.clone(),
3721 target: tgt_id,
3722 kind: "triple",
3723 predicate: Some(t.predicate),
3724 weight: Some(t.confidence),
3725 });
3726 }
3727 Ok(GraphInspectResponse {
3728 node,
3729 full_text,
3730 triples_in: Vec::new(),
3731 triples_out,
3732 })
3733}
3734
3735async fn inspect_document_node(
3736 tenant: &TenantHandle,
3737 tenant_id: &str,
3738 doc_id: String,
3739 node_id_full: String,
3740) -> Result<GraphInspectResponse, ApiError> {
3741 let doc_id_for_err = doc_id.clone();
3742 let doc_id_q = doc_id.clone();
3743 let fetched: Option<(ExpandedDocument, Vec<String>)> = tenant
3749 .read()
3750 .interact(move |conn| {
3751 let doc_row: Option<ExpandedDocument> = conn
3752 .query_row(
3753 "SELECT doc_id, title, source, ingested_at_ms
3754 FROM documents
3755 WHERE doc_id = ?1
3756 AND status = 'active'",
3757 rusqlite::params![&doc_id_q],
3758 |r| {
3759 Ok(ExpandedDocument {
3760 doc_id: r.get(0)?,
3761 title: r.get(1)?,
3762 source: r.get(2)?,
3763 ingested_at_ms: r.get(3)?,
3764 })
3765 },
3766 )
3767 .map(Some)
3768 .or_else(|e| match e {
3769 rusqlite::Error::QueryReturnedNoRows => Ok(None),
3770 other => Err(other),
3771 })?;
3772 let Some(doc) = doc_row else {
3773 return Ok(None);
3774 };
3775 let mut stmt = conn.prepare(
3776 "SELECT content
3777 FROM document_chunks
3778 WHERE doc_id = ?1
3779 ORDER BY chunk_index ASC",
3780 )?;
3781 let chunks = stmt
3782 .query_map(rusqlite::params![&doc_id_q], |r| r.get::<_, String>(0))?
3783 .collect::<rusqlite::Result<Vec<_>>>()?;
3784 Ok::<_, rusqlite::Error>(Some((doc, chunks)))
3785 })
3786 .await
3787 .map_err(ApiError::from)?;
3788
3789 let (doc, chunks) = fetched.ok_or_else(|| {
3790 ApiError::not_found(format!(
3791 "node_id {node_id_full:?} (doc_id {doc_id_for_err}) not found in current tenant"
3792 ))
3793 })?;
3794
3795 let full_text = if chunks.is_empty() {
3796 None
3800 } else {
3801 Some(chunks.join("\n\n"))
3802 };
3803
3804 Ok(GraphInspectResponse {
3805 node: graph_node_for_document(tenant_id, &doc),
3806 full_text,
3807 triples_in: Vec::new(),
3808 triples_out: Vec::new(),
3809 })
3810}
3811
3812async fn inspect_chunk_node(
3813 tenant: &TenantHandle,
3814 tenant_id: &str,
3815 chunk_id: String,
3816 node_id_full: String,
3817) -> Result<GraphInspectResponse, ApiError> {
3818 let chunk_id_for_err = chunk_id.clone();
3819 let chunk_id_q = chunk_id.clone();
3820 let row: Option<(ExpandedChunk, i64)> = tenant
3821 .read()
3822 .interact(move |conn| {
3823 conn.query_row(
3824 "SELECT c.chunk_id, c.chunk_index, c.content, c.created_at_ms
3825 FROM document_chunks c
3826 JOIN documents d ON d.doc_id = c.doc_id
3827 WHERE c.chunk_id = ?1
3828 AND d.status = 'active'",
3829 rusqlite::params![&chunk_id_q],
3830 |r| {
3831 Ok((
3832 ExpandedChunk {
3833 chunk_id: r.get(0)?,
3834 chunk_index: r.get(1)?,
3835 content: r.get(2)?,
3836 },
3837 r.get::<_, i64>(3)?,
3838 ))
3839 },
3840 )
3841 .map(Some)
3842 .or_else(|e| match e {
3843 rusqlite::Error::QueryReturnedNoRows => Ok(None),
3844 other => Err(other),
3845 })
3846 })
3847 .await
3848 .map_err(ApiError::from)?;
3849
3850 let (chunk, created_at_ms) = row.ok_or_else(|| {
3851 ApiError::not_found(format!(
3852 "node_id {node_id_full:?} (chunk_id {chunk_id_for_err}) not found in current tenant"
3853 ))
3854 })?;
3855
3856 let full_text = Some(chunk.content.clone());
3857 let mut node = graph_node_for_chunk(tenant_id, &chunk);
3858 node.ts_ms = Some(created_at_ms);
3861
3862 Ok(GraphInspectResponse {
3863 node,
3864 full_text,
3865 triples_in: Vec::new(),
3866 triples_out: Vec::new(),
3867 })
3868}
3869
3870async fn inspect_cluster_node(
3871 tenant: &TenantHandle,
3872 tenant_id: &str,
3873 cluster_id: String,
3874 node_id_full: String,
3875) -> Result<GraphInspectResponse, ApiError> {
3876 let cluster_id_for_err = cluster_id.clone();
3877 let cluster_id_q = cluster_id.clone();
3878 let row: Option<(Option<String>, i64)> = tenant
3879 .read()
3880 .interact(move |conn| {
3881 conn.query_row(
3882 "SELECT sa.content, c.created_at_ms
3883 FROM clusters c
3884 LEFT JOIN semantic_abstractions sa ON sa.cluster_id = c.cluster_id
3885 WHERE c.cluster_id = ?1",
3886 rusqlite::params![&cluster_id_q],
3887 |r| Ok((r.get::<_, Option<String>>(0)?, r.get::<_, i64>(1)?)),
3888 )
3889 .map(Some)
3890 .or_else(|e| match e {
3891 rusqlite::Error::QueryReturnedNoRows => Ok(None),
3892 other => Err(other),
3893 })
3894 })
3895 .await
3896 .map_err(ApiError::from)?;
3897
3898 let (abstraction, created_at_ms) = row.ok_or_else(|| {
3899 ApiError::not_found(format!(
3900 "node_id {node_id_full:?} (cluster_id {cluster_id_for_err}) not found in current tenant"
3901 ))
3902 })?;
3903
3904 let full_text = match abstraction.as_deref() {
3909 Some(a) => Some(format!("cluster {cluster_id_for_err}\n\n{a}")),
3910 None => Some(format!("cluster {cluster_id_for_err}")),
3911 };
3912
3913 Ok(GraphInspectResponse {
3914 node: graph_node_for_cluster(
3915 tenant_id,
3916 &cluster_id_for_err,
3917 abstraction.as_deref(),
3918 created_at_ms,
3919 ),
3920 full_text,
3921 triples_in: Vec::new(),
3922 triples_out: Vec::new(),
3923 })
3924}
3925
3926async fn inspect_entity_node(
3927 tenant: &TenantHandle,
3928 tenant_id: &str,
3929 entity_value: String,
3930 node_id_full: String,
3931) -> Result<GraphInspectResponse, ApiError> {
3932 let entity_q = entity_value.clone();
3935 let rows: Vec<TripleRow> = tenant
3936 .read()
3937 .interact(move |conn| {
3938 let mut stmt = conn.prepare(
3939 "SELECT subject_id, predicate, object_id, confidence
3940 FROM triples
3941 WHERE (subject_id = ?1 OR object_id = ?1)
3942 AND status = 'active'
3943 ORDER BY valid_from_ms DESC
3944 LIMIT ?2",
3945 )?;
3946 stmt.query_map(
3947 rusqlite::params![&entity_q, GRAPH_INSPECT_ENTITY_TRIPLES_CAP],
3948 |r| {
3949 Ok(TripleRow {
3950 subject_id: r.get(0)?,
3951 predicate: r.get(1)?,
3952 object_id: r.get(2)?,
3953 confidence: r.get(3)?,
3954 })
3955 },
3956 )?
3957 .collect::<rusqlite::Result<Vec<_>>>()
3958 })
3959 .await
3960 .map_err(ApiError::from)?;
3961
3962 if rows.is_empty() {
3963 return Err(ApiError::not_found(format!(
3964 "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"
3965 )));
3966 }
3967
3968 let mut triples_out = Vec::with_capacity(rows.len());
3973 for t in rows {
3974 let other = if t.subject_id == entity_value {
3975 t.object_id
3976 } else {
3977 t.subject_id
3979 };
3980 let tgt_id = format!("ent:{other}");
3981 triples_out.push(GraphEdge {
3982 id: edge_id(&node_id_full, "triple", &tgt_id),
3983 source: node_id_full.clone(),
3984 target: tgt_id,
3985 kind: "triple",
3986 predicate: Some(t.predicate),
3987 weight: Some(t.confidence),
3988 });
3989 }
3990
3991 Ok(GraphInspectResponse {
3992 node: graph_node_for_entity(tenant_id, &entity_value),
3993 full_text: None,
3994 triples_in: Vec::new(),
3995 triples_out,
3996 })
3997}
3998
3999const GRAPH_NEIGHBORS_DEFAULT_LIMIT: u32 = 25;
4066const GRAPH_NEIGHBORS_MAX_LIMIT: u32 = 100;
4068const GRAPH_NEIGHBORS_DEFAULT_THRESHOLD: f32 = 0.75;
4071
4072#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
4075#[serde(rename_all = "snake_case")]
4076enum GraphNeighborsKind {
4077 Explicit,
4078 Semantic,
4079 #[default]
4080 Both,
4081}
4082
4083#[derive(Debug, Deserialize)]
4084struct GraphNeighborsQuery {
4085 #[serde(default)]
4086 kind: Option<GraphNeighborsKind>,
4087 #[serde(default)]
4088 threshold: Option<f32>,
4089 #[serde(default)]
4090 limit: Option<u32>,
4091}
4092
4093async fn graph_neighbors_handler(
4095 TenantExtractor(tenant): TenantExtractor,
4096 Path(id): Path<String>,
4097 Query(q): Query<GraphNeighborsQuery>,
4098) -> Result<Json<GraphExpandResponse>, ApiError> {
4099 let kind = q.kind.unwrap_or_default();
4100 let threshold = q.threshold.unwrap_or(GRAPH_NEIGHBORS_DEFAULT_THRESHOLD);
4101 if !(0.0..=1.0).contains(&threshold) {
4102 return Err(ApiError::bad_request(format!(
4103 "threshold must be in [0.0, 1.0]; got {threshold}"
4104 )));
4105 }
4106 let limit_raw = q.limit.unwrap_or(GRAPH_NEIGHBORS_DEFAULT_LIMIT);
4110 let limit = limit_raw.clamp(1, GRAPH_NEIGHBORS_MAX_LIMIT);
4111
4112 let (node_kind, value) = parse_node_id(&id)?;
4113 let value_owned = value.to_string();
4114 let tenant_id_str = tenant.tenant_id().to_string();
4115 let node_id_full = id;
4116
4117 ensure_neighbors_focal_exists(&tenant, node_kind, &value_owned, &node_id_full).await?;
4124
4125 let (explicit_nodes, explicit_edges) = if matches!(
4127 kind,
4128 GraphNeighborsKind::Explicit | GraphNeighborsKind::Both
4129 ) {
4130 neighbors_explicit(
4131 &tenant,
4132 &tenant_id_str,
4133 node_kind,
4134 &value_owned,
4135 &node_id_full,
4136 limit as i64,
4137 )
4138 .await?
4139 } else {
4140 (Vec::new(), Vec::new())
4141 };
4142
4143 let (semantic_nodes, semantic_edges) = if matches!(
4144 kind,
4145 GraphNeighborsKind::Semantic | GraphNeighborsKind::Both
4146 ) {
4147 match neighbors_semantic(
4148 &tenant,
4149 &tenant_id_str,
4150 node_kind,
4151 &value_owned,
4152 &node_id_full,
4153 limit,
4154 threshold,
4155 )
4156 .await
4157 {
4158 Ok(parts) => parts,
4159 Err(e) => {
4160 if matches!(kind, GraphNeighborsKind::Semantic) {
4171 return Err(e);
4172 }
4173 (Vec::new(), Vec::new())
4174 }
4175 }
4176 } else {
4177 (Vec::new(), Vec::new())
4178 };
4179
4180 let mut explicit_endpoints: std::collections::HashSet<(String, String)> =
4183 std::collections::HashSet::with_capacity(explicit_edges.len());
4184 for e in &explicit_edges {
4185 explicit_endpoints.insert((e.source.clone(), e.target.clone()));
4186 }
4187
4188 let mut nodes: Vec<GraphNode> = Vec::with_capacity(explicit_nodes.len() + semantic_nodes.len());
4189 let mut edges: Vec<GraphEdge> =
4190 Vec::with_capacity(explicit_edges.len() + semantic_edges.len());
4191 let mut seen_node_ids: std::collections::HashSet<String> =
4192 std::collections::HashSet::with_capacity(explicit_nodes.len() + semantic_nodes.len());
4193
4194 for n in explicit_nodes {
4195 if seen_node_ids.insert(n.id.clone()) {
4196 nodes.push(n);
4197 }
4198 }
4199 for e in explicit_edges {
4200 edges.push(e);
4201 }
4202 for n in semantic_nodes {
4203 if seen_node_ids.insert(n.id.clone()) {
4204 nodes.push(n);
4205 }
4206 }
4207 for e in semantic_edges {
4208 if explicit_endpoints.contains(&(e.source.clone(), e.target.clone())) {
4209 continue;
4215 }
4216 edges.push(e);
4217 }
4218
4219 Ok(Json(GraphExpandResponse { nodes, edges }))
4220}
4221
4222async fn ensure_neighbors_focal_exists(
4229 tenant: &TenantHandle,
4230 node_kind: NodeKind,
4231 value: &str,
4232 node_id_full: &str,
4233) -> Result<(), ApiError> {
4234 match node_kind {
4235 NodeKind::Episode => ensure_episode_exists(tenant, value, node_id_full).await,
4236 NodeKind::Cluster => ensure_cluster_exists(tenant, value, node_id_full).await,
4237 NodeKind::Document => ensure_document_exists(tenant, value, node_id_full).await,
4238 NodeKind::Chunk => ensure_chunk_exists(tenant, value, node_id_full).await,
4239 NodeKind::Entity => ensure_entity_referenced(tenant, value, node_id_full).await,
4240 }
4241}
4242
4243async fn ensure_chunk_exists(
4247 tenant: &TenantHandle,
4248 chunk_id: &str,
4249 node_id_full: &str,
4250) -> Result<(), ApiError> {
4251 let chunk_id_q = chunk_id.to_string();
4252 let exists: i64 = tenant
4253 .read()
4254 .interact(move |conn| {
4255 conn.query_row(
4256 "SELECT COUNT(*)
4257 FROM document_chunks c
4258 JOIN documents d ON d.doc_id = c.doc_id
4259 WHERE c.chunk_id = ?1
4260 AND d.status = 'active'",
4261 rusqlite::params![&chunk_id_q],
4262 |r| r.get(0),
4263 )
4264 })
4265 .await
4266 .map_err(ApiError::from)?;
4267 if exists == 0 {
4268 return Err(ApiError::not_found(format!(
4269 "node_id {node_id_full:?} not found in current tenant"
4270 )));
4271 }
4272 Ok(())
4273}
4274
4275async fn ensure_entity_referenced(
4279 tenant: &TenantHandle,
4280 entity_value: &str,
4281 node_id_full: &str,
4282) -> Result<(), ApiError> {
4283 let entity_q = entity_value.to_string();
4284 let exists: i64 = tenant
4285 .read()
4286 .interact(move |conn| {
4287 conn.query_row(
4288 "SELECT COUNT(*)
4289 FROM triples
4290 WHERE (subject_id = ?1 OR object_id = ?1)
4291 AND status = 'active'",
4292 rusqlite::params![&entity_q],
4293 |r| r.get(0),
4294 )
4295 })
4296 .await
4297 .map_err(ApiError::from)?;
4298 if exists == 0 {
4299 return Err(ApiError::not_found(format!(
4300 "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"
4301 )));
4302 }
4303 Ok(())
4304}
4305
4306async fn neighbors_explicit(
4312 tenant: &TenantHandle,
4313 tenant_id: &str,
4314 node_kind: NodeKind,
4315 value: &str,
4316 node_id_full: &str,
4317 limit: i64,
4318) -> Result<(Vec<GraphNode>, Vec<GraphEdge>), ApiError> {
4319 let mut nodes: Vec<GraphNode> = Vec::new();
4320 let mut edges: Vec<GraphEdge> = Vec::new();
4321
4322 match node_kind {
4323 NodeKind::Episode => {
4324 let r1 = expand_cluster_member(tenant, tenant_id, node_kind, value, node_id_full, limit)
4332 .await?;
4333 nodes.extend(r1.nodes);
4334 edges.extend(r1.edges);
4335 let r2 =
4336 expand_triple(tenant, tenant_id, node_kind, value, node_id_full, limit).await?;
4337 nodes.extend(r2.nodes);
4338 edges.extend(r2.edges);
4339 }
4340 NodeKind::Document => {
4341 let r = expand_document_chunk(tenant, tenant_id, node_kind, value, node_id_full, limit)
4344 .await?;
4345 nodes.extend(r.nodes);
4346 edges.extend(r.edges);
4347 }
4348 NodeKind::Chunk => {
4349 let r = expand_document_chunk(tenant, tenant_id, node_kind, value, node_id_full, limit)
4352 .await?;
4353 nodes.extend(r.nodes);
4354 edges.extend(r.edges);
4355 }
4356 NodeKind::Cluster => {
4357 let r = expand_cluster_member(tenant, tenant_id, node_kind, value, node_id_full, limit)
4360 .await?;
4361 nodes.extend(r.nodes);
4362 edges.extend(r.edges);
4363 }
4364 NodeKind::Entity => {
4365 let r =
4368 expand_triple(tenant, tenant_id, node_kind, value, node_id_full, limit).await?;
4369 nodes.extend(r.nodes);
4370 edges.extend(r.edges);
4371 }
4372 }
4373 Ok((nodes, edges))
4374}
4375
4376async fn neighbors_semantic(
4390 tenant: &TenantHandle,
4391 tenant_id: &str,
4392 node_kind: NodeKind,
4393 value: &str,
4394 node_id_full: &str,
4395 limit: u32,
4396 threshold: f32,
4397) -> Result<(Vec<GraphNode>, Vec<GraphEdge>), ApiError> {
4398 match node_kind {
4399 NodeKind::Episode => {
4400 neighbors_semantic_from_episode(
4401 tenant,
4402 tenant_id,
4403 value,
4404 node_id_full,
4405 limit,
4406 threshold,
4407 )
4408 .await
4409 }
4410 NodeKind::Chunk => {
4411 neighbors_semantic_from_chunk(
4412 tenant,
4413 tenant_id,
4414 value,
4415 node_id_full,
4416 limit,
4417 threshold,
4418 )
4419 .await
4420 }
4421 _ => Err(ApiError::bad_request(format!(
4422 "semantic neighbors only valid for episode or chunk source; got {}",
4423 node_kind.as_wire_str()
4424 ))),
4425 }
4426}
4427
4428async fn neighbors_semantic_from_episode(
4429 tenant: &TenantHandle,
4430 tenant_id: &str,
4431 memory_id: &str,
4432 node_id_full: &str,
4433 limit: u32,
4434 threshold: f32,
4435) -> Result<(Vec<GraphNode>, Vec<GraphEdge>), ApiError> {
4436 let memory_id_q = memory_id.to_string();
4437 let memory_id_for_self_excl = memory_id.to_string();
4438 let content: Option<String> = tenant
4439 .read()
4440 .interact(move |conn| {
4441 conn.query_row(
4442 "SELECT content FROM episodes WHERE memory_id = ?1 AND status = 'active'",
4443 rusqlite::params![&memory_id_q],
4444 |r| r.get::<_, String>(0),
4445 )
4446 .map(Some)
4447 .or_else(|e| match e {
4448 rusqlite::Error::QueryReturnedNoRows => Ok(None),
4449 other => Err(other),
4450 })
4451 })
4452 .await
4453 .map_err(ApiError::from)?;
4454
4455 let Some(content) = content else {
4459 return Ok((Vec::new(), Vec::new()));
4460 };
4461
4462 let widened = (limit as usize).saturating_add(1).min(100);
4464 let result = solo_query::recall::run_recall_inner(
4465 tenant.embedder(),
4466 tenant.hnsw(),
4467 tenant.read(),
4468 &content,
4469 widened,
4470 )
4471 .await
4472 .map_err(ApiError::from)?;
4473
4474 let mut nodes = Vec::new();
4475 let mut edges = Vec::new();
4476 for hit in result.hits.into_iter() {
4477 if hit.memory_id == memory_id_for_self_excl {
4478 continue;
4480 }
4481 if nodes.len() as u32 >= limit {
4482 break;
4483 }
4484 let weight = (1.0 - hit.cos_distance).max(0.0);
4485 if weight < threshold {
4486 continue;
4487 }
4488 let target_id = format!("ep:{}", hit.memory_id);
4489 edges.push(GraphEdge {
4490 id: edge_id(node_id_full, "semantic", &target_id),
4491 source: node_id_full.to_string(),
4492 target: target_id,
4493 kind: "semantic",
4494 predicate: None,
4495 weight: Some(weight),
4496 });
4497 nodes.push(GraphNode {
4498 id: format!("ep:{}", hit.memory_id),
4499 kind: NodeKind::Episode.as_wire_str(),
4500 label: episode_label(&hit.content),
4501 ts_ms: None,
4502 tenant_id: tenant_id.to_string(),
4503 preview: Some(truncate_preview(&hit.content, GRAPH_PREVIEW_CHARS)),
4504 });
4505 }
4506 Ok((nodes, edges))
4507}
4508
4509async fn neighbors_semantic_from_chunk(
4510 tenant: &TenantHandle,
4511 tenant_id: &str,
4512 chunk_id: &str,
4513 node_id_full: &str,
4514 limit: u32,
4515 threshold: f32,
4516) -> Result<(Vec<GraphNode>, Vec<GraphEdge>), ApiError> {
4517 let chunk_id_q = chunk_id.to_string();
4518 let chunk_id_for_self_excl = chunk_id.to_string();
4519 let content: Option<String> = tenant
4520 .read()
4521 .interact(move |conn| {
4522 conn.query_row(
4523 "SELECT c.content
4524 FROM document_chunks c
4525 JOIN documents d ON d.doc_id = c.doc_id
4526 WHERE c.chunk_id = ?1
4527 AND d.status = 'active'",
4528 rusqlite::params![&chunk_id_q],
4529 |r| r.get::<_, String>(0),
4530 )
4531 .map(Some)
4532 .or_else(|e| match e {
4533 rusqlite::Error::QueryReturnedNoRows => Ok(None),
4534 other => Err(other),
4535 })
4536 })
4537 .await
4538 .map_err(ApiError::from)?;
4539
4540 let Some(content) = content else {
4541 return Ok((Vec::new(), Vec::new()));
4542 };
4543
4544 let widened = (limit as usize).saturating_add(1).min(100);
4545 let hits = solo_query::doc_search::run_doc_search_inner(
4546 tenant.embedder(),
4547 tenant.hnsw(),
4548 tenant.read(),
4549 &content,
4550 widened,
4551 )
4552 .await
4553 .map_err(ApiError::from)?;
4554
4555 let mut nodes = Vec::new();
4556 let mut edges = Vec::new();
4557 for hit in hits.into_iter() {
4558 if hit.chunk_id == chunk_id_for_self_excl {
4559 continue;
4560 }
4561 if nodes.len() as u32 >= limit {
4562 break;
4563 }
4564 let weight = (1.0 - hit.cos_distance).max(0.0);
4565 if weight < threshold {
4566 continue;
4567 }
4568 let target_id = format!("chunk:{}", hit.chunk_id);
4569 edges.push(GraphEdge {
4570 id: edge_id(node_id_full, "semantic", &target_id),
4571 source: node_id_full.to_string(),
4572 target: target_id,
4573 kind: "semantic",
4574 predicate: None,
4575 weight: Some(weight),
4576 });
4577 let exp = ExpandedChunk {
4578 chunk_id: hit.chunk_id.clone(),
4579 chunk_index: hit.chunk_index as i64,
4580 content: hit.content.clone(),
4581 };
4582 nodes.push(graph_node_for_chunk(tenant_id, &exp));
4583 }
4584 Ok((nodes, edges))
4585}
4586
4587pub const STREAM_HEARTBEAT_SECS: u64 = 30;
4628
4629const STREAM_EVENT_INIT: &str = "init";
4632
4633const STREAM_EVENT_INVALIDATE: &str = "invalidate";
4636
4637const STREAM_EVENT_HEARTBEAT: &str = "heartbeat";
4639
4640async fn graph_stream_handler(
4660 TenantExtractor(tenant): TenantExtractor,
4661) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
4662 let rx = tenant.invalidate_sender().subscribe();
4667 let tenant_id = tenant.tenant_id().to_string();
4668 let stream = build_invalidate_stream(rx, tenant_id, STREAM_HEARTBEAT_SECS);
4669 Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(3600)))
4676}
4677
4678struct StreamState {
4682 rx: broadcast::Receiver<InvalidateEvent>,
4683 heartbeat: tokio::time::Interval,
4684 tenant_id: String,
4685 needs_init: bool,
4688}
4689
4690fn build_invalidate_stream(
4698 rx: broadcast::Receiver<InvalidateEvent>,
4699 tenant_id: String,
4700 heartbeat_secs: u64,
4701) -> impl Stream<Item = Result<Event, Infallible>> {
4702 let start_at = tokio::time::Instant::now() + Duration::from_secs(heartbeat_secs);
4708 let heartbeat =
4709 tokio::time::interval_at(start_at, Duration::from_secs(heartbeat_secs));
4710
4711 let state = StreamState {
4712 rx,
4713 heartbeat,
4714 tenant_id,
4715 needs_init: true,
4716 };
4717 futures::stream::unfold(state, move |mut state| async move {
4718 if state.needs_init {
4722 state.needs_init = false;
4723 let init_payload = serde_json::json!({
4724 "connected": true,
4725 "tenant_id": state.tenant_id,
4726 "ts_ms": chrono::Utc::now().timestamp_millis(),
4727 });
4728 let ev = Event::default()
4729 .event(STREAM_EVENT_INIT)
4730 .json_data(init_payload)
4731 .unwrap_or_else(|_| Event::default().event(STREAM_EVENT_INIT));
4732 return Some((Ok::<Event, Infallible>(ev), state));
4733 }
4734 loop {
4735 tokio::select! {
4736 event = state.rx.recv() => {
4737 match event {
4738 Ok(ev) => {
4739 let sse_event = Event::default()
4740 .event(STREAM_EVENT_INVALIDATE)
4741 .json_data(&ev)
4742 .unwrap_or_else(|_| Event::default()
4743 .event(STREAM_EVENT_INVALIDATE));
4744 return Some((Ok::<Event, Infallible>(sse_event), state));
4745 }
4746 Err(broadcast::error::RecvError::Lagged(n)) => {
4747 tracing::warn!(
4748 lagged = n,
4749 "graph stream subscriber lagged; client will \
4750 resync on the next real invalidate"
4751 );
4752 }
4755 Err(broadcast::error::RecvError::Closed) => {
4756 tracing::debug!(
4757 "graph stream broadcast closed; ending SSE stream"
4758 );
4759 return None;
4760 }
4761 }
4762 }
4763 _ = state.heartbeat.tick() => {
4764 let hb_payload = serde_json::json!({
4765 "ts_ms": chrono::Utc::now().timestamp_millis(),
4766 });
4767 let sse_event = Event::default()
4768 .event(STREAM_EVENT_HEARTBEAT)
4769 .json_data(hb_payload)
4770 .unwrap_or_else(|_| Event::default()
4771 .event(STREAM_EVENT_HEARTBEAT));
4772 return Some((Ok::<Event, Infallible>(sse_event), state));
4773 }
4774 }
4775 }
4776 })
4777}
4778
4779#[derive(Debug, Clone, Serialize)]
4873struct TenantListItem {
4874 id: String,
4877 #[serde(skip_serializing_if = "Option::is_none")]
4880 display_name: Option<String>,
4881 created_at_ms: i64,
4883 #[serde(skip_serializing_if = "Option::is_none")]
4887 last_accessed_ms: Option<i64>,
4888 status: TenantStatusJson,
4893 #[serde(skip_serializing_if = "Option::is_none")]
4896 quota_bytes: Option<u64>,
4897 episode_count: Option<i64>,
4904 size_bytes: Option<u64>,
4909 pct_used: Option<f64>,
4914}
4915
4916#[derive(Debug, Clone, Copy, Serialize)]
4923#[serde(rename_all = "snake_case")]
4924enum TenantStatusJson {
4925 Active,
4926}
4927
4928impl From<&solo_storage::TenantStatus> for TenantStatusJson {
4929 fn from(s: &solo_storage::TenantStatus) -> Self {
4930 match s {
4934 solo_storage::TenantStatus::Active => TenantStatusJson::Active,
4935 solo_storage::TenantStatus::PendingMigration
4939 | solo_storage::TenantStatus::PendingDelete => TenantStatusJson::Active,
4940 }
4941 }
4942}
4943
4944#[derive(Debug, Serialize)]
4946struct TenantsListResponse {
4947 tenants: Vec<TenantListItem>,
4948}
4949
4950const TENANTS_COUNT_HYDRATION_CAP: usize = 50;
4960
4961const X_SOLO_TENANTS_COUNT_CAP_HEADER: &str = "x-solo-tenants-count-cap-reached";
4968
4969async fn tenants_list_handler(
4982 State(state): State<SoloHttpState>,
4983 MaybePrincipal(maybe_principal): MaybePrincipal,
4984) -> Result<Response, ApiError> {
4985 let mut records = state.registry.list_active().await.map_err(ApiError::from)?;
4991
4992 records.retain(|r| matches!(r.status, solo_storage::TenantStatus::Active));
4997
4998 let filtered = filter_tenants_for_principal(records, maybe_principal.as_ref());
5003
5004 let cap = TENANTS_COUNT_HYDRATION_CAP;
5009 let costs = state
5010 .registry
5011 .hydrate_tenant_cost_numbers(&filtered, cap)
5012 .await;
5013 let cap_reached = filtered.len() > cap;
5014
5015 let tenants: Vec<TenantListItem> = filtered
5016 .iter()
5017 .zip(costs.iter())
5018 .map(|(r, cost)| {
5019 let pct_used = match (cost.size_bytes, r.quota_bytes) {
5020 (Some(size), Some(quota)) if quota > 0 => {
5021 let raw = (size as f64) * 100.0 / (quota as f64);
5022 Some(raw.min(100.0))
5023 }
5024 _ => None,
5025 };
5026 TenantListItem {
5027 id: r.tenant_id.to_string(),
5028 display_name: r.display_name.clone(),
5029 created_at_ms: r.created_at_ms,
5030 last_accessed_ms: r.last_accessed_ms,
5031 status: TenantStatusJson::from(&r.status),
5032 quota_bytes: r.quota_bytes,
5033 episode_count: cost.episode_count,
5034 size_bytes: cost.size_bytes,
5035 pct_used,
5036 }
5037 })
5038 .collect();
5039
5040 let body = Json(TenantsListResponse { tenants });
5041 if cap_reached {
5042 let mut resp = body.into_response();
5043 resp.headers_mut().insert(
5044 axum::http::HeaderName::from_static(X_SOLO_TENANTS_COUNT_CAP_HEADER),
5045 axum::http::HeaderValue::from_static("true"),
5046 );
5047 Ok(resp)
5048 } else {
5049 Ok(body.into_response())
5050 }
5051}
5052
5053fn filter_tenants_for_principal(
5066 records: Vec<solo_storage::TenantRecord>,
5067 principal: Option<&AuthenticatedPrincipal>,
5068) -> Vec<solo_storage::TenantRecord> {
5069 let Some(p) = principal else {
5070 return records;
5073 };
5074 if is_single_principal_bearer(p) {
5075 return records;
5078 }
5079 let Some(claim) = p.tenant_claim.as_ref() else {
5083 return Vec::new();
5084 };
5085 records
5086 .into_iter()
5087 .filter(|r| r.tenant_id == *claim)
5088 .collect()
5089}
5090
5091fn is_single_principal_bearer(principal: &AuthenticatedPrincipal) -> bool {
5103 principal.subject == "bearer"
5104 && principal.claims.is_null()
5105 && principal.scopes.is_empty()
5106}
5107
5108#[derive(Debug)]
5113pub struct ApiError {
5114 status: StatusCode,
5115 message: String,
5116}
5117
5118impl ApiError {
5119 fn bad_request(msg: impl Into<String>) -> Self {
5120 Self {
5121 status: StatusCode::BAD_REQUEST,
5122 message: msg.into(),
5123 }
5124 }
5125 fn not_found(msg: impl Into<String>) -> Self {
5126 Self {
5127 status: StatusCode::NOT_FOUND,
5128 message: msg.into(),
5129 }
5130 }
5131 fn internal(msg: impl Into<String>) -> Self {
5132 Self {
5133 status: StatusCode::INTERNAL_SERVER_ERROR,
5134 message: msg.into(),
5135 }
5136 }
5137}
5138
5139impl From<solo_core::Error> for ApiError {
5140 fn from(e: solo_core::Error) -> Self {
5141 use solo_core::Error;
5142 match e {
5143 Error::NotFound(msg) => ApiError::not_found(msg),
5144 Error::InvalidInput(msg) => ApiError::bad_request(msg),
5145 Error::Conflict(msg) => Self {
5146 status: StatusCode::CONFLICT,
5147 message: msg,
5148 },
5149 other => ApiError::internal(other.to_string()),
5150 }
5151 }
5152}
5153
5154impl IntoResponse for ApiError {
5155 fn into_response(self) -> Response {
5156 let body = serde_json::json!({
5157 "error": self.message,
5158 "status": self.status.as_u16(),
5159 });
5160 (self.status, Json(body)).into_response()
5161 }
5162}
5163
5164#[cfg(test)]
5168mod handler_tests {
5169 use super::*;
5178 use axum::body::Body;
5179 use axum::http::{Request, StatusCode};
5180 use http_body_util::BodyExt;
5181 use serde_json::{Value, json};
5182 use solo_storage::test_support::StubVectorIndex;
5183 use solo_storage::{
5184 EmbedderConfig, IdentityConfig, KeyMaterial, ReaderPool, SoloConfig,
5185 StubEmbedder, TenantHandle, TenantRegistry, WriterActor, WriterSpawn,
5186 };
5187 use solo_core::VectorIndex;
5188 use std::sync::Arc as StdArc;
5189 use tower::ServiceExt;
5190
5191 fn fake_config(dim: u32) -> SoloConfig {
5192 SoloConfig {
5193 schema_version: 1,
5194 salt_hex: "00000000000000000000000000000000".to_string(),
5195 embedder: EmbedderConfig {
5196 name: "stub".to_string(),
5197 version: "v1".to_string(),
5198 dim,
5199 dtype: "f32".to_string(),
5200 },
5201 identity: IdentityConfig::default(),
5202 documents: solo_storage::DocumentConfig::default(),
5203 auth: None,
5204 audit: solo_storage::AuditSettings::default(),
5205 redaction: solo_storage::RedactionConfig::default(),
5206 llm: None,
5207 triples: solo_storage::TriplesConfig::default(),
5208 sampling: solo_storage::SamplingConfig::default(),
5209 }
5210 }
5211
5212 struct Harness {
5213 router: axum::Router,
5214 _tmp: tempfile::TempDir,
5215 db_path: std::path::PathBuf,
5216 write_handle_extra: Option<solo_storage::WriteHandle>,
5217 join: Option<std::thread::JoinHandle<()>>,
5218 tenant_handle: StdArc<TenantHandle>,
5223 registry: StdArc<TenantRegistry>,
5227 }
5228
5229 impl Harness {
5230 fn invalidate_sender(&self) -> tokio::sync::broadcast::Sender<InvalidateEvent> {
5237 self.tenant_handle.invalidate_sender().clone()
5238 }
5239 }
5240
5241 impl Harness {
5242 fn new(runtime: &tokio::runtime::Runtime) -> Self {
5243 Self::new_with_auth(runtime, None)
5244 }
5245
5246 fn open_db(&self) -> rusqlite::Connection {
5250 solo_storage::test_support::open_test_db_at(&self.db_path)
5251 }
5252
5253 fn new_with_auth(
5254 runtime: &tokio::runtime::Runtime,
5255 bearer_token: Option<String>,
5256 ) -> Self {
5257 Self::new_with_auth_config(
5258 runtime,
5259 bearer_token.map(|token| crate::auth::AuthConfig::Bearer { token }),
5260 )
5261 }
5262
5263 fn new_with_auth_config(
5264 runtime: &tokio::runtime::Runtime,
5265 auth: Option<crate::auth::AuthConfig>,
5266 ) -> Self {
5267 use solo_storage::embedder_registry::{EmbedderIdentity, get_or_insert_embedder_id};
5268
5269 let tmp = tempfile::TempDir::new().unwrap();
5270 let dim = 16usize;
5271 let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
5272 let embedder: StdArc<dyn solo_core::Embedder> =
5273 StdArc::new(StubEmbedder::new("stub", "v1", dim));
5274 let path = tmp.path().join("test.db");
5275
5276 let embedder_id = {
5277 let conn = solo_storage::test_support::open_test_db_at(&path);
5278 get_or_insert_embedder_id(
5279 &conn,
5280 &EmbedderIdentity {
5281 name: "stub".into(),
5282 version: "v1".into(),
5283 dim: dim as u32,
5284 dtype: "f32".into(),
5285 },
5286 )
5287 .unwrap()
5288 };
5289
5290 let conn = solo_storage::test_support::open_test_db_at(&path);
5291 let WriterSpawn { handle, join } = WriterActor::spawn_full(
5292 conn,
5293 hnsw.clone(),
5294 tmp.path().to_path_buf(),
5295 embedder_id,
5296 );
5297 let pool: ReaderPool =
5298 runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
5299
5300 let tenant_id = solo_core::TenantId::default_tenant();
5303 let tenant_handle = StdArc::new(
5304 TenantHandle::from_parts_for_tests(
5305 tenant_id.clone(),
5306 fake_config(dim as u32),
5307 path.clone(),
5308 tmp.path().to_path_buf(),
5309 embedder_id,
5310 hnsw,
5311 embedder.clone(),
5312 handle.clone(),
5313 std::thread::spawn(|| {}),
5319 pool,
5320 ),
5321 );
5322 let tenant_handle_clone = tenant_handle.clone();
5323
5324 let key = KeyMaterial::from_bytes_for_tests([0u8; 32]);
5328 let registry = StdArc::new(TenantRegistry::for_tests_with_single_tenant(
5329 tmp.path().to_path_buf(),
5330 key,
5331 embedder,
5332 tenant_handle,
5333 ));
5334 let registry_clone = registry.clone();
5335
5336 let state = SoloHttpState {
5337 registry,
5338 default_tenant: tenant_id,
5339 user_aliases: Arc::new(Vec::new()),
5340 };
5341 let router = router_with_auth_config(state, auth);
5342 Harness {
5343 router,
5344 _tmp: tmp,
5345 db_path: path,
5346 write_handle_extra: Some(handle),
5347 join: Some(join),
5348 tenant_handle: tenant_handle_clone,
5349 registry: registry_clone,
5350 }
5351 }
5352
5353 fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
5354 let join = self.join.take();
5355 let extra = self.write_handle_extra.take();
5356 let tenant_handle = self.tenant_handle;
5363 let registry = self.registry;
5369 runtime.block_on(async move {
5370 drop(extra);
5371 drop(tenant_handle); drop(registry); drop(self.router); drop(self._tmp);
5375 if let Some(join) = join {
5376 let (tx, rx) = std::sync::mpsc::channel();
5377 std::thread::spawn(move || {
5378 let _ = tx.send(join.join());
5379 });
5380 tokio::task::spawn_blocking(move || {
5381 rx.recv_timeout(std::time::Duration::from_secs(5))
5382 })
5383 .await
5384 .expect("blocking task")
5385 .expect("writer thread did not exit within 5s")
5386 .expect("writer thread panicked");
5387 }
5388 });
5389 }
5390 }
5391
5392 fn rt() -> tokio::runtime::Runtime {
5393 tokio::runtime::Builder::new_multi_thread()
5394 .worker_threads(2)
5395 .enable_all()
5396 .build()
5397 .unwrap()
5398 }
5399
5400 async fn call(
5404 router: axum::Router,
5405 method: &str,
5406 uri: &str,
5407 body: Option<Value>,
5408 ) -> (StatusCode, Value) {
5409 call_with_auth(router, method, uri, body, None).await
5410 }
5411
5412 async fn call_with_auth(
5413 router: axum::Router,
5414 method: &str,
5415 uri: &str,
5416 body: Option<Value>,
5417 auth: Option<&str>,
5418 ) -> (StatusCode, Value) {
5419 let mut req_builder = Request::builder()
5420 .method(method)
5421 .uri(uri)
5422 .header("content-type", "application/json");
5423 if let Some(a) = auth {
5424 req_builder = req_builder.header("authorization", a);
5425 }
5426 let req = if let Some(b) = body {
5427 let bytes = serde_json::to_vec(&b).unwrap();
5428 req_builder.body(Body::from(bytes)).unwrap()
5429 } else {
5430 req_builder = req_builder.header("content-length", "0");
5431 req_builder.body(Body::empty()).unwrap()
5432 };
5433 let resp = router.oneshot(req).await.expect("oneshot");
5434 let status = resp.status();
5435 let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
5436 let v: Value = if body_bytes.is_empty() {
5437 Value::Null
5438 } else {
5439 serde_json::from_slice(&body_bytes).unwrap_or(Value::Null)
5440 };
5441 (status, v)
5442 }
5443
5444 #[test]
5445 fn health_returns_ok() {
5446 let runtime = rt();
5447 let h = Harness::new(&runtime);
5448 let r = h.router.clone();
5449 let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
5450 assert_eq!(status, StatusCode::OK);
5451 h.shutdown(&runtime);
5452 }
5453
5454 #[test]
5459 fn openapi_json_describes_all_endpoints() {
5460 let runtime = rt();
5461 let h = Harness::new(&runtime);
5462 let r = h.router.clone();
5463 let (status, spec) = runtime.block_on(call(r, "GET", "/openapi.json", None));
5464 assert_eq!(status, StatusCode::OK);
5465 assert!(spec.is_object(), "openapi.json must be a JSON object");
5466
5467 assert!(
5469 spec.get("openapi")
5470 .and_then(|v| v.as_str())
5471 .is_some_and(|s| s.starts_with("3.")),
5472 "missing or wrong openapi version: {spec}"
5473 );
5474 assert!(spec.pointer("/info/title").is_some());
5475 assert!(spec.pointer("/info/version").is_some());
5476
5477 let paths = spec
5479 .get("paths")
5480 .and_then(|v| v.as_object())
5481 .expect("paths must be an object");
5482 for expected in [
5483 "/health",
5484 "/openapi.json",
5485 "/memory",
5486 "/memory/search",
5487 "/memory/consolidate",
5488 "/memory/{id}",
5489 "/memory/themes",
5491 "/memory/facts_about",
5492 "/memory/contradictions",
5493 "/memory/clusters/{cluster_id}",
5495 "/memory/documents",
5497 "/memory/documents/search",
5498 "/memory/documents/{id}",
5499 ] {
5500 assert!(
5501 paths.contains_key(expected),
5502 "openapi paths missing {expected}: {paths:?}"
5503 );
5504 }
5505
5506 let docs = paths.get("/memory/documents").expect("/memory/documents");
5509 assert!(docs.get("post").is_some(), "POST /memory/documents undocumented");
5510 assert!(docs.get("get").is_some(), "GET /memory/documents undocumented");
5511
5512 let docid = paths
5515 .get("/memory/documents/{id}")
5516 .expect("/memory/documents/{id}");
5517 assert!(
5518 docid.get("get").is_some(),
5519 "GET /memory/documents/{{id}} undocumented"
5520 );
5521 assert!(
5522 docid.get("delete").is_some(),
5523 "DELETE /memory/documents/{{id}} undocumented"
5524 );
5525
5526 let memid = paths.get("/memory/{id}").expect("memory/{id}");
5529 assert!(memid.get("get").is_some(), "GET /memory/{{id}} undocumented");
5530 assert!(
5531 memid.get("delete").is_some(),
5532 "DELETE /memory/{{id}} undocumented"
5533 );
5534
5535 for schema_name in [
5537 "RememberRequest",
5538 "RememberResponse",
5539 "RecallRequest",
5540 "RecallResult",
5541 "EpisodeRecord",
5542 "ApiError",
5543 "ConsolidationScope",
5544 "ConsolidationReport",
5545 "ThemeHit",
5547 "FactHit",
5548 "ContradictionHit",
5549 "ClusterRecord",
5551 "IngestDocumentRequest",
5553 "IngestReport",
5554 "ForgetDocumentReport",
5555 "SearchDocsRequest",
5556 "DocSearchHit",
5557 "DocumentInspectResult",
5558 "DocumentSummary",
5559 ] {
5560 let ptr = format!("/components/schemas/{schema_name}");
5561 assert!(
5562 spec.pointer(&ptr).is_some(),
5563 "component schema {schema_name} missing"
5564 );
5565 }
5566
5567 assert!(
5569 spec.pointer("/components/securitySchemes/bearerAuth")
5570 .is_some(),
5571 "bearerAuth security scheme missing"
5572 );
5573
5574 h.shutdown(&runtime);
5575 }
5576
5577 #[test]
5581 fn openapi_json_is_exempt_from_bearer_auth() {
5582 let runtime = rt();
5583 let h = Harness::new_with_auth(&runtime, Some("super-secret".into()));
5584 let r = h.router.clone();
5585 let (status, _body) = runtime.block_on(call(r, "GET", "/openapi.json", None));
5587 assert_eq!(status, StatusCode::OK);
5588 h.shutdown(&runtime);
5589 }
5590
5591 #[test]
5592 fn remember_returns_memory_id() {
5593 let runtime = rt();
5594 let h = Harness::new(&runtime);
5595 let r = h.router.clone();
5596 let (status, body) = runtime.block_on(call(
5597 r,
5598 "POST",
5599 "/memory",
5600 Some(json!({ "content": "http harness test" })),
5601 ));
5602 assert_eq!(status, StatusCode::OK);
5603 let mid = body.get("memory_id").and_then(|v| v.as_str()).unwrap();
5604 assert_eq!(mid.len(), 36, "uuid length");
5605 h.shutdown(&runtime);
5606 }
5607
5608 #[test]
5609 fn empty_content_returns_400() {
5610 let runtime = rt();
5611 let h = Harness::new(&runtime);
5612 let r = h.router.clone();
5613 let (status, body) =
5614 runtime.block_on(call(r, "POST", "/memory", Some(json!({ "content": "" }))));
5615 assert_eq!(status, StatusCode::BAD_REQUEST);
5616 assert!(
5617 body.get("error")
5618 .and_then(|e| e.as_str())
5619 .map(|s| s.contains("must not be empty"))
5620 .unwrap_or(false),
5621 "got: {body}"
5622 );
5623 h.shutdown(&runtime);
5624 }
5625
5626 #[test]
5627 fn empty_query_returns_400() {
5628 let runtime = rt();
5629 let h = Harness::new(&runtime);
5630 let r = h.router.clone();
5631 let (status, body) = runtime.block_on(call(
5632 r,
5633 "POST",
5634 "/memory/search",
5635 Some(json!({ "query": "" })),
5636 ));
5637 assert_eq!(status, StatusCode::BAD_REQUEST);
5638 assert!(
5639 body.get("error")
5640 .and_then(|e| e.as_str())
5641 .map(|s| s.contains("must not be empty"))
5642 .unwrap_or(false),
5643 "got: {body}"
5644 );
5645 h.shutdown(&runtime);
5646 }
5647
5648 #[test]
5649 fn inspect_unknown_returns_404() {
5650 let runtime = rt();
5651 let h = Harness::new(&runtime);
5652 let r = h.router.clone();
5653 let (status, body) = runtime.block_on(call(
5654 r,
5655 "GET",
5656 "/memory/00000000-0000-7000-8000-000000000000",
5657 None,
5658 ));
5659 assert_eq!(status, StatusCode::NOT_FOUND);
5660 assert!(body.get("error").is_some(), "got: {body}");
5661 h.shutdown(&runtime);
5662 }
5663
5664 #[test]
5665 fn inspect_invalid_id_returns_400() {
5666 let runtime = rt();
5667 let h = Harness::new(&runtime);
5668 let r = h.router.clone();
5669 let (status, _body) = runtime.block_on(call(r, "GET", "/memory/not-a-uuid", None));
5670 assert_eq!(status, StatusCode::BAD_REQUEST);
5671 h.shutdown(&runtime);
5672 }
5673
5674 #[test]
5675 fn forget_unknown_returns_404() {
5676 let runtime = rt();
5677 let h = Harness::new(&runtime);
5678 let r = h.router.clone();
5679 let (status, _body) = runtime.block_on(call(
5680 r,
5681 "DELETE",
5682 "/memory/00000000-0000-7000-8000-000000000000",
5683 None,
5684 ));
5685 assert_eq!(status, StatusCode::NOT_FOUND);
5686 h.shutdown(&runtime);
5687 }
5688
5689 #[test]
5697 fn consolidate_endpoint_returns_report() {
5698 let runtime = rt();
5699 let h = Harness::new(&runtime);
5700 let r = h.router.clone();
5701 runtime.block_on(async move {
5702 let (status, body) = call(r.clone(), "POST", "/memory/consolidate", None).await;
5704 assert_eq!(status, StatusCode::OK);
5705 for field in [
5706 "episodes_seen",
5707 "clusters_built",
5708 "episodes_clustered",
5709 "abstractions_built",
5710 "triples_built",
5711 "contradictions_found",
5712 ] {
5713 assert!(
5714 body.get(field).and_then(|v| v.as_u64()).is_some(),
5715 "missing field {field}: {body}"
5716 );
5717 }
5718 assert_eq!(body["episodes_seen"], 0);
5719 assert_eq!(body["clusters_built"], 0);
5720
5721 let (status2, _body2) = call(
5724 r,
5725 "POST",
5726 "/memory/consolidate",
5727 Some(json!({ "window_days": 7 })),
5728 )
5729 .await;
5730 assert_eq!(status2, StatusCode::OK);
5731 });
5732 h.shutdown(&runtime);
5733 }
5734
5735 #[test]
5736 fn auth_required_routes_reject_missing_token() {
5737 let runtime = rt();
5738 let h = Harness::new_with_auth(&runtime, Some("secret-xyz".into()));
5739 let r = h.router.clone();
5740 runtime.block_on(async move {
5741 let (status, _body) = call(
5743 r.clone(),
5744 "POST",
5745 "/memory",
5746 Some(json!({ "content": "x" })),
5747 )
5748 .await;
5749 assert_eq!(status, StatusCode::UNAUTHORIZED);
5750
5751 let (status, _body) = call_with_auth(
5753 r.clone(),
5754 "POST",
5755 "/memory",
5756 Some(json!({ "content": "x" })),
5757 Some("Bearer wrong-token"),
5758 )
5759 .await;
5760 assert_eq!(status, StatusCode::UNAUTHORIZED);
5761
5762 let (status, body) = call_with_auth(
5764 r.clone(),
5765 "POST",
5766 "/memory",
5767 Some(json!({ "content": "authed" })),
5768 Some("Bearer secret-xyz"),
5769 )
5770 .await;
5771 assert_eq!(status, StatusCode::OK);
5772 assert!(body.get("memory_id").is_some());
5773 });
5774 h.shutdown(&runtime);
5775 }
5776
5777 #[test]
5778 fn health_endpoint_does_not_require_auth() {
5779 let runtime = rt();
5780 let h = Harness::new_with_auth(&runtime, Some("secret".into()));
5781 let r = h.router.clone();
5782 let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
5783 assert_eq!(status, StatusCode::OK);
5785 h.shutdown(&runtime);
5786 }
5787
5788 #[test]
5789 fn auth_response_includes_www_authenticate_header() {
5790 let runtime = rt();
5795 let h = Harness::new_with_auth(&runtime, Some("secret".into()));
5796 let r = h.router.clone();
5797 runtime.block_on(async move {
5798 let req = Request::builder()
5799 .method("POST")
5800 .uri("/memory")
5801 .header("content-type", "application/json")
5802 .body(Body::from(serde_json::to_vec(&json!({ "content": "x" })).unwrap()))
5803 .unwrap();
5804 let resp = r.oneshot(req).await.unwrap();
5805 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
5806 let www = resp
5807 .headers()
5808 .get("www-authenticate")
5809 .and_then(|v| v.to_str().ok())
5810 .unwrap_or("");
5811 assert!(
5812 www.starts_with("Bearer"),
5813 "expected WWW-Authenticate: Bearer..., got: {www}"
5814 );
5815 });
5816 h.shutdown(&runtime);
5817 }
5818
5819 fn base64_url_for_test(bytes: &[u8]) -> String {
5827 use base64::Engine;
5828 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
5829 }
5830
5831 async fn spin_fake_idp() -> (wiremock::MockServer, String, Vec<u8>, &'static str) {
5834 use wiremock::matchers::{method, path};
5835 use wiremock::{Mock, MockServer, ResponseTemplate};
5836 let server = MockServer::start().await;
5837 let secret = b"http-test-secret-for-hmac-fixture".to_vec();
5838 let kid = "http-test-kid";
5839 let discovery = serde_json::json!({
5840 "issuer": server.uri(),
5841 "jwks_uri": format!("{}/jwks", server.uri()),
5842 });
5843 Mock::given(method("GET"))
5844 .and(path("/.well-known/openid-configuration"))
5845 .respond_with(ResponseTemplate::new(200).set_body_json(discovery))
5846 .mount(&server)
5847 .await;
5848 let jwks = serde_json::json!({
5849 "keys": [
5850 {
5851 "kty": "oct",
5852 "kid": kid,
5853 "alg": "HS256",
5854 "k": base64_url_for_test(&secret),
5855 }
5856 ]
5857 });
5858 Mock::given(method("GET"))
5859 .and(path("/jwks"))
5860 .respond_with(ResponseTemplate::new(200).set_body_json(jwks))
5861 .mount(&server)
5862 .await;
5863 let discovery_url = format!("{}/.well-known/openid-configuration", server.uri());
5864 (server, discovery_url, secret, kid)
5865 }
5866
5867 fn mint_idp_token(
5868 server_uri: &str,
5869 kid: &str,
5870 secret: &[u8],
5871 tenant_claim: &str,
5872 audience: &str,
5873 ) -> String {
5874 use jsonwebtoken::{Algorithm, EncodingKey, Header};
5875 let mut header = Header::new(Algorithm::HS256);
5876 header.kid = Some(kid.to_string());
5877 let now = std::time::SystemTime::now()
5878 .duration_since(std::time::UNIX_EPOCH)
5879 .unwrap()
5880 .as_secs();
5881 let claims = serde_json::json!({
5882 "iss": server_uri,
5883 "sub": "test-user-1",
5884 "aud": audience,
5885 "exp": now + 600,
5886 "iat": now,
5887 "solo_tenant": tenant_claim,
5888 });
5889 jsonwebtoken::encode(&header, &claims, &EncodingKey::from_secret(secret))
5890 .expect("mint token")
5891 }
5892
5893 #[test]
5894 fn http_oidc_accept_resolves_to_tenant_from_claim() {
5895 let runtime = rt();
5896 let (fake_server, discovery_url, secret, kid) =
5897 runtime.block_on(async { spin_fake_idp().await });
5898 let server_uri = fake_server.uri();
5899 let _server_guard = fake_server;
5901
5902 let auth = crate::auth::AuthConfig::Oidc {
5903 discovery_url,
5904 audience: "test-audience".to_string(),
5905 tenant_claim_name: "solo_tenant".to_string(),
5906 };
5907 let h = Harness::new_with_auth_config(&runtime, Some(auth));
5908 let r = h.router.clone();
5909
5910 let token = mint_idp_token(
5912 &server_uri,
5913 kid,
5914 &secret,
5915 "default",
5916 "test-audience",
5917 );
5918
5919 runtime.block_on(async move {
5920 let (status, body) = call_with_auth(
5922 r.clone(),
5923 "POST",
5924 "/memory",
5925 Some(json!({ "content": "oidc-routed content" })),
5926 Some(&format!("Bearer {token}")),
5927 )
5928 .await;
5929 assert_eq!(status, StatusCode::OK, "got body: {body}");
5930 assert!(body.get("memory_id").is_some(), "no memory_id in {body}");
5931 });
5932 h.shutdown(&runtime);
5933 }
5934
5935 #[test]
5936 fn http_oidc_reject_missing_token_returns_401() {
5937 let runtime = rt();
5938 let (fake_server, discovery_url, _secret, _kid) =
5939 runtime.block_on(async { spin_fake_idp().await });
5940 let _server_guard = fake_server;
5941 let auth = crate::auth::AuthConfig::Oidc {
5942 discovery_url,
5943 audience: "test-audience".to_string(),
5944 tenant_claim_name: "solo_tenant".to_string(),
5945 };
5946 let h = Harness::new_with_auth_config(&runtime, Some(auth));
5947 let r = h.router.clone();
5948 runtime.block_on(async move {
5949 let (status, _body) =
5951 call(r.clone(), "POST", "/memory", Some(json!({ "content": "x" }))).await;
5952 assert_eq!(status, StatusCode::UNAUTHORIZED);
5953
5954 let (status, _body) = call_with_auth(
5956 r.clone(),
5957 "POST",
5958 "/memory",
5959 Some(json!({ "content": "x" })),
5960 Some("Bearer not-a-real-jwt"),
5961 )
5962 .await;
5963 assert_eq!(status, StatusCode::UNAUTHORIZED);
5964 });
5965 h.shutdown(&runtime);
5966 }
5967
5968 #[test]
5969 fn full_remember_recall_inspect_forget_round_trip() {
5970 let runtime = rt();
5971 let h = Harness::new(&runtime);
5972 let r = h.router.clone();
5973 runtime.block_on(async move {
5974 let (status, body) = call(
5976 r.clone(),
5977 "POST",
5978 "/memory",
5979 Some(json!({ "content": "round-trip content" })),
5980 )
5981 .await;
5982 assert_eq!(status, StatusCode::OK);
5983 let mid = body
5984 .get("memory_id")
5985 .and_then(|v| v.as_str())
5986 .unwrap()
5987 .to_string();
5988
5989 let (status, body) = call(
5991 r.clone(),
5992 "POST",
5993 "/memory/search",
5994 Some(json!({ "query": "round-trip content", "limit": 5 })),
5995 )
5996 .await;
5997 assert_eq!(status, StatusCode::OK);
5998 let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
5999 assert!(
6000 hits.iter()
6001 .any(|h| h.get("content").and_then(|c| c.as_str())
6002 == Some("round-trip content")),
6003 "expected hit with content; got: {body}"
6004 );
6005
6006 let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
6008 assert_eq!(status, StatusCode::OK);
6009 assert_eq!(body.get("status").and_then(|v| v.as_str()), Some("active"));
6010
6011 let (status, _body) =
6013 call(r.clone(), "DELETE", &format!("/memory/{mid}"), None).await;
6014 assert_eq!(status, StatusCode::NO_CONTENT);
6015
6016 let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
6018 assert_eq!(status, StatusCode::OK);
6019 assert_eq!(
6020 body.get("status").and_then(|v| v.as_str()),
6021 Some("forgotten")
6022 );
6023
6024 let (status, body) = call(
6026 r.clone(),
6027 "POST",
6028 "/memory/search",
6029 Some(json!({ "query": "round-trip content", "limit": 5 })),
6030 )
6031 .await;
6032 assert_eq!(status, StatusCode::OK);
6033 let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
6034 assert!(
6035 hits.iter().all(|h| h.get("memory_id").and_then(|m| m.as_str())
6036 != Some(mid.as_str())),
6037 "forgotten row should be excluded from recall: {body}"
6038 );
6039 });
6040 h.shutdown(&runtime);
6041 }
6042
6043 #[test]
6050 fn themes_endpoint_returns_empty_array_on_empty_db() {
6051 let runtime = rt();
6052 let h = Harness::new(&runtime);
6053 let r = h.router.clone();
6054 let (status, body) =
6055 runtime.block_on(call(r, "GET", "/memory/themes", None));
6056 assert_eq!(status, StatusCode::OK);
6057 assert!(body.is_array(), "expected array, got {body}");
6058 assert_eq!(body.as_array().unwrap().len(), 0);
6059 h.shutdown(&runtime);
6060 }
6061
6062 #[test]
6063 fn themes_endpoint_passes_through_query_params() {
6064 let runtime = rt();
6065 let h = Harness::new(&runtime);
6066 let r = h.router.clone();
6067 let (status, body) = runtime.block_on(call(
6068 r,
6069 "GET",
6070 "/memory/themes?window_days=7&limit=20",
6071 None,
6072 ));
6073 assert_eq!(status, StatusCode::OK);
6074 assert!(body.is_array(), "expected array, got {body}");
6075 h.shutdown(&runtime);
6076 }
6077
6078 #[test]
6079 fn facts_about_endpoint_requires_subject() {
6080 let runtime = rt();
6081 let h = Harness::new(&runtime);
6082 let r = h.router.clone();
6083 let (status, _body) =
6087 runtime.block_on(call(r, "GET", "/memory/facts_about", None));
6088 assert!(
6089 status == StatusCode::BAD_REQUEST
6090 || status == StatusCode::UNPROCESSABLE_ENTITY,
6091 "expected 400 or 422 for missing subject, got {status}"
6092 );
6093 h.shutdown(&runtime);
6094 }
6095
6096 #[test]
6097 fn facts_about_endpoint_rejects_blank_subject() {
6098 let runtime = rt();
6099 let h = Harness::new(&runtime);
6100 let r = h.router.clone();
6101 let (status, body) = runtime.block_on(call(
6104 r,
6105 "GET",
6106 "/memory/facts_about?subject=%20%20",
6107 None,
6108 ));
6109 assert_eq!(status, StatusCode::BAD_REQUEST);
6110 assert!(
6111 body.get("error")
6112 .and_then(|v| v.as_str())
6113 .is_some_and(|s| s.contains("subject")),
6114 "expected error mentioning subject, got {body}"
6115 );
6116 h.shutdown(&runtime);
6117 }
6118
6119 #[test]
6120 fn facts_about_endpoint_returns_empty_array_for_unknown_subject() {
6121 let runtime = rt();
6122 let h = Harness::new(&runtime);
6123 let r = h.router.clone();
6124 let (status, body) = runtime.block_on(call(
6125 r,
6126 "GET",
6127 "/memory/facts_about?subject=NobodyKnows",
6128 None,
6129 ));
6130 assert_eq!(status, StatusCode::OK);
6131 assert_eq!(body.as_array().unwrap().len(), 0);
6132 h.shutdown(&runtime);
6133 }
6134
6135 #[test]
6136 fn facts_about_endpoint_parses_include_as_object_query_param() {
6137 let runtime = rt();
6145 let h = Harness::new(&runtime);
6146 let r = h.router.clone();
6147 let (status, body) = runtime.block_on(call(
6148 r,
6149 "GET",
6150 "/memory/facts_about?subject=Maya&include_as_object=true",
6151 None,
6152 ));
6153 assert_eq!(
6154 status,
6155 StatusCode::OK,
6156 "expected 200 with include_as_object query param, got {status}"
6157 );
6158 assert!(body.is_array());
6159 h.shutdown(&runtime);
6160 }
6161
6162 #[test]
6163 fn inspect_cluster_endpoint_unknown_id_returns_404() {
6164 let runtime = rt();
6168 let h = Harness::new(&runtime);
6169 let r = h.router.clone();
6170 let (status, body) = runtime.block_on(call(
6171 r,
6172 "GET",
6173 "/memory/clusters/no-such-cluster",
6174 None,
6175 ));
6176 assert_eq!(status, StatusCode::NOT_FOUND);
6177 assert!(
6178 body.get("error")
6179 .and_then(|v| v.as_str())
6180 .is_some_and(|s| s.contains("no-such-cluster")),
6181 "expected error mentioning cluster id, got {body}"
6182 );
6183 h.shutdown(&runtime);
6184 }
6185
6186 #[test]
6187 fn inspect_cluster_endpoint_passes_full_content_query_param() {
6188 let runtime = rt();
6194 let h = Harness::new(&runtime);
6195 let r = h.router.clone();
6196 let (status, _body) = runtime.block_on(call(
6197 r,
6198 "GET",
6199 "/memory/clusters/missing?full_content=true",
6200 None,
6201 ));
6202 assert_eq!(status, StatusCode::NOT_FOUND);
6203 h.shutdown(&runtime);
6204 }
6205
6206 #[test]
6207 fn contradictions_endpoint_returns_empty_array_on_empty_db() {
6208 let runtime = rt();
6209 let h = Harness::new(&runtime);
6210 let r = h.router.clone();
6211 let (status, body) = runtime.block_on(call(
6212 r,
6213 "GET",
6214 "/memory/contradictions",
6215 None,
6216 ));
6217 assert_eq!(status, StatusCode::OK);
6218 assert!(body.is_array());
6219 assert_eq!(body.as_array().unwrap().len(), 0);
6220 h.shutdown(&runtime);
6221 }
6222
6223 #[test]
6224 fn derived_endpoints_require_bearer_when_auth_enabled() {
6225 let runtime = rt();
6226 let h = Harness::new_with_auth(&runtime, Some("secret-token".to_string()));
6227 for path in [
6234 "/memory/themes",
6235 "/memory/facts_about?subject=Sam",
6236 "/memory/contradictions",
6237 "/memory/clusters/any-id",
6238 ] {
6239 let (status, _) = runtime.block_on(call(h.router.clone(), "GET", path, None));
6240 assert_eq!(
6241 status,
6242 StatusCode::UNAUTHORIZED,
6243 "{path} should 401 without token"
6244 );
6245 }
6246 h.shutdown(&runtime);
6247 }
6248
6249 #[test]
6261 fn list_documents_endpoint_returns_empty_array_on_empty_db() {
6262 let runtime = rt();
6263 let h = Harness::new(&runtime);
6264 let r = h.router.clone();
6265 let (status, body) = runtime.block_on(call(r, "GET", "/memory/documents", None));
6266 assert_eq!(status, StatusCode::OK);
6267 assert!(body.is_array(), "expected array, got {body}");
6268 assert_eq!(body.as_array().unwrap().len(), 0);
6269 h.shutdown(&runtime);
6270 }
6271
6272 #[test]
6273 fn list_documents_endpoint_parses_query_params() {
6274 let runtime = rt();
6275 let h = Harness::new(&runtime);
6276 let r = h.router.clone();
6277 let (status, body) = runtime.block_on(call(
6278 r,
6279 "GET",
6280 "/memory/documents?limit=5&offset=0&include_forgotten=true",
6281 None,
6282 ));
6283 assert_eq!(status, StatusCode::OK);
6284 assert!(body.is_array());
6285 h.shutdown(&runtime);
6286 }
6287
6288 #[test]
6289 fn ingest_document_endpoint_rejects_empty_path() {
6290 let runtime = rt();
6291 let h = Harness::new(&runtime);
6292 let r = h.router.clone();
6293 let (status, body) = runtime.block_on(call(
6294 r,
6295 "POST",
6296 "/memory/documents",
6297 Some(json!({ "path": "" })),
6298 ));
6299 assert_eq!(status, StatusCode::BAD_REQUEST);
6300 assert!(
6301 body.get("error")
6302 .and_then(|v| v.as_str())
6303 .is_some_and(|s| s.contains("path")),
6304 "expected error mentioning path, got {body}"
6305 );
6306 h.shutdown(&runtime);
6307 }
6308
6309 #[test]
6310 fn search_docs_endpoint_rejects_empty_query() {
6311 let runtime = rt();
6312 let h = Harness::new(&runtime);
6313 let r = h.router.clone();
6314 let (status, body) = runtime.block_on(call(
6315 r,
6316 "POST",
6317 "/memory/documents/search",
6318 Some(json!({ "query": " " })),
6319 ));
6320 assert_eq!(status, StatusCode::BAD_REQUEST);
6321 assert!(
6322 body.get("error")
6323 .and_then(|v| v.as_str())
6324 .is_some_and(|s| s.contains("must not be empty")
6325 || s.contains("doc_search")),
6326 "expected error mentioning empty query, got {body}"
6327 );
6328 h.shutdown(&runtime);
6329 }
6330
6331 #[test]
6332 fn inspect_document_endpoint_unknown_id_returns_404() {
6333 let runtime = rt();
6334 let h = Harness::new(&runtime);
6335 let r = h.router.clone();
6336 let (status, body) = runtime.block_on(call(
6337 r,
6338 "GET",
6339 "/memory/documents/00000000-0000-7000-8000-000000000000",
6340 None,
6341 ));
6342 assert_eq!(status, StatusCode::NOT_FOUND);
6343 assert!(body.get("error").is_some(), "got: {body}");
6344 h.shutdown(&runtime);
6345 }
6346
6347 #[test]
6348 fn inspect_document_endpoint_rejects_malformed_id() {
6349 let runtime = rt();
6350 let h = Harness::new(&runtime);
6351 let r = h.router.clone();
6352 let (status, _body) =
6353 runtime.block_on(call(r, "GET", "/memory/documents/not-a-uuid", None));
6354 assert_eq!(status, StatusCode::BAD_REQUEST);
6355 h.shutdown(&runtime);
6356 }
6357
6358 #[test]
6359 fn forget_document_endpoint_unknown_id_returns_404() {
6360 let runtime = rt();
6363 let h = Harness::new(&runtime);
6364 let r = h.router.clone();
6365 let (status, _body) = runtime.block_on(call(
6366 r,
6367 "DELETE",
6368 "/memory/documents/00000000-0000-7000-8000-000000000000",
6369 None,
6370 ));
6371 assert_eq!(status, StatusCode::NOT_FOUND);
6372 h.shutdown(&runtime);
6373 }
6374
6375 #[test]
6376 fn forget_document_endpoint_rejects_malformed_id() {
6377 let runtime = rt();
6378 let h = Harness::new(&runtime);
6379 let r = h.router.clone();
6380 let (status, _body) =
6381 runtime.block_on(call(r, "DELETE", "/memory/documents/not-a-uuid", None));
6382 assert_eq!(status, StatusCode::BAD_REQUEST);
6383 h.shutdown(&runtime);
6384 }
6385
6386 #[test]
6387 fn document_endpoints_require_bearer_when_auth_enabled() {
6388 let runtime = rt();
6392 let h = Harness::new_with_auth(&runtime, Some("doc-secret".to_string()));
6393 let cases: &[(&str, &str, Option<Value>)] = &[
6394 ("POST", "/memory/documents", Some(json!({ "path": "/x" }))),
6395 ("GET", "/memory/documents", None),
6396 (
6397 "POST",
6398 "/memory/documents/search",
6399 Some(json!({ "query": "x" })),
6400 ),
6401 (
6402 "GET",
6403 "/memory/documents/00000000-0000-7000-8000-000000000000",
6404 None,
6405 ),
6406 (
6407 "DELETE",
6408 "/memory/documents/00000000-0000-7000-8000-000000000000",
6409 None,
6410 ),
6411 ];
6412 for (method, path, body) in cases {
6413 let (status, _) =
6414 runtime.block_on(call(h.router.clone(), method, path, body.clone()));
6415 assert_eq!(
6416 status,
6417 StatusCode::UNAUTHORIZED,
6418 "{method} {path} should 401 without token"
6419 );
6420 }
6421 h.shutdown(&runtime);
6422 }
6423
6424 #[test]
6425 fn document_endpoints_accept_correct_bearer_token() {
6426 let runtime = rt();
6432 let h = Harness::new_with_auth(&runtime, Some("doc-secret".to_string()));
6433 runtime.block_on(async {
6434 let (status, _) = call_with_auth(
6436 h.router.clone(),
6437 "GET",
6438 "/memory/documents",
6439 None,
6440 Some("Bearer doc-secret"),
6441 )
6442 .await;
6443 assert_eq!(status, StatusCode::OK);
6444
6445 let (status, _) = call_with_auth(
6447 h.router.clone(),
6448 "GET",
6449 "/memory/documents/00000000-0000-7000-8000-000000000000",
6450 None,
6451 Some("Bearer doc-secret"),
6452 )
6453 .await;
6454 assert_eq!(status, StatusCode::NOT_FOUND);
6455 });
6456 h.shutdown(&runtime);
6457 }
6458
6459 #[test]
6466 fn tenant_header_default_resolves() {
6467 let runtime = rt();
6468 let h = Harness::new(&runtime);
6469 let r = h.router.clone();
6470 let (status, _body) = runtime.block_on(async {
6471 let req = Request::builder()
6472 .method("GET")
6473 .uri("/memory/00000000-0000-7000-8000-000000000000")
6474 .header("x-solo-tenant", "default")
6475 .body(Body::empty())
6476 .unwrap();
6477 let resp = r.oneshot(req).await.expect("oneshot");
6478 let s = resp.status();
6479 let _b = resp.into_body().collect().await.unwrap().to_bytes();
6480 (s, _b)
6481 });
6482 assert_eq!(status, StatusCode::NOT_FOUND);
6486 h.shutdown(&runtime);
6487 }
6488
6489 #[test]
6491 fn tenant_header_invalid_returns_400() {
6492 let runtime = rt();
6493 let h = Harness::new(&runtime);
6494 let r = h.router.clone();
6495 let (status, body) = runtime.block_on(async {
6496 let req = Request::builder()
6497 .method("GET")
6498 .uri("/memory/00000000-0000-7000-8000-000000000000")
6499 .header("x-solo-tenant", "UPPER")
6500 .body(Body::empty())
6501 .unwrap();
6502 let resp = r.oneshot(req).await.expect("oneshot");
6503 let s = resp.status();
6504 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
6505 let v: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
6506 (s, v)
6507 });
6508 assert_eq!(status, StatusCode::BAD_REQUEST);
6509 let msg = body.get("error").and_then(|e| e.as_str()).unwrap_or("");
6510 assert!(
6511 msg.to_lowercase().contains("tenant") || msg.to_lowercase().contains("invalid"),
6512 "error must mention tenant/invalid: {msg}"
6513 );
6514 h.shutdown(&runtime);
6515 }
6516
6517 #[test]
6519 fn tenant_header_unknown_returns_404() {
6520 let runtime = rt();
6521 let h = Harness::new(&runtime);
6522 let r = h.router.clone();
6523 let (status, _body) = runtime.block_on(async {
6524 let req = Request::builder()
6525 .method("GET")
6526 .uri("/memory/00000000-0000-7000-8000-000000000000")
6527 .header("x-solo-tenant", "never-registered")
6528 .body(Body::empty())
6529 .unwrap();
6530 let resp = r.oneshot(req).await.expect("oneshot");
6531 let s = resp.status();
6532 let _b = resp.into_body().collect().await.unwrap().to_bytes();
6533 (s, _b)
6534 });
6535 assert_eq!(status, StatusCode::NOT_FOUND);
6536 h.shutdown(&runtime);
6537 }
6538
6539 #[test]
6543 fn tenant_header_missing_defaults_to_state_default_tenant() {
6544 let runtime = rt();
6545 let h = Harness::new(&runtime);
6546 let r = h.router.clone();
6547 let (status, _body) = runtime.block_on(async {
6548 let req = Request::builder()
6549 .method("GET")
6550 .uri("/memory/00000000-0000-7000-8000-000000000000")
6551 .body(Body::empty())
6552 .unwrap();
6553 let resp = r.oneshot(req).await.expect("oneshot");
6554 let s = resp.status();
6555 let _b = resp.into_body().collect().await.unwrap().to_bytes();
6556 (s, _b)
6557 });
6558 assert_eq!(status, StatusCode::NOT_FOUND);
6559 h.shutdown(&runtime);
6560 }
6561
6562 fn seed_episode(
6576 conn: &rusqlite::Connection,
6577 memory_id: &str,
6578 ts_ms: i64,
6579 content: &str,
6580 ) -> i64 {
6581 conn.execute(
6582 "INSERT INTO episodes
6583 (memory_id, ts_ms, source_type, content,
6584 encoding_context_json, tier, status,
6585 confidence, strength, salience,
6586 created_at_ms, updated_at_ms)
6587 VALUES (?1, ?2, 'user_message', ?3,
6588 '{}', 'hot', 'active',
6589 1.0, 0.5, 0.5, ?2, ?2)",
6590 rusqlite::params![memory_id, ts_ms, content],
6591 )
6592 .expect("seed episode");
6593 conn.last_insert_rowid()
6594 }
6595
6596 fn seed_cluster_row(conn: &rusqlite::Connection, cluster_id: &str, created_at_ms: i64) {
6597 conn.execute(
6598 "INSERT INTO clusters (cluster_id, coherence, created_at_ms)
6599 VALUES (?1, 0.5, ?2)",
6600 rusqlite::params![cluster_id, created_at_ms],
6601 )
6602 .expect("seed cluster");
6603 }
6604
6605 fn seed_cluster_member(conn: &rusqlite::Connection, cluster_id: &str, memory_id: &str) {
6606 conn.execute(
6607 "INSERT INTO cluster_episodes (cluster_id, memory_id) VALUES (?1, ?2)",
6608 rusqlite::params![cluster_id, memory_id],
6609 )
6610 .expect("seed cluster_episodes");
6611 }
6612
6613 fn seed_document_row(conn: &rusqlite::Connection, doc_id: &str, title: &str) {
6614 conn.execute(
6615 "INSERT INTO documents
6616 (doc_id, source, title, mime_type, ingested_at_ms,
6617 modified_at_ms, status, chunk_count, content_hash, byte_size)
6618 VALUES (?1, ?2, ?3, 'text/plain', 0, NULL,
6619 'active', 0, ?1, NULL)",
6620 rusqlite::params![doc_id, format!("/tmp/{title}.txt"), title],
6621 )
6622 .expect("seed doc");
6623 }
6624
6625 fn seed_chunk_row(
6626 conn: &rusqlite::Connection,
6627 chunk_id: &str,
6628 doc_id: &str,
6629 chunk_index: i64,
6630 content: &str,
6631 ) {
6632 conn.execute(
6633 "INSERT INTO document_chunks
6634 (chunk_id, doc_id, chunk_index, content,
6635 token_count, start_offset, end_offset, created_at_ms)
6636 VALUES (?1, ?2, ?3, ?4, 1, 0, ?5, 0)",
6637 rusqlite::params![chunk_id, doc_id, chunk_index, content, content.len() as i64],
6638 )
6639 .expect("seed chunk");
6640 }
6641
6642 fn seed_triple_row(
6643 conn: &rusqlite::Connection,
6644 triple_id: &str,
6645 subject: &str,
6646 predicate: &str,
6647 object: &str,
6648 source_episode_rowid: Option<i64>,
6649 ) {
6650 conn.execute(
6651 "INSERT INTO triples
6652 (triple_id, subject_id, predicate, object_id, object_kind,
6653 valid_from_ms, valid_to_ms, confidence, provenance_json,
6654 status, created_at_ms, updated_at_ms, source_episode_id)
6655 VALUES (?1, ?2, ?3, ?4, 'literal', 0, NULL, 0.9, '{}',
6656 'active', 0, 0, ?5)",
6657 rusqlite::params![triple_id, subject, predicate, object, source_episode_rowid],
6658 )
6659 .expect("seed triple");
6660 }
6661
6662 fn seed_abstraction_row(
6665 conn: &rusqlite::Connection,
6666 abstraction_id: &str,
6667 cluster_id: &str,
6668 content: &str,
6669 ) {
6670 conn.execute(
6671 "INSERT INTO semantic_abstractions
6672 (abstraction_id, cluster_id, content, provenance_json,
6673 confidence, created_at_ms)
6674 VALUES (?1, ?2, ?3, '{}', 0.9, 0)",
6675 rusqlite::params![abstraction_id, cluster_id, content],
6676 )
6677 .expect("seed abstraction");
6678 }
6679
6680 fn percent_encode_node_id(node_id: &str) -> String {
6683 let mut out = String::with_capacity(node_id.len());
6684 for c in node_id.chars() {
6685 match c {
6686 ':' => out.push_str("%3A"),
6687 ' ' => out.push_str("%20"),
6688 '&' => out.push_str("%26"),
6689 '+' => out.push_str("%2B"),
6690 '?' => out.push_str("%3F"),
6691 '#' => out.push_str("%23"),
6692 _ => out.push(c),
6693 }
6694 }
6695 out
6696 }
6697
6698 fn graph_uri(node_id: &str, kind: &str) -> String {
6699 let encoded = percent_encode_node_id(node_id);
6700 format!("/v1/graph/expand?node_id={encoded}&kind={kind}")
6701 }
6702
6703 fn graph_uri_with_limit(node_id: &str, kind: &str, limit: u32) -> String {
6704 let encoded = percent_encode_node_id(node_id);
6705 format!("/v1/graph/expand?node_id={encoded}&kind={kind}&limit={limit}")
6706 }
6707
6708 #[test]
6709 fn expand_cluster_member_from_episode_returns_clusters() {
6710 let runtime = rt();
6711 let h = Harness::new(&runtime);
6712 let memory_id = "11111111-1111-7000-8000-000000000001";
6713 {
6714 let conn = h.open_db();
6715 seed_episode(&conn, memory_id, 100, "ep content");
6716 seed_cluster_row(&conn, "cl-a", 200);
6717 seed_cluster_member(&conn, "cl-a", memory_id);
6718 }
6719 let node_id = format!("ep:{memory_id}");
6720 let (status, body) = runtime.block_on(call(
6721 h.router.clone(),
6722 "GET",
6723 &graph_uri(&node_id, "cluster_member"),
6724 None,
6725 ));
6726 assert_eq!(status, StatusCode::OK, "body: {body}");
6727 let nodes = body.get("nodes").and_then(|v| v.as_array()).expect("nodes array");
6728 let edges = body.get("edges").and_then(|v| v.as_array()).expect("edges array");
6729 assert_eq!(nodes.len(), 1, "{body}");
6730 assert_eq!(nodes[0]["id"], "cl:cl-a");
6731 assert_eq!(nodes[0]["kind"], "cluster");
6732 assert_eq!(edges.len(), 1);
6733 assert_eq!(edges[0]["source"], node_id);
6734 assert_eq!(edges[0]["target"], "cl:cl-a");
6735 assert_eq!(edges[0]["kind"], "cluster_member");
6736 h.shutdown(&runtime);
6737 }
6738
6739 #[test]
6740 fn expand_cluster_member_from_cluster_returns_episodes() {
6741 let runtime = rt();
6742 let h = Harness::new(&runtime);
6743 {
6744 let conn = h.open_db();
6745 seed_cluster_row(&conn, "cl-multi", 500);
6746 for i in 0..5 {
6747 let mid = format!("2222{i}222-2222-7000-8000-000000000001");
6748 seed_episode(&conn, &mid, 100 + i as i64, &format!("content {i}"));
6749 seed_cluster_member(&conn, "cl-multi", &mid);
6750 }
6751 }
6752 let (status, body) = runtime.block_on(call(
6753 h.router.clone(),
6754 "GET",
6755 &graph_uri_with_limit("cl:cl-multi", "cluster_member", 3),
6756 None,
6757 ));
6758 assert_eq!(status, StatusCode::OK, "body: {body}");
6759 let nodes = body["nodes"].as_array().unwrap();
6760 let edges = body["edges"].as_array().unwrap();
6761 assert_eq!(nodes.len(), 3, "limit honored: {body}");
6762 assert_eq!(edges.len(), 3);
6763 for n in nodes {
6764 assert_eq!(n["kind"], "episode");
6765 }
6766 h.shutdown(&runtime);
6767 }
6768
6769 #[test]
6770 fn expand_document_chunk_from_document_returns_chunks() {
6771 let runtime = rt();
6772 let h = Harness::new(&runtime);
6773 let doc_id = "33333333-3333-7000-8000-000000000001";
6774 {
6775 let conn = h.open_db();
6776 seed_document_row(&conn, doc_id, "doc A");
6777 seed_chunk_row(&conn, "c2", doc_id, 2, "chunk 2 text");
6780 seed_chunk_row(&conn, "c0", doc_id, 0, "chunk 0 text");
6781 seed_chunk_row(&conn, "c1", doc_id, 1, "chunk 1 text");
6782 seed_chunk_row(&conn, "c3", doc_id, 3, "chunk 3 text");
6783 }
6784 let node_id = format!("doc:{doc_id}");
6785 let (status, body) = runtime.block_on(call(
6786 h.router.clone(),
6787 "GET",
6788 &graph_uri(&node_id, "document_chunk"),
6789 None,
6790 ));
6791 assert_eq!(status, StatusCode::OK, "body: {body}");
6792 let nodes = body["nodes"].as_array().unwrap();
6793 let edges = body["edges"].as_array().unwrap();
6794 assert_eq!(nodes.len(), 4);
6795 assert_eq!(edges.len(), 4);
6796 assert_eq!(nodes[0]["id"], "chunk:c0");
6798 assert_eq!(nodes[1]["id"], "chunk:c1");
6799 assert_eq!(nodes[2]["id"], "chunk:c2");
6800 assert_eq!(nodes[3]["id"], "chunk:c3");
6801 for e in edges {
6802 assert_eq!(e["kind"], "document_chunk");
6803 }
6804 h.shutdown(&runtime);
6805 }
6806
6807 #[test]
6808 fn expand_document_chunk_from_chunk_returns_parent_document() {
6809 let runtime = rt();
6810 let h = Harness::new(&runtime);
6811 let doc_id = "44444444-4444-7000-8000-000000000001";
6812 {
6813 let conn = h.open_db();
6814 seed_document_row(&conn, doc_id, "parent doc");
6815 seed_chunk_row(&conn, "c-orphan", doc_id, 0, "chunk content");
6816 }
6817 let (status, body) = runtime.block_on(call(
6818 h.router.clone(),
6819 "GET",
6820 &graph_uri("chunk:c-orphan", "document_chunk"),
6821 None,
6822 ));
6823 assert_eq!(status, StatusCode::OK, "body: {body}");
6824 let nodes = body["nodes"].as_array().unwrap();
6825 let edges = body["edges"].as_array().unwrap();
6826 assert_eq!(nodes.len(), 1);
6827 assert_eq!(edges.len(), 1);
6828 assert_eq!(nodes[0]["id"], format!("doc:{doc_id}"));
6829 assert_eq!(edges[0]["source"], "chunk:c-orphan");
6830 assert_eq!(edges[0]["target"], format!("doc:{doc_id}"));
6831 h.shutdown(&runtime);
6832 }
6833
6834 #[test]
6835 fn expand_triple_from_episode_returns_entities() {
6836 let runtime = rt();
6837 let h = Harness::new(&runtime);
6838 let memory_id = "55555555-5555-7000-8000-000000000001";
6839 let rowid;
6840 {
6841 let conn = h.open_db();
6842 rowid = seed_episode(&conn, memory_id, 100, "alice works at anthropic");
6843 seed_triple_row(&conn, "t1", "Alice", "works_at", "Anthropic", Some(rowid));
6845 seed_triple_row(&conn, "t2", "Bob", "lives_in", "NYC", Some(rowid));
6846 }
6847 let node_id = format!("ep:{memory_id}");
6848 let (status, body) = runtime.block_on(call(
6849 h.router.clone(),
6850 "GET",
6851 &graph_uri(&node_id, "triple"),
6852 None,
6853 ));
6854 assert_eq!(status, StatusCode::OK, "body: {body}");
6855 let nodes = body["nodes"].as_array().unwrap();
6856 let edges = body["edges"].as_array().unwrap();
6857 assert_eq!(nodes.len(), 4, "expected 4 unique entity nodes: {body}");
6858 assert_eq!(edges.len(), 2);
6859 let ids: std::collections::HashSet<String> = nodes
6860 .iter()
6861 .map(|n| n["id"].as_str().unwrap().to_string())
6862 .collect();
6863 for expected in ["ent:Alice", "ent:Anthropic", "ent:Bob", "ent:NYC"] {
6864 assert!(ids.contains(expected), "missing {expected} in {body}");
6865 }
6866 for e in edges {
6867 assert_eq!(e["kind"], "triple");
6868 assert!(e["predicate"].is_string(), "predicate set: {body}");
6869 }
6870 h.shutdown(&runtime);
6871 }
6872
6873 #[test]
6874 fn expand_triple_from_entity_returns_episodes() {
6875 let runtime = rt();
6876 let h = Harness::new(&runtime);
6877 {
6878 let conn = h.open_db();
6879 let r1 = seed_episode(
6880 &conn,
6881 "66666666-6666-7000-8000-000000000001",
6882 100,
6883 "alice ep one",
6884 );
6885 let r2 = seed_episode(
6886 &conn,
6887 "66666666-6666-7000-8000-000000000002",
6888 200,
6889 "alice ep two",
6890 );
6891 let r3 = seed_episode(
6892 &conn,
6893 "66666666-6666-7000-8000-000000000003",
6894 300,
6895 "alice ep three",
6896 );
6897 seed_triple_row(&conn, "t1", "Alice", "p", "Bob", Some(r1));
6899 seed_triple_row(&conn, "t2", "Carol", "p", "Alice", Some(r2));
6900 seed_triple_row(&conn, "t3", "Alice", "q", "Dave", Some(r3));
6901 seed_triple_row(&conn, "t-orphan", "Alice", "p", "Eve", None);
6903 }
6904 let (status, body) = runtime.block_on(call(
6905 h.router.clone(),
6906 "GET",
6907 &graph_uri("ent:Alice", "triple"),
6908 None,
6909 ));
6910 assert_eq!(status, StatusCode::OK, "body: {body}");
6911 let nodes = body["nodes"].as_array().unwrap();
6912 let edges = body["edges"].as_array().unwrap();
6913 assert_eq!(nodes.len(), 3, "expected 3 episodes: {body}");
6914 assert_eq!(edges.len(), 3);
6915 for n in nodes {
6916 assert_eq!(n["kind"], "episode");
6917 }
6918 for e in edges {
6919 assert_eq!(e["source"], "ent:Alice");
6920 assert_eq!(e["kind"], "triple");
6921 }
6922 h.shutdown(&runtime);
6923 }
6924
6925 #[test]
6926 fn expand_semantic_from_episode_returns_similar() {
6927 let runtime = rt();
6928 let h = Harness::new(&runtime);
6929 runtime.block_on(async {
6935 let mid1 = post_remember(h.router.clone(), "alpha alpha alpha").await;
6936 let _mid2 = post_remember(h.router.clone(), "beta beta beta").await;
6937 let _mid3 = post_remember(h.router.clone(), "gamma gamma gamma").await;
6938 let (status, body) = call(
6940 h.router.clone(),
6941 "GET",
6942 &graph_uri_with_limit(&format!("ep:{mid1}"), "semantic", 5),
6943 None,
6944 )
6945 .await;
6946 assert_eq!(status, StatusCode::OK, "body: {body}");
6947 let nodes = body["nodes"].as_array().unwrap();
6948 let edges = body["edges"].as_array().unwrap();
6949 for n in nodes {
6951 assert_ne!(
6952 n["id"].as_str().unwrap(),
6953 format!("ep:{mid1}"),
6954 "self must be excluded: {body}"
6955 );
6956 }
6957 for e in edges {
6959 assert_eq!(e["kind"], "semantic");
6960 assert!(e["weight"].is_number(), "weight set: {body}");
6961 }
6962 });
6963 h.shutdown(&runtime);
6964 }
6965
6966 async fn post_remember(router: axum::Router, content: &str) -> String {
6968 let (status, body) = call(
6969 router,
6970 "POST",
6971 "/memory",
6972 Some(json!({ "content": content })),
6973 )
6974 .await;
6975 assert_eq!(status, StatusCode::OK, "post failed: {body}");
6976 body["memory_id"].as_str().unwrap().to_string()
6977 }
6978
6979 #[test]
6980 fn expand_400_on_invalid_kind() {
6981 let runtime = rt();
6982 let h = Harness::new(&runtime);
6983 let (status, _body) = runtime.block_on(call(
6984 h.router.clone(),
6985 "GET",
6986 "/v1/graph/expand?node_id=ep:any&kind=banana",
6987 None,
6988 ));
6989 assert!(
6991 status == StatusCode::BAD_REQUEST || status == StatusCode::UNPROCESSABLE_ENTITY,
6992 "expected 400/422 for bad kind, got {status}"
6993 );
6994 h.shutdown(&runtime);
6995 }
6996
6997 #[test]
6998 fn expand_400_on_invalid_node_for_kind() {
6999 let runtime = rt();
7000 let h = Harness::new(&runtime);
7001 let (status, body) = runtime.block_on(call(
7003 h.router.clone(),
7004 "GET",
7005 &graph_uri("cl:doesnt-matter", "semantic"),
7006 None,
7007 ));
7008 assert_eq!(status, StatusCode::BAD_REQUEST);
7009 assert!(
7010 body["error"]
7011 .as_str()
7012 .is_some_and(|s| s.contains("semantic only valid for episode")),
7013 "got: {body}"
7014 );
7015 h.shutdown(&runtime);
7016 }
7017
7018 #[test]
7019 fn expand_404_on_missing_node_id() {
7020 let runtime = rt();
7021 let h = Harness::new(&runtime);
7022 let (status, body) = runtime.block_on(call(
7023 h.router.clone(),
7024 "GET",
7025 &graph_uri("ep:99999999-9999-7000-8000-000000000999", "cluster_member"),
7026 None,
7027 ));
7028 assert_eq!(status, StatusCode::NOT_FOUND, "{body}");
7029 h.shutdown(&runtime);
7030 }
7031
7032 #[test]
7033 fn expand_limit_clamped_at_100() {
7034 let runtime = rt();
7035 let h = Harness::new(&runtime);
7036 {
7038 let conn = h.open_db();
7039 seed_cluster_row(&conn, "cl-huge", 1_000);
7040 for i in 0..150 {
7041 let mid = format!("77777777-7777-7000-8000-{:012}", i);
7042 seed_episode(&conn, &mid, 100 + i as i64, &format!("content {i}"));
7043 seed_cluster_member(&conn, "cl-huge", &mid);
7044 }
7045 }
7046 let (status, body) = runtime.block_on(call(
7047 h.router.clone(),
7048 "GET",
7049 &graph_uri_with_limit("cl:cl-huge", "cluster_member", 999),
7050 None,
7051 ));
7052 assert_eq!(status, StatusCode::OK, "body: {body}");
7053 let nodes = body["nodes"].as_array().unwrap();
7054 assert_eq!(
7055 nodes.len(),
7056 100,
7057 "limit must be silently clamped to 100, got {}",
7058 nodes.len()
7059 );
7060 h.shutdown(&runtime);
7061 }
7062
7063 #[test]
7064 fn expand_bad_node_id_prefix_returns_400() {
7065 let runtime = rt();
7066 let h = Harness::new(&runtime);
7067 let (status, body) = runtime.block_on(call(
7068 h.router.clone(),
7069 "GET",
7070 "/v1/graph/expand?node_id=garbage&kind=cluster_member",
7071 None,
7072 ));
7073 assert_eq!(status, StatusCode::BAD_REQUEST);
7074 assert!(
7075 body["error"]
7076 .as_str()
7077 .is_some_and(|s| s.contains("node_id must be")),
7078 "got: {body}"
7079 );
7080 h.shutdown(&runtime);
7081 }
7082
7083 #[test]
7084 fn expand_respects_tenant_scoping_via_unknown_tenant_header() {
7085 let runtime = rt();
7090 let h = Harness::new(&runtime);
7091 let memory_id = "88888888-8888-7000-8000-000000000001";
7095 {
7096 let conn = h.open_db();
7097 seed_episode(&conn, memory_id, 100, "scoped");
7098 seed_cluster_row(&conn, "cl-scoped", 200);
7099 seed_cluster_member(&conn, "cl-scoped", memory_id);
7100 }
7101 let node_id = format!("ep:{memory_id}");
7102 let r = h.router.clone();
7103 let (status, _body) = runtime.block_on(async {
7104 let req = Request::builder()
7105 .method("GET")
7106 .uri(graph_uri(&node_id, "cluster_member"))
7107 .header("x-solo-tenant", "never-registered-tenant")
7108 .body(Body::empty())
7109 .unwrap();
7110 let resp = r.oneshot(req).await.expect("oneshot");
7111 let s = resp.status();
7112 let _b = resp.into_body().collect().await.unwrap().to_bytes();
7113 (s, _b)
7114 });
7115 assert_eq!(status, StatusCode::NOT_FOUND);
7118 h.shutdown(&runtime);
7119 }
7120
7121 #[test]
7122 fn expand_respects_auth_when_enabled() {
7123 let runtime = rt();
7124 let h = Harness::new_with_auth(&runtime, Some("graph-secret".into()));
7125 let (status, _) = runtime.block_on(call(
7127 h.router.clone(),
7128 "GET",
7129 &graph_uri("ep:any", "cluster_member"),
7130 None,
7131 ));
7132 assert_eq!(status, StatusCode::UNAUTHORIZED);
7133 let (status, _) = runtime.block_on(call_with_auth(
7135 h.router.clone(),
7136 "GET",
7137 &graph_uri("ep:99999999-9999-7000-8000-000000000999", "cluster_member"),
7138 None,
7139 Some("Bearer graph-secret"),
7140 ));
7141 assert_eq!(status, StatusCode::NOT_FOUND);
7142 h.shutdown(&runtime);
7143 }
7144
7145 #[test]
7146 fn expand_works_when_auth_none() {
7147 let runtime = rt();
7148 let h = Harness::new(&runtime);
7149 let (status, _) = runtime.block_on(call(
7152 h.router.clone(),
7153 "GET",
7154 &graph_uri("ep:99999999-9999-7000-8000-000000000999", "cluster_member"),
7155 None,
7156 ));
7157 assert_eq!(status, StatusCode::NOT_FOUND);
7158 h.shutdown(&runtime);
7159 }
7160
7161 async fn call_with_headers(
7174 router: axum::Router,
7175 method: &str,
7176 uri: &str,
7177 ) -> (StatusCode, axum::http::HeaderMap, Value) {
7178 let req = Request::builder()
7179 .method(method)
7180 .uri(uri)
7181 .header("content-length", "0")
7182 .body(Body::empty())
7183 .unwrap();
7184 let resp = router.oneshot(req).await.expect("oneshot");
7185 let status = resp.status();
7186 let headers = resp.headers().clone();
7187 let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
7188 let v: Value = if body_bytes.is_empty() {
7189 Value::Null
7190 } else {
7191 serde_json::from_slice(&body_bytes).unwrap_or(Value::Null)
7192 };
7193 (status, headers, v)
7194 }
7195
7196 #[test]
7197 fn nodes_returns_all_kinds_when_no_filter() {
7198 let runtime = rt();
7199 let h = Harness::new(&runtime);
7200 {
7201 let conn = h.open_db();
7202 let rowid = seed_episode(
7203 &conn,
7204 "aaaaaaaa-0000-7000-8000-000000000001",
7205 100,
7206 "episode one",
7207 );
7208 seed_document_row(&conn, "doc-1", "doc one");
7209 seed_chunk_row(&conn, "chunk-1", "doc-1", 0, "chunk one body");
7210 seed_cluster_row(&conn, "cl-one", 200);
7211 seed_triple_row(
7212 &conn,
7213 "t-one",
7214 "Alice",
7215 "knows",
7216 "Bob",
7217 Some(rowid),
7218 );
7219 }
7220 let (status, body) = runtime.block_on(call(
7221 h.router.clone(),
7222 "GET",
7223 "/v1/graph/nodes",
7224 None,
7225 ));
7226 assert_eq!(status, StatusCode::OK, "body: {body}");
7227 let nodes = body["nodes"].as_array().unwrap();
7228 let kinds: std::collections::HashSet<&str> = nodes
7229 .iter()
7230 .map(|n| n["kind"].as_str().unwrap())
7231 .collect();
7232 for expected in ["episode", "document", "chunk", "cluster", "entity"] {
7233 assert!(
7234 kinds.contains(expected),
7235 "expected {expected} kind in response: {body}"
7236 );
7237 }
7238 h.shutdown(&runtime);
7239 }
7240
7241 #[test]
7242 fn nodes_filter_by_single_kind() {
7243 let runtime = rt();
7244 let h = Harness::new(&runtime);
7245 {
7246 let conn = h.open_db();
7247 seed_episode(&conn, "bbbbbbbb-0000-7000-8000-000000000001", 100, "ep");
7248 seed_document_row(&conn, "doc-only", "d");
7249 seed_cluster_row(&conn, "cl-only", 300);
7250 }
7251 let (status, body) = runtime.block_on(call(
7252 h.router.clone(),
7253 "GET",
7254 "/v1/graph/nodes?kind=episode",
7255 None,
7256 ));
7257 assert_eq!(status, StatusCode::OK, "body: {body}");
7258 let nodes = body["nodes"].as_array().unwrap();
7259 assert!(!nodes.is_empty(), "{body}");
7260 for n in nodes {
7261 assert_eq!(n["kind"], "episode", "kind filter must be exclusive: {body}");
7262 }
7263 h.shutdown(&runtime);
7264 }
7265
7266 #[test]
7267 fn nodes_filter_by_multiple_kinds() {
7268 let runtime = rt();
7269 let h = Harness::new(&runtime);
7270 {
7271 let conn = h.open_db();
7272 seed_episode(&conn, "cccccccc-0000-7000-8000-000000000001", 100, "ep");
7273 seed_document_row(&conn, "doc-multi", "d");
7274 seed_cluster_row(&conn, "cl-multi", 300);
7275 }
7276 let (status, body) = runtime.block_on(call(
7277 h.router.clone(),
7278 "GET",
7279 "/v1/graph/nodes?kind=episode,document",
7280 None,
7281 ));
7282 assert_eq!(status, StatusCode::OK, "body: {body}");
7283 let nodes = body["nodes"].as_array().unwrap();
7284 let kinds: std::collections::HashSet<&str> = nodes
7285 .iter()
7286 .map(|n| n["kind"].as_str().unwrap())
7287 .collect();
7288 assert!(kinds.contains("episode"), "{body}");
7289 assert!(kinds.contains("document"), "{body}");
7290 assert!(
7291 !kinds.contains("cluster"),
7292 "cluster must be filtered out: {body}"
7293 );
7294 h.shutdown(&runtime);
7295 }
7296
7297 #[test]
7298 fn nodes_entity_synthesis_caps_at_200() {
7299 let runtime = rt();
7300 let h = Harness::new(&runtime);
7301 {
7302 let conn = h.open_db();
7303 let rowid = seed_episode(
7308 &conn,
7309 "dddddddd-0000-7000-8000-000000000001",
7310 100,
7311 "ep",
7312 );
7313 for i in 0..250 {
7314 let triple_id = format!("t-cap-{i:03}");
7315 let obj = format!("Entity{i:03}");
7316 seed_triple_row(&conn, &triple_id, "Alice", "knows", &obj, Some(rowid));
7317 }
7318 }
7319 let (status, headers, body) = runtime.block_on(call_with_headers(
7320 h.router.clone(),
7321 "GET",
7322 "/v1/graph/nodes?kind=entity&limit=500",
7323 ));
7324 assert_eq!(status, StatusCode::OK, "body: {body}");
7325 let nodes = body["nodes"].as_array().unwrap();
7326 assert_eq!(
7327 nodes.len(),
7328 200,
7329 "entity cap must be enforced at 200, got {}",
7330 nodes.len()
7331 );
7332 assert_eq!(
7333 headers
7334 .get("x-solo-entity-cap-reached")
7335 .and_then(|v| v.to_str().ok()),
7336 Some("true"),
7337 "cap-reached header missing: headers={headers:?}"
7338 );
7339 for n in nodes {
7340 assert_eq!(n["kind"], "entity");
7341 }
7342 h.shutdown(&runtime);
7343 }
7344
7345 #[test]
7346 fn nodes_since_until_filter_works() {
7347 let runtime = rt();
7348 let h = Harness::new(&runtime);
7349 {
7350 let conn = h.open_db();
7351 seed_episode(
7352 &conn,
7353 "eeeeeeee-0000-7000-8000-000000000001",
7354 100,
7355 "early",
7356 );
7357 seed_episode(
7358 &conn,
7359 "eeeeeeee-0000-7000-8000-000000000002",
7360 500,
7361 "middle",
7362 );
7363 seed_episode(
7364 &conn,
7365 "eeeeeeee-0000-7000-8000-000000000003",
7366 1000,
7367 "late",
7368 );
7369 }
7370 let (status, body) = runtime.block_on(call(
7371 h.router.clone(),
7372 "GET",
7373 "/v1/graph/nodes?kind=episode&since_ms=400&until_ms=600",
7374 None,
7375 ));
7376 assert_eq!(status, StatusCode::OK, "body: {body}");
7377 let nodes = body["nodes"].as_array().unwrap();
7378 assert_eq!(nodes.len(), 1, "{body}");
7379 assert_eq!(
7380 nodes[0]["id"],
7381 "ep:eeeeeeee-0000-7000-8000-000000000002"
7382 );
7383 h.shutdown(&runtime);
7384 }
7385
7386 #[test]
7387 fn nodes_pagination_round_trip() {
7388 let runtime = rt();
7389 let h = Harness::new(&runtime);
7390 {
7391 let conn = h.open_db();
7392 for i in 0..150 {
7393 let mid = format!("f0000000-0000-7000-8000-{i:012}");
7394 seed_episode(&conn, &mid, 1_000 + i as i64, "page");
7397 }
7398 }
7399 let limit = 50u32;
7400 let mut seen: std::collections::HashSet<String> = Default::default();
7401 let mut next_cursor: Option<String> = None;
7402 for page_idx in 0..4 {
7403 let cursor_param = next_cursor
7404 .as_deref()
7405 .map(|c| format!("&cursor={c}"))
7406 .unwrap_or_default();
7407 let uri = format!(
7408 "/v1/graph/nodes?kind=episode&limit={limit}{cursor_param}"
7409 );
7410 let (status, body) =
7411 runtime.block_on(call(h.router.clone(), "GET", &uri, None));
7412 assert_eq!(status, StatusCode::OK, "page {page_idx}: {body}");
7413 let nodes = body["nodes"].as_array().unwrap();
7414 assert!(
7415 nodes.len() <= limit as usize,
7416 "page {page_idx} over-fetched: {body}"
7417 );
7418 for n in nodes {
7419 let id = n["id"].as_str().unwrap().to_string();
7420 assert!(seen.insert(id.clone()), "duplicate id across pages: {id}");
7421 }
7422 next_cursor = body
7423 .get("next_cursor")
7424 .and_then(|v| v.as_str())
7425 .map(|s| s.to_string());
7426 if next_cursor.is_none() {
7427 break;
7428 }
7429 }
7430 assert_eq!(
7431 seen.len(),
7432 150,
7433 "expected 150 distinct ids across pages, got {}",
7434 seen.len()
7435 );
7436 assert!(
7437 next_cursor.is_none(),
7438 "cursor should be null after last page; got {next_cursor:?}"
7439 );
7440 h.shutdown(&runtime);
7441 }
7442
7443 #[test]
7444 fn nodes_respects_tenant_scoping() {
7445 let runtime = rt();
7446 let h = Harness::new(&runtime);
7447 {
7448 let conn = h.open_db();
7449 seed_episode(
7450 &conn,
7451 "11110000-0000-7000-8000-000000000001",
7452 100,
7453 "tenant scope",
7454 );
7455 }
7456 let r = h.router.clone();
7459 let (status, _body) = runtime.block_on(async {
7460 let req = Request::builder()
7461 .method("GET")
7462 .uri("/v1/graph/nodes")
7463 .header("x-solo-tenant", "never-registered-tenant")
7464 .body(Body::empty())
7465 .unwrap();
7466 let resp = r.oneshot(req).await.expect("oneshot");
7467 let s = resp.status();
7468 let _b = resp.into_body().collect().await.unwrap().to_bytes();
7469 (s, _b)
7470 });
7471 assert_eq!(status, StatusCode::NOT_FOUND);
7472 h.shutdown(&runtime);
7473 }
7474
7475 #[test]
7476 fn nodes_respects_auth_when_enabled() {
7477 let runtime = rt();
7478 let h = Harness::new_with_auth(&runtime, Some("nodes-secret".into()));
7479 let (status, _) = runtime.block_on(call(
7480 h.router.clone(),
7481 "GET",
7482 "/v1/graph/nodes",
7483 None,
7484 ));
7485 assert_eq!(
7486 status,
7487 StatusCode::UNAUTHORIZED,
7488 "must reject unauthenticated request"
7489 );
7490 let (status, _) = runtime.block_on(call_with_auth(
7491 h.router.clone(),
7492 "GET",
7493 "/v1/graph/nodes",
7494 None,
7495 Some("Bearer nodes-secret"),
7496 ));
7497 assert_eq!(status, StatusCode::OK, "must pass through with bearer");
7498 h.shutdown(&runtime);
7499 }
7500
7501 #[test]
7502 fn nodes_works_with_auth_none() {
7503 let runtime = rt();
7504 let h = Harness::new(&runtime);
7505 let (status, body) = runtime.block_on(call(
7506 h.router.clone(),
7507 "GET",
7508 "/v1/graph/nodes",
7509 None,
7510 ));
7511 assert_eq!(status, StatusCode::OK, "{body}");
7512 assert!(body.get("nodes").is_some());
7513 h.shutdown(&runtime);
7514 }
7515
7516 #[test]
7519 fn edges_returns_all_default_kinds() {
7520 let runtime = rt();
7521 let h = Harness::new(&runtime);
7522 {
7523 let conn = h.open_db();
7524 let rowid = seed_episode(
7525 &conn,
7526 "22220000-0000-7000-8000-000000000001",
7527 100,
7528 "ep src",
7529 );
7530 seed_triple_row(&conn, "t-def", "Alice", "knows", "Bob", Some(rowid));
7531 seed_document_row(&conn, "doc-e", "doc");
7532 seed_chunk_row(&conn, "c-e", "doc-e", 0, "chunk");
7533 seed_cluster_row(&conn, "cl-e", 200);
7534 seed_cluster_member(
7535 &conn,
7536 "cl-e",
7537 "22220000-0000-7000-8000-000000000001",
7538 );
7539 }
7540 let (status, body) = runtime.block_on(call(
7541 h.router.clone(),
7542 "GET",
7543 "/v1/graph/edges",
7544 None,
7545 ));
7546 assert_eq!(status, StatusCode::OK, "body: {body}");
7547 let edges = body["edges"].as_array().unwrap();
7548 let kinds: std::collections::HashSet<&str> = edges
7549 .iter()
7550 .map(|e| e["kind"].as_str().unwrap())
7551 .collect();
7552 assert!(kinds.contains("triple"), "{body}");
7553 assert!(kinds.contains("document_chunk"), "{body}");
7554 assert!(kinds.contains("cluster_member"), "{body}");
7555 assert!(
7556 !kinds.contains("semantic"),
7557 "semantic is NOT in default response: {body}"
7558 );
7559 h.shutdown(&runtime);
7560 }
7561
7562 #[test]
7563 fn edges_filter_by_node_id_finds_incident_edges() {
7564 let runtime = rt();
7565 let h = Harness::new(&runtime);
7566 let memory_id = "33330000-0000-7000-8000-000000000001";
7567 {
7568 let conn = h.open_db();
7569 let rowid = seed_episode(&conn, memory_id, 100, "ep multi-triple");
7570 seed_triple_row(&conn, "t-a", "Alice", "p", "Bob", Some(rowid));
7571 seed_triple_row(&conn, "t-b", "Alice", "p", "Carol", Some(rowid));
7572 seed_triple_row(&conn, "t-c", "Alice", "p", "Dave", Some(rowid));
7573 let decoy_rowid = seed_episode(
7575 &conn,
7576 "33330000-0000-7000-8000-000000000999",
7577 200,
7578 "decoy",
7579 );
7580 seed_triple_row(
7581 &conn,
7582 "t-decoy",
7583 "Alice",
7584 "p",
7585 "Eve",
7586 Some(decoy_rowid),
7587 );
7588 }
7589 let uri = format!(
7590 "/v1/graph/edges?type=triple&node_id={}",
7591 percent_encode_node_id(&format!("ep:{memory_id}"))
7592 );
7593 let (status, body) =
7594 runtime.block_on(call(h.router.clone(), "GET", &uri, None));
7595 assert_eq!(status, StatusCode::OK, "body: {body}");
7596 let edges = body["edges"].as_array().unwrap();
7597 assert_eq!(edges.len(), 3, "expected 3 incident edges: {body}");
7598 for e in edges {
7599 assert_eq!(e["source"], format!("ep:{memory_id}"));
7600 assert_eq!(e["kind"], "triple");
7601 }
7602 h.shutdown(&runtime);
7603 }
7604
7605 #[test]
7606 fn edges_filter_by_type_works() {
7607 let runtime = rt();
7608 let h = Harness::new(&runtime);
7609 {
7610 let conn = h.open_db();
7611 let rowid = seed_episode(
7612 &conn,
7613 "44440000-0000-7000-8000-000000000001",
7614 100,
7615 "ep",
7616 );
7617 seed_triple_row(&conn, "t-only", "Alice", "p", "Bob", Some(rowid));
7618 seed_document_row(&conn, "doc-skip", "doc");
7619 seed_chunk_row(&conn, "c-skip", "doc-skip", 0, "chunk");
7620 }
7621 let (status, body) = runtime.block_on(call(
7622 h.router.clone(),
7623 "GET",
7624 "/v1/graph/edges?type=triple",
7625 None,
7626 ));
7627 assert_eq!(status, StatusCode::OK, "{body}");
7628 let edges = body["edges"].as_array().unwrap();
7629 assert!(!edges.is_empty(), "{body}");
7630 for e in edges {
7631 assert_eq!(e["kind"], "triple", "{body}");
7632 }
7633 h.shutdown(&runtime);
7634 }
7635
7636 #[test]
7637 fn edges_rejects_semantic_type_with_400() {
7638 let runtime = rt();
7639 let h = Harness::new(&runtime);
7640 let (status, body) = runtime.block_on(call(
7641 h.router.clone(),
7642 "GET",
7643 "/v1/graph/edges?type=semantic",
7644 None,
7645 ));
7646 assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}");
7647 let err = body["error"].as_str().unwrap_or_default();
7648 assert!(
7649 err.contains("/v1/graph/neighbors"),
7650 "error must point to /v1/graph/neighbors: {body}"
7651 );
7652 h.shutdown(&runtime);
7653 }
7654
7655 #[test]
7656 fn edges_pagination_round_trip() {
7657 let runtime = rt();
7658 let h = Harness::new(&runtime);
7659 {
7660 let conn = h.open_db();
7661 let rowid = seed_episode(
7662 &conn,
7663 "55550000-0000-7000-8000-000000000001",
7664 100,
7665 "ep big",
7666 );
7667 for i in 0..60 {
7669 let tid = format!("t-page-{i:03}");
7670 let obj = format!("Obj{i:03}");
7671 seed_triple_row(&conn, &tid, "Alice", "p", &obj, Some(rowid));
7672 }
7673 }
7674 let limit = 25u32;
7675 let mut seen: std::collections::HashSet<String> = Default::default();
7676 let mut next_cursor: Option<String> = None;
7677 for page_idx in 0..5 {
7678 let cursor_param = next_cursor
7679 .as_deref()
7680 .map(|c| format!("&cursor={c}"))
7681 .unwrap_or_default();
7682 let uri = format!(
7683 "/v1/graph/edges?type=triple&limit={limit}{cursor_param}"
7684 );
7685 let (status, body) =
7686 runtime.block_on(call(h.router.clone(), "GET", &uri, None));
7687 assert_eq!(status, StatusCode::OK, "page {page_idx}: {body}");
7688 let edges = body["edges"].as_array().unwrap();
7689 for e in edges {
7690 let id = e["id"].as_str().unwrap().to_string();
7691 assert!(seen.insert(id.clone()), "duplicate edge id: {id}");
7692 }
7693 next_cursor = body
7694 .get("next_cursor")
7695 .and_then(|v| v.as_str())
7696 .map(|s| s.to_string());
7697 if next_cursor.is_none() {
7698 break;
7699 }
7700 }
7701 assert_eq!(
7702 seen.len(),
7703 60,
7704 "expected 60 distinct edges, got {}",
7705 seen.len()
7706 );
7707 assert!(next_cursor.is_none(), "expected exhausted cursor");
7708 h.shutdown(&runtime);
7709 }
7710
7711 #[test]
7712 fn edges_respects_tenant_scoping() {
7713 let runtime = rt();
7714 let h = Harness::new(&runtime);
7715 {
7716 let conn = h.open_db();
7717 let rowid = seed_episode(
7718 &conn,
7719 "66660000-0000-7000-8000-000000000001",
7720 100,
7721 "ep",
7722 );
7723 seed_triple_row(&conn, "t-tenant", "Alice", "p", "Bob", Some(rowid));
7724 }
7725 let r = h.router.clone();
7726 let (status, _) = runtime.block_on(async {
7727 let req = Request::builder()
7728 .method("GET")
7729 .uri("/v1/graph/edges")
7730 .header("x-solo-tenant", "never-registered-tenant")
7731 .body(Body::empty())
7732 .unwrap();
7733 let resp = r.oneshot(req).await.expect("oneshot");
7734 let s = resp.status();
7735 let _b = resp.into_body().collect().await.unwrap().to_bytes();
7736 (s, _b)
7737 });
7738 assert_eq!(status, StatusCode::NOT_FOUND);
7739 h.shutdown(&runtime);
7740 }
7741
7742 #[test]
7743 fn edges_respects_auth_when_enabled() {
7744 let runtime = rt();
7745 let h = Harness::new_with_auth(&runtime, Some("edges-secret".into()));
7746 let (status, _) = runtime.block_on(call(
7747 h.router.clone(),
7748 "GET",
7749 "/v1/graph/edges",
7750 None,
7751 ));
7752 assert_eq!(status, StatusCode::UNAUTHORIZED);
7753 let (status, _) = runtime.block_on(call_with_auth(
7754 h.router.clone(),
7755 "GET",
7756 "/v1/graph/edges",
7757 None,
7758 Some("Bearer edges-secret"),
7759 ));
7760 assert_eq!(status, StatusCode::OK);
7761 h.shutdown(&runtime);
7762 }
7763
7764 fn inspect_uri(node_id: &str) -> String {
7775 format!("/v1/graph/inspect/{}", percent_encode_node_id(node_id))
7779 }
7780
7781 #[test]
7782 fn inspect_episode_returns_full_text_plus_triples_out() {
7783 let runtime = rt();
7784 let h = Harness::new(&runtime);
7785 let memory_id = "a1110000-0000-7000-8000-000000000001";
7786 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.";
7787 {
7788 let conn = h.open_db();
7789 let rowid = seed_episode(&conn, memory_id, 1_715_625_600_000, full_text);
7790 seed_triple_row(&conn, "t-ep-1", "user", "met_with", "Alice", Some(rowid));
7791 seed_triple_row(&conn, "t-ep-2", "user", "discussed", "deploy_pipeline", Some(rowid));
7792 seed_triple_row(&conn, "t-ep-3", "Alice", "works_on", "project", Some(rowid));
7793 }
7794 let (status, body) = runtime.block_on(call(
7795 h.router.clone(),
7796 "GET",
7797 &inspect_uri(&format!("ep:{memory_id}")),
7798 None,
7799 ));
7800 assert_eq!(status, StatusCode::OK, "body: {body}");
7801 assert_eq!(body["node"]["kind"], "episode");
7802 assert_eq!(body["node"]["id"], format!("ep:{memory_id}"));
7803 assert_eq!(
7804 body["full_text"].as_str().unwrap(),
7805 full_text,
7806 "full_text must match episodes.content verbatim, untruncated"
7807 );
7808 let triples_out = body["triples_out"].as_array().unwrap();
7809 assert_eq!(triples_out.len(), 3, "{body}");
7810 let triples_in = body["triples_in"].as_array().unwrap();
7811 assert!(triples_in.is_empty(), "episodes have no triples_in: {body}");
7812 for e in triples_out {
7813 assert_eq!(e["kind"], "triple");
7814 assert_eq!(e["source"], format!("ep:{memory_id}"));
7815 assert!(e["target"].as_str().unwrap().starts_with("ent:"));
7816 assert!(e["predicate"].as_str().is_some());
7817 assert!(e["weight"].as_f64().is_some());
7818 }
7819 h.shutdown(&runtime);
7820 }
7821
7822 #[test]
7823 fn inspect_episode_triples_in_is_empty_for_v10p1() {
7824 let runtime = rt();
7829 let h = Harness::new(&runtime);
7830 let focal = "a2220000-0000-7000-8000-000000000001";
7831 let other = "a2220000-0000-7000-8000-000000000002";
7832 {
7833 let conn = h.open_db();
7834 seed_episode(&conn, focal, 100, "focal episode body");
7835 let other_rowid = seed_episode(&conn, other, 200, "another episode");
7836 for i in 0..5 {
7839 let tid = format!("t-other-{i}");
7840 seed_triple_row(&conn, &tid, "user", "did", "thing", Some(other_rowid));
7841 }
7842 }
7843 let (status, body) = runtime.block_on(call(
7844 h.router.clone(),
7845 "GET",
7846 &inspect_uri(&format!("ep:{focal}")),
7847 None,
7848 ));
7849 assert_eq!(status, StatusCode::OK, "body: {body}");
7850 let triples_in = body["triples_in"].as_array().unwrap();
7851 assert!(
7852 triples_in.is_empty(),
7853 "episode triples_in must be empty regardless of cross-episode entity references: {body}"
7854 );
7855 h.shutdown(&runtime);
7856 }
7857
7858 #[test]
7859 fn inspect_document_returns_full_text_concatenated_from_chunks() {
7860 let runtime = rt();
7861 let h = Harness::new(&runtime);
7862 let doc_id = "d3330000-0000-7000-8000-000000000001";
7863 {
7864 let conn = h.open_db();
7865 seed_document_row(&conn, doc_id, "doc-title");
7866 seed_chunk_row(&conn, "ch-doc-1", doc_id, 0, "First chunk body.");
7867 seed_chunk_row(&conn, "ch-doc-2", doc_id, 1, "Second chunk body.");
7868 seed_chunk_row(&conn, "ch-doc-3", doc_id, 2, "Third chunk body.");
7869 }
7870 let (status, body) = runtime.block_on(call(
7871 h.router.clone(),
7872 "GET",
7873 &inspect_uri(&format!("doc:{doc_id}")),
7874 None,
7875 ));
7876 assert_eq!(status, StatusCode::OK, "body: {body}");
7877 assert_eq!(body["node"]["kind"], "document");
7878 let full_text = body["full_text"].as_str().unwrap();
7879 assert_eq!(
7881 full_text,
7882 "First chunk body.\n\nSecond chunk body.\n\nThird chunk body."
7883 );
7884 assert!(body["triples_in"].as_array().unwrap().is_empty());
7885 assert!(body["triples_out"].as_array().unwrap().is_empty());
7886 h.shutdown(&runtime);
7887 }
7888
7889 #[test]
7890 fn inspect_chunk_returns_text() {
7891 let runtime = rt();
7892 let h = Harness::new(&runtime);
7893 let chunk_body = "This is the body of the chunk being inspected.";
7894 {
7895 let conn = h.open_db();
7896 seed_document_row(&conn, "doc-chunk-host", "host");
7897 seed_chunk_row(&conn, "chunk-inspect-target", "doc-chunk-host", 0, chunk_body);
7898 }
7899 let (status, body) = runtime.block_on(call(
7900 h.router.clone(),
7901 "GET",
7902 &inspect_uri("chunk:chunk-inspect-target"),
7903 None,
7904 ));
7905 assert_eq!(status, StatusCode::OK, "body: {body}");
7906 assert_eq!(body["node"]["kind"], "chunk");
7907 assert_eq!(body["full_text"].as_str().unwrap(), chunk_body);
7908 assert!(body["triples_in"].as_array().unwrap().is_empty());
7909 assert!(body["triples_out"].as_array().unwrap().is_empty());
7910 h.shutdown(&runtime);
7911 }
7912
7913 #[test]
7914 fn inspect_cluster_returns_label_and_abstraction() {
7915 let runtime = rt();
7916 let h = Harness::new(&runtime);
7917 let cluster_id = "cl-inspect-target";
7918 let abstraction_text = "Discussions about the deploy pipeline and on-call rotation.";
7919 {
7920 let conn = h.open_db();
7921 seed_cluster_row(&conn, cluster_id, 12345);
7922 seed_abstraction_row(&conn, "abs-1", cluster_id, abstraction_text);
7923 }
7924 let (status, body) = runtime.block_on(call(
7925 h.router.clone(),
7926 "GET",
7927 &inspect_uri(&format!("cl:{cluster_id}")),
7928 None,
7929 ));
7930 assert_eq!(status, StatusCode::OK, "body: {body}");
7931 assert_eq!(body["node"]["kind"], "cluster");
7932 let full_text = body["full_text"].as_str().unwrap();
7933 assert!(
7934 full_text.contains(cluster_id),
7935 "full_text must include cluster label: {full_text}"
7936 );
7937 assert!(
7938 full_text.contains(abstraction_text),
7939 "full_text must include abstraction text: {full_text}"
7940 );
7941 assert!(full_text.contains("\n\n"), "label and abstraction must be separated: {full_text}");
7944 h.shutdown(&runtime);
7945 }
7946
7947 #[test]
7948 fn inspect_entity_returns_triples_only() {
7949 let runtime = rt();
7950 let h = Harness::new(&runtime);
7951 {
7952 let conn = h.open_db();
7953 let rowid = seed_episode(
7954 &conn,
7955 "e5550000-0000-7000-8000-000000000001",
7956 100,
7957 "host episode",
7958 );
7959 seed_triple_row(&conn, "t-ent-1", "Alice", "knows", "Bob", Some(rowid));
7961 seed_triple_row(&conn, "t-ent-2", "Alice", "works_at", "Anthropic", Some(rowid));
7962 seed_triple_row(&conn, "t-ent-3", "user", "met", "Alice", Some(rowid));
7963 seed_triple_row(&conn, "t-ent-4", "Alice", "owns", "laptop", Some(rowid));
7964 seed_triple_row(&conn, "t-ent-5", "Carol", "mentors", "Alice", Some(rowid));
7965 }
7966 let (status, body) = runtime.block_on(call(
7967 h.router.clone(),
7968 "GET",
7969 &inspect_uri("ent:Alice"),
7970 None,
7971 ));
7972 assert_eq!(status, StatusCode::OK, "body: {body}");
7973 assert_eq!(body["node"]["kind"], "entity");
7974 assert_eq!(body["node"]["id"], "ent:Alice");
7975 assert!(
7976 body["full_text"].is_null(),
7977 "entity full_text must be null (entities have no body): {body}"
7978 );
7979 let triples_out = body["triples_out"].as_array().unwrap();
7980 assert_eq!(triples_out.len(), 5, "{body}");
7981 assert!(body["triples_in"].as_array().unwrap().is_empty());
7982 for e in triples_out {
7983 assert_eq!(e["kind"], "triple");
7984 assert_eq!(e["source"], "ent:Alice");
7985 assert!(e["target"].as_str().unwrap().starts_with("ent:"));
7988 assert_ne!(e["target"], "ent:Alice");
7989 }
7990 h.shutdown(&runtime);
7991 }
7992
7993 #[test]
7994 fn inspect_entity_with_zero_triples_returns_404() {
7995 let runtime = rt();
7996 let h = Harness::new(&runtime);
7997 {
8000 let conn = h.open_db();
8001 let rowid = seed_episode(
8002 &conn,
8003 "e6660000-0000-7000-8000-000000000001",
8004 100,
8005 "ep",
8006 );
8007 seed_triple_row(&conn, "t-other", "Bob", "knows", "Carol", Some(rowid));
8008 }
8009 let (status, body) = runtime.block_on(call(
8010 h.router.clone(),
8011 "GET",
8012 &inspect_uri("ent:Nonexistent"),
8013 None,
8014 ));
8015 assert_eq!(status, StatusCode::NOT_FOUND, "body: {body}");
8016 let err = body["error"].as_str().unwrap_or_default();
8017 assert!(
8018 err.contains("Nonexistent") || err.contains("entity"),
8019 "error must mention entity: {body}"
8020 );
8021 h.shutdown(&runtime);
8022 }
8023
8024 #[test]
8025 fn inspect_404_on_missing_node() {
8026 let runtime = rt();
8028 let h = Harness::new(&runtime);
8029 let (status, body) = runtime.block_on(call(
8030 h.router.clone(),
8031 "GET",
8032 &inspect_uri("ep:99999999-9999-7000-8000-000000000999"),
8033 None,
8034 ));
8035 assert_eq!(status, StatusCode::NOT_FOUND, "body: {body}");
8036 h.shutdown(&runtime);
8037 }
8038
8039 #[test]
8040 fn inspect_400_on_invalid_prefix() {
8041 let runtime = rt();
8042 let h = Harness::new(&runtime);
8043 let (status, body) = runtime.block_on(call(
8044 h.router.clone(),
8045 "GET",
8046 &inspect_uri("xyz:foo"),
8047 None,
8048 ));
8049 assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}");
8050 let err = body["error"].as_str().unwrap_or_default();
8051 assert!(
8052 err.contains("xyz") || err.contains("prefix"),
8053 "error must mention bad prefix: {body}"
8054 );
8055 h.shutdown(&runtime);
8056 }
8057
8058 #[test]
8059 fn inspect_respects_tenant_scoping() {
8060 let runtime = rt();
8061 let h = Harness::new(&runtime);
8062 let memory_id = "a7770000-0000-7000-8000-000000000001";
8063 {
8064 let conn = h.open_db();
8065 seed_episode(&conn, memory_id, 100, "tenant scope");
8066 }
8067 let r = h.router.clone();
8071 let (status, _) = runtime.block_on(async {
8072 let req = Request::builder()
8073 .method("GET")
8074 .uri(inspect_uri(&format!("ep:{memory_id}")))
8075 .header("x-solo-tenant", "never-registered-tenant")
8076 .body(Body::empty())
8077 .unwrap();
8078 let resp = r.oneshot(req).await.expect("oneshot");
8079 let s = resp.status();
8080 let _b = resp.into_body().collect().await.unwrap().to_bytes();
8081 (s, _b)
8082 });
8083 assert_eq!(status, StatusCode::NOT_FOUND);
8084 let (status, body) = runtime.block_on(call(
8086 h.router.clone(),
8087 "GET",
8088 &inspect_uri(&format!("ep:{memory_id}")),
8089 None,
8090 ));
8091 assert_eq!(status, StatusCode::OK, "default tenant must resolve: {body}");
8092 h.shutdown(&runtime);
8093 }
8094
8095 #[test]
8096 fn inspect_respects_auth_when_enabled() {
8097 let runtime = rt();
8098 let h = Harness::new_with_auth(&runtime, Some("inspect-secret".into()));
8099 let (status, _) = runtime.block_on(call(
8101 h.router.clone(),
8102 "GET",
8103 &inspect_uri("ep:99999999-9999-7000-8000-000000000999"),
8104 None,
8105 ));
8106 assert_eq!(status, StatusCode::UNAUTHORIZED);
8107 let (status, _) = runtime.block_on(call_with_auth(
8110 h.router.clone(),
8111 "GET",
8112 &inspect_uri("ep:99999999-9999-7000-8000-000000000999"),
8113 None,
8114 Some("Bearer inspect-secret"),
8115 ));
8116 assert_eq!(status, StatusCode::NOT_FOUND);
8117 h.shutdown(&runtime);
8118 }
8119
8120 fn neighbors_uri(
8134 node_id: &str,
8135 kind: Option<&str>,
8136 threshold: Option<f32>,
8137 limit: Option<u32>,
8138 ) -> String {
8139 let mut qs: Vec<String> = Vec::new();
8140 if let Some(k) = kind {
8141 qs.push(format!("kind={k}"));
8142 }
8143 if let Some(t) = threshold {
8144 qs.push(format!("threshold={t}"));
8145 }
8146 if let Some(l) = limit {
8147 qs.push(format!("limit={l}"));
8148 }
8149 let encoded = percent_encode_node_id(node_id);
8150 if qs.is_empty() {
8151 format!("/v1/graph/neighbors/{encoded}")
8152 } else {
8153 format!("/v1/graph/neighbors/{encoded}?{}", qs.join("&"))
8154 }
8155 }
8156
8157 #[test]
8162 fn neighbors_explicit_only_returns_no_semantic_edges() {
8163 let runtime = rt();
8164 let h = Harness::new(&runtime);
8165 runtime.block_on(async {
8166 let focal = post_remember(h.router.clone(), "alpha alpha alpha").await;
8170 let _other1 = post_remember(h.router.clone(), "beta beta beta").await;
8171 let _other2 = post_remember(h.router.clone(), "gamma gamma gamma").await;
8172 {
8175 let conn = h.open_db();
8176 let rowid: i64 = conn
8177 .query_row(
8178 "SELECT rowid FROM episodes WHERE memory_id = ?1",
8179 rusqlite::params![&focal],
8180 |r| r.get(0),
8181 )
8182 .unwrap();
8183 seed_triple_row(&conn, "t-exp-1", "Alice", "knows", "Bob", Some(rowid));
8184 seed_triple_row(&conn, "t-exp-2", "Alice", "owns", "laptop", Some(rowid));
8185 }
8186 let (status, body) = call(
8187 h.router.clone(),
8188 "GET",
8189 &neighbors_uri(&format!("ep:{focal}"), Some("explicit"), None, None),
8190 None,
8191 )
8192 .await;
8193 assert_eq!(status, StatusCode::OK, "body: {body}");
8194 let edges = body["edges"].as_array().unwrap();
8195 assert!(!edges.is_empty(), "expected explicit edges: {body}");
8196 for e in edges {
8197 assert_ne!(
8198 e["kind"], "semantic",
8199 "kind=explicit must drop semantic edges: {body}"
8200 );
8201 }
8202 });
8203 h.shutdown(&runtime);
8204 }
8205
8206 #[test]
8209 fn neighbors_semantic_only_returns_no_explicit_edges() {
8210 let runtime = rt();
8211 let h = Harness::new(&runtime);
8212 runtime.block_on(async {
8213 let focal = post_remember(h.router.clone(), "alpha alpha alpha").await;
8214 let _other1 = post_remember(h.router.clone(), "beta beta beta").await;
8215 let _other2 = post_remember(h.router.clone(), "gamma gamma gamma").await;
8216 {
8217 let conn = h.open_db();
8218 let rowid: i64 = conn
8219 .query_row(
8220 "SELECT rowid FROM episodes WHERE memory_id = ?1",
8221 rusqlite::params![&focal],
8222 |r| r.get(0),
8223 )
8224 .unwrap();
8225 seed_triple_row(&conn, "t-exp-1", "Alice", "knows", "Bob", Some(rowid));
8226 }
8227 let (status, body) = call(
8229 h.router.clone(),
8230 "GET",
8231 &neighbors_uri(&format!("ep:{focal}"), Some("semantic"), Some(0.0), None),
8232 None,
8233 )
8234 .await;
8235 assert_eq!(status, StatusCode::OK, "body: {body}");
8236 let edges = body["edges"].as_array().unwrap();
8237 for e in edges {
8238 assert_eq!(
8239 e["kind"], "semantic",
8240 "kind=semantic must drop explicit edges: {body}"
8241 );
8242 assert!(e["weight"].is_number(), "semantic edges carry weight: {body}");
8243 }
8244 });
8245 h.shutdown(&runtime);
8246 }
8247
8248 #[test]
8250 fn neighbors_both_default_returns_combined() {
8251 let runtime = rt();
8252 let h = Harness::new(&runtime);
8253 runtime.block_on(async {
8254 let focal = post_remember(h.router.clone(), "alpha alpha alpha").await;
8255 let _other1 = post_remember(h.router.clone(), "beta beta beta").await;
8256 {
8257 let conn = h.open_db();
8258 let rowid: i64 = conn
8259 .query_row(
8260 "SELECT rowid FROM episodes WHERE memory_id = ?1",
8261 rusqlite::params![&focal],
8262 |r| r.get(0),
8263 )
8264 .unwrap();
8265 seed_triple_row(&conn, "t-both-1", "Alice", "met", "Bob", Some(rowid));
8266 }
8267 let (status, body) = call(
8268 h.router.clone(),
8269 "GET",
8270 &neighbors_uri(&format!("ep:{focal}"), None, Some(0.0), None),
8273 None,
8274 )
8275 .await;
8276 assert_eq!(status, StatusCode::OK, "body: {body}");
8277 let edges = body["edges"].as_array().unwrap();
8278 let kinds: std::collections::HashSet<&str> = edges
8279 .iter()
8280 .map(|e| e["kind"].as_str().unwrap())
8281 .collect();
8282 assert!(
8283 kinds.contains("triple"),
8284 "expected at least one triple edge: {body}"
8285 );
8286 assert!(
8287 kinds.contains("semantic"),
8288 "expected at least one semantic edge: {body}"
8289 );
8290 });
8291 h.shutdown(&runtime);
8292 }
8293
8294 #[test]
8299 fn neighbors_dedupes_semantic_when_explicit_exists() {
8300 let runtime = rt();
8301 let h = Harness::new(&runtime);
8302 runtime.block_on(async {
8303 let focal = post_remember(h.router.clone(), "alpha alpha alpha").await;
8304 let _other = post_remember(h.router.clone(), "beta beta beta").await;
8340 {
8341 let conn = h.open_db();
8342 let rowid: i64 = conn
8343 .query_row(
8344 "SELECT rowid FROM episodes WHERE memory_id = ?1",
8345 rusqlite::params![&focal],
8346 |r| r.get(0),
8347 )
8348 .unwrap();
8349 seed_triple_row(
8350 &conn,
8351 "t-dedupe-1",
8352 "Alice",
8353 "knows",
8354 "Bob",
8355 Some(rowid),
8356 );
8357 }
8358 let (status, body) = call(
8359 h.router.clone(),
8360 "GET",
8361 &neighbors_uri(&format!("ep:{focal}"), Some("both"), Some(0.0), None),
8362 None,
8363 )
8364 .await;
8365 assert_eq!(status, StatusCode::OK, "body: {body}");
8366 let edges = body["edges"].as_array().unwrap();
8370 let mut seen: std::collections::HashMap<(String, String), i32> =
8371 std::collections::HashMap::new();
8372 for e in edges {
8373 let key = (
8374 e["source"].as_str().unwrap().to_string(),
8375 e["target"].as_str().unwrap().to_string(),
8376 );
8377 *seen.entry(key).or_insert(0) += 1;
8378 }
8379 for (pair, count) in &seen {
8380 assert_eq!(
8381 *count, 1,
8382 "edge pair {pair:?} appears {count} times -- dedupe rule violated: {body}"
8383 );
8384 }
8385 });
8386 h.shutdown(&runtime);
8387 }
8388
8389 #[test]
8392 fn neighbors_threshold_filters_low_similarity() {
8393 let runtime = rt();
8394 let h = Harness::new(&runtime);
8395 runtime.block_on(async {
8396 let focal = post_remember(h.router.clone(), "alpha alpha alpha").await;
8397 let _o1 = post_remember(h.router.clone(), "beta one").await;
8398 let _o2 = post_remember(h.router.clone(), "beta two").await;
8399 let _o3 = post_remember(h.router.clone(), "beta three").await;
8400 let (status, low_body) = call(
8402 h.router.clone(),
8403 "GET",
8404 &neighbors_uri(&format!("ep:{focal}"), Some("semantic"), Some(0.0), None),
8405 None,
8406 )
8407 .await;
8408 assert_eq!(status, StatusCode::OK, "body: {low_body}");
8409 let low_edge_count = low_body["edges"].as_array().unwrap().len();
8410 let (status, high_body) = call(
8412 h.router.clone(),
8413 "GET",
8414 &neighbors_uri(&format!("ep:{focal}"), Some("semantic"), Some(0.99), None),
8415 None,
8416 )
8417 .await;
8418 assert_eq!(status, StatusCode::OK, "body: {high_body}");
8419 let high_edge_count = high_body["edges"].as_array().unwrap().len();
8420 assert!(
8421 high_edge_count <= low_edge_count,
8422 "high-threshold ({high_edge_count}) must not exceed low-threshold ({low_edge_count}): low={low_body}, high={high_body}"
8423 );
8424 for e in high_body["edges"].as_array().unwrap() {
8427 if let Some(w) = e["weight"].as_f64() {
8428 assert!(
8429 w >= 0.99,
8430 "edge with weight {w} survived threshold=0.99: {e}"
8431 );
8432 }
8433 }
8434 });
8435 h.shutdown(&runtime);
8436 }
8437
8438 #[test]
8441 fn neighbors_limit_clamped_at_100() {
8442 let runtime = rt();
8443 let h = Harness::new(&runtime);
8444 {
8447 let conn = h.open_db();
8448 seed_cluster_row(&conn, "cl-huge-n", 1000);
8449 for i in 0..150 {
8450 let mid = format!("99119911-1111-7000-8000-{:012}", i);
8451 seed_episode(&conn, &mid, 100 + i as i64, &format!("content {i}"));
8452 seed_cluster_member(&conn, "cl-huge-n", &mid);
8453 }
8454 }
8455 let (status, body) = runtime.block_on(call(
8456 h.router.clone(),
8457 "GET",
8458 &neighbors_uri("cl:cl-huge-n", Some("explicit"), None, Some(999)),
8459 None,
8460 ));
8461 assert_eq!(status, StatusCode::OK, "body: {body}");
8462 let edges = body["edges"].as_array().unwrap();
8463 assert_eq!(
8464 edges.len(),
8465 100,
8466 "limit must be silently clamped to 100, got {}",
8467 edges.len()
8468 );
8469 h.shutdown(&runtime);
8470 }
8471
8472 #[test]
8474 fn neighbors_semantic_rejects_document_source() {
8475 let runtime = rt();
8476 let h = Harness::new(&runtime);
8477 let doc_id = "d-semrej-0000-7000-8000-000000000001";
8478 {
8479 let conn = h.open_db();
8480 seed_document_row(&conn, doc_id, "host");
8481 }
8482 let (status, body) = runtime.block_on(call(
8483 h.router.clone(),
8484 "GET",
8485 &neighbors_uri(
8486 &format!("doc:{doc_id}"),
8487 Some("semantic"),
8488 None,
8489 None,
8490 ),
8491 None,
8492 ));
8493 assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}");
8494 let err = body["error"].as_str().unwrap_or_default();
8495 assert!(
8496 err.contains("episode") && err.contains("chunk"),
8497 "error must list supported kinds: {body}"
8498 );
8499 h.shutdown(&runtime);
8500 }
8501
8502 #[test]
8504 fn neighbors_semantic_rejects_cluster_source() {
8505 let runtime = rt();
8506 let h = Harness::new(&runtime);
8507 let cluster_id = "cl-semrej-target";
8508 {
8509 let conn = h.open_db();
8510 seed_cluster_row(&conn, cluster_id, 12345);
8511 }
8512 let (status, body) = runtime.block_on(call(
8513 h.router.clone(),
8514 "GET",
8515 &neighbors_uri(
8516 &format!("cl:{cluster_id}"),
8517 Some("semantic"),
8518 None,
8519 None,
8520 ),
8521 None,
8522 ));
8523 assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}");
8524 h.shutdown(&runtime);
8525 }
8526
8527 #[test]
8531 fn neighbors_entity_returns_triples_only() {
8532 let runtime = rt();
8533 let h = Harness::new(&runtime);
8534 runtime.block_on(async {
8535 let host_mid = post_remember(h.router.clone(), "Alice and Bob talked").await;
8540 {
8541 let conn = h.open_db();
8542 let rowid: i64 = conn
8543 .query_row(
8544 "SELECT rowid FROM episodes WHERE memory_id = ?1",
8545 rusqlite::params![&host_mid],
8546 |r| r.get(0),
8547 )
8548 .unwrap();
8549 seed_triple_row(&conn, "t-ent-n-1", "Alice", "knows", "Bob", Some(rowid));
8550 seed_triple_row(&conn, "t-ent-n-2", "Alice", "works_at", "Acme", Some(rowid));
8551 }
8552 let (status, body) = call(
8553 h.router.clone(),
8554 "GET",
8555 &neighbors_uri("ent:Alice", None, Some(0.0), None),
8556 None,
8557 )
8558 .await;
8559 assert_eq!(status, StatusCode::OK, "body: {body}");
8560 let edges = body["edges"].as_array().unwrap();
8561 assert!(!edges.is_empty(), "expected explicit triples: {body}");
8562 for e in edges {
8563 assert_eq!(
8564 e["kind"], "triple",
8565 "entity focal must produce only triple edges: {body}"
8566 );
8567 }
8568 });
8569 h.shutdown(&runtime);
8570 }
8571
8572 #[test]
8575 fn neighbors_respects_tenant_scoping() {
8576 let runtime = rt();
8577 let h = Harness::new(&runtime);
8578 let memory_id = "a8880000-0000-7000-8000-000000000001";
8579 {
8580 let conn = h.open_db();
8581 seed_episode(&conn, memory_id, 100, "tenant scope");
8582 }
8583 let r = h.router.clone();
8585 let (status, _) = runtime.block_on(async {
8586 let req = Request::builder()
8587 .method("GET")
8588 .uri(neighbors_uri(
8589 &format!("ep:{memory_id}"),
8590 Some("explicit"),
8591 None,
8592 None,
8593 ))
8594 .header("x-solo-tenant", "never-registered-tenant-n")
8595 .body(Body::empty())
8596 .unwrap();
8597 let resp = r.oneshot(req).await.expect("oneshot");
8598 let s = resp.status();
8599 let _b = resp.into_body().collect().await.unwrap().to_bytes();
8600 (s, _b)
8601 });
8602 assert_eq!(status, StatusCode::NOT_FOUND);
8603 let (status, body) = runtime.block_on(call(
8605 h.router.clone(),
8606 "GET",
8607 &neighbors_uri(&format!("ep:{memory_id}"), Some("explicit"), None, None),
8608 None,
8609 ));
8610 assert_eq!(status, StatusCode::OK, "default tenant must resolve: {body}");
8611 h.shutdown(&runtime);
8612 }
8613
8614 #[test]
8617 fn neighbors_respects_auth_when_enabled() {
8618 let runtime = rt();
8619 let h = Harness::new_with_auth(&runtime, Some("neighbors-secret".into()));
8620 let (status, _) = runtime.block_on(call(
8622 h.router.clone(),
8623 "GET",
8624 &neighbors_uri(
8625 "ep:99999999-9999-7000-8000-000000000999",
8626 Some("explicit"),
8627 None,
8628 None,
8629 ),
8630 None,
8631 ));
8632 assert_eq!(status, StatusCode::UNAUTHORIZED);
8633 let (status, _) = runtime.block_on(call_with_auth(
8635 h.router.clone(),
8636 "GET",
8637 &neighbors_uri(
8638 "ep:99999999-9999-7000-8000-000000000999",
8639 Some("explicit"),
8640 None,
8641 None,
8642 ),
8643 None,
8644 Some("Bearer neighbors-secret"),
8645 ));
8646 assert_eq!(status, StatusCode::NOT_FOUND);
8647 h.shutdown(&runtime);
8648 }
8649
8650 #[derive(Debug, Clone)]
8665 struct ParsedSseEvent {
8666 event: String,
8667 data: Value,
8668 }
8669
8670 async fn read_one_sse_event(
8674 body: &mut axum::body::Body,
8675 timeout: std::time::Duration,
8676 ) -> Option<ParsedSseEvent> {
8677 use http_body_util::BodyExt;
8678 let mut buf = String::new();
8679 let start = std::time::Instant::now();
8680 loop {
8681 if start.elapsed() >= timeout {
8682 return None;
8683 }
8684 let remaining = timeout.saturating_sub(start.elapsed());
8685 let frame_res =
8686 tokio::time::timeout(remaining, body.frame()).await;
8687 let frame = match frame_res {
8688 Ok(Some(Ok(f))) => f,
8689 Ok(Some(Err(_))) | Ok(None) => return None,
8690 Err(_) => return None,
8691 };
8692 if let Ok(data) = frame.into_data() {
8693 buf.push_str(&String::from_utf8_lossy(&data));
8694 while let Some(idx) = buf.find("\n\n") {
8696 let block: String = buf.drain(..idx + 2).collect();
8697 if let Some(parsed) = parse_sse_block(&block) {
8698 return Some(parsed);
8699 }
8700 }
8701 }
8702 }
8703 }
8704
8705 fn parse_sse_block(block: &str) -> Option<ParsedSseEvent> {
8709 let mut event: Option<String> = None;
8710 let mut data: Option<String> = None;
8711 for line in block.lines() {
8712 if let Some(rest) = line.strip_prefix("event:") {
8713 event = Some(rest.trim().to_string());
8714 } else if let Some(rest) = line.strip_prefix("data:") {
8715 data = Some(rest.trim().to_string());
8716 }
8717 }
8718 let event = event?;
8719 let data_str = data?;
8720 let data_json = serde_json::from_str(&data_str).ok()?;
8721 Some(ParsedSseEvent {
8722 event,
8723 data: data_json,
8724 })
8725 }
8726
8727 async fn open_sse_stream_inner(
8731 router: axum::Router,
8732 auth: Option<&str>,
8733 tenant: Option<&str>,
8734 ) -> (StatusCode, axum::body::Body) {
8735 let mut builder = Request::builder()
8736 .method("GET")
8737 .uri("/v1/graph/stream");
8738 if let Some(a) = auth {
8739 builder = builder.header("authorization", a);
8740 }
8741 if let Some(t) = tenant {
8742 builder = builder.header("x-solo-tenant", t);
8743 }
8744 let req = builder
8745 .header("content-length", "0")
8746 .body(Body::empty())
8747 .unwrap();
8748 let resp = router.oneshot(req).await.expect("oneshot");
8749 let status = resp.status();
8750 let body = resp.into_body();
8751 (status, body)
8752 }
8753
8754 #[test]
8756 fn stream_emits_init_event_on_connect() {
8757 let runtime = rt();
8758 let h = Harness::new(&runtime);
8759 let r = h.router.clone();
8760 runtime.block_on(async {
8761 let (status, mut body) = open_sse_stream_inner(r, None, None).await;
8762 assert_eq!(status, StatusCode::OK);
8763 let ev = read_one_sse_event(&mut body, std::time::Duration::from_secs(2))
8764 .await
8765 .expect("must receive init event within 2s");
8766 assert_eq!(ev.event, "init");
8767 assert_eq!(ev.data["connected"].as_bool(), Some(true));
8768 assert_eq!(ev.data["tenant_id"].as_str(), Some("default"));
8769 assert!(ev.data["ts_ms"].is_number());
8770 });
8771 h.shutdown(&runtime);
8772 }
8773
8774 #[test]
8777 fn stream_emits_invalidate_after_writer_event() {
8778 let runtime = rt();
8779 let h = Harness::new(&runtime);
8780 let r = h.router.clone();
8781 let sender = h.invalidate_sender();
8782 runtime.block_on(async {
8783 let (status, mut body) = open_sse_stream_inner(r, None, None).await;
8784 assert_eq!(status, StatusCode::OK);
8785 let init = read_one_sse_event(&mut body, std::time::Duration::from_secs(2))
8787 .await
8788 .unwrap();
8789 assert_eq!(init.event, "init");
8790 sender
8792 .send(InvalidateEvent {
8793 reason: "memory.remember".to_string(),
8794 tenant_id: "default".to_string(),
8795 ts_ms: 1_715_625_600_000,
8796 kind: "episode".to_string(),
8797 })
8798 .expect("must have at least one subscriber");
8799 let ev = read_one_sse_event(&mut body, std::time::Duration::from_secs(2))
8801 .await
8802 .expect("invalidate event must arrive within 2s");
8803 assert_eq!(ev.event, "invalidate");
8804 assert_eq!(ev.data["reason"].as_str(), Some("memory.remember"));
8805 assert_eq!(ev.data["tenant_id"].as_str(), Some("default"));
8806 assert_eq!(ev.data["kind"].as_str(), Some("episode"));
8807 });
8808 h.shutdown(&runtime);
8809 }
8810
8811 #[test]
8814 fn stream_emits_invalidate_for_each_writer_command() {
8815 let runtime = rt();
8816 let h = Harness::new(&runtime);
8817 let r = h.router.clone();
8818 let sender = h.invalidate_sender();
8819 let cases = [
8820 ("memory.remember", "episode"),
8821 ("memory.forget", "episode"),
8822 ("memory.consolidate", "cluster"),
8823 ("memory.ingest_document", "document"),
8824 ("memory.forget_document", "document"),
8825 ("memory.triples_extract", "cluster"),
8826 ("memory.reembed", "episode"),
8827 ("gdpr.forget_user", "tenant"),
8828 ];
8829 runtime.block_on(async {
8830 let (status, mut body) = open_sse_stream_inner(r, None, None).await;
8831 assert_eq!(status, StatusCode::OK);
8832 let _ = read_one_sse_event(&mut body, std::time::Duration::from_secs(2))
8834 .await
8835 .unwrap();
8836 for (reason, kind) in cases {
8837 sender
8838 .send(InvalidateEvent {
8839 reason: reason.to_string(),
8840 tenant_id: "default".to_string(),
8841 ts_ms: 1_715_625_600_000,
8842 kind: kind.to_string(),
8843 })
8844 .unwrap();
8845 let ev = read_one_sse_event(&mut body, std::time::Duration::from_secs(2))
8846 .await
8847 .unwrap_or_else(|| panic!("must receive event for {reason}"));
8848 assert_eq!(ev.event, "invalidate");
8849 assert_eq!(
8850 ev.data["reason"].as_str(),
8851 Some(reason),
8852 "reason mismatch"
8853 );
8854 assert_eq!(ev.data["kind"].as_str(), Some(kind), "kind mismatch");
8855 }
8856 });
8857 h.shutdown(&runtime);
8858 }
8859
8860 #[test]
8868 fn stream_emits_heartbeat_when_no_events() {
8869 let runtime = rt();
8870 let h = Harness::new(&runtime);
8871 let sender = h.invalidate_sender();
8872 runtime.block_on(async {
8873 let rx = sender.subscribe();
8876 let stream = build_invalidate_stream(rx, "default".to_string(), 1);
8879 let sse: Sse<_> = Sse::new(stream);
8883 let resp = sse.into_response();
8884 let mut body = resp.into_body();
8885 let first =
8887 read_one_sse_event(&mut body, std::time::Duration::from_secs(2))
8888 .await
8889 .expect("init event must arrive");
8890 assert_eq!(first.event, "init");
8891 let second =
8894 read_one_sse_event(&mut body, std::time::Duration::from_secs(3))
8895 .await
8896 .expect("heartbeat event must arrive within 3s");
8897 assert_eq!(second.event, "heartbeat");
8898 assert!(second.data["ts_ms"].is_number());
8899 });
8900 h.shutdown(&runtime);
8901 }
8902
8903 #[test]
8906 fn stream_concurrent_subscribers_same_tenant() {
8907 let runtime = rt();
8908 let h = Harness::new(&runtime);
8909 let r1 = h.router.clone();
8910 let r2 = h.router.clone();
8911 let r3 = h.router.clone();
8912 let sender = h.invalidate_sender();
8913 runtime.block_on(async {
8914 let (s1, mut body1) = open_sse_stream_inner(r1, None, None).await;
8916 let (s2, mut body2) = open_sse_stream_inner(r2, None, None).await;
8917 let (s3, mut body3) = open_sse_stream_inner(r3, None, None).await;
8918 assert_eq!(s1, StatusCode::OK);
8919 assert_eq!(s2, StatusCode::OK);
8920 assert_eq!(s3, StatusCode::OK);
8921 for body in [&mut body1, &mut body2, &mut body3] {
8923 let ev = read_one_sse_event(body, std::time::Duration::from_secs(2))
8924 .await
8925 .unwrap();
8926 assert_eq!(ev.event, "init");
8927 }
8928 assert!(
8930 sender.receiver_count() >= 3,
8931 "expected ≥3 subscribers, got {}",
8932 sender.receiver_count()
8933 );
8934 sender
8936 .send(InvalidateEvent {
8937 reason: "memory.remember".to_string(),
8938 tenant_id: "default".to_string(),
8939 ts_ms: 1_715_625_600_000,
8940 kind: "episode".to_string(),
8941 })
8942 .expect("send must succeed");
8943 for body in [&mut body1, &mut body2, &mut body3] {
8945 let ev = read_one_sse_event(body, std::time::Duration::from_secs(2))
8946 .await
8947 .unwrap();
8948 assert_eq!(ev.event, "invalidate");
8949 assert_eq!(ev.data["reason"].as_str(), Some("memory.remember"));
8950 }
8951 });
8952 h.shutdown(&runtime);
8953 }
8954
8955 #[test]
8958 fn stream_handles_client_disconnect_gracefully() {
8959 let runtime = rt();
8960 let h = Harness::new(&runtime);
8961 let r = h.router.clone();
8962 let sender = h.invalidate_sender();
8963 let before = sender.receiver_count();
8964 runtime.block_on(async {
8965 let (status, mut body) = open_sse_stream_inner(r, None, None).await;
8966 assert_eq!(status, StatusCode::OK);
8967 let _ = read_one_sse_event(&mut body, std::time::Duration::from_secs(2))
8969 .await
8970 .unwrap();
8971 let during = sender.receiver_count();
8972 assert!(
8973 during > before,
8974 "subscriber count must increase while stream is live (before={before}, during={during})"
8975 );
8976 drop(body);
8980 });
8981 runtime.block_on(async {
8983 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8984 });
8985 let after = sender.receiver_count();
8986 assert!(
8987 after <= before,
8988 "subscriber count must drop back after disconnect (before={before}, after={after})"
8989 );
8990 h.shutdown(&runtime);
8991 }
8992
8993 #[test]
8995 fn stream_respects_auth_when_enabled() {
8996 let runtime = rt();
8997 let h = Harness::new_with_auth(&runtime, Some("stream-secret".into()));
8998 let r = h.router.clone();
8999 runtime.block_on(async {
9000 let (status, _body) = open_sse_stream_inner(r, None, None).await;
9001 assert_eq!(status, StatusCode::UNAUTHORIZED);
9002 });
9003 h.shutdown(&runtime);
9004 }
9005
9006 #[test]
9008 fn stream_works_with_auth_none() {
9009 let runtime = rt();
9010 let h = Harness::new(&runtime);
9011 let r = h.router.clone();
9012 runtime.block_on(async {
9013 let (status, mut body) = open_sse_stream_inner(r, None, None).await;
9014 assert_eq!(status, StatusCode::OK);
9015 let ev = read_one_sse_event(&mut body, std::time::Duration::from_secs(2))
9016 .await
9017 .expect("must receive init event");
9018 assert_eq!(ev.event, "init");
9019 });
9020 h.shutdown(&runtime);
9021 }
9022
9023 #[test]
9025 fn stream_respects_auth_accepts_valid_token() {
9026 let runtime = rt();
9027 let h = Harness::new_with_auth(&runtime, Some("stream-secret".into()));
9028 let r = h.router.clone();
9029 runtime.block_on(async {
9030 let (status, mut body) =
9031 open_sse_stream_inner(r, Some("Bearer stream-secret"), None).await;
9032 assert_eq!(status, StatusCode::OK);
9033 let ev = read_one_sse_event(&mut body, std::time::Duration::from_secs(2))
9034 .await
9035 .expect("must receive init event with valid bearer");
9036 assert_eq!(ev.event, "init");
9037 assert_eq!(ev.data["tenant_id"].as_str(), Some("default"));
9038 });
9039 h.shutdown(&runtime);
9040 }
9041
9042 #[test]
9045 fn stream_respects_tenant_scoping() {
9046 let runtime = rt();
9047 let h = Harness::new(&runtime);
9048 let r = h.router.clone();
9049 runtime.block_on(async {
9050 let (status, _body) =
9051 open_sse_stream_inner(r, None, Some("never-registered-tenant-x")).await;
9052 assert_eq!(status, StatusCode::NOT_FOUND);
9056 });
9057 h.shutdown(&runtime);
9058 }
9059
9060 async fn seed_three_tenants(registry: &TenantRegistry) -> Vec<String> {
9078 use solo_core::TenantId as TenantIdT;
9079 let ids = ["alice", "bob", "default"];
9080 for id in ids {
9081 let tid = TenantIdT::new(id).unwrap();
9082 registry
9083 .with_index(|idx| {
9084 idx.register(&tid, &format!("{id}.db"), Some(&format!("{id} tenant")))
9085 .unwrap();
9086 })
9091 .await;
9092 tokio::time::sleep(std::time::Duration::from_millis(2)).await;
9093 }
9094 vec!["alice".into(), "bob".into(), "default".into()]
9098 }
9099
9100 #[test]
9104 fn tenants_returns_all_when_auth_none() {
9105 let runtime = rt();
9106 let h = Harness::new(&runtime);
9107 let r = h.router.clone();
9108 runtime.block_on(async {
9109 let _expected = seed_three_tenants(&h.registry).await;
9110 let (status, body) = call(r, "GET", "/v1/tenants", None).await;
9111 assert_eq!(status, StatusCode::OK);
9112 let arr = body
9113 .get("tenants")
9114 .and_then(|v| v.as_array())
9115 .expect("tenants array");
9116 assert_eq!(arr.len(), 3, "got body: {body}");
9117 let ids: Vec<&str> =
9118 arr.iter().filter_map(|t| t["id"].as_str()).collect();
9119 assert_eq!(ids, vec!["alice", "bob", "default"]);
9120 });
9121 h.shutdown(&runtime);
9122 }
9123
9124 #[test]
9129 fn tenants_returns_all_when_bearer_auth() {
9130 let runtime = rt();
9131 let h = Harness::new_with_auth(&runtime, Some("tlist-secret".into()));
9132 let r = h.router.clone();
9133 runtime.block_on(async {
9134 seed_three_tenants(&h.registry).await;
9135 let (status, body) = call_with_auth(
9136 r,
9137 "GET",
9138 "/v1/tenants",
9139 None,
9140 Some("Bearer tlist-secret"),
9141 )
9142 .await;
9143 assert_eq!(status, StatusCode::OK, "got body: {body}");
9144 let arr = body["tenants"].as_array().expect("tenants array");
9145 assert_eq!(arr.len(), 3, "bearer must see all tenants");
9146 });
9147 h.shutdown(&runtime);
9148 }
9149
9150 #[test]
9154 fn tenants_filters_to_principal_claim_when_oidc() {
9155 let runtime = rt();
9156 let (fake_server, discovery_url, secret, kid) =
9157 runtime.block_on(async { spin_fake_idp().await });
9158 let server_uri = fake_server.uri();
9159 let _server_guard = fake_server;
9160
9161 let auth = crate::auth::AuthConfig::Oidc {
9162 discovery_url,
9163 audience: "tlist-audience".to_string(),
9164 tenant_claim_name: "solo_tenant".to_string(),
9165 };
9166 let h = Harness::new_with_auth_config(&runtime, Some(auth));
9167 let r = h.router.clone();
9168
9169 runtime.block_on(async {
9170 seed_three_tenants(&h.registry).await;
9171 let token = mint_idp_token(
9172 &server_uri,
9173 kid,
9174 &secret,
9175 "alice",
9176 "tlist-audience",
9177 );
9178 let (status, body) = call_with_auth(
9179 r,
9180 "GET",
9181 "/v1/tenants",
9182 None,
9183 Some(&format!("Bearer {token}")),
9184 )
9185 .await;
9186 assert_eq!(status, StatusCode::OK, "got body: {body}");
9187 let arr = body["tenants"].as_array().expect("tenants array");
9188 assert_eq!(arr.len(), 1, "OIDC alice must see exactly one tenant");
9189 assert_eq!(arr[0]["id"].as_str(), Some("alice"));
9190 });
9191 h.shutdown(&runtime);
9192 }
9193
9194 #[test]
9200 fn tenants_returns_empty_when_oidc_claim_unmatched() {
9201 let runtime = rt();
9202 let (fake_server, discovery_url, secret, kid) =
9203 runtime.block_on(async { spin_fake_idp().await });
9204 let server_uri = fake_server.uri();
9205 let _server_guard = fake_server;
9206
9207 let auth = crate::auth::AuthConfig::Oidc {
9208 discovery_url,
9209 audience: "tlist-audience".to_string(),
9210 tenant_claim_name: "solo_tenant".to_string(),
9211 };
9212 let h = Harness::new_with_auth_config(&runtime, Some(auth));
9213 let r = h.router.clone();
9214
9215 runtime.block_on(async {
9216 seed_three_tenants(&h.registry).await;
9217 let token = mint_idp_token(
9220 &server_uri,
9221 kid,
9222 &secret,
9223 "nonexistent",
9224 "tlist-audience",
9225 );
9226 let (status, body) = call_with_auth(
9227 r,
9228 "GET",
9229 "/v1/tenants",
9230 None,
9231 Some(&format!("Bearer {token}")),
9232 )
9233 .await;
9234 assert_eq!(
9235 status,
9236 StatusCode::OK,
9237 "must be 200 OK, not 404 — don't leak tenant existence: {body}"
9238 );
9239 let arr = body["tenants"].as_array().expect("tenants array");
9240 assert_eq!(
9241 arr.len(),
9242 0,
9243 "unmatched OIDC claim must produce empty list, got: {body}"
9244 );
9245 });
9246 h.shutdown(&runtime);
9247 }
9248
9249 #[test]
9264 fn tenants_response_shape_matches_solo_web_types() {
9265 let runtime = rt();
9266 let h = Harness::new(&runtime);
9267 let r = h.router.clone();
9268 runtime.block_on(async {
9269 let tid = solo_core::TenantId::new("shaped").unwrap();
9272 h.registry
9273 .with_index(|idx| {
9274 idx.register_with_quota(
9275 &tid,
9276 "shaped.db",
9277 Some("Shaped tenant"),
9278 Some(1_048_576),
9279 )
9280 .unwrap();
9281 })
9282 .await;
9283 let (status, body) = call(r, "GET", "/v1/tenants", None).await;
9284 assert_eq!(status, StatusCode::OK);
9285 let item = &body["tenants"][0];
9286 assert_eq!(item["id"].as_str(), Some("shaped"));
9288 assert_eq!(item["display_name"].as_str(), Some("Shaped tenant"));
9289 assert!(
9290 item["created_at_ms"].is_i64(),
9291 "created_at_ms must be an i64, got {item}"
9292 );
9293 assert_eq!(item["status"].as_str(), Some("active"));
9294 assert_eq!(item["quota_bytes"].as_u64(), Some(1_048_576));
9296 assert!(
9302 item["episode_count"].is_null(),
9303 "episode_count must be JSON null when tenant DB is missing, got {item}"
9304 );
9305 assert!(
9306 item["size_bytes"].is_null(),
9307 "size_bytes must be JSON null when tenant DB is missing, got {item}"
9308 );
9309 assert!(
9310 item["pct_used"].is_null(),
9311 "pct_used must be JSON null when size_bytes is null, got {item}"
9312 );
9313 });
9314 h.shutdown(&runtime);
9315 }
9316
9317 #[test]
9322 fn tenants_respects_auth_when_enabled() {
9323 let runtime = rt();
9324 let h = Harness::new_with_auth(&runtime, Some("must-auth".into()));
9325 let r = h.router.clone();
9326 runtime.block_on(async {
9327 seed_three_tenants(&h.registry).await;
9328 let (status, _body) = call(r, "GET", "/v1/tenants", None).await;
9330 assert_eq!(status, StatusCode::UNAUTHORIZED);
9331 });
9332 h.shutdown(&runtime);
9333 }
9334
9335 #[test]
9340 fn tenants_status_filter_excludes_non_active() {
9341 let runtime = rt();
9342 let h = Harness::new(&runtime);
9343 let r = h.router.clone();
9344 runtime.block_on(async {
9345 let keeper = solo_core::TenantId::new("keeper").unwrap();
9348 let migrating = solo_core::TenantId::new("migrating").unwrap();
9349 let deleting = solo_core::TenantId::new("deleting").unwrap();
9350 h.registry
9351 .with_index(|idx| {
9352 idx.register(&keeper, "keeper.db", None).unwrap();
9353 idx.register_with_status(
9354 &migrating,
9355 "migrating.db",
9356 None,
9357 solo_storage::TenantStatus::PendingMigration,
9358 )
9359 .unwrap();
9360 idx.register_with_status(
9361 &deleting,
9362 "deleting.db",
9363 None,
9364 solo_storage::TenantStatus::PendingDelete,
9365 )
9366 .unwrap();
9367 })
9368 .await;
9369 let (status, body) = call(r, "GET", "/v1/tenants", None).await;
9370 assert_eq!(status, StatusCode::OK);
9371 let arr = body["tenants"].as_array().expect("tenants array");
9372 let ids: Vec<&str> =
9373 arr.iter().filter_map(|t| t["id"].as_str()).collect();
9374 assert_eq!(
9375 ids,
9376 vec!["keeper"],
9377 "only Active tenants visible; got: {body}"
9378 );
9379 });
9380 h.shutdown(&runtime);
9381 }
9382
9383 #[test]
9388 fn tenants_returns_empty_array_when_no_tenants_registered() {
9389 let runtime = rt();
9390 let h = Harness::new(&runtime);
9391 let r = h.router.clone();
9392 runtime.block_on(async {
9393 let (status, body) = call(r, "GET", "/v1/tenants", None).await;
9397 assert_eq!(status, StatusCode::OK);
9398 let arr = body["tenants"].as_array().expect("tenants array");
9399 assert_eq!(arr.len(), 0, "expected empty array, got: {body}");
9400 });
9401 h.shutdown(&runtime);
9402 }
9403
9404 fn seed_per_tenant_db_with_episodes(
9430 data_dir: &std::path::Path,
9431 db_filename: &str,
9432 n_active: i64,
9433 n_forgotten: i64,
9434 ) -> std::path::PathBuf {
9435 let tenants_dir = data_dir.join(solo_storage::TENANTS_SUBDIR);
9436 std::fs::create_dir_all(&tenants_dir).unwrap();
9437 let db_path = tenants_dir.join(db_filename);
9438 let mut conn = rusqlite::Connection::open(&db_path).unwrap();
9442 solo_storage::run_migrations(&mut conn).unwrap();
9445 for i in 0..n_active {
9446 conn.execute(
9447 "INSERT INTO episodes (memory_id, ts_ms, source_type, content, confidence, strength, salience, tier, status, created_at_ms, updated_at_ms)
9448 VALUES (?, 0, 'user_message', 'x', 0.5, 0.5, 0.5, 'hot', 'active', 0, 0)",
9449 rusqlite::params![format!("a-{i}")],
9450 )
9451 .unwrap();
9452 }
9453 for i in 0..n_forgotten {
9454 conn.execute(
9455 "INSERT INTO episodes (memory_id, ts_ms, source_type, content, confidence, strength, salience, tier, status, created_at_ms, updated_at_ms)
9456 VALUES (?, 0, 'user_message', 'x', 0.5, 0.5, 0.5, 'hot', 'forgotten', 0, 0)",
9457 rusqlite::params![format!("f-{i}")],
9458 )
9459 .unwrap();
9460 }
9461 drop(conn);
9462 db_path
9463 }
9464
9465 #[test]
9470 fn tenants_response_hydrates_episode_count_when_tenant_has_data() {
9471 let runtime = rt();
9472 let h = Harness::new(&runtime);
9473 let r = h.router.clone();
9474 let data_dir = h._tmp.path().to_path_buf();
9475 runtime.block_on(async {
9476 let tid = solo_core::TenantId::new("counted").unwrap();
9477 seed_per_tenant_db_with_episodes(&data_dir, "counted.db", 3, 2);
9478 h.registry
9479 .with_index(|idx| {
9480 idx.register(&tid, "counted.db", Some("Counted tenant"))
9481 .unwrap();
9482 })
9483 .await;
9484 let (status, body) = call(r, "GET", "/v1/tenants", None).await;
9485 assert_eq!(status, StatusCode::OK);
9486 let item = &body["tenants"][0];
9487 assert_eq!(item["id"].as_str(), Some("counted"));
9488 assert_eq!(
9489 item["episode_count"].as_i64(),
9490 Some(3),
9491 "episode_count must be 3 (active rows only, 2 forgotten excluded); got {item}"
9492 );
9493 });
9494 h.shutdown(&runtime);
9495 }
9496
9497 #[test]
9502 fn tenants_response_hydrates_size_bytes_from_db_file() {
9503 let runtime = rt();
9504 let h = Harness::new(&runtime);
9505 let r = h.router.clone();
9506 let data_dir = h._tmp.path().to_path_buf();
9507 runtime.block_on(async {
9508 let tid = solo_core::TenantId::new("sized").unwrap();
9509 let db_path =
9510 seed_per_tenant_db_with_episodes(&data_dir, "sized.db", 1, 0);
9511 h.registry
9512 .with_index(|idx| {
9513 idx.register(&tid, "sized.db", None).unwrap();
9514 })
9515 .await;
9516 let on_disk = std::fs::metadata(&db_path).unwrap().len();
9517 assert!(on_disk > 0, "test setup: db file should be non-empty");
9518 let (status, body) = call(r, "GET", "/v1/tenants", None).await;
9519 assert_eq!(status, StatusCode::OK);
9520 let item = &body["tenants"][0];
9521 assert_eq!(item["id"].as_str(), Some("sized"));
9522 assert_eq!(
9523 item["size_bytes"].as_u64(),
9524 Some(on_disk),
9525 "size_bytes must match fs::metadata; got {item}"
9526 );
9527 });
9528 h.shutdown(&runtime);
9529 }
9530
9531 #[test]
9536 fn tenants_response_computes_pct_used_when_quota_set() {
9537 let runtime = rt();
9538 let h = Harness::new(&runtime);
9539 let r = h.router.clone();
9540 let data_dir = h._tmp.path().to_path_buf();
9541 runtime.block_on(async {
9542 let tid = solo_core::TenantId::new("quoted").unwrap();
9543 let db_path =
9544 seed_per_tenant_db_with_episodes(&data_dir, "quoted.db", 1, 0);
9545 let on_disk = std::fs::metadata(&db_path).unwrap().len();
9549 let quota = on_disk * 4; h.registry
9551 .with_index(|idx| {
9552 idx.register_with_quota(&tid, "quoted.db", None, Some(quota))
9553 .unwrap();
9554 })
9555 .await;
9556 let (status, body) = call(r, "GET", "/v1/tenants", None).await;
9557 assert_eq!(status, StatusCode::OK);
9558 let item = &body["tenants"][0];
9559 let pct = item["pct_used"].as_f64().expect("pct_used must be a number");
9560 assert!(
9561 (0.0..=100.0).contains(&pct),
9562 "pct_used must be in [0, 100], got {pct}"
9563 );
9564 assert!(
9568 (20.0..=30.0).contains(&pct),
9569 "pct_used must be ~25% for size=quota/4, got {pct}"
9570 );
9571 });
9572 h.shutdown(&runtime);
9573 }
9574
9575 #[test]
9579 fn tenants_response_pct_used_null_when_quota_null() {
9580 let runtime = rt();
9581 let h = Harness::new(&runtime);
9582 let r = h.router.clone();
9583 let data_dir = h._tmp.path().to_path_buf();
9584 runtime.block_on(async {
9585 let tid = solo_core::TenantId::new("unlimited").unwrap();
9586 seed_per_tenant_db_with_episodes(&data_dir, "unlimited.db", 1, 0);
9587 h.registry
9588 .with_index(|idx| {
9589 idx.register(&tid, "unlimited.db", None).unwrap();
9590 })
9591 .await;
9592 let (status, body) = call(r, "GET", "/v1/tenants", None).await;
9593 assert_eq!(status, StatusCode::OK);
9594 let item = &body["tenants"][0];
9595 assert_eq!(item["id"].as_str(), Some("unlimited"));
9596 assert!(
9597 item["quota_bytes"].is_null(),
9598 "test setup: quota_bytes must be null, got {item}"
9599 );
9600 assert!(
9601 item["pct_used"].is_null(),
9602 "pct_used must be JSON null when quota_bytes is null, got {item}"
9603 );
9604 assert!(
9607 item["size_bytes"].is_u64(),
9608 "size_bytes must still be present when quota_bytes is null, got {item}"
9609 );
9610 });
9611 h.shutdown(&runtime);
9612 }
9613
9614 #[test]
9627 fn tenants_response_sets_cap_reached_header_when_over_cap() {
9628 let runtime = rt();
9629 let h = Harness::new(&runtime);
9630 let r = h.router.clone();
9631 runtime.block_on(async {
9632 h.registry
9634 .with_index(|idx| {
9635 for i in 0..51 {
9636 let id = format!("t{i:02}");
9637 let tid = solo_core::TenantId::new(&id).unwrap();
9638 idx.register(&tid, &format!("{id}.db"), None).unwrap();
9639 }
9640 })
9641 .await;
9642 use axum::body::Body;
9644 use axum::http::Request;
9645 use http_body_util::BodyExt;
9646 let req = Request::builder()
9647 .method("GET")
9648 .uri("/v1/tenants")
9649 .body(Body::empty())
9650 .unwrap();
9651 let resp = r.oneshot(req).await.unwrap();
9652 assert_eq!(resp.status(), StatusCode::OK);
9653 let cap_header = resp
9654 .headers()
9655 .get(X_SOLO_TENANTS_COUNT_CAP_HEADER)
9656 .expect("cap-reached header must be present");
9657 assert_eq!(
9658 cap_header.to_str().unwrap(),
9659 "true",
9660 "cap-reached header value must be 'true' when over cap"
9661 );
9662 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
9665 let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
9666 let arr = body["tenants"].as_array().expect("tenants array");
9667 assert_eq!(arr.len(), 51, "got {} tenants", arr.len());
9668 assert!(
9673 arr[50]["episode_count"].is_null(),
9674 "the 51st tenant (beyond cap) must have null episode_count, got {}",
9675 arr[50]
9676 );
9677 });
9678 h.shutdown(&runtime);
9679 }
9680
9681 #[test]
9686 fn tenants_response_omits_cap_header_when_under_cap() {
9687 let runtime = rt();
9688 let h = Harness::new(&runtime);
9689 let r = h.router.clone();
9690 runtime.block_on(async {
9691 seed_three_tenants(&h.registry).await;
9692 use axum::body::Body;
9693 use axum::http::Request;
9694 let req = Request::builder()
9695 .method("GET")
9696 .uri("/v1/tenants")
9697 .body(Body::empty())
9698 .unwrap();
9699 let resp = r.oneshot(req).await.unwrap();
9700 assert_eq!(resp.status(), StatusCode::OK);
9701 assert!(
9702 resp.headers().get(X_SOLO_TENANTS_COUNT_CAP_HEADER).is_none(),
9703 "cap-reached header must be absent under the cap"
9704 );
9705 });
9706 h.shutdown(&runtime);
9707 }
9708
9709 fn make_record(id: &str) -> solo_storage::TenantRecord {
9719 solo_storage::TenantRecord {
9720 tenant_id: solo_core::TenantId::new(id).unwrap(),
9721 db_filename: format!("{id}.db"),
9722 display_name: None,
9723 created_at_ms: 0,
9724 status: solo_storage::TenantStatus::Active,
9725 quota_bytes: None,
9726 last_accessed_ms: None,
9727 }
9728 }
9729
9730 #[test]
9731 fn filter_no_principal_returns_all() {
9732 let records = vec![make_record("a"), make_record("b")];
9733 let out = filter_tenants_for_principal(records.clone(), None);
9734 assert_eq!(out.len(), 2);
9735 assert_eq!(out[0].tenant_id.as_str(), "a");
9736 assert_eq!(out[1].tenant_id.as_str(), "b");
9737 }
9738
9739 #[test]
9740 fn filter_bearer_principal_returns_all() {
9741 let records = vec![make_record("a"), make_record("b")];
9742 let p = AuthenticatedPrincipal::bearer(
9743 solo_core::TenantId::new("a").unwrap(),
9744 );
9745 let out = filter_tenants_for_principal(records, Some(&p));
9746 assert_eq!(out.len(), 2);
9747 }
9748
9749 #[test]
9750 fn filter_oidc_principal_keeps_only_claim() {
9751 let records = vec![make_record("a"), make_record("b"), make_record("c")];
9752 let p = AuthenticatedPrincipal {
9754 subject: "alice@example.com".to_string(),
9755 tenant_claim: Some(solo_core::TenantId::new("b").unwrap()),
9756 scopes: vec!["read".to_string()],
9757 claims: serde_json::json!({ "sub": "alice@example.com" }),
9758 };
9759 let out = filter_tenants_for_principal(records, Some(&p));
9760 assert_eq!(out.len(), 1);
9761 assert_eq!(out[0].tenant_id.as_str(), "b");
9762 }
9763
9764 #[test]
9765 fn filter_oidc_principal_with_no_claim_returns_empty() {
9766 let records = vec![make_record("a")];
9769 let p = AuthenticatedPrincipal {
9770 subject: "alice@example.com".to_string(),
9771 tenant_claim: None,
9772 scopes: vec![],
9773 claims: serde_json::json!({ "sub": "alice@example.com" }),
9774 };
9775 let out = filter_tenants_for_principal(records, Some(&p));
9776 assert!(out.is_empty());
9777 }
9778
9779 #[test]
9780 fn is_single_principal_bearer_discriminator() {
9781 let bearer = AuthenticatedPrincipal::bearer(
9782 solo_core::TenantId::new("default").unwrap(),
9783 );
9784 assert!(is_single_principal_bearer(&bearer));
9785
9786 let oidc = AuthenticatedPrincipal {
9787 subject: "alice".to_string(),
9788 tenant_claim: Some(solo_core::TenantId::new("alice").unwrap()),
9789 scopes: vec![],
9790 claims: serde_json::json!({ "x": 1 }),
9791 };
9792 assert!(!is_single_principal_bearer(&oidc));
9793
9794 let weird = AuthenticatedPrincipal {
9798 subject: "bearer".to_string(),
9799 tenant_claim: Some(solo_core::TenantId::default_tenant()),
9800 scopes: vec![],
9801 claims: serde_json::json!({ "leak": 1 }),
9802 };
9803 assert!(!is_single_principal_bearer(&weird));
9804 }
9805}
9806
9807#[cfg(test)]
9808mod cors_tests {
9809 use super::is_localhost_origin;
9810
9811 #[test]
9812 fn accepts_canonical_localhost_origins() {
9813 assert!(is_localhost_origin("http://localhost"));
9814 assert!(is_localhost_origin("http://localhost:3000"));
9815 assert!(is_localhost_origin("https://localhost:8443"));
9816 assert!(is_localhost_origin("http://127.0.0.1"));
9817 assert!(is_localhost_origin("http://127.0.0.1:5173"));
9818 assert!(is_localhost_origin("http://[::1]"));
9819 assert!(is_localhost_origin("http://[::1]:8080"));
9820 }
9821
9822 #[test]
9823 fn rejects_remote_origins() {
9824 assert!(!is_localhost_origin("http://example.com"));
9825 assert!(!is_localhost_origin("https://malicious.example"));
9826 assert!(!is_localhost_origin("http://192.168.1.5"));
9827 assert!(!is_localhost_origin("http://10.0.0.1"));
9828 }
9829
9830 #[test]
9831 fn rejects_dns_rebinding_tricks() {
9832 assert!(!is_localhost_origin("http://127.0.0.1.nip.io"));
9836 assert!(!is_localhost_origin("http://localhost.evil.com"));
9837 assert!(!is_localhost_origin("http://evil.localhost"));
9838 }
9839
9840 #[test]
9841 fn rejects_non_http_schemes() {
9842 assert!(!is_localhost_origin("file:///"));
9843 assert!(!is_localhost_origin("ws://localhost:3000"));
9844 assert!(!is_localhost_origin("javascript:alert(1)"));
9845 }
9846
9847 #[test]
9848 fn rejects_malformed() {
9849 assert!(!is_localhost_origin(""));
9850 assert!(!is_localhost_origin("localhost"));
9851 assert!(!is_localhost_origin("//localhost"));
9852 }
9853}
9854