clsplusplus 7.3.2__tar.gz → 7.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/PKG-INFO +1 -1
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/pyproject.toml +1 -1
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/api.py +380 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/config.py +13 -0
- clsplusplus-7.4.0/src/clsplusplus/durable_log/__init__.py +21 -0
- clsplusplus-7.4.0/src/clsplusplus/durable_log/file_wal.py +181 -0
- clsplusplus-7.4.0/src/clsplusplus/durable_log/manager.py +149 -0
- clsplusplus-7.4.0/src/clsplusplus/durable_log/record.py +88 -0
- clsplusplus-7.4.0/src/clsplusplus/durable_log/replicas.py +77 -0
- clsplusplus-7.4.0/src/clsplusplus/login_activity.py +97 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/memory_service.py +67 -4
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/models.py +3 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/integration_store.py +28 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/l1_indexing_store.py +64 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/user_store.py +32 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus.egg-info/PKG-INFO +1 -1
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus.egg-info/SOURCES.txt +8 -0
- clsplusplus-7.4.0/tests/test_admin_integrations_activity.py +289 -0
- clsplusplus-7.4.0/tests/test_durable_log.py +317 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/LICENSE +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/README.md +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/setup.cfg +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/__init__.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/abuse_guard.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/api_usage_analytics.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/auth.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/cli.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/client.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/cost_forecast.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/cost_model.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/debug_console.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/demo_llm.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/demo_llm_calls.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/demo_local.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/email_service.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/embeddings.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/funnel_routes.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/geo.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/health_metrics.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/homepage_autopromote.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/idempotency.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/integration_service.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/integrations/__init__.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/integrations/autogen.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/integrations/crewai.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/integrations/langgraph.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/integrations/llamaindex.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/jwt_utils.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/local_routes.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/main.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/mcp_http.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/mcp_server.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/memory_cycle.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/memory_phase.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/metering_v2/__init__.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/metering_v2/__main__.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/metering_v2/billing.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/metering_v2/healthcheck.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/metering_v2/notifier.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/metering_v2/pricing.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/metering_v2/reconciler.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/metering_v2/schema.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/metering_v2/writer.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/metrics.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/middleware.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/namespace_resolver.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/oauth_server.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/permissions.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/plasticity.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/pricing.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/pricing_models.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/pricing_store.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/prompt_log.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/rate_limit.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/razorpay_service.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/rbac_service.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/reconsolidation.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/resilience.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/sleep_cycle.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/__init__.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/base.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/chat_session_store.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/l0_working_buffer.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/l2_schema_graph.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/l3_deep_recess.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/l3_postgres.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/oauth_store.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/rbac_store.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/waitlist_store.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stores/web_events_store.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/stripe_service.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/subscription_watchdog.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/temporal.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/test_suite.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/tier_resolver.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/tiers.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/topical_resonance.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/tracer.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/usage.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/user_embeddings.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/user_pulse.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/user_service.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/waitlist_service.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/webhook_dispatcher.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/weblab.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/weblab_watcher.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/window_limits.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus/write_metrics.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus.egg-info/dependency_links.txt +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus.egg-info/entry_points.txt +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus.egg-info/requires.txt +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/src/clsplusplus.egg-info/top_level.txt +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_abuse_guard.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_admin.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_admin_seed.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_api.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_api_comprehensive.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_api_endpoints.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_api_usage_analytics.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_auth.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_auth_me_api_key.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_autogen_memory.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_billing_e2e.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_burst_hardening.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_client_sdk.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_config.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_cost_forecast.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_crewai_provider.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_cross_llm_memory.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_debug_console.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_demo_llm.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_embeddings.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_extension_cross_model_runtime.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_extension_integration.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_extension_ui.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_feedback_github.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_free_tier_lifecycle.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_full_api_coverage.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_funnel_metrics.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_geo_gating.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_health_metrics.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_idempotency.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_integration_service.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_integration_sync_cli_contract.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_langgraph_store.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_llamaindex_block.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_mcp_connect.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_mcp_oauth.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_mem0_failure_modes.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_memory_cycle.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_memory_phase.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_memory_service.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_memory_write_no_schema_garbage.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_metering_v2_healthcheck.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_metering_v2_notifier.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_metering_v2_pricing.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_metering_v2_reconciler.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_metering_v2_schema.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_metering_v2_writer.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_middleware.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_models.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_overage_billing.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_performance.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_plasticity.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_pricing.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_prototype_e2e.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_rate_limit.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_razorpay_billing.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_razorpay_subscription_webhooks.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_reconsolidation.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_regression.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_resilience.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_security.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_sleep_cycle.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_stores.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_subscription_watchdog.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_tier_resolver.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_tiers.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_usage.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_user_auth.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_user_embeddings.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_user_pulse.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_user_stories.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_waitlist.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_webhook_dispatcher.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_weblab.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_window_limits.py +0 -0
- {clsplusplus-7.3.2 → clsplusplus-7.4.0}/tests/test_write_metrics.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clsplusplus
|
|
3
|
-
Version: 7.
|
|
3
|
+
Version: 7.4.0
|
|
4
4
|
Summary: Brain-inspired, model-agnostic persistent memory for LLMs. Learn, recall, forget — like a brain. Works with OpenAI, Claude, Gemini, Llama.
|
|
5
5
|
Author-email: AlphaForge AI Labs <contact@alphaforge.ai>
|
|
6
6
|
Maintainer-email: Rajamohan Jabbala <contact@alphaforge.ai>
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "clsplusplus"
|
|
7
|
-
version = "7.
|
|
7
|
+
version = "7.4.0"
|
|
8
8
|
description = "Brain-inspired, model-agnostic persistent memory for LLMs. Learn, recall, forget — like a brain. Works with OpenAI, Claude, Gemini, Llama."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -1248,12 +1248,21 @@ def create_app(settings: Optional[Settings] = None) -> FastAPI:
|
|
|
1248
1248
|
"error": "Add CLS_REDIS_URL and CLS_DATABASE_URL in Render Dashboard.",
|
|
1249
1249
|
}
|
|
1250
1250
|
wm = write_metrics.snapshot()
|
|
1251
|
+
wal = None
|
|
1252
|
+
_dl = getattr(memory_service, "_durable_log", None)
|
|
1253
|
+
if _dl is not None:
|
|
1254
|
+
wal = {
|
|
1255
|
+
"pending_reconcile": _dl.pending_reconcile(),
|
|
1256
|
+
"appended": _dl.appended,
|
|
1257
|
+
"sinks": {s.name: {"healthy": s.healthy, "lag": s.lag} for s in _dl.states()},
|
|
1258
|
+
}
|
|
1251
1259
|
return HealthResponse(
|
|
1252
1260
|
status=h["status"],
|
|
1253
1261
|
stores=stores,
|
|
1254
1262
|
writes=wm["total_writes"],
|
|
1255
1263
|
degraded_writes=wm["degraded_writes"],
|
|
1256
1264
|
degraded_write_rate=wm["degraded_write_rate"],
|
|
1265
|
+
wal=wal,
|
|
1257
1266
|
)
|
|
1258
1267
|
|
|
1259
1268
|
@app.get("/v1/health/score", response_model=HealthResponse)
|
|
@@ -1619,6 +1628,14 @@ def create_app(settings: Optional[Settings] = None) -> FastAPI:
|
|
|
1619
1628
|
)
|
|
1620
1629
|
logger.error("Login error: %s: %s", type(e).__name__, e)
|
|
1621
1630
|
raise HTTPException(status_code=500, detail="Login service unavailable")
|
|
1631
|
+
# Real login telemetry for the admin activity dashboard (best-effort).
|
|
1632
|
+
from clsplusplus.login_activity import record_login
|
|
1633
|
+
await record_login(
|
|
1634
|
+
settings,
|
|
1635
|
+
user_id=user.get("id"),
|
|
1636
|
+
email=user.get("email"),
|
|
1637
|
+
method="password",
|
|
1638
|
+
)
|
|
1622
1639
|
response = JSONResponse(content=user)
|
|
1623
1640
|
return _set_session_cookie(response, token)
|
|
1624
1641
|
|
|
@@ -1806,6 +1823,13 @@ def create_app(settings: Optional[Settings] = None) -> FastAPI:
|
|
|
1806
1823
|
_target = (settings.frontend_url.rstrip("/") + _safe_redirect) if settings.frontend_url else _safe_redirect
|
|
1807
1824
|
response = RedirectResponse(_target)
|
|
1808
1825
|
_set_session_cookie(response, token)
|
|
1826
|
+
from clsplusplus.login_activity import record_login
|
|
1827
|
+
await record_login(
|
|
1828
|
+
settings,
|
|
1829
|
+
user_id=user.get("id"),
|
|
1830
|
+
email=user.get("email"),
|
|
1831
|
+
method="google",
|
|
1832
|
+
)
|
|
1809
1833
|
return response
|
|
1810
1834
|
|
|
1811
1835
|
# ------------------------------------------------------------------
|
|
@@ -1927,6 +1951,13 @@ def create_app(settings: Optional[Settings] = None) -> FastAPI:
|
|
|
1927
1951
|
_target = (settings.frontend_url.rstrip("/") + safe_redirect) if settings.frontend_url else safe_redirect
|
|
1928
1952
|
response = RedirectResponse(_target)
|
|
1929
1953
|
_set_session_cookie(response, token)
|
|
1954
|
+
from clsplusplus.login_activity import record_login
|
|
1955
|
+
await record_login(
|
|
1956
|
+
settings,
|
|
1957
|
+
user_id=user.get("id"),
|
|
1958
|
+
email=user.get("email"),
|
|
1959
|
+
method="github",
|
|
1960
|
+
)
|
|
1930
1961
|
return response
|
|
1931
1962
|
|
|
1932
1963
|
# =========================================================================
|
|
@@ -2687,6 +2718,344 @@ def create_app(settings: Optional[Settings] = None) -> FastAPI:
|
|
|
2687
2718
|
"error": "integration data degraded",
|
|
2688
2719
|
}
|
|
2689
2720
|
|
|
2721
|
+
@app.get("/admin/integrations/activity")
|
|
2722
|
+
async def admin_integrations_activity(request: Request):
|
|
2723
|
+
"""Real per-integration ACTIVITY for the admin dashboard.
|
|
2724
|
+
|
|
2725
|
+
Every metric is computed from a real backing store; nothing is
|
|
2726
|
+
fabricated. A source with no attributable data returns null fields and
|
|
2727
|
+
an honest telemetry classification ("partial" or "none") rather than a
|
|
2728
|
+
made-up number. The handler is fully resilient — each backing store is
|
|
2729
|
+
isolated and any failure degrades that section to null/empty instead of
|
|
2730
|
+
returning 500.
|
|
2731
|
+
|
|
2732
|
+
Telemetry honesty contract per integration:
|
|
2733
|
+
- "tracked": the integration writes a dedicated L1 `source` (the
|
|
2734
|
+
framework adapters: crewai/langgraph/llamaindex/autogen) or has its
|
|
2735
|
+
own Redis counters (chrome-extension) or its own table (api-key).
|
|
2736
|
+
- "partial": the integration shares the generic `source="mcp"` bucket
|
|
2737
|
+
so volume is real but NOT separable per-integration
|
|
2738
|
+
(claude-remote-mcp, codex, cursor, n8n).
|
|
2739
|
+
- "none": no attributable signal exists for it yet.
|
|
2740
|
+
"""
|
|
2741
|
+
_require_admin(request)
|
|
2742
|
+
|
|
2743
|
+
from datetime import timezone as _tz
|
|
2744
|
+
now = datetime.now(_tz.utc)
|
|
2745
|
+
_empty_window = {
|
|
2746
|
+
"active_users_24h": 0, "active_users_7d": 0, "logins_24h": 0,
|
|
2747
|
+
"total_memories": 0, "writes_24h": 0, "reads_24h": 0,
|
|
2748
|
+
"api_error_rate_60m": 0.0,
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
def _iso(dt) -> Optional[str]:
|
|
2752
|
+
if dt is None:
|
|
2753
|
+
return None
|
|
2754
|
+
try:
|
|
2755
|
+
return dt.isoformat()
|
|
2756
|
+
except Exception:
|
|
2757
|
+
return None
|
|
2758
|
+
|
|
2759
|
+
# --- Static catalogue: ids/names MUST match the frontend dashboard. ---
|
|
2760
|
+
# `bucket` says where this integration's REAL write volume comes from.
|
|
2761
|
+
# "adapter:<source>" -> dedicated L1 source -> telemetry tracked
|
|
2762
|
+
# "mcp" -> shared mcp source bucket -> telemetry partial
|
|
2763
|
+
# "ext" -> chrome-ext redis counters-> telemetry tracked
|
|
2764
|
+
# "api" -> api_credentials table -> telemetry tracked
|
|
2765
|
+
# None -> nothing attributable -> telemetry none
|
|
2766
|
+
catalogue = [
|
|
2767
|
+
{"id": "claude-remote-mcp", "name": "Claude (Remote MCP)", "category": "mcp", "bucket": "mcp"},
|
|
2768
|
+
{"id": "codex", "name": "Codex", "category": "mcp", "bucket": "mcp"},
|
|
2769
|
+
{"id": "cursor", "name": "Cursor", "category": "mcp", "bucket": "mcp"},
|
|
2770
|
+
{"id": "n8n", "name": "n8n", "category": "mcp", "bucket": "mcp"},
|
|
2771
|
+
{"id": "crewai", "name": "CrewAI", "category": "framework", "bucket": "adapter:crewai"},
|
|
2772
|
+
{"id": "langgraph", "name": "LangGraph", "category": "framework", "bucket": "adapter:langgraph"},
|
|
2773
|
+
{"id": "llamaindex", "name": "LlamaIndex", "category": "framework", "bucket": "adapter:llamaindex"},
|
|
2774
|
+
{"id": "autogen", "name": "AutoGen", "category": "framework", "bucket": "adapter:autogen"},
|
|
2775
|
+
{"id": "chrome-extension", "name": "Chrome Extension", "category": "browser", "bucket": "ext"},
|
|
2776
|
+
{"id": "api-key", "name": "API Key", "category": "api", "bucket": "api"},
|
|
2777
|
+
]
|
|
2778
|
+
|
|
2779
|
+
# --- Source 1: L1 writes grouped by source (real attribution). ------
|
|
2780
|
+
by_source: dict[str, dict] = {}
|
|
2781
|
+
recent_l1_writes: list[dict] = []
|
|
2782
|
+
total_memories: Optional[int] = None
|
|
2783
|
+
try:
|
|
2784
|
+
rows = await memory_service.l1.writes_by_source(since_days=7)
|
|
2785
|
+
by_source = {r["source"]: r for r in rows}
|
|
2786
|
+
except Exception as e:
|
|
2787
|
+
logger.warning("activity: l1 writes_by_source failed: %s", e)
|
|
2788
|
+
try:
|
|
2789
|
+
recent_l1_writes = await memory_service.l1.recent_writes(limit=20)
|
|
2790
|
+
except Exception as e:
|
|
2791
|
+
logger.warning("activity: l1 recent_writes failed: %s", e)
|
|
2792
|
+
try:
|
|
2793
|
+
total_memories = await memory_service.l1.count_all()
|
|
2794
|
+
except Exception as e:
|
|
2795
|
+
logger.warning("activity: l1 count_all failed: %s", e)
|
|
2796
|
+
|
|
2797
|
+
# --- Source 2: chrome-extension Redis analytics. --------------------
|
|
2798
|
+
ext_analytics: dict = {}
|
|
2799
|
+
try:
|
|
2800
|
+
ext_analytics = await _metrics.get_extension_analytics()
|
|
2801
|
+
except Exception as e:
|
|
2802
|
+
logger.warning("activity: extension analytics failed: %s", e)
|
|
2803
|
+
|
|
2804
|
+
# --- Source 3: api-key usage from api_credentials. -----------------
|
|
2805
|
+
api_activity: dict = {}
|
|
2806
|
+
try:
|
|
2807
|
+
api_activity = await _integration_store.api_key_activity(since_days=7)
|
|
2808
|
+
except Exception as e:
|
|
2809
|
+
logger.warning("activity: api_key_activity failed: %s", e)
|
|
2810
|
+
|
|
2811
|
+
# --- Source 4: aggregate usage + active counts (Redis metrics). -----
|
|
2812
|
+
agg: dict = {}
|
|
2813
|
+
active_now = 0
|
|
2814
|
+
dau = 0
|
|
2815
|
+
try:
|
|
2816
|
+
agg = await _metrics.get_aggregate_metrics()
|
|
2817
|
+
except Exception as e:
|
|
2818
|
+
logger.warning("activity: aggregate metrics failed: %s", e)
|
|
2819
|
+
try:
|
|
2820
|
+
active_now = await _metrics.get_active_now(window_seconds=86400)
|
|
2821
|
+
except Exception:
|
|
2822
|
+
active_now = 0
|
|
2823
|
+
try:
|
|
2824
|
+
dau = await _metrics.get_dau()
|
|
2825
|
+
except Exception:
|
|
2826
|
+
dau = 0
|
|
2827
|
+
active_7d = 0
|
|
2828
|
+
try:
|
|
2829
|
+
from datetime import timedelta as _td
|
|
2830
|
+
from clsplusplus.metrics import _redis_client as _mredis
|
|
2831
|
+
client = _mredis(settings.redis_url)
|
|
2832
|
+
seen = set()
|
|
2833
|
+
for d in range(7):
|
|
2834
|
+
day = (now - _td(days=d)).strftime("%Y-%m-%d")
|
|
2835
|
+
try:
|
|
2836
|
+
members = await client.smembers(f"cls:dau:global:{day}")
|
|
2837
|
+
seen.update(members or [])
|
|
2838
|
+
except Exception:
|
|
2839
|
+
continue
|
|
2840
|
+
active_7d = len(seen)
|
|
2841
|
+
except Exception:
|
|
2842
|
+
active_7d = 0
|
|
2843
|
+
|
|
2844
|
+
# --- Source 5: api error rate (health metrics, last 60m). ----------
|
|
2845
|
+
api_error_rate_60m = 0.0
|
|
2846
|
+
try:
|
|
2847
|
+
from clsplusplus.health_metrics import aggregate_health
|
|
2848
|
+
health = await aggregate_health(settings.redis_url, 60)
|
|
2849
|
+
api_error_rate_60m = float(health.get("error_rate") or 0.0)
|
|
2850
|
+
except Exception as e:
|
|
2851
|
+
logger.warning("activity: health aggregate failed: %s", e)
|
|
2852
|
+
|
|
2853
|
+
# --- Source 6: login activity (new instrumentation). ---------------
|
|
2854
|
+
logins_24h = 0
|
|
2855
|
+
recent_logins: list[dict] = []
|
|
2856
|
+
try:
|
|
2857
|
+
from clsplusplus.login_activity import logins_since, recent_logins as _recent_logins
|
|
2858
|
+
logins_24h = await logins_since(settings, 86400)
|
|
2859
|
+
recent_logins = await _recent_logins(settings, limit=20)
|
|
2860
|
+
except Exception as e:
|
|
2861
|
+
logger.warning("activity: login activity failed: %s", e)
|
|
2862
|
+
|
|
2863
|
+
# --- Build per-integration rows honestly. --------------------------
|
|
2864
|
+
# The shared "mcp" bucket covers all four MCP clients at once, so its
|
|
2865
|
+
# write volume cannot be split between them — mark each "partial" and
|
|
2866
|
+
# surface the SHARED bucket totals (clearly partial, not per-client).
|
|
2867
|
+
mcp_bucket = by_source.get("mcp")
|
|
2868
|
+
|
|
2869
|
+
def _health_from(writes, reads, last_at) -> str:
|
|
2870
|
+
if writes is None and reads is None:
|
|
2871
|
+
return "no_data"
|
|
2872
|
+
if (writes or 0) > 0 or (reads or 0) > 0:
|
|
2873
|
+
return "healthy"
|
|
2874
|
+
return "degraded"
|
|
2875
|
+
|
|
2876
|
+
integrations_out = []
|
|
2877
|
+
for c in catalogue:
|
|
2878
|
+
bucket = c["bucket"]
|
|
2879
|
+
telemetry = "none"
|
|
2880
|
+
unique_users = writes = reads = None
|
|
2881
|
+
last_activity = None
|
|
2882
|
+
error_rate = None
|
|
2883
|
+
|
|
2884
|
+
if bucket and bucket.startswith("adapter:"):
|
|
2885
|
+
src = bucket.split(":", 1)[1]
|
|
2886
|
+
row = by_source.get(src)
|
|
2887
|
+
if row:
|
|
2888
|
+
telemetry = "tracked"
|
|
2889
|
+
writes = row["writes"]
|
|
2890
|
+
unique_users = row["unique_namespaces"]
|
|
2891
|
+
reads = None # reads are not attributed per-source in L1
|
|
2892
|
+
last_activity = _iso(row["last_at"])
|
|
2893
|
+
else:
|
|
2894
|
+
telemetry = "tracked" # we DO track this source; just no rows
|
|
2895
|
+
elif bucket == "mcp":
|
|
2896
|
+
# Shared bucket — real volume, but not separable per client.
|
|
2897
|
+
telemetry = "partial"
|
|
2898
|
+
if mcp_bucket:
|
|
2899
|
+
writes = mcp_bucket["writes"]
|
|
2900
|
+
unique_users = mcp_bucket["unique_namespaces"]
|
|
2901
|
+
last_activity = _iso(mcp_bucket["last_at"])
|
|
2902
|
+
elif bucket == "ext":
|
|
2903
|
+
telemetry = "tracked"
|
|
2904
|
+
wau = ext_analytics.get("wau")
|
|
2905
|
+
msgs = ext_analytics.get("messages_captured") or {}
|
|
2906
|
+
if ext_analytics:
|
|
2907
|
+
unique_users = int(wau) if wau is not None else None
|
|
2908
|
+
# Captured messages are the extension's real write signal.
|
|
2909
|
+
writes = sum(int(v) for v in msgs.values()) if msgs else 0
|
|
2910
|
+
sites = ext_analytics.get("site_usage") or {}
|
|
2911
|
+
reads = sum(int(v) for v in sites.values()) if sites else 0
|
|
2912
|
+
elif bucket == "api":
|
|
2913
|
+
telemetry = "tracked"
|
|
2914
|
+
if api_activity:
|
|
2915
|
+
unique_users = api_activity.get("active_integrations")
|
|
2916
|
+
last_activity = _iso(api_activity.get("last_used_at"))
|
|
2917
|
+
|
|
2918
|
+
health = _health_from(writes, reads, last_activity)
|
|
2919
|
+
if telemetry == "none":
|
|
2920
|
+
health = "no_data"
|
|
2921
|
+
|
|
2922
|
+
integrations_out.append({
|
|
2923
|
+
"id": c["id"],
|
|
2924
|
+
"name": c["name"],
|
|
2925
|
+
"category": c["category"],
|
|
2926
|
+
"telemetry": telemetry,
|
|
2927
|
+
"unique_users_7d": unique_users,
|
|
2928
|
+
"writes_7d": writes,
|
|
2929
|
+
"reads_7d": reads,
|
|
2930
|
+
"last_activity": last_activity,
|
|
2931
|
+
"health": health,
|
|
2932
|
+
"error_rate": error_rate,
|
|
2933
|
+
})
|
|
2934
|
+
|
|
2935
|
+
# --- Totals. -------------------------------------------------------
|
|
2936
|
+
totals = {
|
|
2937
|
+
"active_users_24h": int(active_now or dau or 0),
|
|
2938
|
+
"active_users_7d": int(active_7d or 0),
|
|
2939
|
+
"logins_24h": int(logins_24h or 0),
|
|
2940
|
+
"total_memories": int(total_memories) if total_memories is not None else 0,
|
|
2941
|
+
"writes_24h": int(agg.get("write", 0) or 0),
|
|
2942
|
+
"reads_24h": int(agg.get("read", 0) or 0),
|
|
2943
|
+
"api_error_rate_60m": round(float(api_error_rate_60m), 2),
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
# --- Issues: feedback + frustrated pulse (+ best-effort github). ----
|
|
2947
|
+
issues_recent: list[dict] = []
|
|
2948
|
+
try:
|
|
2949
|
+
fb_rows = await user_service.store.get_recent_feedback(limit=15)
|
|
2950
|
+
for f in fb_rows:
|
|
2951
|
+
score = f.get("score") or 0
|
|
2952
|
+
sentiment = f.get("sentiment")
|
|
2953
|
+
if sentiment == "down" or score <= 2:
|
|
2954
|
+
severity = "high"
|
|
2955
|
+
elif score == 3:
|
|
2956
|
+
severity = "med"
|
|
2957
|
+
else:
|
|
2958
|
+
severity = "low"
|
|
2959
|
+
issues_recent.append({
|
|
2960
|
+
"kind": "feedback",
|
|
2961
|
+
"title": (f.get("comment") or f.get("context") or "Feedback").strip()[:120],
|
|
2962
|
+
"who": f.get("email"),
|
|
2963
|
+
"severity": severity,
|
|
2964
|
+
"at": f.get("created_at"),
|
|
2965
|
+
"url": None,
|
|
2966
|
+
})
|
|
2967
|
+
except Exception as e:
|
|
2968
|
+
logger.warning("activity: recent feedback failed: %s", e)
|
|
2969
|
+
|
|
2970
|
+
try:
|
|
2971
|
+
from clsplusplus.user_pulse import list_frustrated_users
|
|
2972
|
+
frustrated = await list_frustrated_users(settings, limit=10)
|
|
2973
|
+
for p in frustrated:
|
|
2974
|
+
issues_recent.append({
|
|
2975
|
+
"kind": "pulse",
|
|
2976
|
+
"title": "User frustration detected",
|
|
2977
|
+
"who": p.get("user_id"),
|
|
2978
|
+
"severity": "high" if p.get("score", 0) >= 75 else "med",
|
|
2979
|
+
"at": _iso(now),
|
|
2980
|
+
"url": None,
|
|
2981
|
+
})
|
|
2982
|
+
except Exception as e:
|
|
2983
|
+
logger.warning("activity: pulse failed: %s", e)
|
|
2984
|
+
|
|
2985
|
+
# Best-effort open GitHub bug count (skip silently when no token).
|
|
2986
|
+
open_issues = len([i for i in issues_recent if i["severity"] == "high"])
|
|
2987
|
+
try:
|
|
2988
|
+
gh_token = getattr(settings, "github_issue_token", "")
|
|
2989
|
+
gh_repo = getattr(settings, "github_issue_repo", "")
|
|
2990
|
+
if gh_token and gh_repo:
|
|
2991
|
+
import httpx as _httpx
|
|
2992
|
+
async with _httpx.AsyncClient(timeout=5.0) as _c:
|
|
2993
|
+
r = await _c.get(
|
|
2994
|
+
f"https://api.github.com/repos/{gh_repo}/issues",
|
|
2995
|
+
params={"state": "open", "labels": "bug", "per_page": 5},
|
|
2996
|
+
headers={
|
|
2997
|
+
"Authorization": f"Bearer {gh_token}",
|
|
2998
|
+
"Accept": "application/vnd.github+json",
|
|
2999
|
+
},
|
|
3000
|
+
)
|
|
3001
|
+
if r.status_code == 200:
|
|
3002
|
+
for gi in r.json()[:5]:
|
|
3003
|
+
issues_recent.append({
|
|
3004
|
+
"kind": "github",
|
|
3005
|
+
"title": (gi.get("title") or "")[:120],
|
|
3006
|
+
"who": (gi.get("user") or {}).get("login"),
|
|
3007
|
+
"severity": "high",
|
|
3008
|
+
"at": gi.get("created_at"),
|
|
3009
|
+
"url": gi.get("html_url"),
|
|
3010
|
+
})
|
|
3011
|
+
except Exception as e:
|
|
3012
|
+
logger.warning("activity: github issues probe failed: %s", e)
|
|
3013
|
+
|
|
3014
|
+
issues_recent = issues_recent[:15]
|
|
3015
|
+
issues = {"open": open_issues, "recent": issues_recent}
|
|
3016
|
+
|
|
3017
|
+
# --- Activity feed: recent logins + recent memory writes. ----------
|
|
3018
|
+
activity: list[tuple] = []
|
|
3019
|
+
for lg in recent_logins:
|
|
3020
|
+
activity.append((
|
|
3021
|
+
lg.get("ts", 0),
|
|
3022
|
+
{
|
|
3023
|
+
"at": lg.get("at"),
|
|
3024
|
+
"user": lg.get("email") or lg.get("user_id"),
|
|
3025
|
+
"integration": None,
|
|
3026
|
+
"action": "login",
|
|
3027
|
+
},
|
|
3028
|
+
))
|
|
3029
|
+
for w in recent_l1_writes:
|
|
3030
|
+
at_iso = _iso(w.get("at"))
|
|
3031
|
+
ts = 0
|
|
3032
|
+
try:
|
|
3033
|
+
ts = int(w["at"].timestamp()) if w.get("at") else 0
|
|
3034
|
+
except Exception:
|
|
3035
|
+
ts = 0
|
|
3036
|
+
src = w.get("source")
|
|
3037
|
+
integ = src if src and src != "user" else None
|
|
3038
|
+
activity.append((
|
|
3039
|
+
ts,
|
|
3040
|
+
{
|
|
3041
|
+
"at": at_iso,
|
|
3042
|
+
"user": w.get("namespace"),
|
|
3043
|
+
"integration": integ,
|
|
3044
|
+
"action": "write",
|
|
3045
|
+
},
|
|
3046
|
+
))
|
|
3047
|
+
activity.sort(key=lambda t: t[0], reverse=True)
|
|
3048
|
+
activity_out = [a[1] for a in activity[:20]]
|
|
3049
|
+
|
|
3050
|
+
return {
|
|
3051
|
+
"generated_at": _iso(now),
|
|
3052
|
+
"window": "7d",
|
|
3053
|
+
"totals": totals,
|
|
3054
|
+
"integrations": integrations_out,
|
|
3055
|
+
"issues": issues,
|
|
3056
|
+
"activity": activity_out,
|
|
3057
|
+
}
|
|
3058
|
+
|
|
2690
3059
|
def _require_admin(request: Request):
|
|
2691
3060
|
"""Helper to enforce admin access on endpoints."""
|
|
2692
3061
|
if not getattr(request.state, "is_admin", False):
|
|
@@ -4017,6 +4386,17 @@ def create_app(settings: Optional[Settings] = None) -> FastAPI:
|
|
|
4017
4386
|
# ── 1. Warm up all persisted namespaces from L1 ────────────────────
|
|
4018
4387
|
await memory_service.startup_preload()
|
|
4019
4388
|
|
|
4389
|
+
# ── 1b. Start the durable WAL (file fsync anchor + Postgres/Redis ──
|
|
4390
|
+
# replicas). With it active, writes ack only after fsync and are
|
|
4391
|
+
# replicated off the hot path; on restart, replay re-ships any
|
|
4392
|
+
# records past the durable checkpoint. No-op if CLS_WAL_ENABLED
|
|
4393
|
+
# is false. Redis client attaches when available.
|
|
4394
|
+
try:
|
|
4395
|
+
_wal_redis = getattr(memory_service, "_redis_client", None) or getattr(app.state, "redis", None)
|
|
4396
|
+
memory_service.init_durable_log(redis_client=_wal_redis)
|
|
4397
|
+
except Exception as e:
|
|
4398
|
+
_api_logger.error("Durable WAL startup failed: %s", e)
|
|
4399
|
+
|
|
4020
4400
|
# ── 2. Periodic hippocampal replay (consolidation / sleep cycle) ───
|
|
4021
4401
|
async def _periodic_replay():
|
|
4022
4402
|
while True:
|
|
@@ -150,6 +150,19 @@ class Settings(BaseSettings):
|
|
|
150
150
|
# current write behavior is unchanged.
|
|
151
151
|
belief_revision_on_write: bool = False # CLS_BELIEF_REVISION_ON_WRITE
|
|
152
152
|
|
|
153
|
+
# ---- Durable write-ahead log (WAL) --------------------------------------
|
|
154
|
+
# Durability contract: a write is acked ONLY after it is fsync'd to the
|
|
155
|
+
# local WAL. Redis + Postgres are async replicas fed from the WAL; if either
|
|
156
|
+
# is unreachable the record is already durable on disk and the background
|
|
157
|
+
# reconciler retries until both confirm, then the record is checkpointed.
|
|
158
|
+
wal_enabled: bool = True # CLS_WAL_ENABLED
|
|
159
|
+
wal_dir: str = "./cls_wal" # CLS_WAL_DIR (mount a persistent disk here in prod)
|
|
160
|
+
wal_fsync: str = "always" # CLS_WAL_FSYNC: "always" (zero-loss) | "everysec"
|
|
161
|
+
wal_segment_max_bytes: int = 64 * 1024 * 1024 # CLS_WAL_SEGMENT_MAX_BYTES (rotate at 64MB)
|
|
162
|
+
wal_redis_stream: str = "cls:wal" # CLS_WAL_REDIS_STREAM
|
|
163
|
+
wal_reconcile_interval_seconds: float = 1.0 # CLS_WAL_RECONCILE_INTERVAL_SECONDS
|
|
164
|
+
wal_reconcile_batch: int = 256 # CLS_WAL_RECONCILE_BATCH
|
|
165
|
+
|
|
153
166
|
# Working buffer
|
|
154
167
|
l0_capacity_tokens: int = 4096
|
|
155
168
|
l0_ttl_seconds: int = 300
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Durable write-ahead log for CLS++.
|
|
2
|
+
|
|
3
|
+
Durability contract: a memory write is acknowledged ONLY after its record has
|
|
4
|
+
been fsync'd to the local WAL. Redis and Postgres are asynchronous replicas fed
|
|
5
|
+
from the WAL — if either is unreachable the record is already durable on disk and
|
|
6
|
+
the background reconciler retries replication until both confirm, after which the
|
|
7
|
+
record is checkpointed and its segment can be truncated.
|
|
8
|
+
|
|
9
|
+
Components:
|
|
10
|
+
- WalRecord the serialized unit of durability (length-prefixed + CRC32)
|
|
11
|
+
- FileWal append + fsync, segmented, crash-safe replay, checkpoint
|
|
12
|
+
- Replica (protocol) an async sink (Redis stream, Postgres outbox) fed from the WAL
|
|
13
|
+
- DurableLog orchestrator: hot-path append/ack + concurrent replication
|
|
14
|
+
+ background reconciler + startup replay
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from clsplusplus.durable_log.record import WalRecord
|
|
18
|
+
from clsplusplus.durable_log.file_wal import FileWal
|
|
19
|
+
from clsplusplus.durable_log.manager import DurableLog, Replica, ReplicaState
|
|
20
|
+
|
|
21
|
+
__all__ = ["WalRecord", "FileWal", "DurableLog", "Replica", "ReplicaState"]
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Local file write-ahead log — the durability anchor.
|
|
2
|
+
|
|
3
|
+
A write is durable the instant ``append()`` returns with ``fsync="always"``:
|
|
4
|
+
the framed record is written and ``os.fsync`` forces it to stable storage before
|
|
5
|
+
we ack the caller. Segments rotate at a size cap; a monotonic ``seq`` fixes apply
|
|
6
|
+
order; a checkpoint records the highest seq confirmed by all replicas so fully
|
|
7
|
+
drained segments can be deleted.
|
|
8
|
+
|
|
9
|
+
This file knows nothing about Redis or Postgres — it is the on-disk source of
|
|
10
|
+
truth they replicate from.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from typing import Iterator, Optional
|
|
19
|
+
|
|
20
|
+
from clsplusplus.durable_log.record import WalRecord, iter_frames
|
|
21
|
+
|
|
22
|
+
_SEG_PREFIX = "wal-"
|
|
23
|
+
_SEG_SUFFIX = ".log"
|
|
24
|
+
_CHECKPOINT = "checkpoint"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _seg_name(start_seq: int) -> str:
|
|
28
|
+
return f"{_SEG_PREFIX}{start_seq:020d}{_SEG_SUFFIX}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _seg_start(name: str) -> int:
|
|
32
|
+
return int(name[len(_SEG_PREFIX): -len(_SEG_SUFFIX)])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FileWal:
|
|
36
|
+
def __init__(self, directory: str, *, fsync: str = "always",
|
|
37
|
+
segment_max_bytes: int = 64 * 1024 * 1024):
|
|
38
|
+
self.dir = directory
|
|
39
|
+
self.fsync_mode = fsync
|
|
40
|
+
self.segment_max_bytes = segment_max_bytes
|
|
41
|
+
self._lock = threading.Lock()
|
|
42
|
+
self._fh = None # current segment file handle (ab)
|
|
43
|
+
self._cur_start = 0 # start seq of current segment
|
|
44
|
+
self._cur_bytes = 0
|
|
45
|
+
self._next_seq = 1
|
|
46
|
+
self._last_fsync = 0.0
|
|
47
|
+
os.makedirs(self.dir, exist_ok=True)
|
|
48
|
+
self._open_tail()
|
|
49
|
+
|
|
50
|
+
# -- lifecycle ----------------------------------------------------------
|
|
51
|
+
def _segments(self) -> list[str]:
|
|
52
|
+
names = [f for f in os.listdir(self.dir)
|
|
53
|
+
if f.startswith(_SEG_PREFIX) and f.endswith(_SEG_SUFFIX)]
|
|
54
|
+
return sorted(names, key=_seg_start)
|
|
55
|
+
|
|
56
|
+
def _open_tail(self) -> None:
|
|
57
|
+
segs = self._segments()
|
|
58
|
+
if not segs:
|
|
59
|
+
self._cur_start = 1
|
|
60
|
+
self._next_seq = 1
|
|
61
|
+
path = os.path.join(self.dir, _seg_name(self._cur_start))
|
|
62
|
+
self._fh = open(path, "ab", buffering=0)
|
|
63
|
+
self._cur_bytes = 0
|
|
64
|
+
return
|
|
65
|
+
# Resume the last segment; recover next_seq by scanning intact frames.
|
|
66
|
+
last = segs[-1]
|
|
67
|
+
self._cur_start = _seg_start(last)
|
|
68
|
+
path = os.path.join(self.dir, last)
|
|
69
|
+
with open(path, "rb") as f:
|
|
70
|
+
buf = f.read()
|
|
71
|
+
max_seq = self._cur_start - 1
|
|
72
|
+
good_bytes = 0
|
|
73
|
+
for rec, size in iter_frames(buf):
|
|
74
|
+
max_seq = rec.seq
|
|
75
|
+
good_bytes += size
|
|
76
|
+
# Truncate any torn tail so future appends are frame-aligned.
|
|
77
|
+
if good_bytes != len(buf):
|
|
78
|
+
with open(path, "r+b") as f:
|
|
79
|
+
f.truncate(good_bytes)
|
|
80
|
+
self._next_seq = max_seq + 1
|
|
81
|
+
self._cur_bytes = good_bytes
|
|
82
|
+
self._fh = open(path, "ab", buffering=0)
|
|
83
|
+
|
|
84
|
+
def close(self) -> None:
|
|
85
|
+
with self._lock:
|
|
86
|
+
if self._fh is not None:
|
|
87
|
+
self._fh.flush()
|
|
88
|
+
os.fsync(self._fh.fileno())
|
|
89
|
+
self._fh.close()
|
|
90
|
+
self._fh = None
|
|
91
|
+
|
|
92
|
+
# -- append (the ack gate) ---------------------------------------------
|
|
93
|
+
def append(self, record: WalRecord) -> WalRecord:
|
|
94
|
+
"""Assign seq, frame, write, and fsync. Returns the stamped record.
|
|
95
|
+
|
|
96
|
+
When this returns under fsync="always", the record is on stable storage.
|
|
97
|
+
Blocking; callers on the event loop should use ``asyncio.to_thread``.
|
|
98
|
+
"""
|
|
99
|
+
with self._lock:
|
|
100
|
+
record.seq = self._next_seq
|
|
101
|
+
frame = record.encode()
|
|
102
|
+
if self._cur_bytes and self._cur_bytes + len(frame) > self.segment_max_bytes:
|
|
103
|
+
self._rotate()
|
|
104
|
+
self._fh.write(frame)
|
|
105
|
+
self._cur_bytes += len(frame)
|
|
106
|
+
self._next_seq += 1
|
|
107
|
+
self._maybe_fsync()
|
|
108
|
+
return record
|
|
109
|
+
|
|
110
|
+
def _rotate(self) -> None:
|
|
111
|
+
self._fh.flush()
|
|
112
|
+
os.fsync(self._fh.fileno())
|
|
113
|
+
self._fh.close()
|
|
114
|
+
self._cur_start = self._next_seq
|
|
115
|
+
path = os.path.join(self.dir, _seg_name(self._cur_start))
|
|
116
|
+
self._fh = open(path, "ab", buffering=0)
|
|
117
|
+
self._cur_bytes = 0
|
|
118
|
+
|
|
119
|
+
def _maybe_fsync(self) -> None:
|
|
120
|
+
if self.fsync_mode == "always":
|
|
121
|
+
self._fh.flush()
|
|
122
|
+
os.fsync(self._fh.fileno())
|
|
123
|
+
return
|
|
124
|
+
# "everysec": flush now, fsync at most once per second.
|
|
125
|
+
self._fh.flush()
|
|
126
|
+
now = time.time()
|
|
127
|
+
if now - self._last_fsync >= 1.0:
|
|
128
|
+
os.fsync(self._fh.fileno())
|
|
129
|
+
self._last_fsync = now
|
|
130
|
+
|
|
131
|
+
# -- replay -------------------------------------------------------------
|
|
132
|
+
def read_from(self, after_seq: int = 0) -> Iterator[WalRecord]:
|
|
133
|
+
"""Yield every intact record with seq > after_seq, in seq order."""
|
|
134
|
+
for name in self._segments():
|
|
135
|
+
path = os.path.join(self.dir, name)
|
|
136
|
+
with open(path, "rb") as f:
|
|
137
|
+
buf = f.read()
|
|
138
|
+
for rec, _ in iter_frames(buf):
|
|
139
|
+
if rec.seq > after_seq:
|
|
140
|
+
yield rec
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def next_seq(self) -> int:
|
|
144
|
+
return self._next_seq
|
|
145
|
+
|
|
146
|
+
# -- checkpoint / truncation -------------------------------------------
|
|
147
|
+
def read_checkpoint(self) -> int:
|
|
148
|
+
path = os.path.join(self.dir, _CHECKPOINT)
|
|
149
|
+
try:
|
|
150
|
+
with open(path, "r") as f:
|
|
151
|
+
return int(f.read().strip() or "0")
|
|
152
|
+
except (FileNotFoundError, ValueError):
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
def checkpoint(self, confirmed_seq: int) -> None:
|
|
156
|
+
"""Persist the highest seq confirmed by ALL replicas, then delete any
|
|
157
|
+
segment whose records are entirely <= confirmed_seq (keep the tail).
|
|
158
|
+
"""
|
|
159
|
+
with self._lock:
|
|
160
|
+
path = os.path.join(self.dir, _CHECKPOINT)
|
|
161
|
+
tmp = path + ".tmp"
|
|
162
|
+
with open(tmp, "w") as f:
|
|
163
|
+
f.write(str(confirmed_seq))
|
|
164
|
+
f.flush()
|
|
165
|
+
os.fsync(f.fileno())
|
|
166
|
+
os.replace(tmp, path)
|
|
167
|
+
self._truncate_below(confirmed_seq)
|
|
168
|
+
|
|
169
|
+
def _truncate_below(self, confirmed_seq: int) -> None:
|
|
170
|
+
segs = self._segments()
|
|
171
|
+
# A segment is fully drained if the NEXT segment starts at or below
|
|
172
|
+
# confirmed_seq+1. Never delete the current (tail) segment.
|
|
173
|
+
for i in range(len(segs) - 1):
|
|
174
|
+
nxt_start = _seg_start(segs[i + 1])
|
|
175
|
+
if nxt_start <= confirmed_seq + 1:
|
|
176
|
+
try:
|
|
177
|
+
os.remove(os.path.join(self.dir, segs[i]))
|
|
178
|
+
except FileNotFoundError:
|
|
179
|
+
pass
|
|
180
|
+
else:
|
|
181
|
+
break
|