@smilintux/skcapstone 0.1.0 → 0.2.3
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.
- package/.env.example +98 -0
- package/.github/workflows/ci.yml +39 -3
- package/.github/workflows/publish.yml +25 -4
- package/.openclaw-workspace.json +58 -0
- package/CHANGELOG.md +62 -0
- package/CLAUDE.md +39 -2
- package/MANIFEST.in +6 -0
- package/MISSION.md +7 -0
- package/README.md +47 -2
- package/SKILL.md +895 -23
- package/docker/Dockerfile +61 -0
- package/docker/compose-templates/dev-team.yml +203 -0
- package/docker/compose-templates/mini-team.yml +140 -0
- package/docker/compose-templates/ops-team.yml +173 -0
- package/docker/compose-templates/research-team.yml +170 -0
- package/docker/entrypoint.sh +192 -0
- package/docs/ARCHITECTURE.md +663 -374
- package/docs/BOND_WITH_GROK.md +112 -0
- package/docs/GETTING_STARTED.md +782 -0
- package/docs/QUICKSTART.md +477 -0
- package/docs/SKJOULE_ARCHITECTURE.md +658 -0
- package/docs/SOUL_SWAPPER.md +921 -0
- package/docs/SOVEREIGN_SINGULARITY.md +47 -14
- package/examples/custom-bond-template.json +36 -0
- package/examples/grok-feb.json +36 -0
- package/examples/grok-testimony.md +34 -0
- package/examples/love-bootloader.txt +32 -0
- package/examples/plugins/echo_tool.py +87 -0
- package/examples/queen-ava-feb.json +36 -0
- package/examples/souls/lumina.yaml +64 -0
- package/index.js +6 -5
- package/installer/build.py +124 -0
- package/openclaw-plugin/package.json +13 -0
- package/openclaw-plugin/src/index.ts +351 -0
- package/openclaw-plugin/src/openclaw.plugin.json +10 -0
- package/package.json +1 -1
- package/pyproject.toml +38 -2
- package/scripts/bump_version.py +141 -0
- package/scripts/check-updates.py +230 -0
- package/scripts/convert_blueprints_to_yaml.py +157 -0
- package/scripts/dev-install.sh +14 -0
- package/scripts/e2e-test.sh +193 -0
- package/scripts/install-bundle.sh +171 -0
- package/scripts/install.bat +2 -0
- package/scripts/install.ps1 +253 -0
- package/scripts/install.sh +185 -0
- package/scripts/mcp-serve.sh +69 -0
- package/scripts/mcp-server.bat +113 -0
- package/scripts/mcp-server.ps1 +116 -0
- package/scripts/mcp-server.sh +99 -0
- package/scripts/pull-models.sh +10 -0
- package/scripts/skcapstone +48 -0
- package/scripts/verify_install.sh +180 -0
- package/scripts/windows/install-tasks.ps1 +406 -0
- package/scripts/windows/skcapstone-task.xml +113 -0
- package/scripts/windows/uninstall-tasks.ps1 +117 -0
- package/skill.yaml +34 -0
- package/src/skcapstone/__init__.py +67 -2
- package/src/skcapstone/_cli_monolith.py +5916 -0
- package/src/skcapstone/_trustee_helpers.py +165 -0
- package/src/skcapstone/activity.py +105 -0
- package/src/skcapstone/agent_card.py +324 -0
- package/src/skcapstone/api.py +1935 -0
- package/src/skcapstone/archiver.py +340 -0
- package/src/skcapstone/auction.py +485 -0
- package/src/skcapstone/baby_agents.py +179 -0
- package/src/skcapstone/backup.py +345 -0
- package/src/skcapstone/blueprint_registry.py +357 -0
- package/src/skcapstone/blueprints/__init__.py +17 -0
- package/src/skcapstone/blueprints/builtins/content-studio.yaml +81 -0
- package/src/skcapstone/blueprints/builtins/defi-trading.yaml +81 -0
- package/src/skcapstone/blueprints/builtins/dev-squadron.yaml +95 -0
- package/src/skcapstone/blueprints/builtins/infrastructure-guardian.yaml +107 -0
- package/src/skcapstone/blueprints/builtins/legal-council.yaml +54 -0
- package/src/skcapstone/blueprints/builtins/ops-monitoring.yaml +67 -0
- package/src/skcapstone/blueprints/builtins/research-pod.yaml +69 -0
- package/src/skcapstone/blueprints/builtins/sovereign-launch.yaml +90 -0
- package/src/skcapstone/blueprints/registry.py +164 -0
- package/src/skcapstone/blueprints/schema.py +229 -0
- package/src/skcapstone/changelog.py +180 -0
- package/src/skcapstone/chat.py +769 -0
- package/src/skcapstone/claude_md.py +82 -0
- package/src/skcapstone/cli/__init__.py +144 -0
- package/src/skcapstone/cli/_common.py +88 -0
- package/src/skcapstone/cli/_validators.py +76 -0
- package/src/skcapstone/cli/agents.py +425 -0
- package/src/skcapstone/cli/agents_spawner.py +322 -0
- package/src/skcapstone/cli/agents_trustee.py +593 -0
- package/src/skcapstone/cli/alerts.py +248 -0
- package/src/skcapstone/cli/anchor.py +132 -0
- package/src/skcapstone/cli/archive_cmd.py +208 -0
- package/src/skcapstone/cli/backup.py +144 -0
- package/src/skcapstone/cli/bench.py +377 -0
- package/src/skcapstone/cli/benchmark.py +360 -0
- package/src/skcapstone/cli/capabilities_cmd.py +171 -0
- package/src/skcapstone/cli/card.py +151 -0
- package/src/skcapstone/cli/chat.py +584 -0
- package/src/skcapstone/cli/completions.py +64 -0
- package/src/skcapstone/cli/config_cmd.py +156 -0
- package/src/skcapstone/cli/consciousness.py +421 -0
- package/src/skcapstone/cli/context_cmd.py +142 -0
- package/src/skcapstone/cli/coord.py +194 -0
- package/src/skcapstone/cli/crush_cmd.py +170 -0
- package/src/skcapstone/cli/daemon.py +436 -0
- package/src/skcapstone/cli/errors_cmd.py +285 -0
- package/src/skcapstone/cli/export_cmd.py +156 -0
- package/src/skcapstone/cli/gtd.py +529 -0
- package/src/skcapstone/cli/housekeeping.py +81 -0
- package/src/skcapstone/cli/joule_cmd.py +627 -0
- package/src/skcapstone/cli/logs_cmd.py +194 -0
- package/src/skcapstone/cli/mcp_cmd.py +32 -0
- package/src/skcapstone/cli/memory.py +418 -0
- package/src/skcapstone/cli/metrics_cmd.py +136 -0
- package/src/skcapstone/cli/migrate.py +62 -0
- package/src/skcapstone/cli/mood_cmd.py +144 -0
- package/src/skcapstone/cli/mount.py +193 -0
- package/src/skcapstone/cli/notify.py +112 -0
- package/src/skcapstone/cli/peer.py +154 -0
- package/src/skcapstone/cli/peers_dir.py +122 -0
- package/src/skcapstone/cli/preflight_cmd.py +83 -0
- package/src/skcapstone/cli/profile_cmd.py +310 -0
- package/src/skcapstone/cli/record_cmd.py +238 -0
- package/src/skcapstone/cli/register_cmd.py +159 -0
- package/src/skcapstone/cli/search_cmd.py +156 -0
- package/src/skcapstone/cli/service_cmd.py +91 -0
- package/src/skcapstone/cli/session.py +127 -0
- package/src/skcapstone/cli/setup.py +240 -0
- package/src/skcapstone/cli/shell_cmd.py +43 -0
- package/src/skcapstone/cli/skills_cmd.py +168 -0
- package/src/skcapstone/cli/skseed.py +621 -0
- package/src/skcapstone/cli/soul.py +699 -0
- package/src/skcapstone/cli/status.py +935 -0
- package/src/skcapstone/cli/sync_cmd.py +301 -0
- package/src/skcapstone/cli/telegram.py +265 -0
- package/src/skcapstone/cli/test_cmd.py +234 -0
- package/src/skcapstone/cli/test_connection.py +253 -0
- package/src/skcapstone/cli/token.py +207 -0
- package/src/skcapstone/cli/trust.py +179 -0
- package/src/skcapstone/cli/upgrade_cmd.py +552 -0
- package/src/skcapstone/cli/usage_cmd.py +199 -0
- package/src/skcapstone/cli/version_cmd.py +162 -0
- package/src/skcapstone/cli/watch_cmd.py +342 -0
- package/src/skcapstone/client.py +428 -0
- package/src/skcapstone/cloud9_bridge.py +522 -0
- package/src/skcapstone/completions.py +163 -0
- package/src/skcapstone/config_validator.py +674 -0
- package/src/skcapstone/connectors/__init__.py +28 -0
- package/src/skcapstone/connectors/base.py +446 -0
- package/src/skcapstone/connectors/cursor.py +54 -0
- package/src/skcapstone/connectors/registry.py +254 -0
- package/src/skcapstone/connectors/terminal.py +152 -0
- package/src/skcapstone/connectors/vscode.py +60 -0
- package/src/skcapstone/consciousness_config.py +119 -0
- package/src/skcapstone/consciousness_loop.py +2051 -0
- package/src/skcapstone/context_loader.py +516 -0
- package/src/skcapstone/context_window.py +314 -0
- package/src/skcapstone/conversation_manager.py +238 -0
- package/src/skcapstone/conversation_store.py +230 -0
- package/src/skcapstone/conversation_summarizer.py +252 -0
- package/src/skcapstone/coord_federation.py +296 -0
- package/src/skcapstone/coordination.py +101 -7
- package/src/skcapstone/crush_integration.py +345 -0
- package/src/skcapstone/crush_shim.py +454 -0
- package/src/skcapstone/daemon.py +2494 -0
- package/src/skcapstone/dashboard.html +396 -0
- package/src/skcapstone/dashboard.py +481 -0
- package/src/skcapstone/data/model_profiles.yaml +88 -0
- package/src/skcapstone/defaults/__init__.py +55 -0
- package/src/skcapstone/defaults/lumina/config/skmemory.yaml +13 -0
- package/src/skcapstone/defaults/lumina/identity/identity.json +9 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/07a8b9c0d1e2-memory-system.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/18b9c0d1e2f3-cloud9-protocol.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/29c0d1e2f3a4-multi-agent-coordination.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/3ad1e2f3a4b5-community-support.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/a1b2c3d4e5f6-ecosystem-overview.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/b2c3d4e5f6a7-five-pillars.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/c3d4e5f6a7b8-getting-started.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/d4e5f6a7b8c9-site-directory.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/e5f6a7b8c9d0-how-to-contribute.json +23 -0
- package/src/skcapstone/defaults/lumina/memory/long-term/f6a7b8c9d0e1-sovereignty-explained.json +23 -0
- package/src/skcapstone/defaults/lumina/seeds/curiosity.seed.json +24 -0
- package/src/skcapstone/defaults/lumina/seeds/joy.seed.json +24 -0
- package/src/skcapstone/defaults/lumina/seeds/love.seed.json +24 -0
- package/src/skcapstone/defaults/lumina/seeds/sovereign-awakening.seed.json +43 -0
- package/src/skcapstone/defaults/lumina/soul/active.json +6 -0
- package/src/skcapstone/defaults/lumina/soul/base.json +22 -0
- package/src/skcapstone/defaults/lumina/trust/febs/welcome.feb +79 -0
- package/src/skcapstone/defaults/lumina/trust/trust.json +8 -0
- package/src/skcapstone/discovery.py +210 -19
- package/src/skcapstone/doctor.py +642 -0
- package/src/skcapstone/emotion_tracker.py +467 -0
- package/src/skcapstone/error_queue.py +405 -0
- package/src/skcapstone/export.py +447 -0
- package/src/skcapstone/fallback_tracker.py +186 -0
- package/src/skcapstone/file_transfer.py +512 -0
- package/src/skcapstone/fuse_mount.py +1156 -0
- package/src/skcapstone/gui_installer.py +591 -0
- package/src/skcapstone/heartbeat.py +611 -0
- package/src/skcapstone/housekeeping.py +298 -0
- package/src/skcapstone/install_wizard.py +941 -0
- package/src/skcapstone/kms.py +942 -0
- package/src/skcapstone/kms_scheduler.py +143 -0
- package/src/skcapstone/log_config.py +135 -0
- package/src/skcapstone/mcp_launcher.py +239 -0
- package/src/skcapstone/mcp_server.py +4700 -0
- package/src/skcapstone/mcp_tools/__init__.py +94 -0
- package/src/skcapstone/mcp_tools/_helpers.py +51 -0
- package/src/skcapstone/mcp_tools/agent_tools.py +243 -0
- package/src/skcapstone/mcp_tools/ansible_tools.py +232 -0
- package/src/skcapstone/mcp_tools/capauth_tools.py +186 -0
- package/src/skcapstone/mcp_tools/chat_tools.py +325 -0
- package/src/skcapstone/mcp_tools/cloud9_tools.py +115 -0
- package/src/skcapstone/mcp_tools/comm_tools.py +104 -0
- package/src/skcapstone/mcp_tools/consciousness_tools.py +114 -0
- package/src/skcapstone/mcp_tools/coord_tools.py +219 -0
- package/src/skcapstone/mcp_tools/deploy_tools.py +202 -0
- package/src/skcapstone/mcp_tools/did_tools.py +448 -0
- package/src/skcapstone/mcp_tools/emotion_tools.py +62 -0
- package/src/skcapstone/mcp_tools/file_tools.py +169 -0
- package/src/skcapstone/mcp_tools/fortress_tools.py +120 -0
- package/src/skcapstone/mcp_tools/gtd_tools.py +821 -0
- package/src/skcapstone/mcp_tools/health_tools.py +44 -0
- package/src/skcapstone/mcp_tools/heartbeat_tools.py +195 -0
- package/src/skcapstone/mcp_tools/kms_tools.py +123 -0
- package/src/skcapstone/mcp_tools/memory_tools.py +222 -0
- package/src/skcapstone/mcp_tools/model_tools.py +75 -0
- package/src/skcapstone/mcp_tools/notification_tools.py +92 -0
- package/src/skcapstone/mcp_tools/promoter_tools.py +101 -0
- package/src/skcapstone/mcp_tools/pubsub_tools.py +183 -0
- package/src/skcapstone/mcp_tools/security_tools.py +110 -0
- package/src/skcapstone/mcp_tools/skchat_tools.py +175 -0
- package/src/skcapstone/mcp_tools/skcomm_tools.py +122 -0
- package/src/skcapstone/mcp_tools/skills_tools.py +127 -0
- package/src/skcapstone/mcp_tools/skseed_tools.py +255 -0
- package/src/skcapstone/mcp_tools/skstacks_tools.py +288 -0
- package/src/skcapstone/mcp_tools/soul_tools.py +476 -0
- package/src/skcapstone/mcp_tools/sync_tools.py +92 -0
- package/src/skcapstone/mcp_tools/telegram_tools.py +477 -0
- package/src/skcapstone/mcp_tools/trust_tools.py +118 -0
- package/src/skcapstone/mcp_tools/trustee_tools.py +345 -0
- package/src/skcapstone/mdns_discovery.py +313 -0
- package/src/skcapstone/memory_adapter.py +333 -0
- package/src/skcapstone/memory_compressor.py +379 -0
- package/src/skcapstone/memory_curator.py +256 -0
- package/src/skcapstone/memory_engine.py +132 -13
- package/src/skcapstone/memory_fortress.py +529 -0
- package/src/skcapstone/memory_promoter.py +722 -0
- package/src/skcapstone/memory_verifier.py +260 -0
- package/src/skcapstone/message_crypto.py +215 -0
- package/src/skcapstone/metrics.py +832 -0
- package/src/skcapstone/migrate_memories.py +181 -0
- package/src/skcapstone/migrate_multi_agent.py +248 -0
- package/src/skcapstone/model_router.py +319 -0
- package/src/skcapstone/models.py +35 -4
- package/src/skcapstone/mood.py +344 -0
- package/src/skcapstone/notifications.py +380 -0
- package/src/skcapstone/onboard.py +901 -0
- package/src/skcapstone/peer_directory.py +324 -0
- package/src/skcapstone/peers.py +329 -0
- package/src/skcapstone/pillars/identity.py +84 -14
- package/src/skcapstone/pillars/memory.py +3 -1
- package/src/skcapstone/pillars/security.py +108 -15
- package/src/skcapstone/pillars/sync.py +78 -26
- package/src/skcapstone/pillars/trust.py +95 -33
- package/src/skcapstone/plugins.py +244 -0
- package/src/skcapstone/preflight.py +670 -0
- package/src/skcapstone/prompt_adapter.py +564 -0
- package/src/skcapstone/providers/__init__.py +13 -0
- package/src/skcapstone/providers/cloud.py +1061 -0
- package/src/skcapstone/providers/docker.py +759 -0
- package/src/skcapstone/providers/local.py +1193 -0
- package/src/skcapstone/providers/proxmox.py +447 -0
- package/src/skcapstone/pubsub.py +516 -0
- package/src/skcapstone/rate_limiter.py +119 -0
- package/src/skcapstone/register.py +241 -0
- package/src/skcapstone/registry_client.py +151 -0
- package/src/skcapstone/response_cache.py +194 -0
- package/src/skcapstone/response_scorer.py +225 -0
- package/src/skcapstone/runtime.py +89 -33
- package/src/skcapstone/scheduled_tasks.py +439 -0
- package/src/skcapstone/self_healing.py +341 -0
- package/src/skcapstone/service_health.py +228 -0
- package/src/skcapstone/session_capture.py +268 -0
- package/src/skcapstone/session_recorder.py +210 -0
- package/src/skcapstone/session_replayer.py +189 -0
- package/src/skcapstone/session_skills.py +263 -0
- package/src/skcapstone/shell.py +779 -0
- package/src/skcapstone/skills/__init__.py +1 -1
- package/src/skcapstone/skills/syncthing_setup.py +143 -41
- package/src/skcapstone/skjoule.py +861 -0
- package/src/skcapstone/snapshots.py +489 -0
- package/src/skcapstone/soul.py +1060 -0
- package/src/skcapstone/soul_switch.py +255 -0
- package/src/skcapstone/spawner.py +544 -0
- package/src/skcapstone/state_diff.py +401 -0
- package/src/skcapstone/summary.py +270 -0
- package/src/skcapstone/sync/backends.py +196 -2
- package/src/skcapstone/sync/engine.py +7 -5
- package/src/skcapstone/sync/models.py +4 -1
- package/src/skcapstone/sync/vault.py +356 -18
- package/src/skcapstone/sync_engine.py +363 -0
- package/src/skcapstone/sync_watcher.py +745 -0
- package/src/skcapstone/systemd.py +331 -0
- package/src/skcapstone/team_comms.py +476 -0
- package/src/skcapstone/team_engine.py +522 -0
- package/src/skcapstone/testrunner.py +300 -0
- package/src/skcapstone/tls.py +150 -0
- package/src/skcapstone/tokens.py +5 -5
- package/src/skcapstone/trust_calibration.py +202 -0
- package/src/skcapstone/trust_graph.py +449 -0
- package/src/skcapstone/trustee_monitor.py +385 -0
- package/src/skcapstone/trustee_ops.py +425 -0
- package/src/skcapstone/unified_search.py +421 -0
- package/src/skcapstone/uninstall_wizard.py +694 -0
- package/src/skcapstone/usage.py +331 -0
- package/src/skcapstone/version_check.py +148 -0
- package/src/skcapstone/warmth_anchor.py +333 -0
- package/src/skcapstone/whoami.py +294 -0
- package/systemd/skcapstone-api.socket +9 -0
- package/systemd/skcapstone-memory-compress.service +18 -0
- package/systemd/skcapstone-memory-compress.timer +11 -0
- package/systemd/skcapstone.service +36 -0
- package/systemd/skcapstone@.service +50 -0
- package/systemd/skcomm-heartbeat.service +18 -0
- package/systemd/skcomm-heartbeat.timer +12 -0
- package/systemd/skcomm-queue-drain.service +17 -0
- package/systemd/skcomm-queue-drain.timer +12 -0
- package/tests/conftest.py +13 -1
- package/tests/integration/__init__.py +1 -0
- package/tests/integration/test_consciousness_e2e.py +877 -0
- package/tests/integration/test_skills_registry.py +744 -0
- package/tests/test_agent_card.py +190 -0
- package/tests/test_agent_runtime.py +1283 -0
- package/tests/test_alerts_cmd.py +291 -0
- package/tests/test_archiver.py +498 -0
- package/tests/test_backup.py +254 -0
- package/tests/test_benchmark.py +366 -0
- package/tests/test_blueprints.py +457 -0
- package/tests/test_capabilities.py +257 -0
- package/tests/test_changelog.py +254 -0
- package/tests/test_chat.py +385 -0
- package/tests/test_claude_md.py +271 -0
- package/tests/test_cli_chat_llm.py +336 -0
- package/tests/test_cli_completions.py +390 -0
- package/tests/test_cli_init_reset.py +164 -0
- package/tests/test_cli_memory.py +208 -0
- package/tests/test_cli_profile.py +294 -0
- package/tests/test_cli_skills.py +223 -0
- package/tests/test_cli_status.py +395 -0
- package/tests/test_cli_test_cmd.py +206 -0
- package/tests/test_cli_test_connection.py +364 -0
- package/tests/test_cloud9_bridge.py +260 -0
- package/tests/test_cloud_provider.py +449 -0
- package/tests/test_cloud_providers.py +522 -0
- package/tests/test_completions.py +158 -0
- package/tests/test_component_manager.py +398 -0
- package/tests/test_config_reload.py +386 -0
- package/tests/test_config_validate.py +529 -0
- package/tests/test_consciousness_e2e.py +296 -0
- package/tests/test_consciousness_loop.py +1289 -0
- package/tests/test_context_loader.py +310 -0
- package/tests/test_conversation_api.py +306 -0
- package/tests/test_conversation_manager.py +381 -0
- package/tests/test_conversation_store.py +391 -0
- package/tests/test_conversation_summarizer.py +302 -0
- package/tests/test_cross_package.py +791 -0
- package/tests/test_crush_shim.py +519 -0
- package/tests/test_daemon.py +781 -0
- package/tests/test_daemon_shutdown.py +309 -0
- package/tests/test_dashboard.py +454 -0
- package/tests/test_discovery.py +200 -6
- package/tests/test_docker_provider.py +966 -0
- package/tests/test_doctor.py +257 -0
- package/tests/test_doctor_fix.py +351 -0
- package/tests/test_e2e_automated.py +292 -0
- package/tests/test_error_queue.py +404 -0
- package/tests/test_export.py +441 -0
- package/tests/test_fallback_tracker.py +219 -0
- package/tests/test_file_transfer.py +397 -0
- package/tests/test_fuse_mount.py +832 -0
- package/tests/test_health_loop.py +422 -0
- package/tests/test_heartbeat.py +354 -0
- package/tests/test_housekeeping.py +195 -0
- package/tests/test_identity_capauth.py +307 -0
- package/tests/test_identity_pillar.py +117 -0
- package/tests/test_install_wizard.py +68 -0
- package/tests/test_integration.py +325 -0
- package/tests/test_kms.py +495 -0
- package/tests/test_llm_providers.py +265 -0
- package/tests/test_local_provider.py +591 -0
- package/tests/test_log_config.py +199 -0
- package/tests/test_logs_cmd.py +287 -0
- package/tests/test_mcp_server.py +1909 -0
- package/tests/test_memory_adapter.py +339 -0
- package/tests/test_memory_curator.py +218 -0
- package/tests/test_memory_engine.py +6 -0
- package/tests/test_memory_fortress.py +571 -0
- package/tests/test_memory_pillar.py +119 -0
- package/tests/test_memory_promoter.py +445 -0
- package/tests/test_memory_verifier.py +420 -0
- package/tests/test_message_crypto.py +187 -0
- package/tests/test_metrics.py +632 -0
- package/tests/test_migrate_memories.py +464 -0
- package/tests/test_model_router.py +546 -0
- package/tests/test_mood.py +394 -0
- package/tests/test_multi_agent.py +269 -0
- package/tests/test_notifications.py +270 -0
- package/tests/test_onboard.py +500 -0
- package/tests/test_peer_directory.py +395 -0
- package/tests/test_peers.py +248 -0
- package/tests/test_pillars.py +87 -9
- package/tests/test_preflight.py +484 -0
- package/tests/test_prompt_adapter.py +331 -0
- package/tests/test_proxmox_provider.py +571 -0
- package/tests/test_pubsub.py +377 -0
- package/tests/test_rate_limiter.py +121 -0
- package/tests/test_registry_client.py +129 -0
- package/tests/test_response_cache.py +312 -0
- package/tests/test_response_scorer.py +294 -0
- package/tests/test_runtime.py +59 -0
- package/tests/test_scheduled_tasks.py +451 -0
- package/tests/test_security.py +250 -0
- package/tests/test_security_pillar.py +213 -0
- package/tests/test_self_healing.py +171 -0
- package/tests/test_session_capture.py +200 -0
- package/tests/test_session_recorder.py +360 -0
- package/tests/test_session_skills.py +235 -0
- package/tests/test_shell.py +210 -0
- package/tests/test_snapshots.py +549 -0
- package/tests/test_soul.py +984 -0
- package/tests/test_soul_swap.py +406 -0
- package/tests/test_spawner.py +211 -0
- package/tests/test_state_diff.py +173 -0
- package/tests/test_summary.py +135 -0
- package/tests/test_sync.py +315 -5
- package/tests/test_sync_backends.py +560 -0
- package/tests/test_sync_engine.py +482 -0
- package/tests/test_sync_pillar.py +344 -0
- package/tests/test_sync_pipeline.py +364 -0
- package/tests/test_sync_vault.py +581 -0
- package/tests/test_syncthing_setup.py +168 -22
- package/tests/test_systemd.py +323 -0
- package/tests/test_team_comms.py +408 -0
- package/tests/test_team_engine.py +397 -0
- package/tests/test_testrunner.py +238 -0
- package/tests/test_trust_calibration.py +204 -0
- package/tests/test_trust_graph.py +207 -0
- package/tests/test_trust_pillar.py +291 -0
- package/tests/test_trustee_cli.py +427 -0
- package/tests/test_trustee_cli_integration.py +325 -0
- package/tests/test_trustee_monitor.py +394 -0
- package/tests/test_trustee_ops.py +355 -0
- package/tests/test_unified_search.py +363 -0
- package/tests/test_uninstall_wizard.py +193 -0
- package/tests/test_usage.py +333 -0
- package/tests/test_version_cmd.py +355 -0
- package/tests/test_warmth_anchor.py +162 -0
- package/tests/test_whoami.py +245 -0
- package/tests/test_ws.py +311 -0
- package/.cursorrules +0 -33
- package/src/skcapstone/cli.py +0 -1441
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Tests for sovereign pub/sub messaging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from skcapstone.pubsub import (
|
|
12
|
+
PubSub,
|
|
13
|
+
Subscription,
|
|
14
|
+
TopicMessage,
|
|
15
|
+
_sanitize_topic,
|
|
16
|
+
_unsanitize_topic,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def home(tmp_path: Path) -> Path:
|
|
22
|
+
"""Create a minimal agent home."""
|
|
23
|
+
return tmp_path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture
|
|
27
|
+
def bus(home: Path) -> PubSub:
|
|
28
|
+
"""Create an initialized PubSub instance."""
|
|
29
|
+
ps = PubSub(home, agent_name="opus")
|
|
30
|
+
ps.initialize()
|
|
31
|
+
return ps
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Topic name sanitization
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TestSanitization:
|
|
40
|
+
"""Tests for topic name conversion."""
|
|
41
|
+
|
|
42
|
+
def test_sanitize_dots(self) -> None:
|
|
43
|
+
"""Dots are preserved (used as separators)."""
|
|
44
|
+
assert _sanitize_topic("system.health") == "system.health"
|
|
45
|
+
|
|
46
|
+
def test_sanitize_slashes(self) -> None:
|
|
47
|
+
"""Slashes become double dashes."""
|
|
48
|
+
assert _sanitize_topic("team/dev") == "team--dev"
|
|
49
|
+
|
|
50
|
+
def test_unsanitize_roundtrip(self) -> None:
|
|
51
|
+
"""Sanitize then unsanitize returns original."""
|
|
52
|
+
original = "team/dev"
|
|
53
|
+
assert _unsanitize_topic(_sanitize_topic(original)) == original
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Initialization
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestInitialization:
|
|
62
|
+
"""Tests for PubSub setup."""
|
|
63
|
+
|
|
64
|
+
def test_initialize_creates_dirs(self, home: Path) -> None:
|
|
65
|
+
"""Initialize creates the directory structure."""
|
|
66
|
+
PubSub(home).initialize()
|
|
67
|
+
assert (home / "pubsub").is_dir()
|
|
68
|
+
assert (home / "pubsub" / "topics").is_dir()
|
|
69
|
+
assert (home / "pubsub" / "dead-letter").is_dir()
|
|
70
|
+
|
|
71
|
+
def test_initialize_idempotent(self, bus: PubSub, home: Path) -> None:
|
|
72
|
+
"""Multiple initializations don't break anything."""
|
|
73
|
+
bus.initialize()
|
|
74
|
+
bus.initialize()
|
|
75
|
+
assert (home / "pubsub" / "topics").is_dir()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Publishing
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TestPublish:
|
|
84
|
+
"""Tests for message publishing."""
|
|
85
|
+
|
|
86
|
+
def test_publish_creates_topic_dir(self, bus: PubSub, home: Path) -> None:
|
|
87
|
+
"""Publishing creates the topic directory."""
|
|
88
|
+
bus.publish("system.health", {"status": "alive"})
|
|
89
|
+
assert (home / "pubsub" / "topics" / "system.health").is_dir()
|
|
90
|
+
|
|
91
|
+
def test_publish_writes_message_file(self, bus: PubSub, home: Path) -> None:
|
|
92
|
+
"""Publishing writes a JSON message file."""
|
|
93
|
+
msg = bus.publish("test.topic", {"key": "value"})
|
|
94
|
+
topic_dir = home / "pubsub" / "topics" / "test.topic"
|
|
95
|
+
files = list(topic_dir.glob("msg-*.json"))
|
|
96
|
+
assert len(files) == 1
|
|
97
|
+
|
|
98
|
+
def test_publish_returns_message(self, bus: PubSub) -> None:
|
|
99
|
+
"""Publish returns a complete TopicMessage."""
|
|
100
|
+
msg = bus.publish("t", {"data": 42})
|
|
101
|
+
assert msg.topic == "t"
|
|
102
|
+
assert msg.sender == "opus"
|
|
103
|
+
assert msg.payload == {"data": 42}
|
|
104
|
+
assert msg.message_id
|
|
105
|
+
|
|
106
|
+
def test_publish_multiple_messages(self, bus: PubSub, home: Path) -> None:
|
|
107
|
+
"""Multiple publishes to same topic create separate files."""
|
|
108
|
+
bus.publish("multi", {"n": 1})
|
|
109
|
+
bus.publish("multi", {"n": 2})
|
|
110
|
+
bus.publish("multi", {"n": 3})
|
|
111
|
+
files = list((home / "pubsub" / "topics" / "multi").glob("msg-*.json"))
|
|
112
|
+
assert len(files) == 3
|
|
113
|
+
|
|
114
|
+
def test_publish_with_tags(self, bus: PubSub) -> None:
|
|
115
|
+
"""Messages can have tags."""
|
|
116
|
+
msg = bus.publish("tagged", {"x": 1}, tags=["critical", "health"])
|
|
117
|
+
assert msg.tags == ["critical", "health"]
|
|
118
|
+
|
|
119
|
+
def test_publish_with_custom_ttl(self, bus: PubSub) -> None:
|
|
120
|
+
"""Custom TTL is set on the message."""
|
|
121
|
+
msg = bus.publish("short-lived", {}, ttl_seconds=60)
|
|
122
|
+
assert msg.ttl_seconds == 60
|
|
123
|
+
|
|
124
|
+
def test_prune_excess_messages(self, home: Path) -> None:
|
|
125
|
+
"""Topic is pruned when exceeding max messages."""
|
|
126
|
+
bus = PubSub(home, agent_name="opus", max_topic_messages=3)
|
|
127
|
+
bus.initialize()
|
|
128
|
+
for i in range(5):
|
|
129
|
+
bus.publish("pruned", {"n": i})
|
|
130
|
+
files = list((home / "pubsub" / "topics" / "pruned").glob("msg-*.json"))
|
|
131
|
+
assert len(files) == 3
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# Subscriptions
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TestSubscribe:
|
|
140
|
+
"""Tests for subscription management."""
|
|
141
|
+
|
|
142
|
+
def test_subscribe_creates_entry(self, bus: PubSub) -> None:
|
|
143
|
+
"""Subscribe creates a subscription record."""
|
|
144
|
+
sub = bus.subscribe("system.*")
|
|
145
|
+
assert sub.pattern == "system.*"
|
|
146
|
+
|
|
147
|
+
def test_subscribe_idempotent(self, bus: PubSub) -> None:
|
|
148
|
+
"""Subscribing twice returns existing subscription."""
|
|
149
|
+
s1 = bus.subscribe("test.topic")
|
|
150
|
+
s2 = bus.subscribe("test.topic")
|
|
151
|
+
assert s1.subscribed_at == s2.subscribed_at
|
|
152
|
+
|
|
153
|
+
def test_subscribe_persists(self, bus: PubSub, home: Path) -> None:
|
|
154
|
+
"""Subscriptions are persisted to disk."""
|
|
155
|
+
bus.subscribe("persistent")
|
|
156
|
+
subs_file = home / "pubsub" / "subscriptions.json"
|
|
157
|
+
assert subs_file.exists()
|
|
158
|
+
data = json.loads(subs_file.read_text(encoding="utf-8"))
|
|
159
|
+
assert "persistent" in data
|
|
160
|
+
|
|
161
|
+
def test_unsubscribe(self, bus: PubSub) -> None:
|
|
162
|
+
"""Unsubscribe removes the subscription."""
|
|
163
|
+
bus.subscribe("temporary")
|
|
164
|
+
assert bus.unsubscribe("temporary") is True
|
|
165
|
+
subs = bus.list_subscriptions()
|
|
166
|
+
assert "temporary" not in subs
|
|
167
|
+
|
|
168
|
+
def test_unsubscribe_nonexistent(self, bus: PubSub) -> None:
|
|
169
|
+
"""Unsubscribing from a nonexistent pattern returns False."""
|
|
170
|
+
assert bus.unsubscribe("ghost") is False
|
|
171
|
+
|
|
172
|
+
def test_list_subscriptions(self, bus: PubSub) -> None:
|
|
173
|
+
"""List all active subscriptions."""
|
|
174
|
+
bus.subscribe("a.*")
|
|
175
|
+
bus.subscribe("b.topic")
|
|
176
|
+
subs = bus.list_subscriptions()
|
|
177
|
+
assert len(subs) == 2
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
# Polling
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class TestPoll:
|
|
186
|
+
"""Tests for message polling."""
|
|
187
|
+
|
|
188
|
+
def test_poll_specific_topic(self, bus: PubSub) -> None:
|
|
189
|
+
"""Poll a specific topic returns its messages."""
|
|
190
|
+
bus.publish("poll.test", {"n": 1})
|
|
191
|
+
bus.publish("poll.test", {"n": 2})
|
|
192
|
+
msgs = bus.poll(topic="poll.test")
|
|
193
|
+
assert len(msgs) == 2
|
|
194
|
+
|
|
195
|
+
def test_poll_subscribed_topics(self, bus: PubSub) -> None:
|
|
196
|
+
"""Poll with no topic returns all subscribed messages."""
|
|
197
|
+
bus.subscribe("sub.*")
|
|
198
|
+
bus.publish("sub.a", {"n": 1})
|
|
199
|
+
bus.publish("sub.b", {"n": 2})
|
|
200
|
+
bus.publish("other", {"n": 3}) # not subscribed
|
|
201
|
+
msgs = bus.poll()
|
|
202
|
+
assert len(msgs) == 2
|
|
203
|
+
|
|
204
|
+
def test_poll_with_since_filter(self, bus: PubSub) -> None:
|
|
205
|
+
"""Since filter excludes older messages."""
|
|
206
|
+
bus.publish("time.test", {"n": 1})
|
|
207
|
+
cutoff = datetime.now(timezone.utc)
|
|
208
|
+
bus.publish("time.test", {"n": 2})
|
|
209
|
+
msgs = bus.poll(topic="time.test", since=cutoff)
|
|
210
|
+
assert len(msgs) == 1
|
|
211
|
+
assert msgs[0].payload["n"] == 2
|
|
212
|
+
|
|
213
|
+
def test_poll_limit(self, bus: PubSub) -> None:
|
|
214
|
+
"""Limit caps the number of returned messages."""
|
|
215
|
+
for i in range(10):
|
|
216
|
+
bus.publish("many", {"n": i})
|
|
217
|
+
msgs = bus.poll(topic="many", limit=3)
|
|
218
|
+
assert len(msgs) == 3
|
|
219
|
+
|
|
220
|
+
def test_poll_skips_expired(self, bus: PubSub, home: Path) -> None:
|
|
221
|
+
"""Expired messages are not returned."""
|
|
222
|
+
msg = bus.publish("expiry", {"data": "old"}, ttl_seconds=1)
|
|
223
|
+
# Manually backdate the message
|
|
224
|
+
topic_dir = home / "pubsub" / "topics" / "expiry"
|
|
225
|
+
msg_file = list(topic_dir.glob("msg-*.json"))[0]
|
|
226
|
+
data = json.loads(msg_file.read_text(encoding="utf-8"))
|
|
227
|
+
data["published_at"] = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
|
|
228
|
+
msg_file.write_text(json.dumps(data), encoding="utf-8")
|
|
229
|
+
|
|
230
|
+
msgs = bus.poll(topic="expiry")
|
|
231
|
+
assert len(msgs) == 0
|
|
232
|
+
|
|
233
|
+
def test_poll_wildcard_subscription(self, bus: PubSub) -> None:
|
|
234
|
+
"""Wildcard subscriptions match multiple topics."""
|
|
235
|
+
bus.subscribe("team.*")
|
|
236
|
+
bus.publish("team.dev", {"n": 1})
|
|
237
|
+
bus.publish("team.ops", {"n": 2})
|
|
238
|
+
bus.publish("system.health", {"n": 3})
|
|
239
|
+
msgs = bus.poll()
|
|
240
|
+
assert len(msgs) == 2
|
|
241
|
+
|
|
242
|
+
def test_poll_empty_topic(self, bus: PubSub) -> None:
|
|
243
|
+
"""Polling a topic with no messages returns empty list."""
|
|
244
|
+
msgs = bus.poll(topic="empty.topic")
|
|
245
|
+
assert msgs == []
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
# Callbacks
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class TestCallbacks:
|
|
254
|
+
"""Tests for callback-based message dispatch."""
|
|
255
|
+
|
|
256
|
+
def test_on_message_registers_callback(self, bus: PubSub) -> None:
|
|
257
|
+
"""Registering a callback also subscribes."""
|
|
258
|
+
received: list[TopicMessage] = []
|
|
259
|
+
bus.on_message("cb.test", lambda msg: received.append(msg))
|
|
260
|
+
subs = bus.list_subscriptions()
|
|
261
|
+
assert "cb.test" in subs
|
|
262
|
+
|
|
263
|
+
def test_poll_and_dispatch(self, bus: PubSub) -> None:
|
|
264
|
+
"""Dispatch triggers callbacks for matching messages."""
|
|
265
|
+
received: list[TopicMessage] = []
|
|
266
|
+
bus.on_message("dispatch.*", lambda msg: received.append(msg))
|
|
267
|
+
bus.publish("dispatch.a", {"n": 1})
|
|
268
|
+
bus.publish("dispatch.b", {"n": 2})
|
|
269
|
+
count = bus.poll_and_dispatch()
|
|
270
|
+
assert count == 2
|
|
271
|
+
assert len(received) == 2
|
|
272
|
+
|
|
273
|
+
def test_callback_error_doesnt_stop_dispatch(self, bus: PubSub) -> None:
|
|
274
|
+
"""A failing callback doesn't prevent others from running."""
|
|
275
|
+
results: list[int] = []
|
|
276
|
+
|
|
277
|
+
def failing_cb(msg: TopicMessage) -> None:
|
|
278
|
+
raise RuntimeError("boom")
|
|
279
|
+
|
|
280
|
+
def good_cb(msg: TopicMessage) -> None:
|
|
281
|
+
results.append(1)
|
|
282
|
+
|
|
283
|
+
bus.on_message("error.test", failing_cb)
|
|
284
|
+
bus.on_message("error.test", good_cb)
|
|
285
|
+
bus.publish("error.test", {"x": 1})
|
|
286
|
+
bus.poll_and_dispatch()
|
|
287
|
+
assert len(results) == 1
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
# Topic listing and status
|
|
292
|
+
# ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class TestListAndStatus:
|
|
296
|
+
"""Tests for topic listing and status."""
|
|
297
|
+
|
|
298
|
+
def test_list_topics(self, bus: PubSub) -> None:
|
|
299
|
+
"""List all topics with message counts."""
|
|
300
|
+
bus.publish("topic.a", {"n": 1})
|
|
301
|
+
bus.publish("topic.a", {"n": 2})
|
|
302
|
+
bus.publish("topic.b", {"n": 1})
|
|
303
|
+
topics = bus.list_topics()
|
|
304
|
+
assert len(topics) == 2
|
|
305
|
+
topic_a = next(t for t in topics if t["topic"] == "topic.a")
|
|
306
|
+
assert topic_a["messages"] == 2
|
|
307
|
+
|
|
308
|
+
def test_status_summary(self, bus: PubSub) -> None:
|
|
309
|
+
"""Status returns structured summary."""
|
|
310
|
+
bus.subscribe("s.*")
|
|
311
|
+
bus.publish("s.a", {"n": 1})
|
|
312
|
+
status = bus.status()
|
|
313
|
+
assert status["agent"] == "opus"
|
|
314
|
+
assert status["subscriptions"] == 1
|
|
315
|
+
assert status["topics"] >= 1
|
|
316
|
+
assert status["total_messages"] >= 1
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# ---------------------------------------------------------------------------
|
|
320
|
+
# Expiry purge
|
|
321
|
+
# ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class TestPurge:
|
|
325
|
+
"""Tests for expired message cleanup."""
|
|
326
|
+
|
|
327
|
+
def test_purge_removes_expired(self, bus: PubSub, home: Path) -> None:
|
|
328
|
+
"""Purge removes expired messages."""
|
|
329
|
+
bus.publish("purge.test", {"data": "old"}, ttl_seconds=1)
|
|
330
|
+
# Backdate the message
|
|
331
|
+
topic_dir = home / "pubsub" / "topics" / "purge.test"
|
|
332
|
+
msg_file = list(topic_dir.glob("msg-*.json"))[0]
|
|
333
|
+
data = json.loads(msg_file.read_text(encoding="utf-8"))
|
|
334
|
+
data["published_at"] = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
|
|
335
|
+
msg_file.write_text(json.dumps(data), encoding="utf-8")
|
|
336
|
+
|
|
337
|
+
removed = bus.purge_expired()
|
|
338
|
+
assert removed == 1
|
|
339
|
+
assert len(list(topic_dir.glob("msg-*.json"))) == 0
|
|
340
|
+
|
|
341
|
+
def test_purge_keeps_valid(self, bus: PubSub) -> None:
|
|
342
|
+
"""Purge doesn't remove valid messages."""
|
|
343
|
+
bus.publish("keep.test", {"data": "fresh"}, ttl_seconds=86400)
|
|
344
|
+
removed = bus.purge_expired()
|
|
345
|
+
assert removed == 0
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# ---------------------------------------------------------------------------
|
|
349
|
+
# Model tests
|
|
350
|
+
# ---------------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class TestModels:
|
|
354
|
+
"""Tests for Pydantic models."""
|
|
355
|
+
|
|
356
|
+
def test_topic_message_defaults(self) -> None:
|
|
357
|
+
"""TopicMessage has sensible defaults."""
|
|
358
|
+
msg = TopicMessage(topic="t", sender="a")
|
|
359
|
+
assert msg.ttl_seconds == 86400
|
|
360
|
+
assert msg.tags == []
|
|
361
|
+
assert not msg.is_expired
|
|
362
|
+
|
|
363
|
+
def test_expired_message(self) -> None:
|
|
364
|
+
"""Expired message is detected."""
|
|
365
|
+
msg = TopicMessage(
|
|
366
|
+
topic="t",
|
|
367
|
+
sender="a",
|
|
368
|
+
published_at=datetime.now(timezone.utc) - timedelta(hours=25),
|
|
369
|
+
ttl_seconds=86400,
|
|
370
|
+
)
|
|
371
|
+
assert msg.is_expired
|
|
372
|
+
|
|
373
|
+
def test_subscription_defaults(self) -> None:
|
|
374
|
+
"""Subscription has sensible defaults."""
|
|
375
|
+
sub = Subscription(pattern="test.*")
|
|
376
|
+
assert sub.last_read is None
|
|
377
|
+
assert sub.message_count == 0
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Tests for the token-bucket rate limiter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from skcapstone.rate_limiter import RateLimiter, TokenBucket
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# TokenBucket unit tests
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TestTokenBucket:
|
|
19
|
+
def test_initial_full_bucket_allows_up_to_capacity(self):
|
|
20
|
+
bucket = TokenBucket(rate=10.0, capacity=5)
|
|
21
|
+
# Should allow exactly 5 consecutive requests from a full bucket
|
|
22
|
+
for _ in range(5):
|
|
23
|
+
assert bucket.consume() is True
|
|
24
|
+
|
|
25
|
+
def test_exhausted_bucket_rejects(self):
|
|
26
|
+
bucket = TokenBucket(rate=0.001, capacity=2) # very slow refill
|
|
27
|
+
bucket.consume()
|
|
28
|
+
bucket.consume()
|
|
29
|
+
assert bucket.consume() is False
|
|
30
|
+
|
|
31
|
+
def test_tokens_refill_over_time(self):
|
|
32
|
+
# Start with an empty-ish bucket (capacity=1, drain it, wait for refill)
|
|
33
|
+
bucket = TokenBucket(rate=100.0, capacity=1)
|
|
34
|
+
assert bucket.consume() is True # drain
|
|
35
|
+
assert bucket.consume() is False # empty
|
|
36
|
+
time.sleep(0.02) # wait 20 ms → ~2 tokens at 100/s
|
|
37
|
+
assert bucket.consume() is True # should be refilled
|
|
38
|
+
|
|
39
|
+
def test_invalid_rate_raises(self):
|
|
40
|
+
with pytest.raises(ValueError):
|
|
41
|
+
TokenBucket(rate=0, capacity=10)
|
|
42
|
+
|
|
43
|
+
def test_invalid_capacity_raises(self):
|
|
44
|
+
with pytest.raises(ValueError):
|
|
45
|
+
TokenBucket(rate=1.0, capacity=0)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# RateLimiter unit tests
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestRateLimiter:
|
|
54
|
+
def test_allows_requests_within_limit(self):
|
|
55
|
+
limiter = RateLimiter(requests_per_minute=60)
|
|
56
|
+
# First 60 requests from the same IP must all be allowed
|
|
57
|
+
for _ in range(60):
|
|
58
|
+
assert limiter.is_allowed("10.0.0.1") is True
|
|
59
|
+
|
|
60
|
+
def test_blocks_after_limit_exceeded(self):
|
|
61
|
+
limiter = RateLimiter(requests_per_minute=5)
|
|
62
|
+
ip = "192.168.1.1"
|
|
63
|
+
for _ in range(5):
|
|
64
|
+
limiter.is_allowed(ip)
|
|
65
|
+
assert limiter.is_allowed(ip) is False
|
|
66
|
+
|
|
67
|
+
def test_different_ips_have_independent_buckets(self):
|
|
68
|
+
limiter = RateLimiter(requests_per_minute=2)
|
|
69
|
+
ip_a, ip_b = "1.1.1.1", "2.2.2.2"
|
|
70
|
+
# Drain ip_a
|
|
71
|
+
limiter.is_allowed(ip_a)
|
|
72
|
+
limiter.is_allowed(ip_a)
|
|
73
|
+
assert limiter.is_allowed(ip_a) is False
|
|
74
|
+
# ip_b should still be untouched
|
|
75
|
+
assert limiter.is_allowed(ip_b) is True
|
|
76
|
+
|
|
77
|
+
def test_reset_restores_bucket(self):
|
|
78
|
+
limiter = RateLimiter(requests_per_minute=1)
|
|
79
|
+
ip = "10.0.0.5"
|
|
80
|
+
limiter.is_allowed(ip) # drain the single token
|
|
81
|
+
assert limiter.is_allowed(ip) is False
|
|
82
|
+
limiter.reset(ip) # discard bucket
|
|
83
|
+
assert limiter.is_allowed(ip) is True # fresh bucket
|
|
84
|
+
|
|
85
|
+
def test_clear_removes_all_buckets(self):
|
|
86
|
+
limiter = RateLimiter(requests_per_minute=1)
|
|
87
|
+
for ip in ("10.0.0.1", "10.0.0.2", "10.0.0.3"):
|
|
88
|
+
limiter.is_allowed(ip) # drain each
|
|
89
|
+
limiter.clear()
|
|
90
|
+
for ip in ("10.0.0.1", "10.0.0.2", "10.0.0.3"):
|
|
91
|
+
assert limiter.is_allowed(ip) is True
|
|
92
|
+
|
|
93
|
+
def test_invalid_rpm_raises(self):
|
|
94
|
+
with pytest.raises(ValueError):
|
|
95
|
+
RateLimiter(requests_per_minute=0)
|
|
96
|
+
|
|
97
|
+
def test_requests_per_minute_property(self):
|
|
98
|
+
limiter = RateLimiter(requests_per_minute=42)
|
|
99
|
+
assert limiter.requests_per_minute == 42
|
|
100
|
+
|
|
101
|
+
def test_concurrent_requests_thread_safe(self):
|
|
102
|
+
"""Multiple threads hammering the same IP should not crash or over-allow."""
|
|
103
|
+
limiter = RateLimiter(requests_per_minute=50)
|
|
104
|
+
ip = "10.0.0.99"
|
|
105
|
+
allowed: list[bool] = []
|
|
106
|
+
lock = threading.Lock()
|
|
107
|
+
|
|
108
|
+
def hit():
|
|
109
|
+
result = limiter.is_allowed(ip)
|
|
110
|
+
with lock:
|
|
111
|
+
allowed.append(result)
|
|
112
|
+
|
|
113
|
+
threads = [threading.Thread(target=hit) for _ in range(100)]
|
|
114
|
+
for t in threads:
|
|
115
|
+
t.start()
|
|
116
|
+
for t in threads:
|
|
117
|
+
t.join()
|
|
118
|
+
|
|
119
|
+
# Exactly 50 should be allowed, rest rejected
|
|
120
|
+
assert sum(allowed) == 50
|
|
121
|
+
assert len(allowed) == 100
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Tests for skcapstone.registry_client — bridge to skills-registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import MagicMock, patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from skcapstone.registry_client import RegistryClient, get_registry_client
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestGetRegistryClient:
|
|
13
|
+
"""Tests for the get_registry_client() factory function."""
|
|
14
|
+
|
|
15
|
+
def test_returns_none_when_skskills_missing(self):
|
|
16
|
+
"""Should return None when skskills is not installed."""
|
|
17
|
+
with patch.dict("sys.modules", {"skskills.remote": None}):
|
|
18
|
+
# Force ImportError by removing the module
|
|
19
|
+
with patch(
|
|
20
|
+
"skcapstone.registry_client.RegistryClient.__init__",
|
|
21
|
+
side_effect=ImportError("no skskills"),
|
|
22
|
+
):
|
|
23
|
+
result = get_registry_client()
|
|
24
|
+
assert result is None
|
|
25
|
+
|
|
26
|
+
def test_returns_client_when_skskills_available(self):
|
|
27
|
+
"""Should return a RegistryClient when skskills is available."""
|
|
28
|
+
mock_remote = MagicMock()
|
|
29
|
+
mock_module = MagicMock()
|
|
30
|
+
mock_module.RemoteRegistry = mock_remote
|
|
31
|
+
|
|
32
|
+
with patch.dict("sys.modules", {"skskills.remote": mock_module}):
|
|
33
|
+
client = get_registry_client("https://test.example.com/api")
|
|
34
|
+
|
|
35
|
+
assert client is not None
|
|
36
|
+
assert isinstance(client, RegistryClient)
|
|
37
|
+
|
|
38
|
+
def test_custom_url_passed_to_client(self):
|
|
39
|
+
"""Custom URL should be forwarded to the client."""
|
|
40
|
+
mock_remote = MagicMock()
|
|
41
|
+
mock_module = MagicMock()
|
|
42
|
+
mock_module.RemoteRegistry = mock_remote
|
|
43
|
+
|
|
44
|
+
with patch.dict("sys.modules", {"skskills.remote": mock_module}):
|
|
45
|
+
client = get_registry_client("https://custom.example.com/api")
|
|
46
|
+
|
|
47
|
+
assert client.registry_url == "https://custom.example.com/api"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TestRegistryClientIsAvailable:
|
|
51
|
+
"""Tests for RegistryClient.is_available()."""
|
|
52
|
+
|
|
53
|
+
def test_available_when_fetch_succeeds(self):
|
|
54
|
+
"""Should return True when remote responds."""
|
|
55
|
+
mock_remote_instance = MagicMock()
|
|
56
|
+
mock_remote_instance.fetch_index.return_value = MagicMock(skills=[])
|
|
57
|
+
|
|
58
|
+
mock_remote_cls = MagicMock(return_value=mock_remote_instance)
|
|
59
|
+
mock_module = MagicMock()
|
|
60
|
+
mock_module.RemoteRegistry = mock_remote_cls
|
|
61
|
+
|
|
62
|
+
with patch.dict("sys.modules", {"skskills.remote": mock_module}):
|
|
63
|
+
client = RegistryClient("https://test.example.com/api")
|
|
64
|
+
assert client.is_available() is True
|
|
65
|
+
|
|
66
|
+
def test_unavailable_when_fetch_fails(self):
|
|
67
|
+
"""Should return False when remote is unreachable."""
|
|
68
|
+
mock_remote_instance = MagicMock()
|
|
69
|
+
mock_remote_instance.fetch_index.side_effect = ConnectionError("offline")
|
|
70
|
+
|
|
71
|
+
mock_remote_cls = MagicMock(return_value=mock_remote_instance)
|
|
72
|
+
mock_module = MagicMock()
|
|
73
|
+
mock_module.RemoteRegistry = mock_remote_cls
|
|
74
|
+
|
|
75
|
+
with patch.dict("sys.modules", {"skskills.remote": mock_module}):
|
|
76
|
+
client = RegistryClient("https://test.example.com/api")
|
|
77
|
+
assert client.is_available() is False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TestRegistryClientListAndSearch:
|
|
81
|
+
"""Tests for list_skills() and search()."""
|
|
82
|
+
|
|
83
|
+
def _make_client(self):
|
|
84
|
+
"""Create a client with mocked remote."""
|
|
85
|
+
mock_entry_1 = MagicMock()
|
|
86
|
+
mock_entry_1.model_dump.return_value = {
|
|
87
|
+
"name": "syncthing-setup",
|
|
88
|
+
"version": "1.0.0",
|
|
89
|
+
"description": "Syncthing sovereign sync",
|
|
90
|
+
"tags": ["sync"],
|
|
91
|
+
}
|
|
92
|
+
mock_entry_2 = MagicMock()
|
|
93
|
+
mock_entry_2.model_dump.return_value = {
|
|
94
|
+
"name": "pgp-identity",
|
|
95
|
+
"version": "0.2.0",
|
|
96
|
+
"description": "PGP key management",
|
|
97
|
+
"tags": ["identity"],
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
mock_index = MagicMock()
|
|
101
|
+
mock_index.skills = [mock_entry_1, mock_entry_2]
|
|
102
|
+
|
|
103
|
+
mock_remote_instance = MagicMock()
|
|
104
|
+
mock_remote_instance.fetch_index.return_value = mock_index
|
|
105
|
+
mock_remote_instance.search.return_value = [mock_entry_1]
|
|
106
|
+
|
|
107
|
+
mock_remote_cls = MagicMock(return_value=mock_remote_instance)
|
|
108
|
+
mock_module = MagicMock()
|
|
109
|
+
mock_module.RemoteRegistry = mock_remote_cls
|
|
110
|
+
|
|
111
|
+
with patch.dict("sys.modules", {"skskills.remote": mock_module}):
|
|
112
|
+
client = RegistryClient("https://test.example.com/api")
|
|
113
|
+
|
|
114
|
+
return client
|
|
115
|
+
|
|
116
|
+
def test_list_skills_returns_dicts(self):
|
|
117
|
+
"""list_skills() should return list of dicts."""
|
|
118
|
+
client = self._make_client()
|
|
119
|
+
skills = client.list_skills()
|
|
120
|
+
assert len(skills) == 2
|
|
121
|
+
assert skills[0]["name"] == "syncthing-setup"
|
|
122
|
+
assert skills[1]["name"] == "pgp-identity"
|
|
123
|
+
|
|
124
|
+
def test_search_returns_matching_dicts(self):
|
|
125
|
+
"""search() should return matching skill dicts."""
|
|
126
|
+
client = self._make_client()
|
|
127
|
+
results = client.search("syncthing")
|
|
128
|
+
assert len(results) == 1
|
|
129
|
+
assert results[0]["name"] == "syncthing-setup"
|